"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Loader2, Table, X, Zap } from "lucide-react"; import type { DocumentTable, TableRowData } from "@/types/online-table"; import { DEFAULT_TABLE_COLUMNS, DEFAULT_TABLE_ROWS, DEFAULT_TABLE_SCHEMA, createDefaultTableSnapshot, saveOnlineTable, } from "@/lib/online-table"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader"; import { extractRowsForPreview } from "@/components/online-table/utils"; interface FullScreenTableEditorProps { tableId: string; onClose: () => void; } // Luckysheet 容器的 ID const LUCKY_SHEET_CONTAINER_ID = "luckysheet-editor-container"; type LuckysheetSelection = | { row?: [number, number]; column?: [number, number]; row_focus?: number; column_focus?: number; } | undefined; const ENABLE_SINGLE_CLICK_EDIT = true; 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"; }; const isElementInsideEditorToolbar = (element: HTMLElement | null) => { if (!element) return false; if (element.closest(".luckysheet-wa-editor")) { return true; } if (element.closest(".luckysheet-modal-dialog")) { return true; } return false; }; const FullScreenTableEditor: React.FC = ({ tableId, onClose }) => { const containerRef = useRef(null); const isApplyingSnapshotRef = useRef(false); const hasInitializedRef = useRef(false); const lastTableIdRef = useRef(null); const isLuckysheetReady = useLuckysheetLoader(); const [tableData, setTableData] = useState(null); const [isTableLoading, setIsTableLoading] = useState(true); const [tableError, setTableError] = useState(null); const [isSaving, setIsSaving] = useState(false); const [hasPendingChanges, setHasPendingChanges] = useState(false); const [saveError, setSaveError] = useState(null); const [lastSyncedAt, setLastSyncedAt] = useState(null); const persistSnapshotRef = useRef<((reason: "auto" | "close") => Promise) | null>(null); const lastPointerDownInGridRef = useRef(false); const savingHintTimerRef = useRef(null); const [showSavingHint, setShowSavingHint] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); const [isSavingTitle, setIsSavingTitle] = useState(false); 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(() => { if (typeof window !== "undefined") { (window as unknown as { __wolaiFullScreenState?: { isLoaded: boolean; tableReady: boolean } }).__wolaiFullScreenState = { isLoaded: isLuckysheetReady, tableReady: !!tableData, }; } }, [isLuckysheetReady, tableData]); const fetchTable = useCallback((id: string) => { setIsTableLoading(true); setTableError(null); setTableData(null); fetch(`/api/tables/${id}`) .then((response) => { if (!response.ok) { throw new Error(`Failed to load table ${id}`); } return response.json() as Promise; }) .then((data) => { setTableData(data); }) .catch((error) => { console.error(error); setTableError("无法加载表格数据,请稍后重试。"); setTableData(null); }) .finally(() => setIsTableLoading(false)); }, []); useEffect(() => { fetchTable(tableId); hasInitializedRef.current = false; lastTableIdRef.current = tableId; }, [fetchTable, tableId]); useEffect(() => { if (tableData) { setIsTableLoading(false); } }, [tableData]); useEffect(() => { if (tableData?.title !== undefined) { setRenameValue(tableData.title ?? ""); } }, [tableData?.title]); const luckysheetSheets = useMemo(() => { if (tableData?.snapshot?.luckysheet && Array.isArray(tableData.snapshot.luckysheet) && tableData.snapshot.luckysheet.length > 0) { return tableData.snapshot.luckysheet; } const snapshot = createDefaultTableSnapshot(tableData?.schema ?? DEFAULT_TABLE_SCHEMA); return snapshot.luckysheet ?? []; }, [tableData]); const normalizedSheets = useMemo(() => { return luckysheetSheets.map((sheet: any) => ({ ...sheet, celldata: Array.isArray(sheet.celldata) ? sheet.celldata : [], config: { ...(sheet.config ?? {}), rowlen: { ...(sheet.config?.rowlen ?? {}) }, columnlen: { ...(sheet.config?.columnlen ?? {}) }, merge: { ...(sheet.config?.merge ?? {}) }, }, })); }, [luckysheetSheets]); const persistSnapshot = useCallback(async (reason: "auto" | "close") => { if (!tableData || !window.luckysheet || typeof window.luckysheet.getluckysheetfile !== "function") { return; } startSavingHint(); setIsSaving(true); setSaveError(null); try { const luckysheetData = window.luckysheet.getluckysheetfile?.() ?? luckysheetSheets; const rows = extractRowsForPreview( luckysheetData, (tableData.schema?.columns ?? []).map((item) => ({ id: item.id })), ).filter((row) => row && typeof row === "object" && Object.keys(row).length > 0); const snapshot = { ...(tableData.snapshot ?? {}), rows, luckysheet: Array.isArray(luckysheetData) ? luckysheetData : luckysheetSheets, }; await saveOnlineTable(tableId, { snapshot, rows, schema: tableData.schema, }); setHasPendingChanges(false); setLastSyncedAt(Date.now()); window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } })); } catch (error) { console.error("保存 Luckysheet 数据失败", error); setSaveError(reason === "close" ? "关闭前保存失败,请重试" : "自动保存失败"); } finally { stopSavingHint(); setIsSaving(false); } }, [startSavingHint, stopSavingHint, tableData, tableId]); const debouncedPersist = useDebouncedCallback(() => { void persistSnapshot("auto"); }, 1200); useEffect(() => { persistSnapshotRef.current = persistSnapshot; }, [persistSnapshot]); useEffect(() => { return () => { debouncedPersist.cancel(); void persistSnapshotRef.current?.("close"); stopSavingHint(); }; }, [debouncedPersist, stopSavingHint]); useEffect(() => { if (!isLuckysheetReady) { lastPointerDownInGridRef.current = false; return; } const container = document.getElementById(LUCKY_SHEET_CONTAINER_ID); if (!container) { return; } const handlePointerDownInside = () => { lastPointerDownInGridRef.current = true; }; const handlePointerDownDocument = (event: PointerEvent) => { if (!(event.target instanceof Node)) { return; } if (!container.contains(event.target)) { lastPointerDownInGridRef.current = false; } }; container.addEventListener("pointerdown", handlePointerDownInside); document.addEventListener("pointerdown", handlePointerDownDocument); return () => { container.removeEventListener("pointerdown", handlePointerDownInside); document.removeEventListener("pointerdown", handlePointerDownDocument); }; }, [isLuckysheetReady]); 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 shouldAutoFocusEditor = useCallback( (target: EventTarget | null) => { if (!isLuckysheetReady || !tableData) { return false; } const container = document.getElementById(LUCKY_SHEET_CONTAINER_ID); if (!container) { return false; } const editor = document.getElementById("luckysheet-rich-text-editor"); if (!editor || document.activeElement === editor) { return false; } const targetElement = target instanceof HTMLElement ? target : null; if (isElementInsideEditorToolbar(targetElement)) { return false; } const activeElement = document.activeElement as HTMLElement | null; if (isElementInsideEditorToolbar(activeElement)) { return false; } if (targetElement && container.contains(targetElement)) { return true; } if (activeElement && container.contains(activeElement)) { return true; } return lastPointerDownInGridRef.current; }, [isLuckysheetReady, tableData], ); const ensureInlineEditor = useCallback( (target: EventTarget | null) => { if (!shouldAutoFocusEditor(target)) { return false; } if (isApplyingSnapshotRef.current) { return false; } if (!window.luckysheet || typeof window.luckysheet.enterEditMode !== "function") { return false; } window.luckysheet.enterEditMode(); focusLuckysheetEditor(); return true; }, [focusLuckysheetEditor, shouldAutoFocusEditor], ); useEffect(() => { if (!isLuckysheetReady) { return; } const handleKeydown = (event: KeyboardEvent) => { if (!isPrintableKey(event)) { return; } ensureInlineEditor(event.target); }; const handleCompositionStart = (event: CompositionEvent) => { ensureInlineEditor(event.target); }; window.addEventListener("keydown", handleKeydown, true); window.addEventListener("compositionstart", handleCompositionStart, true); return () => { window.removeEventListener("keydown", handleKeydown, true); window.removeEventListener("compositionstart", handleCompositionStart, true); }; }, [ensureInlineEditor, isLuckysheetReady]); 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 tryEnterSingleClickEdit = useCallback( (range: LuckysheetSelection[] | undefined) => { if (!ENABLE_SINGLE_CLICK_EDIT) { return; } if (isApplyingSnapshotRef.current) { return; } if (typeof window === "undefined") { return; } const luckysheetInstance = window.luckysheet; if (!luckysheetInstance || typeof luckysheetInstance.enterEditMode !== "function") { return; } if (!isSingleCellSelection(range)) { return; } setTimeout(() => { const editor = document.getElementById("luckysheet-rich-text-editor"); if (editor && document.activeElement === editor && isInlineEditorVisible()) { return; } luckysheetInstance.enterEditMode?.(); focusLuckysheetEditor(); }, 0); }, [focusLuckysheetEditor, isSingleCellSelection], ); useEffect(() => { if (!isLuckysheetReady) { return; } const container = document.getElementById(LUCKY_SHEET_CONTAINER_ID); if (!container) { return; } const handlePointerUp: EventListener = (event) => { const target = event.target instanceof Node ? event.target : null; if (target && !container.contains(target)) { return; } containerRef.current?.focus(); requestAnimationFrame(() => { const selection = window.luckysheet?.getluckysheet_select_save?.(); if (!selection) { return; } const normalized = Array.isArray(selection) ? (selection as LuckysheetSelection[]) : [selection as LuckysheetSelection]; tryEnterSingleClickEdit(normalized); }); }; const events = ["pointerup", "mouseup", "touchend"] as const; events.forEach((eventName) => { container.addEventListener(eventName, handlePointerUp, true); }); return () => { events.forEach((eventName) => { container.removeEventListener(eventName, handlePointerUp, true); }); }; }, [isLuckysheetReady, tryEnterSingleClickEdit]); // Luckysheet 初始化和清理 useEffect(() => { if (!isLuckysheetReady || !tableData || !containerRef.current || !window.luckysheet) { return; } if (hasInitializedRef.current && lastTableIdRef.current === tableId) { return; } hasInitializedRef.current = true; isApplyingSnapshotRef.current = true; if (containerRef.current.children.length > 0) { window.luckysheet?.destroy?.(LUCKY_SHEET_CONTAINER_ID); containerRef.current.innerHTML = ""; } const gridKey = tableData?.grid_key ?? tableId; const loadUrl = gridKey ? `/api/luckysheet/load?gridKey=${gridKey}` : ""; const options = { container: LUCKY_SHEET_CONTAINER_ID, title: tableData.title ?? tableId, lang: "zh", showinfobar: false, showtoolbar: true, showsheetbar: true, showstatisticBar: true, allowEdit: true, row: DEFAULT_TABLE_ROWS, column: DEFAULT_TABLE_COLUMNS, data: normalizedSheets, allowUpdate: false, gridKey, loadUrl, pointEdit: true, pointEditZoom: typeof window !== "undefined" && window.devicePixelRatio ? window.devicePixelRatio : 1, uploadImage: async (file: File) => { const formData = new FormData(); formData.append("image", file); const response = await fetch("/api/luckysheet/upload-image", { method: "POST", body: formData, }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.url) { throw new Error(payload?.msg ?? "上传图片失败"); } return payload.url as string; }, imageUrlHandle: (url: string) => url, hook: { workbookCreateAfter: () => { setIsTableLoading(false); }, updated: () => { if (isApplyingSnapshotRef.current) return; setHasPendingChanges(true); debouncedPersist(); }, cellEditBefore: () => { focusLuckysheetEditor(); }, rangeSelect: (_sheet: unknown, selectedRange: LuckysheetSelection[] | LuckysheetSelection | undefined) => { const normalizedRange = Array.isArray(selectedRange) ? (selectedRange as LuckysheetSelection[]) : selectedRange ? [selectedRange] : undefined; focusLuckysheetEditor(); tryEnterSingleClickEdit(normalizedRange); }, rangeMoveAfter: (_oldRange: LuckysheetSelection[] | undefined, newRange: LuckysheetSelection[] | undefined) => { focusLuckysheetEditor(); tryEnterSingleClickEdit(newRange); }, }, }; if (process.env.NODE_ENV !== "production") { console.log("[FullScreenTableEditor] init luckysheet", { tableId, sheets: options.data }); } try { window.luckysheet?.create?.(options as any); } catch (error) { console.error("Luckysheet 初始化失败", error); setTableError("Luckysheet 初始化失败,请重试"); isApplyingSnapshotRef.current = false; return; } // 等待首帧渲染完成再开放 updated 事件 setTimeout(() => { isApplyingSnapshotRef.current = false; }, 0); return () => { if (window.luckysheet) { window.luckysheet?.destroy?.(LUCKY_SHEET_CONTAINER_ID); } if (containerRef.current) { containerRef.current.innerHTML = ""; } hasInitializedRef.current = false; }; }, [debouncedPersist, normalizedSheets, isLuckysheetReady, tableData, tableId, focusLuckysheetEditor, tryEnterSingleClickEdit]); const handleClose = async () => { await persistSnapshot("close"); onClose(); }; const showLoadingOverlay = !isLuckysheetReady || isTableLoading; const loadingMessage = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据..."; const handleRenameSubmit = useCallback(async () => { if (!tableData) { setIsRenaming(false); return; } const nextTitle = (renameValue || "").trim() || "未命名表格"; if (nextTitle === tableData.title) { setIsRenaming(false); return; } setIsSavingTitle(true); try { const updated = await saveOnlineTable(tableId, { title: nextTitle }); const finalTitle = updated.title ?? nextTitle; setTableData((prev) => (prev ? { ...prev, title: finalTitle } : prev)); window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } })); } catch (error) { console.error("重命名表格失败", error); setRenameValue(tableData.title ?? ""); } finally { setIsRenaming(false); setIsSavingTitle(false); } }, [renameValue, tableData, tableId]); const statusText = saveError ? saveError : isSaving && showSavingHint ? "同步中..." : hasPendingChanges ? "有未保存变更" : lastSyncedAt ? `已同步 ${new Date(lastSyncedAt).toLocaleTimeString()}` : "准备就绪"; return (
{/* 顶部工具栏 */}
{isRenaming ? ( setRenameValue(e.target.value)} onBlur={handleRenameSubmit} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void handleRenameSubmit(); } if (e.key === "Escape") { e.preventDefault(); setRenameValue(tableData?.title ?? ""); setIsRenaming(false); } }} /> ) : ( )} 协同模式 (单用户模式)
{statusText}
{/* Luckysheet 容器 */}
{showLoadingOverlay && (

{loadingMessage}

)} {tableError && !showLoadingOverlay && (

{tableError}

)}
); }; export default FullScreenTableEditor;