- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
1268 lines
45 KiB
TypeScript
1268 lines
45 KiB
TypeScript
"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: () => (
|
|
<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,
|
|
},
|
|
);
|
|
|
|
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<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;
|
|
}
|
|
|
|
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<DocumentStats>(initialStats ?? defaultStats);
|
|
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
|
|
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<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[]>([]);
|
|
const shouldStartEditing = canEditDocument;
|
|
const [isEditing, setIsEditing] = useState(() => shouldStartEditing);
|
|
const [keepEditorMounted, setKeepEditorMounted] = useState(() => shouldStartEditing);
|
|
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const editorUnmountTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const pendingOpenTableRef = useRef<string | null>(null);
|
|
const latestBlocksRef = useRef<Json | null>(null);
|
|
const pageRootRef = useRef<HTMLDivElement>(null);
|
|
const readViewRootRef = useRef<HTMLDivElement>(null);
|
|
const pendingRestoreSnapshotRef = useRef<DocumentSnapshot | null>(null);
|
|
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 ?? "保存失败");
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
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<HTMLInputElement>) => {
|
|
if (!canEditDocument) return;
|
|
const value = event.target.value;
|
|
setPageTitleDraft(value);
|
|
debouncedPersistTitle(value);
|
|
};
|
|
|
|
const handleTitleBlur = () => {
|
|
if (!canEditDocument) return;
|
|
void persistTitle(pageTitle);
|
|
};
|
|
|
|
const handleTitleKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
event.currentTarget.blur();
|
|
}
|
|
};
|
|
|
|
const persistOptions = useCallback(
|
|
async (patch: Partial<PageOptionsState>) => {
|
|
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<PageOptionsState>,
|
|
});
|
|
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
|
|
},
|
|
[options, persistOptions, readOnly],
|
|
);
|
|
|
|
const setOptionPatch = useCallback(
|
|
(patch: Partial<PageOptionsState>) => {
|
|
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<HTMLElement>(`[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 (
|
|
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
|
<div className={pageRootClass} ref={pageRootRef}>
|
|
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
|
<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>
|
|
{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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="relative flex-1 overflow-y-auto px-12 py-6">
|
|
{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" ? (
|
|
<EditorHost
|
|
documentId={documentId}
|
|
workspaceId={workspaceId}
|
|
initialContent={content}
|
|
title={pageTitle}
|
|
hostKind={activeHostKind}
|
|
initialRevision={contentRevision}
|
|
initialConflictDetectionKey={conflictDetectionKey}
|
|
pageOptions={options}
|
|
readOnly={readOnly}
|
|
onStatsChange={handleStatsChange}
|
|
onSnapshot={handleSnapshot}
|
|
onCloseToc={closeToc}
|
|
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
|
|
handlePersistedMetaChange(meta);
|
|
}}
|
|
onHostEvent={handleHostEvent}
|
|
onRequestFallback={(payload) => {
|
|
requestFallbackToBlockNote(payload.reason, payload.error);
|
|
}}
|
|
/>
|
|
) : (
|
|
<BlockNoteEditor
|
|
documentId={documentId}
|
|
workspaceId={workspaceId}
|
|
initialContent={content}
|
|
title={pageTitle}
|
|
initialRevision={contentRevision}
|
|
initialConflictDetectionKey={conflictDetectionKey}
|
|
pageOptions={options}
|
|
readOnly={readOnly}
|
|
onStatsChange={handleStatsChange}
|
|
onSnapshot={handleSnapshot}
|
|
onCloseToc={closeToc}
|
|
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
|
|
handlePersistedMetaChange(meta);
|
|
}}
|
|
/>
|
|
)}
|
|
</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>
|
|
)}
|
|
<PageBacklinksPanel
|
|
className="mt-10"
|
|
workspaceId={workspaceId}
|
|
documentId={documentId}
|
|
defaultCollapsed={options.collapseBacklinks}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{showInspector && (
|
|
<PageOptionsSidebar
|
|
documentId={documentId}
|
|
options={options}
|
|
stats={stats}
|
|
onToggle={toggleOption}
|
|
onSetPageFont={handleSetPageFont}
|
|
onSetLayoutDensity={handleSetLayoutDensity}
|
|
onSetEmbedDefaultToCursor={inspectorCanUseEditorBridge ? handleSetEmbedDefaultToCursor : undefined}
|
|
onClearEmbedDefault={handleClearEmbedDefault}
|
|
onExport={handleExport}
|
|
onOpenHistory={() => 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}
|
|
/>
|
|
)}
|
|
</div>
|
|
<DocumentHistoryDrawer
|
|
open={historyOpen}
|
|
onOpenChange={setHistoryOpen}
|
|
history={history}
|
|
onRestore={handleRestoreSnapshot}
|
|
/>
|
|
<MoveEmbedPickerHost />
|
|
<DocumentCommentsDrawer />
|
|
<DocumentAiAgentPanel
|
|
documentId={documentId}
|
|
getLatestPageAggregateSnapshot={getLatestPageAggregateSnapshot}
|
|
onPersistedMetaChange={handlePersistedMetaChange}
|
|
onPageHeadTitleChange={handleAiPageHeadTitleChange}
|
|
/>
|
|
</ImagePickerProvider>
|
|
);
|
|
}
|