"use client"; import dynamic from "next/dynamic"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options"; import { useEditorBridgeStore } from "@/store/editor-bridge"; import { usePageLayoutStore } from "@/store/page-layout"; import { useCurrentDocumentStore } from "@/store/current-document"; import type { Json } from "@/types/supabase"; import type { DocumentSnapshot } from "@/types/document"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { ImagePickerProvider } from "@/components/media/image-picker-context"; import { useRouter } from "next/navigation"; import { DocumentAiAgentPanel } from "./DocumentAiAgentPanel"; import { CONTENT_LOADING_DELAY_MS } from "@/lib/constants"; import { useCommentsUiStore } from "@/store/comments-ui"; import { useAppPreferencesStore } from "@/store/app-preferences"; import { useMoveEmbedPickerStore } from "@/store/move-embed-picker"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { DocumentToc } from "@/components/editor/document-toc"; import { DocumentReadView } from "@/components/editor/document-read-view"; import { emitDocumentsChanged } from "@/lib/events"; import { extractPageBlocks } from "@/lib/documents/page-subtree"; import { deleteDocumentCommand, embedDocumentCommand, moveDocumentCommand, } from "@/lib/documents/tree-command-client"; import { executePageHeadCommand, executePageLayoutCommand, type PageBodyPersistedMeta, } from "@/lib/documents/page-command-client"; import { EditorHost } from "@/components/editor/editor-host"; import { DEFAULT_EDITOR_HOST_KIND, isLeptosTiptapHostKind, type EditorHostKind, } from "@/components/editor/editor-host-config"; import type { EditorHostEvent, EditorHostFallbackReason, } from "@/components/editor/editor-host-types"; import type { PageAggregateProjection } from "@/lib/documents/page-aggregate"; import { usePageHeadTitle } from "@/components/editor/use-page-head-title"; import { createPageAggregateClientState, pageAggregateClientStateReducer, selectPageAggregateClientAiSnapshot, selectPageAggregateClientPageSubtree, } from "@/components/editor/page-aggregate-client-state"; const BlockNoteEditor = dynamic( () => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor), { ssr: false, loading: () => (
编辑器加载中...
), }, ); const PageOptionsSidebar = dynamic( () => import("@/components/editor/page-options-sidebar").then((mod) => mod.PageOptionsSidebar), { ssr: false }, ); const PageBacklinksPanel = dynamic( () => import("@/components/editor/page-backlinks-panel").then((mod) => mod.PageBacklinksPanel), { ssr: false, loading: () => null, }, ); const DocumentHistoryDrawer = dynamic( () => import("@/components/editor/document-history-drawer").then((mod) => mod.DocumentHistoryDrawer), { ssr: false, loading: () => null, }, ); const MoveEmbedPickerHost = dynamic( () => import("@/components/documents/move-embed-picker-host").then((mod) => mod.MoveEmbedPickerHost), { ssr: false, loading: () => null, }, ); const DocumentCommentsDrawer = dynamic( () => import("@/components/editor/document-comments-drawer").then((mod) => mod.DocumentCommentsDrawer), { ssr: false, loading: () => null, }, ); export interface DocumentContentProps { page: PageAggregateProjection; openTableId?: string | null; editorHostKind?: EditorHostKind; } const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 }; const EDITOR_UNMOUNT_GRACE_MS = 1000; const FALLBACK_TRIGGER_HISTORY_LIMIT = 20; export async function persistPageTitleAndNotifyDocumentsChanged(input: { documentId: string; workspaceId: string; title: string; persistTitleCommand: (payload: { documentId: string; workspaceId: string; title: string }) => Promise; notifyDocumentsChanged: (documentId?: string) => void; }): Promise { const payload = input.title.trim() || "无标题"; await input.persistTitleCommand({ documentId: input.documentId, workspaceId: input.workspaceId, title: payload, }); input.notifyDocumentsChanged(input.documentId); return payload; } export function DocumentContent({ page, openTableId, editorHostKind = DEFAULT_EDITOR_HOST_KIND, }: DocumentContentProps) { const documentId = page.identity.documentId; const workspaceId = page.identity.workspaceId; const initialTitle = page.head.title; const updatedAt = page.head.updatedAt; const readOnly = page.head.permissions.readOnly; const disableDownload = page.head.permissions.disableDownload; const disableCopy = page.head.permissions.disableCopy; const initialContent = page.body.content; const initialStats = page.stats; const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent); const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch); const canEditDocument = !readOnly; const [pageClientState, dispatchPageClientState] = useReducer( pageAggregateClientStateReducer, page, createPageAggregateClientState, ); const options = pageClientState.options; const [stats, setStats] = useState(initialStats ?? defaultStats); const [history, setHistory] = useState([]); const [historyOpen, setHistoryOpen] = useState(false); const editorBridge = useEditorBridgeStore((state) => state.bridge); const router = useRouter(); const showInspector = usePageLayoutStore((state) => state.showInspector); const openCommentsForPage = useCommentsUiStore((s) => s.openForPage); const spellCheck = useAppPreferencesStore((s) => s.spellCheck); const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker); const { displayTitle: pageTitle, committedTitle: committedPageTitle, setDraftTitle: setPageTitleDraft, commitPersistedTitle, } = usePageHeadTitle({ documentId, fallbackTitle: initialTitle, }); const content = pageClientState.content; const contentRevision = pageClientState.contentRevision; const conflictDetectionKey = pageClientState.conflictDetectionKey; const [contentLoading, setContentLoading] = useState(() => initialContent == null); const [contentError, setContentError] = useState(null); const [contentReloadKey, setContentReloadKey] = useState(0); const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false); const shouldUseRuntimeHost = isLeptosTiptapHostKind(editorHostKind); const requestedHostKind = shouldUseRuntimeHost ? editorHostKind : "blocknote"; const [activeHostKind, setActiveHostKind] = useState<"blocknote" | EditorHostKind>(() => requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind, ); const [hostRuntimeLoadFailure, setHostRuntimeLoadFailure] = useState(null); const [hostInitFailure, setHostInitFailure] = useState(null); const [hostCommandFailure, setHostCommandFailure] = useState(null); const [hostSaveFailure, setHostSaveFailure] = useState(null); const [hostFallbackCount, setHostFallbackCount] = useState(0); const [lastFallbackReason, setLastFallbackReason] = useState(null); const [lastFallbackAt, setLastFallbackAt] = useState(null); const [hostStatus, setHostStatus] = useState("idle"); const [hostEventAt, setHostEventAt] = useState(null); const fallbackTriggerHistoryRef = useRef([]); const shouldStartEditing = canEditDocument; const [isEditing, setIsEditing] = useState(() => shouldStartEditing); const [keepEditorMounted, setKeepEditorMounted] = useState(() => shouldStartEditing); const contentLoadingTimerRef = useRef | null>(null); const editorUnmountTimerRef = useRef | null>(null); const pendingOpenTableRef = useRef(null); const latestBlocksRef = useRef(null); const pageRootRef = useRef(null); const readViewRootRef = useRef(null); const pendingRestoreSnapshotRef = useRef(null); const lastCopyBlockedAtRef = useRef(0); const hasRequestedFallbackRef = useRef(false); const resetHostObservability = useCallback((nextHost: "blocknote" | EditorHostKind) => { setHostStatus(nextHost === "blocknote" ? "blocknote_active" : "booting"); setHostEventAt(new Date().toISOString()); setHostRuntimeLoadFailure(null); setHostInitFailure(null); setHostCommandFailure(null); setHostSaveFailure(null); setHostFallbackCount(0); setLastFallbackReason(null); setLastFallbackAt(null); fallbackTriggerHistoryRef.current = []; hasRequestedFallbackRef.current = false; }, []); const requestFallbackToBlockNote = useCallback( (reason: EditorHostFallbackReason, error?: string | null) => { if (hasRequestedFallbackRef.current) { return; } hasRequestedFallbackRef.current = true; const now = new Date().toISOString(); setActiveHostKind("blocknote"); setHostFallbackCount((prev) => prev + 1); setLastFallbackReason(reason); setLastFallbackAt(now); setHostStatus("blocknote_fallback"); setHostEventAt(now); fallbackTriggerHistoryRef.current = [now, ...fallbackTriggerHistoryRef.current].slice( 0, FALLBACK_TRIGGER_HISTORY_LIMIT, ); if (reason === "runtime_load_failed") { setHostRuntimeLoadFailure(error ?? "runtime 加载失败"); } else if (reason === "host_init_failed") { setHostInitFailure(error ?? "host 初始化失败"); } else if (reason === "command_failed") { setHostCommandFailure(error ?? "命令执行失败"); } else if (reason === "save_failed") { setHostSaveFailure(error ?? "保存失败"); } }, [], ); useEffect(() => { setCurrentDocument(documentId, readOnly, disableDownload, disableCopy); return () => { clearIfMatch(documentId); }; }, [clearIfMatch, disableCopy, disableDownload, documentId, readOnly, setCurrentDocument]); useEffect(() => { if (!disableCopy) return; const toElement = (node: Node | null): Element | null => { if (!node) return null; if (node.nodeType === Node.TEXT_NODE) { return node.parentElement; } return node instanceof Element ? node : null; }; const isEventInsidePage = () => { const root = pageRootRef.current; if (!root) return false; const selection = typeof window !== "undefined" ? window.getSelection() : null; const anchor = selection?.anchorNode ?? null; const focus = selection?.focusNode ?? null; const anchorEl = toElement(anchor); const focusEl = toElement(focus); return Boolean((anchorEl && root.contains(anchorEl)) || (focusEl && root.contains(focusEl))); }; const notifyBlocked = () => { const now = Date.now(); if (now - lastCopyBlockedAtRef.current < 1200) return; lastCopyBlockedAtRef.current = now; window.alert("该页面已禁止复制"); }; const onCopy = (event: ClipboardEvent) => { if (!isEventInsidePage()) return; event.preventDefault(); event.stopPropagation(); notifyBlocked(); }; const onCut = (event: ClipboardEvent) => { if (!isEventInsidePage()) return; event.preventDefault(); event.stopPropagation(); notifyBlocked(); }; const onKeyDown = (event: KeyboardEvent) => { if (!isEventInsidePage()) return; const key = String(event.key ?? "").toLowerCase(); const ctrlOrMeta = event.ctrlKey || event.metaKey; if (!ctrlOrMeta) return; if (key === "c" || key === "x" || key === "insert") { event.preventDefault(); event.stopPropagation(); notifyBlocked(); } }; document.addEventListener("copy", onCopy, true); document.addEventListener("cut", onCut, true); document.addEventListener("keydown", onKeyDown, true); return () => { document.removeEventListener("copy", onCopy, true); document.removeEventListener("cut", onCut, true); document.removeEventListener("keydown", onKeyDown, true); }; }, [disableCopy]); useEffect(() => { setActiveHostKind(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind); resetHostObservability(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind); }, [requestedHostKind, resetHostObservability]); useEffect(() => { const tableId = (openTableId ?? "").trim(); if (!tableId) return; if (!editorBridge?.openTableFullScreen) return; if (pendingOpenTableRef.current === tableId) return; pendingOpenTableRef.current = tableId; editorBridge.openTableFullScreen(tableId); if (typeof window !== "undefined") { try { const url = new URL(window.location.href); url.searchParams.delete("openTableId"); window.history.replaceState({}, "", url.toString()); } catch { router.replace(`/documents/${documentId}`); } } }, [documentId, editorBridge, openTableId, router]); useEffect(() => { dispatchPageClientState({ type: "update_server_page_subtree_title", title: committedPageTitle, }); }, [committedPageTitle]); useEffect(() => { dispatchPageClientState({ type: "hydrate_from_page", page, }); }, [page]); useEffect(() => { setStats(initialStats ?? defaultStats); }, [initialStats]); useEffect(() => { const nextBlocks = extractPageBlocks(content); latestBlocksRef.current = nextBlocks.length > 0 ? (nextBlocks as Json) : null; }, [content]); useEffect(() => { const shouldForceEdit = Boolean((openTableId ?? "").trim()) && canEditDocument; if (shouldForceEdit) { setIsEditing(true); setKeepEditorMounted(true); } }, [canEditDocument, openTableId]); useEffect(() => { if (isEditing) { if (editorUnmountTimerRef.current) { clearTimeout(editorUnmountTimerRef.current); editorUnmountTimerRef.current = null; } setKeepEditorMounted(true); return; } if (editorUnmountTimerRef.current) { clearTimeout(editorUnmountTimerRef.current); } editorUnmountTimerRef.current = setTimeout(() => { setKeepEditorMounted(false); editorUnmountTimerRef.current = null; }, EDITOR_UNMOUNT_GRACE_MS); return () => { if (editorUnmountTimerRef.current) { clearTimeout(editorUnmountTimerRef.current); editorUnmountTimerRef.current = null; } }; }, [isEditing]); useEffect(() => { let canceled = false; const controller = new AbortController(); const load = async () => { setContentError(null); setContentLoading(initialContent == null); dispatchPageClientState({ type: "hydrate_from_page", page, }); setShowContentLoadingIndicator(false); if (initialContent != null) { return; } if (contentLoadingTimerRef.current) { clearTimeout(contentLoadingTimerRef.current); contentLoadingTimerRef.current = null; } contentLoadingTimerRef.current = setTimeout(() => { if (!canceled) { setShowContentLoadingIndicator(true); } }, CONTENT_LOADING_DELAY_MS); try { const response = await fetch( `/api/documents/page?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`, { method: "GET", credentials: "include", signal: controller.signal, }, ); if (!response.ok) { const payload = await response.json().catch(() => ({})); throw new Error(payload?.error ?? "加载页面内容失败"); } const payload = (await response.json()) as { page?: PageAggregateProjection; }; const reloadedPage = payload.page ?? null; const reloadedBody = reloadedPage?.body ?? null; if (canceled) return; if (reloadedPage) { dispatchPageClientState({ type: "hydrate_from_page", page: { ...reloadedPage, body: { ...reloadedBody, content: reloadedBody?.content ?? null, revision: typeof reloadedBody?.revision === "number" && Number.isInteger(reloadedBody.revision) ? reloadedBody.revision : 0, conflictDetectionKey: typeof reloadedBody?.conflictDetectionKey === "string" && reloadedBody.conflictDetectionKey.trim() ? reloadedBody.conflictDetectionKey : `${documentId}:0`, }, }, }); } } catch (error) { if (canceled) return; if ((error as { name?: string })?.name === "AbortError") return; setContentError(error instanceof Error ? error.message : "加载页面内容失败"); } finally { if (!canceled) { setContentLoading(false); setShowContentLoadingIndicator(false); } if (contentLoadingTimerRef.current) { clearTimeout(contentLoadingTimerRef.current); contentLoadingTimerRef.current = null; } } }; void load(); return () => { canceled = true; controller.abort(); if (contentLoadingTimerRef.current) { clearTimeout(contentLoadingTimerRef.current); contentLoadingTimerRef.current = null; } }; }, [contentReloadKey, documentId, initialContent, page, workspaceId]); const persistTitle = useCallback( async (nextTitle: string) => { if (readOnly) return; try { const payload = await persistPageTitleAndNotifyDocumentsChanged({ documentId, workspaceId, title: nextTitle, persistTitleCommand: executePageHeadCommand, notifyDocumentsChanged: emitDocumentsChanged, }); commitPersistedTitle(payload); dispatchPageClientState({ type: "update_server_page_subtree_title", title: payload, }); } catch (error) { console.error("更新页面标题失败", error); } }, [commitPersistedTitle, documentId, readOnly, workspaceId], ); const handleAiPageHeadTitleChange = useCallback( (nextTitle: string) => { setPageTitleDraft(nextTitle); commitPersistedTitle(nextTitle); dispatchPageClientState({ type: "update_server_page_subtree_title", title: nextTitle, }); emitDocumentsChanged(documentId); void persistTitle(nextTitle); }, [commitPersistedTitle, documentId, persistTitle, setPageTitleDraft], ); const debouncedPersistTitle = useDebouncedCallback((value: string) => { void persistTitle(value); }, 600); const handleTitleChange = (event: ChangeEvent) => { if (!canEditDocument) return; const value = event.target.value; setPageTitleDraft(value); debouncedPersistTitle(value); }; const handleTitleBlur = () => { if (!canEditDocument) return; void persistTitle(pageTitle); }; const handleTitleKeyDown = (event: ReactKeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } }; const persistOptions = useCallback( async (patch: Partial) => { if (readOnly) return; try { await executePageLayoutCommand({ documentId, workspaceId, pageOptions: patch, }); } catch (error) { console.error(error); } }, [documentId, readOnly, workspaceId], ); const toggleOption = useCallback( (key: BooleanPageOptionKey) => { if (readOnly) return; const nextValue = !options[key]; dispatchPageClientState({ type: "patch_page_options", patch: { [key]: nextValue } as Partial, }); void persistOptions({ [key]: nextValue } as Partial); }, [options, persistOptions, readOnly], ); const setOptionPatch = useCallback( (patch: Partial) => { if (readOnly) return; dispatchPageClientState({ type: "patch_page_options", patch, }); void persistOptions(patch); }, [persistOptions, readOnly], ); const closeToc = useCallback(() => { if (readOnly) return; if (!options.showToc) return; dispatchPageClientState({ type: "patch_page_options", patch: { showToc: false }, }); void persistOptions({ showToc: false }); }, [options.showToc, persistOptions, readOnly]); const handleSetPageFont = useCallback( (font: PageFont) => { setOptionPatch({ pageFont: font }); }, [setOptionPatch], ); const handleSetLayoutDensity = useCallback( (density: PageLayoutDensity) => { setOptionPatch({ layoutDensity: density }); }, [setOptionPatch], ); const handleSetEmbedDefaultToCursor = useCallback(() => { if (!canEditDocument) return; if (!isEditing) { setIsEditing(true); return; } const blockId = editorBridge?.getCursorBlockId?.() ?? null; if (!blockId) { window.alert("未找到当前光标所在块,请先在编辑器中点击任意块"); return; } setOptionPatch({ embedDefaultBlockId: blockId }); window.alert("已设置“嵌入默认位置”"); }, [canEditDocument, editorBridge, isEditing, setOptionPatch]); const handleClearEmbedDefault = useCallback(() => { if (readOnly) return; setOptionPatch({ embedDefaultBlockId: null }); window.alert("已清除“嵌入默认位置”"); }, [readOnly, setOptionPatch]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { const root = pageRootRef.current; if (root) { const target = event.target; if (target && target instanceof Node && !root.contains(target)) { return; } } const ctrlOrMeta = event.ctrlKey || event.metaKey; if (!ctrlOrMeta) return; if (!event.shiftKey) return; const key = String(event.key ?? "").toLowerCase(); if (key === "l") { event.preventDefault(); toggleOption("showToc"); } else if (key === "c") { event.preventDefault(); toggleOption("protectEditing"); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [toggleOption]); const buildDocumentUrl = useCallback((id: string): string => { if (typeof window === "undefined" || !window.location) { return `/documents/${id}`; } return `${window.location.origin}/documents/${id}`; }, []); const copyText = useCallback(async (text: string, successMessage: string) => { if (typeof navigator !== "undefined" && navigator.clipboard) { try { await navigator.clipboard.writeText(text); window.alert(successMessage); return; } catch { } } window.prompt("复制失败,请手动复制内容", text); }, []); const handleCopyPageLink = useCallback( async (includeTitle: boolean) => { const url = buildDocumentUrl(documentId); if (includeTitle) { const text = `${pageTitle || "无标题"}\n${url}`; await copyText(text, "标题 + 链接已复制"); return; } await copyText(url, "页面链接已复制"); }, [buildDocumentUrl, copyText, documentId, pageTitle], ); const handleCopyPageReference = useCallback( async (mode: "inline" | "embed") => { const template = mode === "inline" ? `((${documentId}))` : `{{${documentId}}}`; await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制"); }, [copyText, documentId], ); const handleUndo = useCallback(() => { if (!isEditing) { setIsEditing(true); return; } editorBridge?.undo?.(); }, [editorBridge, isEditing]); const handleRedo = useCallback(() => { if (!isEditing) { setIsEditing(true); return; } editorBridge?.redo?.(); }, [editorBridge, isEditing]); const handleDeletePage = useCallback(async () => { if (readOnly) return; const ok = window.confirm("确定删除该页面吗?删除后会进入垃圾桶。"); if (!ok) return; try { await deleteDocumentCommand({ documentId, workspaceId }); } catch (error) { const payload = error instanceof Error ? error.message : null; window.alert(payload ?? "删除失败"); return; } router.push("/"); router.refresh(); }, [documentId, readOnly, router, workspaceId]); const handleOpenMoveEmbed = useCallback(() => { if (readOnly) return; openMoveEmbedPicker({ workspaceId, defaultMode: "move", modes: ["move", "embed"], allowRoot: true, excludeIds: [documentId], onPick: async (mode, targetId) => { if (mode === "move") { try { await moveDocumentCommand({ documentId, parentId: targetId ?? null, position: 999999, workspaceId, }); } catch (error) { window.alert(error instanceof Error ? error.message : "移动失败"); return; } window.alert("移动成功"); router.refresh(); return; } if (!targetId) { window.alert("请选择目标页面"); return; } try { await embedDocumentCommand({ sourceId: documentId, targetId, }); } catch (error) { const message = error instanceof Error ? error.message : "嵌入失败"; window.alert(message); return; } window.alert("已嵌入到目标页面"); }, }); }, [documentId, openMoveEmbedPicker, readOnly, router, workspaceId]); const handleAddToTemplates = useCallback(async () => { if (readOnly) return; const ok = window.confirm("将该页面添加为模板?"); if (!ok) return; const resp = await fetch("/api/documents/template", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId, isTemplate: true }), }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "设置模板失败"); return; } window.alert("已添加为模板"); router.refresh(); }, [documentId, readOnly, router]); const formattedUpdatedAt = useMemo(() => { if (!updatedAt) return ""; const date = new Date(updatedAt); if (Number.isNaN(date.getTime())) return ""; return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }).format(date); }, [updatedAt]); const handleExport = useCallback(() => { if (disableDownload) { window.alert("该页面已禁止下载"); return; } const latest = history[0]; const exportBlocks = latest?.blocks ?? latestBlocksRef.current; if (!exportBlocks) { window.alert("暂无可导出的内容"); return; } const payload = JSON.stringify(exportBlocks, null, 2); const blob = new Blob([payload], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${pageTitle ?? "未命名页面"}-${new Date().toISOString()}.json`; anchor.click(); URL.revokeObjectURL(url); }, [disableDownload, history, pageTitle]); const pageRootClass = cn( "flex h-full overflow-hidden bg-wolai-bg", options.pageFont === "song" && "wolai-page-font-song", options.pageFont === "kai" && "wolai-page-font-kai", options.layoutDensity === "compact" && "wolai-page-density-compact", options.layoutDensity === "spacious" && "wolai-page-density-spacious", options.smallText && "wolai-small-text", options.hideChildPages && "wolai-hide-child-pages", ); const pageSubtree = useMemo( () => selectPageAggregateClientPageSubtree(pageClientState, pageTitle), [pageClientState, pageTitle], ); const readViewTocEntries = useMemo( () => (pageSubtree?.outline ?? []) .filter((entry) => typeof entry.anchorBlockId === "string" && entry.anchorBlockId.trim()) .map(({ anchorBlockId, level, numbering, title: entryTitle }) => ({ id: anchorBlockId as string, level, numbering, title: entryTitle, })), [pageSubtree], ); const getLatestPageAggregateSnapshot = useCallback( () => selectPageAggregateClientAiSnapshot(pageClientState, { workspaceId, pageTitle, }), [pageClientState, pageTitle, workspaceId], ); const handlePersistedMetaChange = useCallback((meta: PageBodyPersistedMeta) => { dispatchPageClientState({ type: "apply_persisted_body_meta", meta, }); }, []); const inspectorCanUseEditorBridge = canEditDocument && isEditing; const jumpToHeading = useCallback((headingId: string) => { const targetRoot = !isEditing ? readViewRootRef.current : pageRootRef.current; const target = targetRoot?.querySelector(`[data-id="${headingId}"]`); if (target) { target.scrollIntoView({ behavior: "smooth", block: "center" }); } }, [isEditing]); const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => { dispatchPageClientState({ type: "apply_local_content_snapshot", content: payload.blocks, }); latestBlocksRef.current = payload.blocks; setHistory((prev) => { const now = Date.now(); if (prev.length > 0 && now - prev[0].timestamp < 4000) { return prev; } const snapshot: DocumentSnapshot = { id: `${now}-${Math.random().toString(36).slice(2, 8)}`, timestamp: now, blocks: payload.blocks, stats: payload.stats, }; return [snapshot, ...prev].slice(0, 15); }); }, []); const persistStatsRequest = useCallback((next: DocumentStats) => { void fetch("/api/documents/stats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ documentId, workspaceId, stats: next }), }).catch((error) => console.error(error)); }, [documentId, workspaceId]); const persistStats = useDebouncedCallback(persistStatsRequest, 1500); const handleStatsChange = useCallback( (nextStats: DocumentStats) => { setStats(nextStats); persistStats(nextStats); }, [persistStats], ); const handleRestoreSnapshot = useCallback( (snapshot: DocumentSnapshot) => { if (!isEditing || !editorBridge) { pendingRestoreSnapshotRef.current = snapshot; setIsEditing(true); return; } editorBridge.replaceWithSnapshot(snapshot.blocks); setHistoryOpen(false); }, [editorBridge, isEditing], ); const handleEnterEditMode = useCallback(() => { if (!canEditDocument) return; setIsEditing(true); }, [canEditDocument]); const handleExitEditMode = useCallback(() => { setIsEditing(false); }, []); useEffect(() => { if (!isEditing) return; if (!editorBridge) return; const pendingSnapshot = pendingRestoreSnapshotRef.current; if (!pendingSnapshot) return; pendingRestoreSnapshotRef.current = null; editorBridge.replaceWithSnapshot(pendingSnapshot.blocks); setHistoryOpen(false); }, [editorBridge, isEditing]); const handleHostEvent = useCallback((event: EditorHostEvent) => { setHostEventAt(event.at); if (event.kind === "status_changed") { setHostStatus(event.status); return; } if (event.kind === "runtime_load_failed") { setHostRuntimeLoadFailure(event.message); return; } if (event.kind === "host_init_failed") { setHostInitFailure(event.message); return; } if (event.kind === "command_failed") { setHostCommandFailure(event.message); return; } if (event.kind === "save_failed") { setHostSaveFailure(event.message); return; } }, []); const hostObservability = useMemo( () => ({ requestedHostKind, activeHostKind, status: hostStatus, runtimeLoadFailed: hostRuntimeLoadFailure, hostInitFailed: hostInitFailure, commandFailed: hostCommandFailure, saveFailed: hostSaveFailure, fallbackCount: hostFallbackCount, lastFallbackReason, lastFallbackAt, lastEventAt: hostEventAt, fallbackTimestamps: fallbackTriggerHistoryRef.current, }), [ activeHostKind, hostEventAt, hostFallbackCount, hostInitFailure, hostCommandFailure, hostRuntimeLoadFailure, hostSaveFailure, hostStatus, lastFallbackAt, lastFallbackReason, requestedHostKind, ], ); const activeHostFailureMessage = hostRuntimeLoadFailure ?? hostInitFailure ?? hostCommandFailure ?? hostSaveFailure; const showFallbackBanner = requestedHostKind !== "blocknote" && activeHostKind === "blocknote" && lastFallbackReason != null; const showFailureBanner = requestedHostKind !== "blocknote" && activeHostKind !== "blocknote" && activeHostFailureMessage != null; return (
{isEditing && canEditDocument ? (
) : (

{pageTitle || "无标题"}

)} {readOnly ? (

该页面为只读共享,无法编辑。

) : options.protectEditing && isEditing ? (

当前页面已开启编辑保护,关闭后方可修改内容。

) : null}

最近更新:{formattedUpdatedAt}

{canEditDocument && false && (
{isEditing ? ( ) : ( )}
)}
{contentLoading ? ( showContentLoadingIndicator ? (
页面内容加载中...
) : (
) ) : contentError ? (
{contentError}
) : (
{showFallbackBanner ? (
默认 island 主编辑器已自动回退到 BlockNote
原因:{lastFallbackReason} {lastFallbackAt ? `,时间:${lastFallbackAt}` : ""}
) : null} {showFailureBanner ? (
island 主链出现错误
{activeHostFailureMessage}
) : null} {keepEditorMounted && (
{activeHostKind !== "blocknote" ? ( { handlePersistedMetaChange(meta); }} onHostEvent={handleHostEvent} onRequestFallback={(payload) => { requestFallbackToBlockNote(payload.reason, payload.error); }} /> ) : ( { handlePersistedMetaChange(meta); }} /> )}
)}
{!isEditing && (
)}
)}
{showInspector && ( setHistoryOpen(true)} onOpenComments={() => openCommentsForPage({ workspaceId, documentId })} onUndo={canEditDocument ? handleUndo : undefined} onRedo={canEditDocument ? handleRedo : undefined} onDeletePage={canEditDocument ? handleDeletePage : undefined} onOpenMoveEmbedPicker={canEditDocument ? handleOpenMoveEmbed : undefined} onCopyPageLink={handleCopyPageLink} onCopyPageReference={handleCopyPageReference} onAddToTemplates={canEditDocument ? handleAddToTemplates : undefined} /> )}
); }