"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).blocks; if (Array.isArray(maybeBlocks) && maybeBlocks.length > 0) { return maybeBlocks as Json; } } return undefined; }; const extractInlineText = (block: Block): 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[]): TocEntry[] => { const counters = [0, 0, 0, 0, 0]; const entries: TocEntry[] = []; const walk = (targetBlocks: Block[]) => { 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[]); } }); }; walk(blocks); return entries; }; const findBlockById = ( blocks: Block[], id: string, ): Block | 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[], id); if (child) return child; } } return undefined; }; const syncProgressMeters = (editorInstance: ReturnType) => { if (!editorInstance) { return; } const blocks = editorInstance.topLevelBlocks as Block[]; const progressStats = new Map< string, { done: number; doing: number; total: number } >(); let activeProgressId: string | null = null; const traverse = (targetBlocks: Block[]) => { 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[]); } }); }; 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(null); const [tocEntries, setTocEntries] = useState([]); const [fullScreenTableId, setFullScreenTableId] = useState(null); const isFullScreenTableOpen = fullScreenTableId !== null; const revisionRef = useRef(initialRevision); const conflictDetectionKeyRef = useRef(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 = {}; 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>(new Set()); const previousMindmapBlockIdsRef = useRef>(new Set()); const previousOnlineTableIdsRef = useRef>(new Set()); const onlineTableDeleteTimestampsRef = useRef>(new Map()); const onlineTableRestoreRetryCountRef = useRef>(new Map()); const onlineTableRestoreRetryTimerRef = useRef>(new Map()); const onlineTableRestoringRef = useRef>(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; }; if (!w.__wolaiMindmapDeletingKeys) { w.__wolaiMindmapDeletingKeys = new Set(); } 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[]) => { const assetIds = new Set(); const mindmapBlockIds = new Set(); const onlineTableIds = new Set(); const walk = (target: Block[]) => { 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[]); } }); }; 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[] | undefined; if (!blocks || blocks.length === 0 || !editor) return; const toRemove: string[] = []; const walk = (target: Block[]) => { 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[]); } }); }; 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[] | undefined; if (!blocks || blocks.length === 0) return; const toRemove: string[] = []; const walk = (target: Block[]) => { 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[]); } }); }; 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[]; 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(`[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 | null, block: Block, 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[]): DocumentStats => { let characterCount = 0; let wordCount = 0; let todoTotal = 0; let todoDone = 0; const accumulate = (targetBlocks: Block[]) => { 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 计为完成 // - checkListItem(BlockNote 默认块):按 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[]); } }); }; 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(".wolai-editor"); if (root) { root.setAttribute("spellcheck", spellCheck ? "true" : "false"); root.querySelectorAll("[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 ( <>
{!isFullScreenTableOpen && ( ) => ( )} /> )}
{isSaving ? "保存中..." : saveError ? saveError : "已保存"}
{/* 全屏表格编辑器 Modal */} {fullScreenTableId && ( setFullScreenTableId(null)} /> )} ); }