Files
mnote/wolai-frontend/src/components/online-table/HeadlessTableViewer.tsx
T
2026-04-13 19:21:42 +08:00

452 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;