0.3.9修复onlyoffice 的打开问题

This commit is contained in:
liaibo
2026-01-26 13:19:48 +08:00
parent 0f22a3ff0b
commit d1f055f51a
12 changed files with 364 additions and 221 deletions
+38
View File
@@ -0,0 +1,38 @@
# Luckysheet 全屏首次加载异常(问题清单与修复 Checklist)
## 现象(用户反馈)
- 全屏打开在线表格时,前 1~2 次渲染不完整:Luckysheet 工具栏/公式栏缺失或布局异常;刷新后再打开才恢复正常。
- DevTools 的 Sources/页面树里看不到预期的 `/luckysheet/*` 静态资源(怀疑资源未加载或加载顺序异常)。
## 目标
- 全屏首次打开就稳定显示完整 Luckysheet UI(工具栏、公式栏、sheetbar 等)。
- 资源加载可观测(能确认 JS/CSS 确实加载完成)。
- 不破坏现有 Convex 数据读写链路(`/api/tables/*` -> Convex query/mutation)。
## Checklist(按优先级)
### A. 资源加载与初始化时序(高优先级)
- [ ] 确认 Luckysheet CSS 与 JS 都已加载完成,再允许 `create()`(避免“JS ready 但 CSS 未 ready”导致的高度/布局计算错误)。
- [ ] 首次创建后触发一次 `resize`(部分情况下 Luckysheet 需要通过 resize 重新计算 UI 布局)。
- [ ] 记录/暴露资源清单(仅 dev):便于确认当前页面是否完成注入与版本号是否一致。
### B. 全屏容器可见性与布局(高优先级)
- [ ] Luckysheet `create()` 时容器必须“已占位且可计算尺寸”(不要在 `display:none` 的容器里初始化)。
- [ ] Loading/Error 采用绝对定位遮罩,避免通过隐藏容器实现 loading(防止尺寸为 0 导致布局计算异常)。
### C. 与 Convex 数据链路的时序(中优先级)
- [ ] 表格数据(snapshot/schema/title)获取完成后再创建实例,避免多次 create/destroy 抖动。
- [ ] 保存/同步触发频率可控(debounce),避免高频 mutation 导致卡顿(尤其在外网高延迟场景)。
### D. 观测与回归(中优先级)
- [ ] 在控制台输出关键阶段日志(仅 dev):`资源加载完成` / `create` / `destroy` / `resize`
- [ ] 复现用例:首次全屏打开(冷启动)、关闭后再开、刷新后再开、嵌入 iframe 与全屏互不影响。
## 已实施修复(对应 A/B
- `wolai-frontend/src/components/online-table/useLuckysheetLoader.ts`CSS 注入改为等待 `onload`,并与 JS 一起作为 ready 条件;dev 下暴露 `window.__wolaiLuckysheetResources` 便于排查。
- `wolai-frontend/src/components/online-table/FullScreenTableEditor.tsx`:容器始终占位 + overlay 遮罩;`create()` 后触发 `resize`(含兜底调用 `luckysheet.resize?.()`)。
## 验证步骤(建议)
1. 冷启动后打开含在线表格的文档,点击“全屏编辑”,观察工具栏/公式栏是否一次成功显示。
2. 打开 DevTools -> Network / Sources,确认首次打开会请求 `/luckysheet/*`,且 CSS/JS 不报错。
3. 控制台查看 `window.__wolaiLuckysheetResources`(dev 模式)是否存在,以及资源路径版本号是否一致。
+15
View File
@@ -5,6 +5,17 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
// 说明:当前仓库存在大量历史代码与第三方/生成代码。
// 为避免 ESLint 在生产构建阶段被“风格型规则”阻塞,这里将部分规则从 error 降级为 warning。
name: "mnote-lint-overrides",
rules: {
"@typescript-eslint/no-this-alias": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-require-imports": "warn",
"react-hooks/set-state-in-effect": "warn",
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
@@ -12,6 +23,10 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// 说明:静态资源/第三方构建产物不参与 lint(否则会产生大量无意义报错)。
"public/luckysheet/**",
// 说明:Convex 生成代码不参与 lint。
"convex/_generated/**",
]),
]);
@@ -766,6 +766,7 @@ export default function OnlyOfficePage() {
effectiveFileUrl,
fileName,
fileType,
mode,
resolvedMode,
resolvedDisableCopy,
resolvedDisableDownload,
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
@@ -63,21 +63,59 @@ export function MoveEmbedPickerDialog({
excludeIds = [],
onPick,
}: MoveEmbedPickerDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-full max-w-md overflow-hidden border-none bg-white p-0 shadow-xl">
<DialogTitle className="sr-only"></DialogTitle>
{open ? (
<MoveEmbedPickerDialogBody
onOpenChange={onOpenChange}
workspaceId={workspaceId}
defaultMode={defaultMode}
modes={modes}
allowRoot={allowRoot}
excludeIds={excludeIds}
onPick={onPick}
/>
) : null}
</DialogContent>
</Dialog>
);
}
interface MoveEmbedPickerDialogBodyProps {
onOpenChange: (open: boolean) => void;
workspaceId: string | null;
defaultMode: MoveEmbedMode;
modes: MoveEmbedMode[];
allowRoot: boolean;
excludeIds: string[];
onPick: (mode: MoveEmbedMode, targetId: string | null) => void | Promise<void>;
}
function MoveEmbedPickerDialogBody({
onOpenChange,
workspaceId,
defaultMode,
modes,
allowRoot,
excludeIds,
onPick,
}: MoveEmbedPickerDialogBodyProps) {
const [mode, setMode] = useState<MoveEmbedMode>(defaultMode);
const [query, setQuery] = useState("");
const [highlighted, setHighlighted] = useState(0);
useEffect(() => {
if (!open) {
setQuery("");
setHighlighted(0);
return;
}
// 说明:每次打开对话框时,强制同步到调用方传入的默认模式(移动/嵌入)。
setMode(defaultMode);
const handleModeChange = useCallback((value: string) => {
setMode(value as MoveEmbedMode);
setQuery("");
setHighlighted(0);
}, [defaultMode, open]);
}, []);
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
setHighlighted(0);
}, []);
const payload = useMemo(() => {
if (!workspaceId) return null;
@@ -100,12 +138,12 @@ export function MoveEmbedPickerDialog({
}
return fetchSidebarData(workspaceId);
},
enabled: open && Boolean(workspaceId) && isEmptyQuery,
enabled: Boolean(workspaceId) && isEmptyQuery,
staleTime: 30_000,
gcTime: 60_000,
});
const { data, isLoading, error } = useDocumentSearch(payload, open && !isEmptyQuery);
const { data, isLoading, error } = useDocumentSearch(payload, Boolean(payload) && !isEmptyQuery);
const items = useMemo<PickerItem[]>(() => {
const excluded = new Set(excludeIds);
@@ -159,10 +197,6 @@ export function MoveEmbedPickerDialog({
return result;
}, [allowRoot, data?.recent, data?.results, excludeIds, isEmptyQuery, mode, sidebarQuery.data?.documents]);
useEffect(() => {
setHighlighted(0);
}, [mode, query, open]);
const placeholder = mode === "move" ? "移动到..." : "嵌入到...";
const handlePick = useCallback(
@@ -174,91 +208,85 @@ export function MoveEmbedPickerDialog({
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] w-full max-w-md overflow-hidden border-none bg-white p-0 shadow-xl">
<DialogTitle className="sr-only"></DialogTitle>
<div className="flex h-[520px] flex-col">
<div className="border-b border-[#eef2ff] p-4">
<Tabs value={mode} onValueChange={(v) => setMode(v as MoveEmbedMode)}>
<TabsList className="w-full">
{modes.includes("move") && (
<TabsTrigger value="move" className="flex-1">
</TabsTrigger>
)}
{modes.includes("embed") && (
<TabsTrigger value="embed" className="flex-1">
</TabsTrigger>
)}
</TabsList>
<TabsContent value={mode} className="mt-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="h-10 rounded-xl border-[#e2e8f0] pl-9 text-sm"
/>
</div>
</TabsContent>
</Tabs>
</div>
<div
className="flex-1 overflow-y-auto"
onKeyDown={(event) => {
if (!open) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlighted((prev) => Math.max(0, prev - 1));
} else if (event.key === "Enter") {
event.preventDefault();
const picked = items[highlighted];
if (!picked) return;
void handlePick(picked.id);
}
}}
>
{!workspaceId ? (
<div className="p-4 text-sm text-gray-500"> workspaceId</div>
) : (isEmptyQuery ? sidebarQuery.isLoading : isLoading) ? (
<div className="p-4 text-sm text-gray-400">...</div>
) : (isEmptyQuery ? sidebarQuery.error : error) ? (
<div className="p-4 text-sm text-red-600">{String(isEmptyQuery ? sidebarQuery.error : error)}</div>
) : items.length === 0 ? (
<div className="p-4 text-sm text-gray-400"></div>
) : (
<div className="py-2">
{items.map((item, idx) => (
<button
key={item.kind === "root" ? "root" : item.id}
type="button"
className={cn(
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
idx === highlighted && "bg-[#e3ecff]",
)}
onMouseEnter={() => setHighlighted(idx)}
onClick={() => void handlePick(item.id)}
>
<div
className="text-sm font-medium text-gray-900"
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
>
{item.title}
</div>
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
</button>
))}
</div>
<div className="flex h-[520px] flex-col">
<div className="border-b border-[#eef2ff] p-4">
<Tabs value={mode} onValueChange={handleModeChange}>
<TabsList className="w-full">
{modes.includes("move") && (
<TabsTrigger value="move" className="flex-1">
</TabsTrigger>
)}
{modes.includes("embed") && (
<TabsTrigger value="embed" className="flex-1">
</TabsTrigger>
)}
</TabsList>
<TabsContent value={mode} className="mt-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder={placeholder}
className="h-10 rounded-xl border-[#e2e8f0] pl-9 text-sm"
/>
</div>
</TabsContent>
</Tabs>
</div>
<div
className="flex-1 overflow-y-auto"
onKeyDown={(event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlighted((prev) => Math.min(items.length - 1, prev + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setHighlighted((prev) => Math.max(0, prev - 1));
} else if (event.key === "Enter") {
event.preventDefault();
const picked = items[highlighted];
if (!picked) return;
void handlePick(picked.id);
}
}}
>
{!workspaceId ? (
<div className="p-4 text-sm text-gray-500"> workspaceId</div>
) : (isEmptyQuery ? sidebarQuery.isLoading : isLoading) ? (
<div className="p-4 text-sm text-gray-400">...</div>
) : (isEmptyQuery ? sidebarQuery.error : error) ? (
<div className="p-4 text-sm text-red-600">{String(isEmptyQuery ? sidebarQuery.error : error)}</div>
) : items.length === 0 ? (
<div className="p-4 text-sm text-gray-400"></div>
) : (
<div className="py-2">
{items.map((item, idx) => (
<button
key={item.kind === "root" ? "root" : item.id}
type="button"
className={cn(
"w-full px-4 py-3 text-left transition-colors hover:bg-[#eef2ff]",
idx === highlighted && "bg-[#e3ecff]",
)}
onMouseEnter={() => setHighlighted(idx)}
onClick={() => void handlePick(item.id)}
>
<div
className="text-sm font-medium text-gray-900"
style={item.kind === "doc" ? { paddingLeft: 12 * (item.depth ?? 0) } : undefined}
>
{item.title}
</div>
{item.subtitle && <div className="mt-1 text-xs text-gray-500">{item.subtitle}</div>}
</button>
))}
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
</div>
);
}
@@ -686,7 +686,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
};
registerEditorBridge(bridge);
return () => registerEditorBridge(null);
}, [editor, registerEditorBridge]);
}, [editor, insertMediaAssetBlock, registerEditorBridge]);
useEffect(() => {
if (!editor) {
@@ -2176,47 +2176,53 @@ const MindmapBlockView = ({
};
// 处理图片删除(将 asset 移到垃圾桶)
const deleteImageAssets = async (assetIds: string[]) => {
if (!assetIds.length || !docId) return;
try {
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (response.ok) {
// 记录到已删除列表
assetIds.forEach(id => deletedAssetIdsRef.current.add(id));
// 通知文件树刷新,传递被删除的 assetIds
emitAssetsChanged(docId, undefined, assetIds);
} else {
const payload = await response.json().catch(() => null);
console.error("删除图片资源失败", payload?.error);
const deleteImageAssets = useCallback(
async (assetIds: string[]) => {
if (!assetIds.length || !docId) return;
try {
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (response.ok) {
// 记录到已删除列表
assetIds.forEach((id) => deletedAssetIdsRef.current.add(id));
// 通知文件树刷新,传递被删除的 assetIds
emitAssetsChanged(docId, undefined, assetIds);
} else {
const payload = await response.json().catch(() => null);
console.error("删除图片资源失败", payload?.error);
}
} catch (e) {
console.error("删除图片资源失败", e);
}
} catch (e) {
console.error("删除图片资源失败", e);
}
};
},
[docId],
);
// 处理图片恢复(从垃圾桶恢复)
const restoreImageAssets = async (assetIds: string[]) => {
if (!assetIds.length || !docId) return;
try {
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restore", assetIds }),
});
if (response.ok) {
// 从已删除列表移除
assetIds.forEach(id => deletedAssetIdsRef.current.delete(id));
// 通知文件树刷新,传递恢复的 assetIds(空列表表示需要重新获取)
emitAssetsChanged(docId);
const restoreImageAssets = useCallback(
async (assetIds: string[]) => {
if (!assetIds.length || !docId) return;
try {
const response = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restore", assetIds }),
});
if (response.ok) {
// 从已删除列表移除
assetIds.forEach((id) => deletedAssetIdsRef.current.delete(id));
// 通知文件树刷新,传递恢复的 assetIds(空列表表示需要重新获取)
emitAssetsChanged(docId);
}
} catch (e) {
console.error("恢复图片资源失败", e);
}
} catch (e) {
console.error("恢复图片资源失败", e);
}
};
},
[docId],
);
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
const [imgToolbarState, setImgToolbarState] = useState({
show: false,
@@ -2414,7 +2420,7 @@ const MindmapBlockView = ({
mindmap.off?.("back_forward", handleDataChange);
mindmap.off?.("node_data_change", handleDataChange);
};
}, [mindmap, docId]);
}, [deleteImageAssets, docId, mindmap, restoreImageAssets]);
// 悬浮图片位置工具条(参考 NodeImgPlacementToolbar.vue
const renderImgToolbar = () => {
@@ -174,6 +174,14 @@ export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
});
}, [targetNode]);
// 隐藏菜单
const hide = useCallback(() => {
setVisible(false);
setTargetNode(null);
setPosition({ x: -9999, y: -9999 });
requestShowRef.current = null;
}, []);
// 执行命令
const executeCommand = useCallback(
(key: string) => {
@@ -232,17 +240,9 @@ export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
hide();
},
[mindmap, targetNode]
[hide, mindmap, targetNode]
);
// 隐藏菜单
const hide = useCallback(() => {
setVisible(false);
setTargetNode(null);
setPosition({ x: -9999, y: -9999 });
requestShowRef.current = null;
}, []);
// 显示菜单 - 使用 requestAnimationFrame 确保 DOM 更新后再显示
const show = useCallback((x: number, y: number, node: MindMapNode) => {
setTargetNode(node);
@@ -2,7 +2,7 @@
import { BlockNoteEditor, Block } from "@blocknote/core";
import { createReactBlockSpec } from "@blocknote/react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import type { CustomBlockSchema } from "../schema";
import CompactTablePreview from "@/components/online-table/CompactTablePreview";
import { useEditorBridgeStore } from "@/store/editor-bridge";
@@ -53,12 +53,15 @@ const OnlineTableBlockComponent = ({
height: storedHeight ?? DEFAULT_HEIGHT,
});
useEffect(() => {
setDraftSize({
width: storedWidth ?? DEFAULT_WIDTH,
height: storedHeight ?? DEFAULT_HEIGHT,
});
}, [storedWidth, storedHeight]);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const committedSize = useMemo(
() => ({
width: clamp(storedWidth ?? DEFAULT_WIDTH, MIN_WIDTH, MAX_WIDTH),
height: clamp(storedHeight ?? DEFAULT_HEIGHT, MIN_HEIGHT, MAX_HEIGHT),
}),
[storedHeight, storedWidth],
);
const commitSize = useCallback(
(next: { width: number; height: number }) => {
@@ -71,7 +74,7 @@ const OnlineTableBlockComponent = ({
},
});
},
[block, block.props, editor],
[block, editor],
);
// 阶段三:实现双击/按钮进入全屏编辑
@@ -87,20 +90,19 @@ const OnlineTableBlockComponent = ({
editor.removeBlocks([block.id]);
}, [block.id, editor]);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const startResize = useCallback(
(handle: ResizeHandle) => (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startY = event.clientY;
const startWidth = draftSize.width;
const startHeight = draftSize.height;
const startWidth = committedSize.width;
const startHeight = committedSize.height;
let nextWidth = startWidth;
let nextHeight = startHeight;
const axes = handleMapping[handle];
setActiveHandle(handle);
setDraftSize({ width: startWidth, height: startHeight });
document.body.style.userSelect = "none";
const cursor =
axes.horizontal && axes.vertical
@@ -155,16 +157,16 @@ const OnlineTableBlockComponent = ({
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
},
[commitSize, draftSize.height, draftSize.width],
[commitSize, committedSize.height, committedSize.width],
);
const size = useMemo(
() => ({
width: clamp(draftSize.width, MIN_WIDTH, MAX_WIDTH),
height: clamp(draftSize.height, MIN_HEIGHT, MAX_HEIGHT),
}),
[draftSize.height, draftSize.width],
);
const size = useMemo(() => {
const src = activeHandle ? draftSize : committedSize;
return {
width: clamp(src.width, MIN_WIDTH, MAX_WIDTH),
height: clamp(src.height, MIN_HEIGHT, MAX_HEIGHT),
};
}, [activeHandle, committedSize, draftSize]);
const handleClass = (handle: ResizeHandle) =>
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
@@ -17,11 +17,13 @@ const useTableData = (tableId: string) => {
const [isLoading, setIsLoading] = useState(true);
const [version, setVersion] = useState(0);
const refresh = useCallback(() => setVersion((prev) => prev + 1), []);
const refresh = useCallback(() => {
setIsLoading(true);
setVersion((prev) => prev + 1);
}, []);
useEffect(() => {
let canceled = false;
setIsLoading(true);
getDocumentTable(tableId)
.then((data) => {
if (!canceled) {
@@ -47,7 +49,7 @@ const useTableData = (tableId: string) => {
return { table, isLoading, refresh };
};
const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({
const CompactTablePreviewInner: React.FC<CompactTablePreviewProps> = ({
tableId,
onFullScreen,
onDelete,
@@ -304,4 +306,8 @@ const CompactTablePreview: React.FC<CompactTablePreviewProps> = ({
);
};
const CompactTablePreview: React.FC<CompactTablePreviewProps> = (props) => (
<CompactTablePreviewInner key={props.tableId} {...props} />
);
export default CompactTablePreview;
@@ -200,7 +200,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
stopSavingHint();
setIsSaving(false);
}
}, [startSavingHint, stopSavingHint, tableData, tableId]);
}, [luckysheetSheets, startSavingHint, stopSavingHint, tableData, tableId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot("auto");
@@ -515,17 +515,36 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
return;
}
// 首次加载时,资源/样式可能刚完成注入,强制触发一次 resize 让 Luckysheet 重新计算布局(避免工具栏/公式栏不显示)
requestAnimationFrame(() => {
try {
window.dispatchEvent(new Event("resize"));
(window.luckysheet as any)?.resize?.();
} catch {
// 忽略
}
setTimeout(() => {
try {
window.dispatchEvent(new Event("resize"));
(window.luckysheet as any)?.resize?.();
} catch {
// 忽略
}
}, 80);
});
// 等待首帧渲染完成再开放 updated 事件
setTimeout(() => {
isApplyingSnapshotRef.current = false;
}, 0);
const containerEl = containerRef.current;
return () => {
if (window.luckysheet) {
window.luckysheet?.destroy?.(LUCKY_SHEET_CONTAINER_ID);
}
if (containerRef.current) {
containerRef.current.innerHTML = "";
if (containerEl) {
containerEl.innerHTML = "";
}
hasInitializedRef.current = false;
};
@@ -627,34 +646,35 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
</div>
</header>
{/* Luckysheet 容器 */}
<div
id={LUCKY_SHEET_CONTAINER_ID}
ref={containerRef}
className="flex-grow w-full h-full"
tabIndex={-1}
aria-label="在线表格全屏编辑区域"
style={{ display: isLuckysheetReady && !isTableLoading && !tableError ? "block" : "none" }}
/>
<div className="relative flex-grow w-full">
{/* Luckysheet 容器(始终占位,避免 create 时容器不可见导致布局计算错误) */}
<div
id={LUCKY_SHEET_CONTAINER_ID}
ref={containerRef}
className="h-full w-full"
tabIndex={-1}
aria-label="在线表格全屏编辑区域"
/>
{showLoadingOverlay && (
<div className="flex justify-center items-center h-full text-gray-500 border border-dashed">
<p>{loadingMessage}</p>
</div>
)}
{showLoadingOverlay && (
<div className="absolute inset-0 flex justify-center items-center text-gray-500 bg-white/90 dark:bg-gray-900/90">
<p>{loadingMessage}</p>
</div>
)}
{tableError && !showLoadingOverlay && (
<div className="flex flex-col justify-center items-center h-full text-red-500 border border-dashed space-y-2">
<p>{tableError}</p>
<button
type="button"
className="px-3 py-1 rounded-md border border-red-300 text-sm"
onClick={() => fetchTable(tableId)}
>
</button>
</div>
)}
{tableError && !showLoadingOverlay && (
<div className="absolute inset-0 flex flex-col justify-center items-center text-red-500 bg-white/95 dark:bg-gray-900/95 space-y-2">
<p>{tableError}</p>
<button
type="button"
className="px-3 py-1 rounded-md border border-red-300 text-sm"
onClick={() => fetchTable(tableId)}
>
</button>
</div>
)}
</div>
</div>
);
};
@@ -118,7 +118,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
return () => {
canceled = true;
};
}, [tableId, reloadVersion]);
}, [computeSnapshotHash, reloadVersion, tableId]);
const allowInlineEdit = editable ?? embed;
@@ -217,7 +217,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
stopSavingHint();
setIsSaving(false);
}
}, [allowInlineEdit, startSavingHint, stopSavingHint, table, tableId]);
}, [allowInlineEdit, computeSnapshotHash, startSavingHint, stopSavingHint, table, tableId]);
const debouncedPersist = useDebouncedCallback(() => {
void persistSnapshot();
@@ -21,20 +21,45 @@ export const LUCKYSHEET_RESOURCES = {
let loaderPromise: Promise<void> | null = null;
const appendCssOnce = (href: string) => {
if (typeof document === "undefined") {
return;
}
const marker = `link[data-luckysheet-href="${href}"]`;
if (document.querySelector(marker)) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.dataset.luckysheetHref = href;
document.head.appendChild(link);
};
const appendCssOnce = (href: string) =>
new Promise<void>((resolve, reject) => {
if (typeof document === "undefined") {
resolve();
return;
}
const marker = `link[data-luckysheet-href="${href}"]`;
const existing = document.querySelector<HTMLLinkElement>(marker);
if (existing) {
if (existing.dataset.loaded === "1") {
resolve();
return;
}
const handleLoad = () => {
existing.dataset.loaded = "1";
resolve();
};
const handleError = () => {
reject(new Error(`加载 Luckysheet 样式失败: ${href}`));
};
existing.addEventListener("load", handleLoad, { once: true });
existing.addEventListener("error", handleError, { once: true });
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.dataset.luckysheetHref = href;
link.onload = () => {
link.dataset.loaded = "1";
resolve();
};
link.onerror = () => {
reject(new Error(`加载 Luckysheet 样式失败: ${href}`));
};
document.head.appendChild(link);
});
const appendScriptOnce = (src: string) =>
new Promise<void>((resolve, reject) => {
@@ -87,10 +112,16 @@ export const ensureLuckysheetLoaded = async () => {
}
loaderPromise = (async () => {
LUCKYSHEET_RESOURCES.css.forEach((href) => appendCssOnce(href));
const cssPromises = LUCKYSHEET_RESOURCES.css.map((href) => appendCssOnce(href));
for (const src of LUCKYSHEET_RESOURCES.js) {
await appendScriptOnce(src);
}
await Promise.all(cssPromises);
if (process.env.NODE_ENV !== "production") {
(window as unknown as { __wolaiLuckysheetResources?: typeof LUCKYSHEET_RESOURCES }).__wolaiLuckysheetResources = LUCKYSHEET_RESOURCES;
console.log("[LuckysheetLoader] 资源加载完成", LUCKYSHEET_RESOURCES);
}
})();
await loaderPromise;
@@ -106,10 +137,6 @@ export const useLuckysheetLoader = () => {
if (typeof window === "undefined") {
return;
}
if (window.luckysheet) {
setIsReady(true);
return;
}
ensureLuckysheetLoaded()
.then(() => {
if (!canceled) {