Files
mnote/wolai-frontend/src/components/online-table/HeadlessTableViewer.tsx
T

435 lines
15 KiB
TypeScript
Raw Normal View History

2025-11-29 05:16:23 +08:00
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2, RotateCw } from "lucide-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 { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils";
2025-11-29 10:46:40 +08:00
import { supabaseBrowser } from "@/lib/supabase/client";
2025-11-29 05:16:23 +08:00
2025-11-29 09:35:36 +08:00
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";
};
2025-11-29 05:16:23 +08:00
interface HeadlessTableViewerProps {
tableId: string;
embed?: boolean;
2025-11-29 09:35:36 +08:00
editable?: boolean;
2025-11-29 05:16:23 +08:00
}
const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
2025-11-29 09:35:36 +08:00
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
2025-11-29 05:16:23 +08:00
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);
2025-11-29 10:46:40 +08:00
const [showSavingHint, setShowSavingHint] = useState(false);
const savingHintTimerRef = useRef<number | null>(null);
const lastRemoteSyncedAtRef = useRef<string | null>(null);
const lastSnapshotHashRef = useRef<string | null>(null);
const computeSnapshotHash = useCallback((snapshot: unknown) => {
try {
return JSON.stringify(snapshot ?? {});
} catch {
return null;
}
}, []);
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);
}, []);
2025-11-29 05:16:23 +08:00
useEffect(() => {
let canceled = false;
setIsLoading(true);
setError(null);
getDocumentTable(tableId)
.then((data) => {
if (!canceled) {
2025-11-29 10:46:40 +08:00
lastSnapshotHashRef.current = computeSnapshotHash(data.snapshot);
if ((data as { last_synced_at?: string }).last_synced_at) {
lastRemoteSyncedAtRef.current = (data as { last_synced_at?: string }).last_synced_at ?? null;
}
2025-11-29 05:16:23 +08:00
setTable(data);
}
})
.catch((err) => {
console.error("加载表格失败", err);
if (!canceled) {
setTable(null);
setError("无法加载表格数据");
}
})
.finally(() => {
if (!canceled) {
setIsLoading(false);
}
});
return () => {
canceled = true;
};
}, [tableId, reloadVersion]);
2025-11-29 13:37:57 +08:00
2025-11-29 09:35:36 +08:00
const allowInlineEdit = editable ?? embed;
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]);
2025-11-29 05:16:23 +08:00
const persistSnapshot = useCallback(async () => {
2025-11-29 09:35:36 +08:00
if (!allowInlineEdit || !table || !window.luckysheet || typeof window.luckysheet.getluckysheetfile !== "function") {
2025-11-29 05:16:23 +08:00
return;
}
2025-11-29 10:46:40 +08:00
startSavingHint();
2025-11-29 05:16:23 +08:00
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 ?? [],
};
2025-11-29 10:46:40 +08:00
lastSnapshotHashRef.current = computeSnapshotHash(snapshot);
2025-11-29 05:16:23 +08:00
await saveOnlineTable(tableId, {
snapshot,
rows,
schema: table.schema,
});
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
} catch (err) {
console.error("内嵌表格保存失败", err);
setSaveError("自动保存失败");
} finally {
2025-11-29 10:46:40 +08:00
stopSavingHint();
2025-11-29 05:16:23 +08:00
setIsSaving(false);
}
2025-11-29 10:46:40 +08:00
}, [allowInlineEdit, startSavingHint, stopSavingHint, table, tableId]);
2025-11-29 05:16:23 +08:00
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot();
}, 1200);
useEffect(() => {
return () => {
debouncedPersist.cancel();
2025-11-29 10:46:40 +08:00
stopSavingHint();
2025-11-29 05:16:23 +08:00
};
2025-11-29 10:46:40 +08:00
}, [debouncedPersist, stopSavingHint]);
useEffect(() => {
if (!tableId) return;
const channel = supabaseBrowser
.channel(`table-${tableId}-live`)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "public", table: "document_tables", filter: `id=eq.${tableId}` },
(payload) => {
const next = payload.new as DocumentTable | null;
if (!next) return;
const nextSynced = (next as { last_synced_at?: string }).last_synced_at ?? null;
if (nextSynced && lastRemoteSyncedAtRef.current && nextSynced <= lastRemoteSyncedAtRef.current) {
return;
}
lastRemoteSyncedAtRef.current = nextSynced;
const nextHash = computeSnapshotHash(next.snapshot);
const currentHash = lastSnapshotHashRef.current;
if (nextHash && currentHash && nextHash === currentHash) {
return; // 相同快照无需重建,避免闪烁
}
lastSnapshotHashRef.current = nextHash;
setTable(next);
},
)
.subscribe();
return () => {
supabaseBrowser.removeChannel(channel);
};
}, [tableId]);
2025-11-29 05:16:23 +08:00
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,
2025-11-29 09:35:36 +08:00
allowEdit: allowInlineEdit,
2025-11-29 05:16:23 +08:00
allowUpdate: false,
enableAddBackTop: false,
enableAddRow: false,
row: DEFAULT_TABLE_ROWS,
column: DEFAULT_TABLE_COLUMNS,
data: sheets,
2025-11-29 09:35:36 +08:00
pointEdit: allowInlineEdit,
2025-11-29 05:16:23 +08:00
pointEditZoom: window.devicePixelRatio ?? 1,
2025-11-29 09:35:36 +08:00
pointEditUpdate: allowInlineEdit
2025-11-29 05:16:23 +08:00
? () => {
debouncedPersist();
}
: undefined,
2025-11-29 09:35:36 +08:00
hook: allowInlineEdit
2025-11-29 05:16:23 +08:00
? {
updated: () => {
debouncedPersist();
},
}
: undefined,
});
return () => {
if (window.luckysheet && typeof window.luckysheet.destroy === "function") {
window.luckysheet.destroy(containerId);
}
};
2025-11-29 09:35:36 +08:00
}, [allowInlineEdit, containerId, debouncedPersist, isLuckysheetReady, table, tableId]);
useEffect(() => {
if (!allowInlineEdit || !isLuckysheetReady) {
return;
}
const container = document.getElementById(containerId);
if (!container) {
return;
}
const handlePointerUp = (event: PointerEvent | MouseEvent | TouchEvent) => {
const target = event.target instanceof Node ? event.target : null;
if (target && !container.contains(target)) {
return;
}
tryEnterInlineEdit();
};
const events: Array<keyof DocumentEventMap> = ["pointerup", "mouseup", "touchend"];
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]);
2025-11-29 05:16:23 +08:00
const overlayVisible = isLoading || !isLuckysheetReady;
const overlayText = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
2025-11-29 13:37:57 +08:00
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]);
2025-11-29 05:16:23 +08:00
return (
<div
2025-11-29 13:37:57 +08:00
className={
embed ? "h-full w-full bg-transparent overflow-hidden" : "min-h-screen w-full bg-white"
}
2025-11-29 05:16:23 +08:00
style={embedContainerStyle}
>
<div
2025-11-29 13:37:57 +08:00
className={
embed ? "relative h-full w-full overflow-hidden" : "relative h-[calc(100vh-64px)] w-full"
}
2025-11-29 05:16:23 +08:00
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>
)}
2025-11-29 10:46:40 +08:00
{allowInlineEdit && ((isSaving && showSavingHint) || saveError) && (
2025-11-29 05:16:23 +08:00
<div className="pointer-events-none absolute bottom-2 right-3 flex flex-col items-end text-xs">
2025-11-29 10:46:40 +08:00
{isSaving && showSavingHint && (
<span className="rounded-md bg-white/80 px-2 py-0.5 text-gray-500 shadow">同步中...</span>
)}
2025-11-29 05:16:23 +08:00
{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;
2025-11-29 13:37:57 +08:00