Files
mnote/wolai-frontend/src/components/editor/blocknote-editor.tsx
T
lix-2026 b33ffb99e7 feat: 收口文档桥接与 OnlyOffice/Sidebar 回归
- 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器

- 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线

- 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
2026-04-15 03:06:29 +08:00

1352 lines
47 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 "@blocknote/core/style.css";
import "@blocknote/react/style.css";
import "@blocknote/mantine/style.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BlockNoteView } from "@blocknote/mantine";
import {
SideMenuController,
useCreateBlockNote,
type SideMenuProps,
} from "@blocknote/react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as Y from "yjs";
import type { Block } from "@blocknote/core";
import { cn } from "@/lib/utils";
import type { Json } from "@/types/supabase";
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
import type { MediaAsset } from "@/types/media";
import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu";
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import type { ReferenceTarget } from "@/types/search";
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
import { clamp, MIN_BLOCK_LEVEL, MAX_BLOCK_LEVEL } from "@/lib/constants";
import { ASSETS_CHANGED_EVENT, ASSETS_RESTORED_EVENT, emitAssetsChanged } from "@/lib/events";
import { deleteOnlineTable } from "@/lib/online-table";
import { useAppPreferencesStore } from "@/store/app-preferences";
import { useCommentsUiStore } from "@/store/comments-ui";
import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
interface BlockNoteEditorProps {
documentId: string;
workspaceId: string;
initialContent: unknown;
initialRevision?: number | null;
initialConflictDetectionKey?: string | null;
pageOptions: PageOptionsState;
readOnly?: boolean;
onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
onCloseToc?: () => void;
onPersistedMetaChange?: (payload: {
revision: number | null;
conflictDetectionKey: string | null;
}) => void;
}
const extractInitialBlocks = (content: unknown): Json | undefined => {
if (Array.isArray(content) && content.length > 0) {
return content as Json;
}
if (content && typeof content === "object") {
const maybeBlocks = (content as Record<string, unknown>).blocks;
if (Array.isArray(maybeBlocks) && maybeBlocks.length > 0) {
return maybeBlocks as Json;
}
}
return undefined;
};
const extractInlineText = (block: Block<CustomBlockSchema>): string => {
const inlineNodes = (block.content ?? []) as Array<{ text?: string }>;
return inlineNodes.map((node) => (typeof node.text === "string" ? node.text : "")).join("").trim();
};
const buildHeadingToc = (blocks: Block<CustomBlockSchema>[]): TocEntry[] => {
const counters = [0, 0, 0, 0, 0];
const entries: TocEntry[] = [];
const walk = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (block.type === "heading") {
const level = clamp(Number(block.props.level) || 1, MIN_BLOCK_LEVEL, MAX_BLOCK_LEVEL);
counters[level - 1] += 1;
for (let i = level; i < counters.length; i += 1) {
counters[i] = 0;
}
const numbering = counters.slice(0, level).filter((value) => value > 0).join(".");
entries.push({
id: block.id,
level,
numbering,
title: extractInlineText(block),
});
}
if (block.children && block.children.length > 0) {
walk(block.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
return entries;
};
const findBlockById = (
blocks: Block<CustomBlockSchema>[],
id: string,
): Block<CustomBlockSchema> | undefined => {
for (const block of blocks) {
if (block.id === id) return block;
if (block.children && block.children.length > 0) {
const child = findBlockById(block.children as Block<CustomBlockSchema>[], id);
if (child) return child;
}
}
return undefined;
};
const syncProgressMeters = (editorInstance: ReturnType<typeof useCreateBlockNote>) => {
if (!editorInstance) {
return;
}
const blocks = editorInstance.topLevelBlocks as Block<CustomBlockSchema>[];
const progressStats = new Map<
string,
{ done: number; doing: number; total: number }
>();
let activeProgressId: string | null = null;
const traverse = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (block.type === "progressMeter" && block.props.auto) {
activeProgressId = block.id;
progressStats.set(block.id, { done: 0, doing: 0, total: 0 });
} else if (block.type === "progressMeter" && !block.props.auto) {
activeProgressId = null;
} else if (block.type === "heading") {
activeProgressId = null;
} else if (block.type === "advancedTodo" && activeProgressId) {
const currentStat = progressStats.get(activeProgressId);
if (!currentStat) return;
if (block.props.status === "cancelled") {
return;
}
currentStat.total += 1;
if (block.props.status === "done") {
currentStat.done += 1;
} else if (block.props.status === "doing") {
currentStat.doing += 1;
}
}
if (block.children && block.children.length > 0) {
traverse(block.children as Block<CustomBlockSchema>[]);
}
});
};
traverse(blocks);
progressStats.forEach((stat, progressId) => {
const block = findBlockById(blocks, progressId);
if (!block) return;
if (block.type !== "progressMeter") return;
const weightedDone = stat.done + stat.doing * 0.5;
const percent = stat.total === 0 ? 0 : Math.min(100, Math.round((weightedDone / stat.total) * 100));
const summary = stat.total === 0 ? "暂无条目" : `${stat.done}/${stat.total} 完成`;
if (block.props.percent !== percent || block.props.summary !== summary) {
editorInstance.updateBlock(block, {
props: {
percent,
summary,
},
});
}
});
};
export function BlockNoteEditor({
documentId,
workspaceId,
initialContent,
initialRevision = null,
initialConflictDetectionKey = null,
pageOptions,
readOnly = false,
onStatsChange,
onSnapshot,
onCloseToc,
onPersistedMetaChange,
}: BlockNoteEditorProps) {
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
const isFullScreenTableOpen = fullScreenTableId !== null;
const revisionRef = useRef<number | null>(initialRevision);
const conflictDetectionKeyRef = useRef<string | null>(initialConflictDetectionKey);
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
const showStructure = useAppPreferencesStore((s) => s.showStructure);
const openCommentsForBlock = useCommentsUiStore((s) => s.openForBlock);
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
const { isAuthenticated } = useConvexAuth();
const threads = useQuery(
api.comments.listThreadsByDocument,
isAuthenticated && documentId ? { documentId, includeResolved: false } : "skip",
);
const unresolvedCommentCountByBlockId = useMemo(() => {
const map: Record<string, number> = {};
if (!Array.isArray(threads)) return map;
for (const t of threads as any[]) {
const bid = String(t?.blockId ?? "");
if (!bid) continue;
map[bid] = (map[bid] ?? 0) + 1;
}
return map;
}, [threads]);
const normalizedInitialContent = useMemo(
() => extractInitialBlocks(initialContent),
[initialContent],
);
const collaboration = useMemo(() => {
const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL;
if (!url) return null;
const doc = new Y.Doc();
const provider = new HocuspocusProvider({
url,
name: `document.${documentId}`,
document: doc,
});
return { doc, provider };
}, [documentId]);
const editor = useCreateBlockNote(
{
initialContent: normalizedInitialContent as never,
schema: customBlockSchema,
placeholders: {
default: "输入'/'选择,按 空格 打开AI...",
emptyDocument: "输入'/'选择,按 空格 打开AI...",
},
collaboration: collaboration
? {
provider: collaboration.provider,
fragment: collaboration.doc.getXmlFragment("wolai"),
user: {
name: "访客",
color: "#2563eb",
},
}
: undefined,
},
[documentId, normalizedInitialContent],
);
useEffect(
() => () => {
collaboration?.provider.destroy();
collaboration?.doc.destroy();
},
[collaboration],
);
useEffect(() => {
revisionRef.current = initialRevision;
}, [initialRevision]);
useEffect(() => {
conflictDetectionKeyRef.current = initialConflictDetectionKey;
}, [initialConflictDetectionKey]);
const saveContent = useCallback(
async (content: Json) => {
setIsSaving(true);
try {
setSaveError(null);
const blockCount = Array.isArray(content) ? content.length : null;
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
buildDocumentSavePayload({
documentId,
workspaceId,
revision: revisionRef.current,
content,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount,
}),
),
});
if (!response.ok) {
let message = "保存失败";
try {
const payload = await response.json();
if (payload && typeof payload === "object" && typeof payload.error === "string") {
message = payload.error;
}
} catch {
// ignore
}
setSaveError(message);
throw new Error(message);
}
const payload = await response.json() as {
revision?: number | null;
conflictDetectionKey?: string | null;
};
const nextRevision =
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
const nextConflictDetectionKey =
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
revisionRef.current = nextRevision ?? null;
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
onPersistedMetaChange?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
});
setSaveError(null);
} catch (error) {
if (error instanceof Error) {
setSaveError(error.message);
}
} finally {
setIsSaving(false);
}
},
[documentId, onPersistedMetaChange, workspaceId],
);
const debouncedSave = useDebouncedCallback(saveContent, 800);
const previousAssetsRef = useRef<Set<string>>(new Set());
const previousMindmapBlockIdsRef = useRef<Set<string>>(new Set());
const previousOnlineTableIdsRef = useRef<Set<string>>(new Set());
const onlineTableDeleteTimestampsRef = useRef<Map<string, number>>(new Map());
const onlineTableRestoreRetryCountRef = useRef<Map<string, number>>(new Map());
const onlineTableRestoreRetryTimerRef = useRef<Map<string, number>>(new Map());
const onlineTableRestoringRef = useRef<Set<string>>(new Set());
const clearMindmapAutosaveCache = useCallback((targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
const prefix = "wolai-mindmap-autosave-";
const targetPrefix = `${prefix}${targetDocumentId}`;
const directKey = mindmapId ? `${targetPrefix}:${mindmapId}` : targetPrefix;
const keys: string[] = [];
for (let i = 0; i < window.localStorage.length; i += 1) {
const k = window.localStorage.key(i);
if (!k) continue;
if (mindmapId) {
if (k === directKey) keys.push(k);
} else if (k === targetPrefix || k.startsWith(`${targetPrefix}:`)) {
keys.push(k);
}
}
keys.forEach((k) => window.localStorage.removeItem(k));
} catch {
// ignore
}
}, []);
const markMindmapDeleting = useCallback((targetDocumentId: string, mindmapId?: string) => {
if (typeof window === "undefined") return;
try {
const w = window as unknown as {
__wolaiMindmapDeletingKeys?: Set<string>;
};
if (!w.__wolaiMindmapDeletingKeys) {
w.__wolaiMindmapDeletingKeys = new Set<string>();
}
const key = mindmapId ? `${targetDocumentId}:${mindmapId}` : targetDocumentId;
w.__wolaiMindmapDeletingKeys.add(key);
window.setTimeout(() => {
try {
w.__wolaiMindmapDeletingKeys?.delete(key);
} catch {
// ignore
}
}, 8000);
} catch {
// ignore
}
}, []);
const collectAssets = useCallback((blocks: Block<CustomBlockSchema>[]) => {
const assetIds = new Set<string>();
const mindmapBlockIds = new Set<string>();
const onlineTableIds = new Set<string>();
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (b.type === "media") {
const id = (b.props as { assetId?: string })?.assetId;
if (id) assetIds.add(id);
}
if (b.type === "mindmap") {
mindmapBlockIds.add(b.id);
}
if (b.type === "onlineTable") {
const id = (b.props as { tableId?: string })?.tableId;
if (id) onlineTableIds.add(id);
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
return { assetIds, mindmapBlockIds, onlineTableIds };
}, []);
const deleteAssets = useCallback(
async (assetIds: string[]) => {
if (assetIds.length === 0) return;
const resp = await fetch("/api/media/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", assetIds }),
});
if (!resp.ok) {
// 如果后端返回未找到,说明已被其他端删除,忽略即可
if (resp.status !== 404) {
console.error("删除附件失败", await resp.text());
}
return;
}
emitAssetsChanged(documentId);
},
[documentId],
);
const deleteMindmapAssets = useCallback(
async (mindmapIds: string[]) => {
if (mindmapIds.length === 0) return;
// 关键:当用户在编辑器里“直接删除 mindmap 块”(例如 Backspace/原生删除)时,
// React 会先卸载 MindmapBlock;若此时未及时标记“删除中”,MindmapBlock 的卸载清理会
// 把最后一次数据 POST 回 /api/mindmap/...,导致侧边栏的 mindmap 文件看起来没有被同步删除。
// 因此这里必须在发起 DELETE 前先标记并清理 autosave,确保卸载清理跳过持久化写回。
const unique = Array.from(new Set(mindmapIds)).filter((id) => typeof id === "string" && id);
unique.forEach((mindmapId) => {
markMindmapDeleting(documentId, mindmapId);
clearMindmapAutosaveCache(documentId, mindmapId);
});
await Promise.all(
mindmapIds.map(async (mindmapId) => {
try {
const resp = await fetch(`/api/mindmap/${documentId}/${mindmapId}`, { method: "DELETE" });
if (!resp.ok) {
console.error("删除思维导图失败", mindmapId, await resp.text().catch(() => ""));
}
} catch (error) {
console.error("删除思维导图失败", mindmapId, error);
} finally {
markMindmapDeleting(documentId, mindmapId);
clearMindmapAutosaveCache(documentId, mindmapId);
}
}),
);
emitAssetsChanged(documentId, undefined, undefined, false, mindmapIds);
},
[clearMindmapAutosaveCache, documentId, markMindmapDeleting],
);
const deleteOnlineTables = useCallback(
async (tableIds: string[]) => {
const unique = Array.from(new Set(tableIds)).filter((id) => typeof id === "string" && id);
if (unique.length === 0) return;
await Promise.all(
unique.map(async (tableId) => {
try {
await deleteOnlineTable(tableId);
// 通知侧边栏/其它视图:立即从文件树移除,并触发订阅更新
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
emitAssetsChanged(documentId);
}
} catch (error) {
console.error("删除在线表格失败", tableId, error);
}
}),
);
},
[documentId],
);
useEffect(() => {
return () => {
// 清理 restore 重试计时器,避免页面卸载后继续触发网络请求
onlineTableRestoreRetryTimerRef.current.forEach((timerId) => {
try {
window.clearTimeout(timerId);
} catch {
// ignore
}
});
onlineTableRestoreRetryTimerRef.current.clear();
onlineTableRestoreRetryCountRef.current.clear();
onlineTableRestoringRef.current.clear();
};
}, []);
const restoreOnlineTableIfNeeded = useCallback(
async (tableId: string) => {
const ts = onlineTableDeleteTimestampsRef.current.get(tableId);
if (!ts) return;
// 仅对“最近删除”的表格做恢复(用于 Ctrl+Z / Undo),避免首次加载时误触发 restore
if (Date.now() - ts > 10 * 60 * 1000) {
onlineTableDeleteTimestampsRef.current.delete(tableId);
onlineTableRestoreRetryCountRef.current.delete(tableId);
const pendingTimer = onlineTableRestoreRetryTimerRef.current.get(tableId);
if (pendingTimer) {
try {
window.clearTimeout(pendingTimer);
} catch {
// ignore
}
onlineTableRestoreRetryTimerRef.current.delete(tableId);
}
return;
}
if (onlineTableRestoringRef.current.has(tableId)) {
return;
}
onlineTableRestoringRef.current.add(tableId);
try {
const resp = await fetch("/api/tables/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tableId }),
});
if (!resp.ok) {
const prev = onlineTableRestoreRetryCountRef.current.get(tableId) ?? 0;
const next = prev + 1;
onlineTableRestoreRetryCountRef.current.set(tableId, next);
if (next <= 3 && typeof window !== "undefined" && !onlineTableRestoreRetryTimerRef.current.has(tableId)) {
const timerId = window.setTimeout(() => {
onlineTableRestoreRetryTimerRef.current.delete(tableId);
void restoreOnlineTableIfNeeded(tableId);
}, 600 * next);
onlineTableRestoreRetryTimerRef.current.set(tableId, timerId);
}
return;
}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
emitAssetsChanged(documentId);
}
onlineTableDeleteTimestampsRef.current.delete(tableId);
onlineTableRestoreRetryCountRef.current.delete(tableId);
} catch (error) {
console.error("恢复在线表格失败", tableId, error);
const prev = onlineTableRestoreRetryCountRef.current.get(tableId) ?? 0;
const next = prev + 1;
onlineTableRestoreRetryCountRef.current.set(tableId, next);
if (next <= 3 && typeof window !== "undefined" && !onlineTableRestoreRetryTimerRef.current.has(tableId)) {
const timerId = window.setTimeout(() => {
onlineTableRestoreRetryTimerRef.current.delete(tableId);
void restoreOnlineTableIfNeeded(tableId);
}, 600 * next);
onlineTableRestoreRetryTimerRef.current.set(tableId, timerId);
}
} finally {
onlineTableRestoringRef.current.delete(tableId);
}
},
[documentId],
);
// 监听侧边栏删除事件,主动移除编辑区遗留块
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent)?.detail as {
docId?: string;
assetIds?: string[];
mindmapDeleted?: boolean;
mindmapAssetIds?: string[];
};
if (!detail || detail.docId !== documentId) return;
const assetIds = detail.assetIds ?? [];
const mindmapDeleted = Boolean(detail.mindmapDeleted);
const mindmapAssetIds = Array.isArray(detail.mindmapAssetIds)
? (detail.mindmapAssetIds.filter((id) => typeof id === "string") as string[])
: [];
if (assetIds.length === 0 && !mindmapDeleted && mindmapAssetIds.length === 0) return;
if (mindmapDeleted || mindmapAssetIds.length > 0) {
// 标记“删除中”,避免 MindmapBlock 卸载清理里把 autosave 写回导致“复活”
if (mindmapAssetIds.length > 0) {
mindmapAssetIds.forEach((id) => markMindmapDeleting(documentId, id));
} else {
markMindmapDeleting(documentId);
}
// 双保险:等卸载完成后再清一次 autosave,避免删除后再次插入出现旧内容(重复)
window.setTimeout(() => {
if (mindmapAssetIds.length > 0) {
mindmapAssetIds.forEach((id) => clearMindmapAutosaveCache(documentId, id));
} else {
clearMindmapAutosaveCache(documentId);
}
}, 0);
}
const blocks = editor?.topLevelBlocks as Block<CustomBlockSchema>[] | undefined;
if (!blocks || blocks.length === 0 || !editor) return;
const toRemove: string[] = [];
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (b.type === "mindmap") {
if (mindmapDeleted) {
toRemove.push(b.id);
} else if (mindmapAssetIds.length > 0 && mindmapAssetIds.includes(b.id)) {
toRemove.push(b.id);
}
}
if (assetIds.length > 0 && b.type === "media") {
const id = (b.props as { assetId?: string })?.assetId;
if (id && assetIds.includes(id)) {
toRemove.push(b.id);
}
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
if (toRemove.length > 0) {
try {
editor.removeBlocks(toRemove);
} catch {
// ignore:可能已被其它链路先行删除(例如块菜单/工具栏触发的 removeBlocks
}
}
};
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
return () => window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
}, [clearMindmapAutosaveCache, documentId, editor, markMindmapDeleting]);
// 监听在线表格删除事件(文件树/其它页面触发),主动移除编辑区的 onlineTable 块,并关闭全屏窗口
useEffect(() => {
if (!editor) {
return;
}
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
if (!tableId) return;
onlineTableDeleteTimestampsRef.current.set(tableId, Date.now());
const blocks = editor?.topLevelBlocks as Block<CustomBlockSchema>[] | undefined;
if (!blocks || blocks.length === 0) return;
const toRemove: string[] = [];
const walk = (target: Block<CustomBlockSchema>[]) => {
target.forEach((b) => {
if (b.type === "onlineTable") {
const id = (b.props as { tableId?: string })?.tableId;
if (id && id === tableId) {
toRemove.push(b.id);
}
}
if (Array.isArray(b.children) && b.children.length > 0) {
walk(b.children as Block<CustomBlockSchema>[]);
}
});
};
walk(blocks);
if (toRemove.length > 0) {
try {
editor.removeBlocks(toRemove);
} catch {
// ignore
}
}
setFullScreenTableId((prev) => (prev === tableId ? null : prev));
};
window.addEventListener("online-table-deleted", handler as EventListener);
return () => window.removeEventListener("online-table-deleted", handler as EventListener);
}, [editor]);
useEffect(() => {
if (!editor) {
return undefined;
}
let disposed = false;
const runSync = () => {
if (disposed) {
return;
}
const blocks = editor.topLevelBlocks;
debouncedSave(blocks as Json);
const typedBlocks = blocks as Block<CustomBlockSchema>[];
setTocEntries(buildHeadingToc(typedBlocks));
syncProgressMeters(editor);
const stats = computeDocumentStats(typedBlocks);
onStatsChange?.(stats);
onSnapshot?.({ blocks: blocks as Json, stats });
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
const { assetIds, mindmapBlockIds, onlineTableIds } = collectAssets(typedBlocks);
const prevAssets = previousAssetsRef.current;
const removedAssets = [...prevAssets].filter((id) => !assetIds.has(id));
if (removedAssets.length > 0) {
void deleteAssets(removedAssets);
}
previousAssetsRef.current = assetIds;
const prevMindmaps = previousMindmapBlockIdsRef.current;
const removedMindmaps = [...prevMindmaps].filter((id) => !mindmapBlockIds.has(id));
if (removedMindmaps.length > 0) {
void deleteMindmapAssets(removedMindmaps);
}
previousMindmapBlockIdsRef.current = mindmapBlockIds;
const prevTables = previousOnlineTableIdsRef.current;
const removedTables = [...prevTables].filter((id) => !onlineTableIds.has(id));
if (removedTables.length > 0) {
removedTables.forEach((id) => onlineTableDeleteTimestampsRef.current.set(id, Date.now()));
void deleteOnlineTables(removedTables);
}
const addedTables = [...onlineTableIds].filter((id) => !prevTables.has(id));
if (addedTables.length > 0) {
addedTables.forEach((id) => void restoreOnlineTableIfNeeded(id));
}
previousOnlineTableIdsRef.current = onlineTableIds;
};
runSync();
const unsubscribe = editor.onEditorContentChange(runSync) as unknown as
| undefined
| (() => void);
return () => {
disposed = true;
unsubscribe?.();
};
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, onSnapshot, onStatsChange, restoreOnlineTableIfNeeded]);
const jumpToHeading = useCallback((headingId: string) => {
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, []);
const editorWrapperClass = cn(
"relative min-h-[60vh] rounded-2xl border border-transparent bg-white p-4 shadow-sm",
pageOptions.smallText ? "text-[15px]" : "text-[16px]",
);
const blocknoteClass = cn(
"wolai-editor min-h-full",
(showStructure || pageOptions.showStructure) && "wolai-editor-show-structure",
pageOptions.showHeadingNumbers && "wolai-heading-numbering",
isFullScreenTableOpen && "pointer-events-none select-none",
);
const buildDocumentPath = (documentId: string): string => {
if (typeof window === "undefined" || !window.location) {
return `/documents/${documentId}`;
}
return `${window.location.origin}/documents/${documentId}`;
};
const trimTrailingCharacter = (
editorInstance: ReturnType<typeof useCreateBlockNote> | null,
block: Block<CustomBlockSchema>,
char: string,
) => {
if (!editorInstance) {
return;
}
const content = (Array.isArray(block.content) ? [...block.content] : []) as any[];
for (let index = content.length - 1; index >= 0; index -= 1) {
const node = content[index] as any;
if (typeof node?.text === "string" && node.text.endsWith(char)) {
const nextText = node.text.slice(0, -1);
if (nextText.length === 0) {
content.splice(index, 1);
} else {
content[index] = { ...(node as any), text: nextText } as any;
}
editorInstance.updateBlock(block, { content });
break;
}
}
};
const generateBlockId = () => {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `ref_${Math.random().toString(36).slice(2, 10)}`;
};
const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats => {
let characterCount = 0;
let wordCount = 0;
let todoTotal = 0;
let todoDone = 0;
const accumulate = (targetBlocks: Block<CustomBlockSchema>[]) => {
targetBlocks.forEach((block) => {
if (Array.isArray(block.content)) {
(block.content as any[]).forEach((node: any) => {
if (typeof node.text === "string") {
const text = node.text;
characterCount += text.length;
const trimmed = text.trim();
if (trimmed.length === 0) {
return;
}
const tokens = trimmed.split(/\s+/).filter(Boolean);
if (tokens.length > 1) {
wordCount += tokens.length;
} else {
wordCount += trimmed.replace(/\s+/g, "").length;
}
}
});
}
// 待办统计:
// - advancedTodo:取消不计入总数;done 计为完成
// - checkListItemBlockNote 默认块):按 checked 统计
if (block.type === "advancedTodo") {
const status = String((block.props as any)?.status ?? "todo");
if (status !== "cancelled") {
todoTotal += 1;
if (status === "done") {
todoDone += 1;
}
}
} else if (block.type === "checkListItem") {
todoTotal += 1;
if (Boolean((block.props as any)?.checked)) {
todoDone += 1;
}
}
if (block.children && block.children.length > 0) {
accumulate(block.children as Block<CustomBlockSchema>[]);
}
});
};
accumulate(blocks);
return {
wordCount,
characterCount,
blockCount: blocks.length,
todoTotal,
todoDone,
};
};
const insertMediaAssetBlock = useCallback(
(asset: MediaAsset) => {
if (!editor) {
return;
}
const topBlocks = editor.topLevelBlocks as any[];
// 避免重复插入(例如:恢复事件重复触发/多端同时恢复)
const exists = topBlocks.some((b) => {
if (b.type !== "media") return false;
const id = (b.props as { assetId?: string })?.assetId;
return Boolean(id && asset.id && String(id) === String(asset.id));
});
if (exists) {
return;
}
const fileUrl = asset.file_url ?? "";
if (!fileUrl) {
return;
}
const cursor = editor.getTextCursorPosition();
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
if (!referenceBlock) {
try {
// 兜底:部分情况下(例如删除掉最后一个块)topLevelBlocks 可能为空,先补一个段落作为插入锚点
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
const nextTopBlocks = editor.topLevelBlocks as any[];
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
} catch {
// ignore
}
if (!referenceBlock) return;
}
editor.insertBlocks(
[
{
type: "media",
props: {
fileUrl,
thumbnailUrl: asset.thumbnail_url ?? fileUrl,
assetId: asset.id,
assetType: asset.asset_type ?? "image",
fileName: asset.file_name ?? "",
fileSize: asset.file_size ?? undefined,
mimeType: asset.mime_type ?? "",
ocrStatus: asset.ocr_status ?? "idle",
documentId,
},
},
],
referenceBlock,
"after",
);
},
[documentId, editor],
);
const insertMindmapBlock = useCallback(
(args: { documentId: string; mindmapId: string }) => {
if (!editor) return;
// 仅允许插入到当前打开的页面
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
const mindmapId = String(args.mindmapId ?? "").trim();
if (!mindmapId) return;
const topBlocks = editor.topLevelBlocks as any[];
const exists = topBlocks.some((b) => b.type === "mindmap" && String(b.id) === mindmapId);
if (exists) return;
const cursor = editor.getTextCursorPosition();
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
if (!referenceBlock) {
try {
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
const nextTopBlocks = editor.topLevelBlocks as any[];
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
} catch {
// ignore
}
if (!referenceBlock) return;
}
editor.insertBlocks(
[
{
id: mindmapId,
type: "mindmap",
props: { docId: documentId },
content: [],
} as any,
],
referenceBlock,
"after",
);
},
[documentId, editor],
);
const insertOnlineTableBlock = useCallback(
(args: { documentId: string; tableId: string }) => {
if (!editor) return;
// 仅允许插入到当前打开的页面
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
const tableId = String(args.tableId ?? "").trim();
if (!tableId) return;
const topBlocks = editor.topLevelBlocks as any[];
const exists = topBlocks.some((b) => {
if (b.type !== "onlineTable") return false;
const id = (b.props as { tableId?: string })?.tableId;
return Boolean(id && String(id) === tableId);
});
if (exists) return;
const cursor = editor.getTextCursorPosition();
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
if (!referenceBlock) {
try {
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
const nextTopBlocks = editor.topLevelBlocks as any[];
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
} catch {
// ignore
}
if (!referenceBlock) return;
}
editor.insertBlocks(
[
{
type: "onlineTable",
props: { tableId, title: "未命名表格" },
content: [],
} as any,
],
referenceBlock,
"after",
);
},
[documentId, editor],
);
useEffect(() => {
if (!editor) return undefined;
const handler = (event: Event) => {
const detail = (event as CustomEvent)?.detail as
| { docId?: string; kind?: "media"; assetId?: string; asset?: MediaAsset }
| { docId?: string; kind?: "mindmap"; mindmapId?: string }
| { docId?: string; kind?: "table"; tableId?: string };
if (!detail || !detail.docId) return;
if (String(detail.docId) !== String(documentId)) return;
if ((detail as any).kind === "mindmap") {
const mindmapId = String((detail as any).mindmapId ?? "").trim();
if (!mindmapId) return;
insertMindmapBlock({ documentId: detail.docId, mindmapId });
return;
}
if ((detail as any).kind === "table") {
const tableId = String((detail as any).tableId ?? "").trim();
if (!tableId) return;
insertOnlineTableBlock({ documentId: detail.docId, tableId });
return;
}
if ((detail as any).kind === "media") {
const assetId = String((detail as any).assetId ?? "").trim();
const asset = ((detail as any).asset ?? null) as MediaAsset | null;
if (!assetId) return;
void (async () => {
// 恢复列表里的 file_url 可能为空/不可用,优先用 sign 接口拿最新可访问链接
let fileUrl = (asset?.file_url ?? "").trim();
if (!fileUrl) {
try {
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
if (res.ok) {
const payload = (await res.json().catch(() => null)) as any;
fileUrl = String(payload?.signedUrl ?? "").trim();
}
} catch {
// ignore
}
}
if (!fileUrl) return;
insertMediaAssetBlock({
...(asset ?? ({} as MediaAsset)),
id: assetId,
document_id: documentId,
file_url: fileUrl,
thumbnail_url: (asset?.thumbnail_url ?? fileUrl) as any,
} as MediaAsset);
})();
}
};
window.addEventListener(ASSETS_RESTORED_EVENT, handler);
return () => window.removeEventListener(ASSETS_RESTORED_EVENT, handler);
}, [documentId, editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock]);
const uploadClipboardMedia = useCallback(
async (file: File) => {
if (!workspaceId) {
throw new Error("缺少空间信息,无法上传文件");
}
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", documentId);
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { asset: MediaAsset };
if (!payload.asset) {
throw new Error("上传返回数据缺失");
}
emitAssetsChanged(documentId, payload.asset);
return payload.asset;
},
[documentId, workspaceId],
);
useEffect(() => {
if (!editor) {
registerEditorBridge(null);
return;
}
const bridge = {
undo: () => {
try {
editor.focus();
editor.undo();
} catch {
// ignore
}
},
redo: () => {
try {
editor.focus();
editor.redo();
} catch {
// ignore
}
},
openTableFullScreen: (tableId: string) => {
setFullScreenTableId(tableId);
},
insertMediaAsset: (asset: MediaAsset) => {
insertMediaAssetBlock(asset);
},
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
insertMindmapBlock(args);
},
insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => {
insertOnlineTableBlock(args);
},
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
const blockId = cursor?.block?.id ?? null;
const text = aliasText || target.title || "无标题";
editor.insertInlineContent([
{
type: "link",
href: buildDocumentPath(target.id),
content: text,
},
" ",
]);
return { blockId };
},
insertEmbedReference: (target: ReferenceTarget) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
const referenceBlock =
cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
const blockId = generateBlockId();
editor.insertBlocks(
[
{
id: blockId,
type: "pageReference",
props: {
pageId: target.id,
title: target.title ?? "无标题",
},
},
],
referenceBlock,
"after",
);
return { blockId };
},
replaceWithSnapshot: (payload: Json) => {
editor.focus();
const nextBlocks = Array.isArray(payload)
? payload
: Array.isArray((payload as { blocks?: Json }).blocks)
? ((payload as { blocks?: Json }).blocks as Json)
: [];
if (!Array.isArray(nextBlocks)) {
return;
}
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
},
getCursorBlockId: () => {
try {
const cursor = editor.getTextCursorPosition();
return cursor?.block?.id ?? null;
} catch {
return null;
}
},
};
registerEditorBridge(bridge);
return () => registerEditorBridge(null);
}, [editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock, registerEditorBridge]);
useEffect(() => {
if (!editor) return;
if (!workspaceId) return;
const onKeyDown = (event: KeyboardEvent) => {
const ctrlOrMeta = event.ctrlKey || event.metaKey;
if (!ctrlOrMeta) return;
if (!event.altKey) return;
const key = String(event.key ?? "").toLowerCase();
if (key !== "m") return;
event.preventDefault();
const cursor = editor.getTextCursorPosition();
const blockId = cursor?.block?.id ?? null;
if (blockId) {
openCommentsForBlock({ workspaceId, documentId, blockId });
} else {
openCommentsForPage({ workspaceId, documentId });
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [documentId, editor, openCommentsForBlock, openCommentsForPage, workspaceId]);
useEffect(() => {
if (!editor) {
return undefined;
}
// 说明:BlockNote 的 contenteditable 节点不直接暴露 spellcheck props。
// 这里用 DOM 属性实现“全局选项:拼写检查”。
try {
const root = document.querySelector<HTMLElement>(".wolai-editor");
if (root) {
root.setAttribute("spellcheck", spellCheck ? "true" : "false");
root.querySelectorAll<HTMLElement>("[contenteditable]").forEach((el) => {
el.setAttribute("spellcheck", spellCheck ? "true" : "false");
});
}
} catch {
// ignore
}
const handlePaste = (event: ClipboardEvent) => {
const activeElement = event.target;
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
return;
}
const items = Array.from(event.clipboardData?.files ?? []);
if (items.length === 0) {
return;
}
const imageFile = items.find((candidate) => candidate.type?.startsWith("image/"));
if (!imageFile) {
return;
}
event.preventDefault();
void (async () => {
try {
const asset = await uploadClipboardMedia(imageFile);
insertMediaAssetBlock(asset);
} catch (error) {
console.error(error);
window.alert((error as Error).message ?? "粘贴图片失败,请稍后重试");
}
})();
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [editor, insertMediaAssetBlock, spellCheck, uploadClipboardMedia]);
useEffect(() => {
if (!editor) {
return;
}
const buffer = { char: "", blockId: "", timestamp: 0 };
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "[" && event.key !== "#") {
buffer.char = "";
buffer.blockId = "";
buffer.timestamp = 0;
return;
}
const activeElement = document.activeElement;
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
return;
}
const { block } = editor.getTextCursorPosition();
if (!block) {
return;
}
const now = Date.now();
if (
buffer.char === event.key &&
buffer.blockId === block.id &&
now - buffer.timestamp < 450
) {
event.preventDefault();
trimTrailingCharacter(editor, block, event.key);
openReferencePalette({
referenceMode: event.key === "[" ? "inline" : "embed",
});
buffer.char = "";
buffer.blockId = "";
buffer.timestamp = 0;
} else {
buffer.char = event.key;
buffer.blockId = block.id;
buffer.timestamp = now;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [editor, openReferencePalette]);
const layoutClass = cn(
"relative mx-auto w-full",
pageOptions.wideLayout ? "max-w-none" : "max-w-[980px]",
);
return (
<>
<div className={layoutClass}>
<div className={editorWrapperClass}>
<BlockNoteView
editor={editor}
theme="light"
slashMenu={false}
sideMenu={false}
editable={!pageOptions.protectEditing && !readOnly}
className={blocknoteClass}
>
{!isFullScreenTableOpen && (
<SideMenuController
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
<CustomSideMenu
{...props}
currentDocumentId={documentId}
workspaceId={workspaceId}
unresolvedCommentCountByBlockId={unresolvedCommentCountByBlockId}
/>
)}
/>
)}
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView>
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
{isSaving ? "保存中..." : saveError ? saveError : "已保存"}
</div>
</div>
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
</div>
<MoveEmbedPickerHost />
{/* 全屏表格编辑器 Modal */}
{fullScreenTableId && (
<FullScreenTableEditor
tableId={fullScreenTableId}
onClose={() => setFullScreenTableId(null)}
/>
)}
</>
);
}