chore: release 0.0.1
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
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;
|
||||
@@ -20,68 +22,34 @@ interface FullScreenTableEditorProps {
|
||||
// Luckysheet 容器的 ID
|
||||
const LUCKY_SHEET_CONTAINER_ID = "luckysheet-editor-container";
|
||||
|
||||
// Luckysheet 资源路径 (相对于 public 目录)
|
||||
const LUCKY_SHEET_RESOURCES = {
|
||||
css: [
|
||||
"/luckysheet/css/luckysheet.css",
|
||||
"/luckysheet/plugins/plugins.css",
|
||||
"/luckysheet/plugins/css/pluginsCss.css",
|
||||
"/luckysheet/assets/iconfont/iconfont.css",
|
||||
],
|
||||
js: [
|
||||
"/luckysheet/plugins/js/plugin.js",
|
||||
"/luckysheet/luckysheet.umd.js",
|
||||
],
|
||||
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 extractRowsForPreview = (luckysheetData: any, columns: Array<{ id: string }>): TableRowData[] => {
|
||||
const sheet = Array.isArray(luckysheetData) ? luckysheetData[0] : null;
|
||||
if (!sheet) return [];
|
||||
const columnIds = columns.length > 0 ? columns.map((item) => item.id) : Array.from({ length: DEFAULT_TABLE_COLUMNS }, (_, idx) => `col${idx + 1}`);
|
||||
const rows: TableRowData[] = [];
|
||||
const grid = Array.isArray(sheet.data) ? sheet.data : [];
|
||||
|
||||
const pickValue = (cell: any) => {
|
||||
if (!cell) return undefined;
|
||||
if (cell.m !== undefined && cell.m !== null) return cell.m;
|
||||
if (cell.v?.m !== undefined && cell.v?.m !== null) return cell.v.m;
|
||||
if (cell.v?.v !== undefined && cell.v?.v !== null) return cell.v.v;
|
||||
if (cell.v !== undefined && cell.v !== null && typeof cell.v !== "object") return cell.v;
|
||||
if (cell.w !== undefined && cell.w !== null) return cell.w;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
grid.forEach((row: any[], rowIndex: number) => {
|
||||
if (!Array.isArray(row)) return;
|
||||
const rowObj: TableRowData = {};
|
||||
let hasValue = false;
|
||||
columnIds.forEach((colId, colIndex) => {
|
||||
const value = pickValue(row[colIndex]);
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
rowObj[colId] = value;
|
||||
hasValue = true;
|
||||
}
|
||||
});
|
||||
if (hasValue) {
|
||||
rows.push(rowObj);
|
||||
}
|
||||
});
|
||||
|
||||
if (rows.length === 0 && Array.isArray(sheet.celldata)) {
|
||||
const map = new Map<number, TableRowData>();
|
||||
sheet.celldata.forEach((cell: { r: number; c: number; v: unknown }) => {
|
||||
const value = pickValue(cell?.v ?? cell);
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
const existing = map.get(cell.r) ?? {};
|
||||
existing[columnIds[cell.c] ?? `col${cell.c + 1}`] = value;
|
||||
map.set(cell.r, existing);
|
||||
});
|
||||
Array.from(map.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.forEach(([, row]) => rows.push(row));
|
||||
const isElementInsideEditorToolbar = (element: HTMLElement | null) => {
|
||||
if (!element) return false;
|
||||
if (element.closest(".luckysheet-wa-editor")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return rows;
|
||||
if (element.closest(".luckysheet-modal-dialog")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId, onClose }) => {
|
||||
@@ -89,7 +57,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
const isApplyingSnapshotRef = useRef(false);
|
||||
const hasInitializedRef = useRef(false);
|
||||
const lastTableIdRef = useRef<string | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const isLuckysheetReady = useLuckysheetLoader();
|
||||
const [tableData, setTableData] = useState<DocumentTable | null>(null);
|
||||
const [isTableLoading, setIsTableLoading] = useState(true);
|
||||
const [tableError, setTableError] = useState<string | null>(null);
|
||||
@@ -98,6 +66,16 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
|
||||
const persistSnapshotRef = useRef<((reason: "auto" | "close") => Promise<void>) | null>(null);
|
||||
const lastPointerDownInGridRef = useRef(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);
|
||||
@@ -127,46 +105,11 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
lastTableIdRef.current = tableId;
|
||||
}, [fetchTable, tableId]);
|
||||
|
||||
// 动态加载 Luckysheet 资源
|
||||
useEffect(() => {
|
||||
if (window.luckysheet) {
|
||||
setIsLoaded(true);
|
||||
return;
|
||||
if (tableData) {
|
||||
setIsTableLoading(false);
|
||||
}
|
||||
|
||||
const loadResource = (tag: "link" | "script", url: string) => {
|
||||
if (document.querySelector(`${tag}[href="${url}"]`) || document.querySelector(`${tag}[src="${url}"]`)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tag === "link") {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = url;
|
||||
document.head.appendChild(link);
|
||||
return true;
|
||||
} else if (tag === "script") {
|
||||
return new Promise<void>((resolve) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = url;
|
||||
script.onload = () => resolve();
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
LUCKY_SHEET_RESOURCES.css.forEach((url) => loadResource("link", url));
|
||||
|
||||
const loadJsSequentially = async () => {
|
||||
for (const url of LUCKY_SHEET_RESOURCES.js) {
|
||||
await loadResource("script", url);
|
||||
}
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
loadJsSequentially();
|
||||
}, []);
|
||||
}, [tableData]);
|
||||
|
||||
const luckysheetSheets = useMemo(() => {
|
||||
if (tableData?.snapshot?.luckysheet && Array.isArray(tableData.snapshot.luckysheet) && tableData.snapshot.luckysheet.length > 0) {
|
||||
@@ -176,6 +119,19 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
return snapshot.luckysheet ?? [];
|
||||
}, [tableData]);
|
||||
|
||||
const normalizedSheets = useMemo(() => {
|
||||
return luckysheetSheets.map((sheet) => ({
|
||||
...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;
|
||||
@@ -193,12 +149,11 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
rows,
|
||||
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : luckysheetSheets,
|
||||
};
|
||||
const updated = await saveOnlineTable(tableId, {
|
||||
await saveOnlineTable(tableId, {
|
||||
snapshot,
|
||||
rows,
|
||||
schema: tableData.schema,
|
||||
});
|
||||
setTableData(updated);
|
||||
setHasPendingChanges(false);
|
||||
setLastSyncedAt(Date.now());
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
|
||||
@@ -225,9 +180,166 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
};
|
||||
}, [debouncedPersist]);
|
||||
|
||||
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(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const editor = document.getElementById("luckysheet-rich-text-editor");
|
||||
if (editor && typeof editor.focus === "function") {
|
||||
editor.focus();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
luckysheetInstance.enterEditMode();
|
||||
focusLuckysheetEditor();
|
||||
}, 0);
|
||||
},
|
||||
[focusLuckysheetEditor, isSingleCellSelection],
|
||||
);
|
||||
|
||||
// Luckysheet 初始化和清理
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !tableData || !containerRef.current || !window.luckysheet) {
|
||||
if (!isLuckysheetReady || !tableData || !containerRef.current || !window.luckysheet) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -243,6 +355,8 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
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,
|
||||
@@ -254,17 +368,64 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
allowEdit: true,
|
||||
row: DEFAULT_TABLE_ROWS,
|
||||
column: DEFAULT_TABLE_COLUMNS,
|
||||
data: luckysheetSheets,
|
||||
data: normalizedSheets,
|
||||
allowUpdate: false,
|
||||
gridKey,
|
||||
loadUrl,
|
||||
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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
window.luckysheet.create(options);
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[FullScreenTableEditor] init luckysheet", { tableId, sheets: options.data });
|
||||
}
|
||||
try {
|
||||
window.luckysheet.create(options);
|
||||
} catch (error) {
|
||||
console.error("Luckysheet 初始化失败", error);
|
||||
setTableError("Luckysheet 初始化失败,请重试");
|
||||
isApplyingSnapshotRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 等待首帧渲染完成再开放 updated 事件
|
||||
setTimeout(() => {
|
||||
@@ -275,16 +436,20 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
if (window.luckysheet) {
|
||||
window.luckysheet.destroy(LUCKY_SHEET_CONTAINER_ID);
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = "";
|
||||
}
|
||||
hasInitializedRef.current = false;
|
||||
};
|
||||
}, [debouncedPersist, luckysheetSheets, isLoaded, tableData, tableId]);
|
||||
}, [debouncedPersist, normalizedSheets, isLuckysheetReady, tableData, tableId, focusLuckysheetEditor, tryEnterSingleClickEdit]);
|
||||
|
||||
const handleClose = async () => {
|
||||
await persistSnapshot("close");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const showLoadingOverlay = !isLoaded || isTableLoading;
|
||||
const loadingMessage = !isLoaded ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
|
||||
const showLoadingOverlay = !isLuckysheetReady || isTableLoading;
|
||||
const loadingMessage = !isLuckysheetReady ? "正在加载 Luckysheet 资源..." : "正在加载表格数据...";
|
||||
|
||||
const statusText = saveError
|
||||
? saveError
|
||||
@@ -325,7 +490,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
id={LUCKY_SHEET_CONTAINER_ID}
|
||||
ref={containerRef}
|
||||
className="flex-grow w-full h-full"
|
||||
style={{ display: isLoaded && !isTableLoading && !tableError ? "block" : "none" }}
|
||||
style={{ display: isLuckysheetReady && !isTableLoading && !tableError ? "block" : "none" }}
|
||||
/>
|
||||
|
||||
{showLoadingOverlay && (
|
||||
|
||||
Reference in New Issue
Block a user