Files
mnote/wolai-frontend/src/components/editor/document-content.tsx
T

1281 lines
46 KiB
TypeScript
Raw Normal View History

2025-11-23 10:55:04 +08:00
"use client";
import dynamic from "next/dynamic";
2026-01-24 12:32:51 +08:00
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
2026-02-01 08:47:40 +08:00
import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options";
2025-11-23 10:55:04 +08:00
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { usePageLayoutStore } from "@/store/page-layout";
2026-01-24 12:32:51 +08:00
import { useCurrentDocumentStore } from "@/store/current-document";
2025-11-23 10:55:04 +08:00
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";
2026-01-10 10:35:21 +08:00
import { useRouter } from "next/navigation";
2026-01-10 23:08:56 +08:00
import { DocumentAiAgentPanel } from "./DocumentAiAgentPanel";
2026-01-21 18:21:10 +08:00
import { CONTENT_LOADING_DELAY_MS } from "@/lib/constants";
2026-02-01 08:47:40 +08:00
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, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
import {
deleteDocumentCommand,
embedDocumentCommand,
moveDocumentCommand,
updatePageOptionsCommand,
updatePageTitleCommand,
} from "@/lib/documents/tree-command-client";
2026-04-21 06:26:35 +08:00
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";
2025-11-23 10:55:04 +08:00
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">编辑器加载中...</div>
),
},
);
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,
},
);
2025-11-23 10:55:04 +08:00
export interface DocumentContentProps {
page: PageAggregateProjection;
2026-01-10 10:35:21 +08:00
openTableId?: string | null;
2026-04-21 06:26:35 +08:00
editorHostKind?: EditorHostKind;
2025-11-23 10:55:04 +08:00
}
const defaultOptions: PageOptionsState = {
wideLayout: false,
smallText: false,
showHeadingNumbers: true,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: true,
2026-02-01 08:47:40 +08:00
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
2025-11-23 10:55:04 +08:00
};
2026-02-01 08:47:40 +08:00
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<unknown>;
notifyDocumentsChanged: (documentId?: string) => void;
}): Promise<string> {
const payload = input.title.trim() || "无标题";
await input.persistTitleCommand({
documentId: input.documentId,
workspaceId: input.workspaceId,
title: payload,
});
input.notifyDocumentsChanged(input.documentId);
return payload;
}
2025-11-23 10:55:04 +08:00
export function DocumentContent({
page,
2026-01-10 10:35:21 +08:00
openTableId,
editorHostKind = DEFAULT_EDITOR_HOST_KIND,
2025-11-23 10:55:04 +08:00
}: 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 initialOptions = page.layout.pageOptions;
const initialContent = page.body.content;
const initialContentRevision = page.body.revision;
const initialConflictDetectionKey = page.body.conflictDetectionKey;
const initialPageSubtree = page.tree.pageSubtree;
const initialStats = page.stats;
2026-01-24 12:32:51 +08:00
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
const canEditDocument = !readOnly;
2025-11-23 10:55:04 +08:00
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
const [historyOpen, setHistoryOpen] = useState(false);
const editorBridge = useEditorBridgeStore((state) => state.bridge);
2026-01-10 10:35:21 +08:00
const router = useRouter();
2025-11-23 10:55:04 +08:00
const showInspector = usePageLayoutStore((state) => state.showInspector);
2026-02-01 08:47:40 +08:00
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,
});
2026-01-10 10:35:21 +08:00
const [content, setContent] = useState<unknown>(initialContent);
const [serverContentSnapshot, setServerContentSnapshot] = useState<unknown>(initialContent);
const [serverPageSubtreeSnapshot, setServerPageSubtreeSnapshot] = useState<PageSubtreeProjection | null>(
initialPageSubtree,
);
const [serverPageSubtreeTitle, setServerPageSubtreeTitle] = useState<string>(initialTitle);
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
2026-01-10 10:35:21 +08:00
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
const [contentError, setContentError] = useState<string | null>(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<string | null>(null);
const [hostInitFailure, setHostInitFailure] = useState<string | null>(null);
const [hostCommandFailure, setHostCommandFailure] = useState<string | null>(null);
const [hostSaveFailure, setHostSaveFailure] = useState<string | null>(null);
const [hostFallbackCount, setHostFallbackCount] = useState<number>(0);
const [lastFallbackReason, setLastFallbackReason] = useState<EditorHostFallbackReason | null>(null);
const [lastFallbackAt, setLastFallbackAt] = useState<string | null>(null);
const [hostStatus, setHostStatus] = useState<string>("idle");
const [hostEventAt, setHostEventAt] = useState<string | null>(null);
const fallbackTriggerHistoryRef = useRef<string[]>([]);
2026-04-21 06:26:35 +08:00
const shouldStartEditing = canEditDocument;
const [isEditing, setIsEditing] = useState(() => shouldStartEditing);
const [keepEditorMounted, setKeepEditorMounted] = useState(() => shouldStartEditing);
2026-01-10 10:35:21 +08:00
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editorUnmountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2026-01-10 10:35:21 +08:00
const pendingOpenTableRef = useRef<string | null>(null);
2026-01-10 23:08:56 +08:00
const latestBlocksRef = useRef<Json | null>(null);
2026-01-24 12:32:51 +08:00
const pageRootRef = useRef<HTMLDivElement>(null);
const readViewRootRef = useRef<HTMLDivElement>(null);
const pendingRestoreSnapshotRef = useRef<DocumentSnapshot | null>(null);
2026-01-24 12:32:51 +08:00
const lastCopyBlockedAtRef = useRef<number>(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 ?? "保存失败");
}
},
[],
);
2026-01-24 12:32:51 +08:00
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;
};
2026-01-24 12:32:51 +08:00
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);
2026-01-24 12:32:51 +08:00
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]);
2026-01-10 10:35:21 +08:00
useEffect(() => {
setActiveHostKind(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
resetHostObservability(requestedHostKind === "blocknote" ? "blocknote" : requestedHostKind);
}, [requestedHostKind, resetHostObservability]);
2026-01-10 10:35:21 +08:00
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]);
2025-11-23 10:55:04 +08:00
useEffect(() => {
setServerPageSubtreeTitle(committedPageTitle);
}, [committedPageTitle]);
useEffect(() => {
setServerPageSubtreeSnapshot(initialPageSubtree);
}, [initialPageSubtree]);
2025-11-23 10:55:04 +08:00
useEffect(() => {
setOptions(initialOptions ?? defaultOptions);
}, [initialOptions]);
useEffect(() => {
setStats(initialStats ?? defaultStats);
}, [initialStats]);
useEffect(() => {
setContentRevision(initialContentRevision);
}, [initialContentRevision]);
useEffect(() => {
setConflictDetectionKey(initialConflictDetectionKey);
}, [initialConflictDetectionKey]);
useEffect(() => {
setServerContentSnapshot(initialContent);
}, [initialContent]);
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]);
2025-11-23 10:55:04 +08:00
2026-01-10 10:35:21 +08:00
useEffect(() => {
let canceled = false;
const controller = new AbortController();
const load = async () => {
setContentError(null);
setContentLoading(initialContent == null);
setContent(initialContent);
setShowContentLoadingIndicator(false);
if (initialContent != null) {
return;
}
if (contentLoadingTimerRef.current) {
clearTimeout(contentLoadingTimerRef.current);
contentLoadingTimerRef.current = null;
}
contentLoadingTimerRef.current = setTimeout(() => {
if (!canceled) {
setShowContentLoadingIndicator(true);
}
2026-01-21 18:21:10 +08:00
}, CONTENT_LOADING_DELAY_MS);
2026-01-10 10:35:21 +08:00
try {
2026-04-14 13:22:29 +08:00
const response = await fetch(
`/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
{
2026-04-21 06:26:35 +08:00
method: "GET",
credentials: "include",
signal: controller.signal,
2026-04-14 13:22:29 +08:00
},
);
2026-01-10 10:35:21 +08:00
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload?.error ?? "加载页面内容失败");
}
const payload = (await response.json()) as {
content?: unknown;
revision?: number | null;
conflictDetectionKey?: string | null;
pageSubtree?: PageSubtreeProjection | null;
};
2026-01-10 10:35:21 +08:00
if (canceled) return;
setContent(payload.content ?? null);
setServerContentSnapshot(payload.content ?? null);
setContentRevision(
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: 0,
);
setConflictDetectionKey(
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: `${documentId}:0`,
);
setServerPageSubtreeSnapshot(payload.pageSubtree ?? null);
setServerPageSubtreeTitle(
typeof payload.pageSubtree?.rootNode.metadata.title === "string" &&
payload.pageSubtree.rootNode.metadata.title.trim()
? payload.pageSubtree.rootNode.metadata.title
: committedPageTitle,
);
2026-01-10 10:35:21 +08:00
} 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;
}
};
}, [committedPageTitle, contentReloadKey, documentId, initialContent, workspaceId]);
2026-01-10 10:35:21 +08:00
2025-11-23 10:55:04 +08:00
const persistTitle = useCallback(
async (nextTitle: string) => {
2026-01-22 18:53:20 +08:00
if (readOnly) return;
try {
const payload = await persistPageTitleAndNotifyDocumentsChanged({
documentId,
workspaceId,
title: nextTitle,
persistTitleCommand: updatePageTitleCommand,
notifyDocumentsChanged: emitDocumentsChanged,
});
commitPersistedTitle(payload);
setServerPageSubtreeTitle(payload);
} catch (error) {
console.error("更新页面标题失败", error);
}
2025-11-23 10:55:04 +08:00
},
[commitPersistedTitle, documentId, readOnly, workspaceId],
);
const handleAiPageHeadTitleChange = useCallback(
(nextTitle: string) => {
setPageTitleDraft(nextTitle);
commitPersistedTitle(nextTitle);
setServerPageSubtreeTitle(nextTitle);
emitDocumentsChanged(documentId);
},
[commitPersistedTitle, documentId, setPageTitleDraft],
2025-11-23 10:55:04 +08:00
);
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
void persistTitle(value);
}, 600);
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (!canEditDocument) return;
2025-11-23 10:55:04 +08:00
const value = event.target.value;
setPageTitleDraft(value);
2025-11-23 10:55:04 +08:00
debouncedPersistTitle(value);
};
const handleTitleBlur = () => {
if (!canEditDocument) return;
2025-11-23 10:55:04 +08:00
void persistTitle(pageTitle);
};
2026-01-24 12:32:51 +08:00
const handleTitleKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
2025-11-23 10:55:04 +08:00
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
};
const persistOptions = useCallback(
async (patch: Partial<PageOptionsState>) => {
2026-01-22 18:53:20 +08:00
if (readOnly) return;
2025-11-23 10:55:04 +08:00
try {
await updatePageOptionsCommand({
documentId,
workspaceId,
pageOptions: patch,
2025-11-23 10:55:04 +08:00
});
} catch (error) {
console.error(error);
}
},
2026-04-14 13:22:29 +08:00
[documentId, readOnly, workspaceId],
2025-11-23 10:55:04 +08:00
);
2026-02-01 08:47:40 +08:00
const toggleOption = useCallback(
(key: BooleanPageOptionKey) => {
if (readOnly) return;
setOptions((prev) => {
const nextValue = !prev[key];
const next = { ...prev, [key]: nextValue };
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
return next;
});
},
[persistOptions, readOnly],
);
const setOptionPatch = useCallback(
(patch: Partial<PageOptionsState>) => {
if (readOnly) return;
setOptions((prev) => {
const next = { ...prev, ...patch };
void persistOptions(patch);
return next;
});
},
[persistOptions, readOnly],
);
const closeToc = useCallback(() => {
2026-01-22 18:53:20 +08:00
if (readOnly) return;
2025-11-23 10:55:04 +08:00
setOptions((prev) => {
2026-02-01 08:47:40 +08:00
if (!prev.showToc) return prev;
const next = { ...prev, showToc: false };
void persistOptions({ showToc: false });
2025-11-23 10:55:04 +08:00
return next;
});
2026-02-01 08:47:40 +08:00
}, [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;
}
2026-02-01 08:47:40 +08:00
const blockId = editorBridge?.getCursorBlockId?.() ?? null;
if (!blockId) {
window.alert("未找到当前光标所在块,请先在编辑器中点击任意块");
return;
}
setOptionPatch({ embedDefaultBlockId: blockId });
window.alert("已设置“嵌入默认位置”");
}, [canEditDocument, editorBridge, isEditing, setOptionPatch]);
2026-02-01 08:47:40 +08:00
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;
}
2026-02-01 08:47:40 +08:00
editorBridge?.undo?.();
}, [editorBridge, isEditing]);
2026-02-01 08:47:40 +08:00
const handleRedo = useCallback(() => {
if (!isEditing) {
setIsEditing(true);
return;
}
2026-02-01 08:47:40 +08:00
editorBridge?.redo?.();
}, [editorBridge, isEditing]);
2026-02-01 08:47:40 +08:00
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 ?? "删除失败");
2026-02-01 08:47:40 +08:00
return;
}
router.push("/");
router.refresh();
}, [documentId, readOnly, router, workspaceId]);
2026-02-01 08:47:40 +08:00
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 : "移动失败");
2026-02-01 08:47:40 +08:00
return;
}
window.alert("移动成功");
router.refresh();
return;
}
if (!targetId) {
window.alert("请选择目标页面");
2026-02-01 08:47:40 +08:00
return;
}
try {
await embedDocumentCommand({
sourceId: documentId,
targetId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "嵌入失败";
window.alert(message);
return;
}
2026-02-01 08:47:40 +08:00
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]);
2025-11-23 10:55:04 +08:00
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);
2025-11-23 10:55:04 +08:00
}, [updatedAt]);
const handleExport = useCallback(() => {
2026-01-24 12:32:51 +08:00
if (disableDownload) {
window.alert("该页面已禁止下载");
return;
}
2025-11-23 10:55:04 +08:00
const latest = history[0];
const exportBlocks = latest?.blocks ?? latestBlocksRef.current;
if (!exportBlocks) {
2025-11-23 10:55:04 +08:00
window.alert("暂无可导出的内容");
return;
}
const payload = JSON.stringify(exportBlocks, null, 2);
2025-11-23 10:55:04 +08:00
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`;
2025-11-23 10:55:04 +08:00
anchor.click();
URL.revokeObjectURL(url);
}, [disableDownload, history, pageTitle]);
2025-11-23 10:55:04 +08:00
2026-02-01 08:47:40 +08:00
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(() => {
const hasServerPageSubtree = Boolean(serverPageSubtreeSnapshot);
const titleUnchanged = pageTitle === serverPageSubtreeTitle;
const contentUnchanged = content === serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return serverPageSubtreeSnapshot;
}
return null;
}, [
content,
pageTitle,
serverContentSnapshot,
serverPageSubtreeSnapshot,
serverPageSubtreeTitle,
]);
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 getLatestBlocks = useCallback(() => latestBlocksRef.current, []);
const getLatestPageSubtree = useCallback(() => pageSubtree, [pageSubtree]);
const getLatestPersistedMeta = useCallback(
() => ({
workspaceId,
revision: contentRevision,
conflictDetectionKey,
}),
[conflictDetectionKey, contentRevision, workspaceId],
);
const inspectorCanUseEditorBridge = canEditDocument && isEditing;
const jumpToHeading = useCallback((headingId: string) => {
const targetRoot = !isEditing ? readViewRootRef.current : pageRootRef.current;
const target = targetRoot?.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
if (target) {
target.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, [isEditing]);
2026-02-01 08:47:40 +08:00
2025-11-23 10:55:04 +08:00
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
setContent(payload.blocks);
2026-01-10 23:08:56 +08:00
latestBlocksRef.current = payload.blocks;
2025-11-23 10:55:04 +08:00
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" },
2026-04-14 13:22:29 +08:00
body: JSON.stringify({ documentId, workspaceId, stats: next }),
2025-11-23 10:55:04 +08:00
}).catch((error) => console.error(error));
2026-04-14 13:22:29 +08:00
}, [documentId, workspaceId]);
2025-11-23 10:55:04 +08:00
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);
2025-11-23 10:55:04 +08:00
return;
}
editorBridge.replaceWithSnapshot(snapshot.blocks);
setHistoryOpen(false);
},
[editorBridge, isEditing],
2025-11-23 10:55:04 +08:00
);
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;
2025-11-23 10:55:04 +08:00
return (
2026-01-18 19:01:31 +08:00
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
2026-02-01 08:47:40 +08:00
<div className={pageRootClass} ref={pageRootRef}>
2025-11-23 10:55:04 +08:00
<div className="flex h-full flex-1 flex-col overflow-hidden">
2026-01-18 19:01:31 +08:00
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0 flex-1">
{isEditing && canEditDocument ? (
<div className="relative">
<input
value={pageTitle}
onChange={handleTitleChange}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
placeholder="无标题"
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
aria-label="页面标题"
disabled={options.protectEditing || readOnly}
spellCheck={spellCheck}
/>
</div>
) : (
<h1 className="break-words text-3xl font-semibold text-wolai-text-primary">
{pageTitle || "无标题"}
</h1>
)}
{readOnly ? (
<p className="mt-1 text-sm text-gray-500">该页面为只读共享,无法编辑。</p>
) : options.protectEditing && isEditing ? (
<p className="mt-1 text-sm text-[#b91c1c]">当前页面已开启编辑保护,关闭后方可修改内容。</p>
) : null}
<p className="text-sm text-wolai-text-secondary">最近更新:{formattedUpdatedAt}</p>
</div>
2026-04-21 06:26:35 +08:00
{canEditDocument && false && (
<div className="flex shrink-0 items-center gap-2">
{isEditing ? (
<Button type="button" variant="outline" size="sm" onClick={handleExitEditMode}>
返回阅读
</Button>
) : (
<Button type="button" size="sm" onClick={handleEnterEditMode}>
进入编辑
</Button>
)}
</div>
)}
2025-11-23 10:55:04 +08:00
</div>
</div>
<div className="relative flex-1 overflow-y-auto px-12 py-6">
2026-01-10 10:35:21 +08:00
{contentLoading ? (
showContentLoadingIndicator ? (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">
页面内容加载中...
</div>
) : (
<div className="h-64" />
)
) : contentError ? (
<div className="flex h-64 flex-col items-center justify-center gap-2 text-sm text-red-600">
<div>{contentError}</div>
<button
type="button"
className="rounded-md border border-red-200 bg-red-50 px-3 py-1 text-sm text-red-700 hover:bg-red-100"
onClick={() => {
setContentError(null);
setContentLoading(true);
setContentReloadKey((prev) => prev + 1);
}}
>
重试
</button>
</div>
) : (
<div className="relative">
{showFallbackBanner ? (
<div className="mb-4 flex items-center justify-between rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
<div>
<div className="font-medium">
默认 island 主编辑器已自动回退到 BlockNote
</div>
<div className="mt-1 text-xs text-amber-700">
原因:{lastFallbackReason}
{lastFallbackAt ? `,时间:${lastFallbackAt}` : ""}
</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setActiveHostKind(requestedHostKind);
resetHostObservability(requestedHostKind);
}}
>
重试 island 主链
</Button>
</div>
) : null}
{showFailureBanner ? (
<div className="mb-4 flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<div>
<div className="font-medium">island 主链出现错误</div>
<div className="mt-1 text-xs text-red-600">{activeHostFailureMessage}</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => requestFallbackToBlockNote("explicit_fallback", activeHostFailureMessage)}
>
切回 BlockNote
</Button>
</div>
) : null}
{keepEditorMounted && (
<div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
{activeHostKind !== "blocknote" ? (
2026-04-21 06:26:35 +08:00
<EditorHost
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
title={pageTitle}
hostKind={activeHostKind}
2026-04-21 06:26:35 +08:00
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
onHostEvent={handleHostEvent}
onRequestFallback={(payload) => {
requestFallbackToBlockNote(payload.reason, payload.error);
}}
2026-04-21 06:26:35 +08:00
/>
) : (
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
title={pageTitle}
2026-04-21 06:26:35 +08:00
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
/>
)}
</div>
)}
<div
className="sr-only"
data-editor-host-observability={JSON.stringify(hostObservability)}
data-editor-host-active={hostObservability.activeHostKind}
data-editor-host-requested={hostObservability.requestedHostKind}
data-editor-host-status={hostObservability.status}
data-editor-host-runtime-load-failed={
hostObservability.runtimeLoadFailed ? "1" : "0"
}
data-editor-host-init-failed={hostObservability.hostInitFailed ? "1" : "0"}
data-editor-host-command-failed={hostObservability.commandFailed ? "1" : "0"}
data-editor-host-save-failed={hostObservability.saveFailed ? "1" : "0"}
data-editor-host-fallback-count={String(hostObservability.fallbackCount)}
data-editor-host-last-fallback-reason={hostObservability.lastFallbackReason ?? ""}
/>
{!isEditing && (
<div className="relative" ref={readViewRootRef}>
<DocumentReadView
content={content}
documentId={documentId}
options={options}
pageSubtree={pageSubtree}
className="mx-auto w-full max-w-[980px]"
/>
<DocumentToc
entries={readViewTocEntries}
visible={options.showToc}
onJump={jumpToHeading}
onClose={closeToc}
/>
</div>
)}
</div>
2026-01-10 10:35:21 +08:00
)}
2026-02-01 08:47:40 +08:00
<PageBacklinksPanel
className="mt-10"
workspaceId={workspaceId}
documentId={documentId}
defaultCollapsed={options.collapseBacklinks}
/>
2025-11-23 10:55:04 +08:00
</div>
</div>
{showInspector && (
<PageOptionsSidebar
documentId={documentId}
options={options}
stats={stats}
onToggle={toggleOption}
2026-02-01 08:47:40 +08:00
onSetPageFont={handleSetPageFont}
onSetLayoutDensity={handleSetLayoutDensity}
onSetEmbedDefaultToCursor={inspectorCanUseEditorBridge ? handleSetEmbedDefaultToCursor : undefined}
2026-02-01 08:47:40 +08:00
onClearEmbedDefault={handleClearEmbedDefault}
2025-11-23 10:55:04 +08:00
onExport={handleExport}
onOpenHistory={() => setHistoryOpen(true)}
2026-02-01 08:47:40 +08:00
onOpenComments={() => openCommentsForPage({ workspaceId, documentId })}
onUndo={canEditDocument ? handleUndo : undefined}
onRedo={canEditDocument ? handleRedo : undefined}
onDeletePage={canEditDocument ? handleDeletePage : undefined}
onOpenMoveEmbedPicker={canEditDocument ? handleOpenMoveEmbed : undefined}
2026-02-01 08:47:40 +08:00
onCopyPageLink={handleCopyPageLink}
onCopyPageReference={handleCopyPageReference}
onAddToTemplates={canEditDocument ? handleAddToTemplates : undefined}
2025-11-23 10:55:04 +08:00
/>
)}
</div>
<DocumentHistoryDrawer
open={historyOpen}
onOpenChange={setHistoryOpen}
history={history}
onRestore={handleRestoreSnapshot}
/>
<MoveEmbedPickerHost />
2026-02-01 08:47:40 +08:00
<DocumentCommentsDrawer />
<DocumentAiAgentPanel
documentId={documentId}
getLatestBlocks={getLatestBlocks}
getLatestPageSubtree={getLatestPageSubtree}
getLatestPersistedMeta={getLatestPersistedMeta}
onPersistedMetaChange={(meta) => {
setContentRevision(meta.revision);
setConflictDetectionKey(meta.conflictDetectionKey);
}}
onPageHeadTitleChange={handleAiPageHeadTitleChange}
/>
2025-11-23 10:55:04 +08:00
</ImagePickerProvider>
);
}