452 lines
15 KiB
TypeScript
452 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { Loader2, RotateCw } from "lucide-react";
|
||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||
import type { DocumentTable } from "@/types/online-table";
|
||
import {
|
||
DEFAULT_TABLE_COLUMNS,
|
||
DEFAULT_TABLE_ROWS,
|
||
DEFAULT_TABLE_SCHEMA,
|
||
createDefaultTableSnapshot,
|
||
getDocumentTable,
|
||
saveOnlineTable,
|
||
} from "@/lib/online-table";
|
||
import { api } from "@/lib/convex/api";
|
||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
|
||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||
import { extractRowsForPreview } from "@/components/online-table/utils";
|
||
|
||
type LuckysheetSelection =
|
||
| {
|
||
row?: [number, number];
|
||
column?: [number, number];
|
||
row_focus?: number;
|
||
column_focus?: number;
|
||
}
|
||
| undefined;
|
||
|
||
const isPrintableKey = (event: KeyboardEvent) => {
|
||
if (event.defaultPrevented) return false;
|
||
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
||
if (event.key === "Enter" || event.key === "Tab" || event.key === "Escape") return false;
|
||
if (event.key.length === 1) return true;
|
||
return event.key === "Process" || event.key === "Unidentified";
|
||
};
|
||
|
||
const isInlineEditorVisible = () => {
|
||
const inputBox = document.getElementById("luckysheet-input-box");
|
||
if (!inputBox) {
|
||
return false;
|
||
}
|
||
const style = window.getComputedStyle(inputBox);
|
||
return style.top !== "-10000px" && style.display !== "none";
|
||
};
|
||
|
||
interface HeadlessTableViewerProps {
|
||
tableId: string;
|
||
embed?: boolean;
|
||
editable?: boolean;
|
||
}
|
||
|
||
const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
|
||
|
||
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
|
||
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const isLuckysheetReady = useLuckysheetLoader();
|
||
const [table, setTable] = useState<DocumentTable | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [reloadVersion, setReloadVersion] = useState(0);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [saveError, setSaveError] = useState<string | null>(null);
|
||
const [showSavingHint, setShowSavingHint] = useState(false);
|
||
const savingHintTimerRef = useRef<number | null>(null);
|
||
const lastLocalPersistAtRef = useRef<number>(0);
|
||
|
||
const convexEnabled = isConvexEnabled();
|
||
const { isAuthenticated } = useConvexAuth();
|
||
const currentUser = useQuery(api.users.currentUser, convexEnabled && isAuthenticated ? {} : "skip");
|
||
const userId =
|
||
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
|
||
? String((currentUser as any)._id)
|
||
: "";
|
||
|
||
const shouldFetchTable = Boolean(convexEnabled && userId && tableId);
|
||
const tableFromConvex = useQuery(
|
||
api.tables.get,
|
||
shouldFetchTable ? { userId, tableId } : "skip",
|
||
);
|
||
const updateTable = useMutation(api.tables.update);
|
||
|
||
const allowInlineEdit = editable ?? embed;
|
||
|
||
const startSavingHint = useCallback(() => {
|
||
if (savingHintTimerRef.current !== null) return;
|
||
savingHintTimerRef.current = window.setTimeout(() => {
|
||
setShowSavingHint(true);
|
||
}, 700);
|
||
}, []);
|
||
|
||
const stopSavingHint = useCallback(() => {
|
||
if (savingHintTimerRef.current !== null) {
|
||
clearTimeout(savingHintTimerRef.current);
|
||
savingHintTimerRef.current = null;
|
||
}
|
||
setShowSavingHint(false);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let canceled = false;
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
if (convexEnabled) {
|
||
// Convex 模式下走 useQuery 实时订阅,这里仅维护与旧逻辑兼容的 loading/error 状态
|
||
if (tableFromConvex === undefined) {
|
||
// still loading
|
||
return () => {
|
||
canceled = true;
|
||
};
|
||
}
|
||
if (!canceled) {
|
||
if (tableFromConvex === null) {
|
||
setTable(null);
|
||
setError("无法加载表格数据");
|
||
} else {
|
||
// 可编辑内嵌视图:避免每次自动保存后立即重建 UI(会闪烁)。
|
||
// 我们在本组件触发保存后的短时间内,忽略来自订阅的回写更新。
|
||
if (allowInlineEdit && Date.now() - lastLocalPersistAtRef.current < 1500) {
|
||
// ignore
|
||
} else {
|
||
setTable(tableFromConvex as unknown as DocumentTable);
|
||
}
|
||
}
|
||
setIsLoading(false);
|
||
}
|
||
return () => {
|
||
canceled = true;
|
||
};
|
||
}
|
||
|
||
getDocumentTable(tableId)
|
||
.then((data) => {
|
||
if (!canceled) {
|
||
setTable(data);
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
console.error("加载表格失败", err);
|
||
if (!canceled) {
|
||
setTable(null);
|
||
setError("无法加载表格数据");
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (!canceled) {
|
||
setIsLoading(false);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
canceled = true;
|
||
};
|
||
}, [allowInlineEdit, convexEnabled, reloadVersion, tableFromConvex, tableId]);
|
||
|
||
|
||
const focusLuckysheetEditor = useCallback(() => {
|
||
const applyFocus = () => {
|
||
const editor = document.getElementById("luckysheet-rich-text-editor");
|
||
if (editor && typeof editor.focus === "function") {
|
||
editor.focus();
|
||
const selection = window.getSelection();
|
||
if (selection && editor.childNodes.length > 0) {
|
||
const range = document.createRange();
|
||
range.selectNodeContents(editor);
|
||
range.collapse(false);
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
}
|
||
}
|
||
};
|
||
requestAnimationFrame(() => {
|
||
applyFocus();
|
||
setTimeout(applyFocus, 0);
|
||
});
|
||
}, []);
|
||
|
||
const isSingleCellSelection = useCallback((range: LuckysheetSelection[] | undefined) => {
|
||
if (!Array.isArray(range) || range.length !== 1) return false;
|
||
const target = range[0];
|
||
if (!target) {
|
||
return false;
|
||
}
|
||
const rowRange = target.row ?? (typeof target.row_focus === "number" ? [target.row_focus, target.row_focus] : undefined);
|
||
const columnRange = target.column ?? (typeof target.column_focus === "number" ? [target.column_focus, target.column_focus] : undefined);
|
||
if (!rowRange || !columnRange) {
|
||
return false;
|
||
}
|
||
return rowRange[0] === rowRange[1] && columnRange[0] === columnRange[1];
|
||
}, []);
|
||
|
||
const tryEnterInlineEdit = useCallback(() => {
|
||
if (!allowInlineEdit) {
|
||
return false;
|
||
}
|
||
const luckysheetInstance = window.luckysheet;
|
||
if (!luckysheetInstance || typeof luckysheetInstance.enterEditMode !== "function") {
|
||
return false;
|
||
}
|
||
const selection = luckysheetInstance.getluckysheet_select_save?.();
|
||
const normalized = Array.isArray(selection)
|
||
? (selection as LuckysheetSelection[])
|
||
: selection
|
||
? [selection as LuckysheetSelection]
|
||
: undefined;
|
||
if (!isSingleCellSelection(normalized)) {
|
||
return false;
|
||
}
|
||
setTimeout(() => {
|
||
const editor = document.getElementById("luckysheet-rich-text-editor");
|
||
if (editor && document.activeElement === editor && isInlineEditorVisible()) {
|
||
return;
|
||
}
|
||
luckysheetInstance.enterEditMode?.();
|
||
focusLuckysheetEditor();
|
||
}, 0);
|
||
return true;
|
||
}, [allowInlineEdit, focusLuckysheetEditor, isSingleCellSelection]);
|
||
|
||
const persistSnapshot = useCallback(async () => {
|
||
if (!allowInlineEdit || !table || !window.luckysheet || typeof window.luckysheet.getluckysheetfile !== "function") {
|
||
return;
|
||
}
|
||
startSavingHint();
|
||
setIsSaving(true);
|
||
setSaveError(null);
|
||
try {
|
||
const luckysheetData = window.luckysheet.getluckysheetfile?.() ?? [];
|
||
const rows = extractRowsForPreview(
|
||
luckysheetData,
|
||
(table.schema?.columns ?? []).map((item) => ({ id: item.id })),
|
||
).filter((row) => row && typeof row === "object" && Object.keys(row).length > 0);
|
||
const snapshot = {
|
||
...(table.snapshot ?? {}),
|
||
rows,
|
||
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : table.snapshot?.luckysheet ?? [],
|
||
};
|
||
lastLocalPersistAtRef.current = Date.now();
|
||
if (convexEnabled && userId) {
|
||
await updateTable({
|
||
userId,
|
||
tableId,
|
||
snapshot,
|
||
rows,
|
||
schema: table.schema,
|
||
});
|
||
} else {
|
||
await saveOnlineTable(tableId, {
|
||
snapshot,
|
||
rows,
|
||
schema: table.schema,
|
||
});
|
||
}
|
||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
|
||
} catch (err) {
|
||
console.error("内嵌表格保存失败", err);
|
||
setSaveError("自动保存失败");
|
||
} finally {
|
||
stopSavingHint();
|
||
setIsSaving(false);
|
||
}
|
||
}, [allowInlineEdit, convexEnabled, startSavingHint, stopSavingHint, table, tableId, updateTable, userId]);
|
||
|
||
const debouncedPersist = useDebouncedCallback(() => {
|
||
void persistSnapshot();
|
||
}, 1200);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
debouncedPersist.cancel();
|
||
stopSavingHint();
|
||
};
|
||
}, [debouncedPersist, stopSavingHint]);
|
||
|
||
useEffect(() => {
|
||
if (!tableId) return;
|
||
// Supabase 已移除:实时订阅由 Convex useQuery 承担(见上方 tableFromConvex)
|
||
}, [tableId]);
|
||
|
||
useEffect(() => {
|
||
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
|
||
return;
|
||
}
|
||
|
||
if (typeof window.luckysheet.destroy === "function") {
|
||
window.luckysheet.destroy(containerId);
|
||
}
|
||
containerRef.current.innerHTML = "";
|
||
|
||
const sheets =
|
||
(table.snapshot?.luckysheet && Array.isArray(table.snapshot.luckysheet) && table.snapshot.luckysheet.length > 0)
|
||
? table.snapshot.luckysheet
|
||
: (createDefaultTableSnapshot(table.schema ?? DEFAULT_TABLE_SCHEMA).luckysheet ?? []);
|
||
|
||
window.luckysheet?.create?.({
|
||
container: containerId,
|
||
title: table.title ?? tableId,
|
||
lang: "zh",
|
||
showinfobar: false,
|
||
showtoolbar: false,
|
||
showsheetbar: sheets.length > 1,
|
||
showstatisticBar: false,
|
||
allowEdit: allowInlineEdit,
|
||
allowUpdate: false,
|
||
enableAddBackTop: false,
|
||
enableAddRow: false,
|
||
row: DEFAULT_TABLE_ROWS,
|
||
column: DEFAULT_TABLE_COLUMNS,
|
||
data: sheets,
|
||
pointEdit: allowInlineEdit,
|
||
pointEditZoom: window.devicePixelRatio ?? 1,
|
||
pointEditUpdate: allowInlineEdit
|
||
? () => {
|
||
debouncedPersist();
|
||
}
|
||
: undefined,
|
||
hook: allowInlineEdit
|
||
? {
|
||
updated: () => {
|
||
debouncedPersist();
|
||
},
|
||
}
|
||
: undefined,
|
||
} as any);
|
||
|
||
return () => {
|
||
if (window.luckysheet && typeof window.luckysheet.destroy === "function") {
|
||
window.luckysheet.destroy(containerId);
|
||
}
|
||
};
|
||
}, [allowInlineEdit, containerId, debouncedPersist, isLuckysheetReady, table, tableId]);
|
||
|
||
useEffect(() => {
|
||
if (!allowInlineEdit || !isLuckysheetReady) {
|
||
return;
|
||
}
|
||
const container = document.getElementById(containerId);
|
||
if (!container) {
|
||
return;
|
||
}
|
||
const handlePointerUp: EventListener = (event) => {
|
||
const target = event.target instanceof Node ? event.target : null;
|
||
if (target && !container.contains(target)) {
|
||
return;
|
||
}
|
||
tryEnterInlineEdit();
|
||
};
|
||
const events = ["pointerup", "mouseup", "touchend"] as const;
|
||
events.forEach((eventName) => container.addEventListener(eventName, handlePointerUp, true));
|
||
return () => {
|
||
events.forEach((eventName) => container.removeEventListener(eventName, handlePointerUp, true));
|
||
};
|
||
}, [allowInlineEdit, containerId, isLuckysheetReady, tryEnterInlineEdit]);
|
||
|
||
useEffect(() => {
|
||
if (!allowInlineEdit || !isLuckysheetReady) {
|
||
return;
|
||
}
|
||
const handleKeydown = (event: KeyboardEvent) => {
|
||
if (!isPrintableKey(event)) {
|
||
return;
|
||
}
|
||
tryEnterInlineEdit();
|
||
};
|
||
const handleCompositionStart = () => {
|
||
tryEnterInlineEdit();
|
||
};
|
||
window.addEventListener("keydown", handleKeydown, true);
|
||
window.addEventListener("compositionstart", handleCompositionStart, true);
|
||
return () => {
|
||
window.removeEventListener("keydown", handleKeydown, true);
|
||
window.removeEventListener("compositionstart", handleCompositionStart, true);
|
||
};
|
||
}, [allowInlineEdit, isLuckysheetReady, tryEnterInlineEdit]);
|
||
|
||
const overlayVisible = isLoading || !isLuckysheetReady;
|
||
const overlayText = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
|
||
|
||
const embedContainerStyle = embed
|
||
? { height: "360px", minHeight: "360px", width: "100%", overflow: "hidden" as const }
|
||
: undefined;
|
||
|
||
useEffect(() => {
|
||
if (!embed) return;
|
||
const prevDocOverflow = document.documentElement.style.overflow;
|
||
const prevBodyOverflow = document.body.style.overflow;
|
||
document.documentElement.style.overflow = "hidden";
|
||
document.body.style.overflow = "hidden";
|
||
return () => {
|
||
document.documentElement.style.overflow = prevDocOverflow;
|
||
document.body.style.overflow = prevBodyOverflow;
|
||
};
|
||
}, [embed]);
|
||
|
||
return (
|
||
<div
|
||
className={
|
||
embed ? "h-full w-full bg-transparent overflow-hidden" : "min-h-screen w-full bg-white"
|
||
}
|
||
style={embedContainerStyle}
|
||
>
|
||
<div
|
||
className={
|
||
embed ? "relative h-full w-full overflow-hidden" : "relative h-[calc(100vh-64px)] w-full"
|
||
}
|
||
style={embedContainerStyle}
|
||
>
|
||
<div
|
||
id={containerId}
|
||
ref={containerRef}
|
||
className="h-full w-full"
|
||
style={{ display: isLuckysheetReady && !!table && !error ? "block" : "none" }}
|
||
/>
|
||
|
||
{overlayVisible && (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/90">
|
||
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
|
||
<span className="text-sm text-gray-500">{overlayText}</span>
|
||
</div>
|
||
)}
|
||
|
||
{error && !overlayVisible && (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-white/95 text-red-500">
|
||
<span className="text-sm">{error}</span>
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-2 rounded-md border border-red-300 px-3 py-1 text-sm"
|
||
onClick={() => setReloadVersion((value) => value + 1)}
|
||
>
|
||
<RotateCw className="h-4 w-4" />
|
||
重试
|
||
</button>
|
||
</div>
|
||
)}
|
||
{allowInlineEdit && ((isSaving && showSavingHint) || saveError) && (
|
||
<div className="pointer-events-none absolute bottom-2 right-3 flex flex-col items-end text-xs">
|
||
{isSaving && showSavingHint && (
|
||
<span className="rounded-md bg-white/80 px-2 py-0.5 text-gray-500 shadow">同步中...</span>
|
||
)}
|
||
{saveError && <span className="mt-1 rounded-md bg-white/80 px-2 py-0.5 text-red-500 shadow">{saveError}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default HeadlessTableViewer;
|
||
|