"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"; import { supabaseBrowser } from "@/lib/supabase/client"; 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 = ({ tableId, embed = false, editable }) => { const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]); const containerRef = useRef(null); const isLuckysheetReady = useLuckysheetLoader(); const [table, setTable] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [reloadVersion, setReloadVersion] = useState(0); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [showSavingHint, setShowSavingHint] = useState(false); const savingHintTimerRef = useRef(null); const lastRemoteSyncedAtRef = useRef(null); const lastSnapshotHashRef = useRef(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); }, []); useEffect(() => { let canceled = false; setIsLoading(true); setError(null); getDocumentTable(tableId) .then((data) => { if (!canceled) { 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; } setTable(data); } }) .catch((err) => { console.error("加载表格失败", err); if (!canceled) { setTable(null); setError("无法加载表格数据"); } }) .finally(() => { if (!canceled) { setIsLoading(false); } }); return () => { canceled = true; }; }, [tableId, reloadVersion]); 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]); 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 ?? [], }; lastSnapshotHashRef.current = computeSnapshotHash(snapshot); 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, startSavingHint, stopSavingHint, table, tableId]); const debouncedPersist = useDebouncedCallback(() => { void persistSnapshot(); }, 1200); useEffect(() => { return () => { debouncedPersist.cancel(); stopSavingHint(); }; }, [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]); 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, }); 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 = (event: PointerEvent | MouseEvent | TouchEvent) => { const target = event.target instanceof Node ? event.target : null; if (target && !container.contains(target)) { return; } tryEnterInlineEdit(); }; const events: Array = ["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]); 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 (
{overlayVisible && (
{overlayText}
)} {error && !overlayVisible && (
{error}
)} {allowInlineEdit && ((isSaving && showSavingHint) || saveError) && (
{isSaving && showSavingHint && ( 同步中... )} {saveError && {saveError}}
)}
); }; export default HeadlessTableViewer;