feat: land page aggregate and phase7 document ai mainline
- 收口 page aggregate 读取、本地状态与命令客户端\n- 接入 phase7 document ai sidecar 与前端编排入口\n- 更新 architecture 与 design 状态迁移
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
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";
|
||||
@@ -21,14 +30,17 @@ 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 { extractPageBlocks } from "@/lib/documents/page-subtree";
|
||||
import {
|
||||
deleteDocumentCommand,
|
||||
embedDocumentCommand,
|
||||
moveDocumentCommand,
|
||||
updatePageOptionsCommand,
|
||||
updatePageTitleCommand,
|
||||
} 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,
|
||||
@@ -41,6 +53,12 @@ import type {
|
||||
} 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),
|
||||
@@ -95,21 +113,6 @@ export interface DocumentContentProps {
|
||||
editorHostKind?: EditorHostKind;
|
||||
}
|
||||
|
||||
const defaultOptions: PageOptionsState = {
|
||||
wideLayout: false,
|
||||
smallText: false,
|
||||
showHeadingNumbers: true,
|
||||
showToc: false,
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
showWordCount: true,
|
||||
collapseBacklinks: false,
|
||||
pageFont: "default",
|
||||
layoutDensity: "normal",
|
||||
hideChildPages: false,
|
||||
showBlockRefCount: false,
|
||||
embedDefaultBlockId: null,
|
||||
};
|
||||
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;
|
||||
@@ -143,16 +146,17 @@ export function DocumentContent({
|
||||
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;
|
||||
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
|
||||
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
|
||||
const canEditDocument = !readOnly;
|
||||
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
|
||||
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);
|
||||
@@ -171,14 +175,9 @@ export function DocumentContent({
|
||||
documentId,
|
||||
fallbackTitle: initialTitle,
|
||||
});
|
||||
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);
|
||||
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);
|
||||
@@ -353,33 +352,23 @@ export function DocumentContent({
|
||||
}, [documentId, editorBridge, openTableId, router]);
|
||||
|
||||
useEffect(() => {
|
||||
setServerPageSubtreeTitle(committedPageTitle);
|
||||
dispatchPageClientState({
|
||||
type: "update_server_page_subtree_title",
|
||||
title: committedPageTitle,
|
||||
});
|
||||
}, [committedPageTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
setServerPageSubtreeSnapshot(initialPageSubtree);
|
||||
}, [initialPageSubtree]);
|
||||
|
||||
useEffect(() => {
|
||||
setOptions(initialOptions ?? defaultOptions);
|
||||
}, [initialOptions]);
|
||||
dispatchPageClientState({
|
||||
type: "hydrate_from_page",
|
||||
page,
|
||||
});
|
||||
}, [page]);
|
||||
|
||||
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;
|
||||
@@ -424,7 +413,10 @@ export function DocumentContent({
|
||||
const load = async () => {
|
||||
setContentError(null);
|
||||
setContentLoading(initialContent == null);
|
||||
setContent(initialContent);
|
||||
dispatchPageClientState({
|
||||
type: "hydrate_from_page",
|
||||
page,
|
||||
});
|
||||
setShowContentLoadingIndicator(false);
|
||||
|
||||
if (initialContent != null) {
|
||||
@@ -443,7 +435,7 @@ export function DocumentContent({
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/documents/content?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
`/api/documents/page?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`,
|
||||
{
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
@@ -455,31 +447,31 @@ export function DocumentContent({
|
||||
throw new Error(payload?.error ?? "加载页面内容失败");
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
content?: unknown;
|
||||
revision?: number | null;
|
||||
conflictDetectionKey?: string | null;
|
||||
pageSubtree?: PageSubtreeProjection | null;
|
||||
page?: PageAggregateProjection;
|
||||
};
|
||||
const reloadedPage = payload.page ?? null;
|
||||
const reloadedBody = reloadedPage?.body ?? null;
|
||||
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,
|
||||
);
|
||||
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;
|
||||
@@ -506,7 +498,7 @@ export function DocumentContent({
|
||||
contentLoadingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [committedPageTitle, contentReloadKey, documentId, initialContent, workspaceId]);
|
||||
}, [contentReloadKey, documentId, initialContent, page, workspaceId]);
|
||||
|
||||
const persistTitle = useCallback(
|
||||
async (nextTitle: string) => {
|
||||
@@ -516,11 +508,14 @@ export function DocumentContent({
|
||||
documentId,
|
||||
workspaceId,
|
||||
title: nextTitle,
|
||||
persistTitleCommand: updatePageTitleCommand,
|
||||
persistTitleCommand: executePageHeadCommand,
|
||||
notifyDocumentsChanged: emitDocumentsChanged,
|
||||
});
|
||||
commitPersistedTitle(payload);
|
||||
setServerPageSubtreeTitle(payload);
|
||||
dispatchPageClientState({
|
||||
type: "update_server_page_subtree_title",
|
||||
title: payload,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("更新页面标题失败", error);
|
||||
}
|
||||
@@ -532,10 +527,14 @@ export function DocumentContent({
|
||||
(nextTitle: string) => {
|
||||
setPageTitleDraft(nextTitle);
|
||||
commitPersistedTitle(nextTitle);
|
||||
setServerPageSubtreeTitle(nextTitle);
|
||||
dispatchPageClientState({
|
||||
type: "update_server_page_subtree_title",
|
||||
title: nextTitle,
|
||||
});
|
||||
emitDocumentsChanged(documentId);
|
||||
void persistTitle(nextTitle);
|
||||
},
|
||||
[commitPersistedTitle, documentId, setPageTitleDraft],
|
||||
[commitPersistedTitle, documentId, persistTitle, setPageTitleDraft],
|
||||
);
|
||||
|
||||
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
|
||||
@@ -565,7 +564,7 @@ export function DocumentContent({
|
||||
async (patch: Partial<PageOptionsState>) => {
|
||||
if (readOnly) return;
|
||||
try {
|
||||
await updatePageOptionsCommand({
|
||||
await executePageLayoutCommand({
|
||||
documentId,
|
||||
workspaceId,
|
||||
pageOptions: patch,
|
||||
@@ -580,37 +579,37 @@ export function DocumentContent({
|
||||
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;
|
||||
const nextValue = !options[key];
|
||||
dispatchPageClientState({
|
||||
type: "patch_page_options",
|
||||
patch: { [key]: nextValue } as Partial<PageOptionsState>,
|
||||
});
|
||||
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
|
||||
},
|
||||
[persistOptions, readOnly],
|
||||
[options, persistOptions, readOnly],
|
||||
);
|
||||
|
||||
const setOptionPatch = useCallback(
|
||||
(patch: Partial<PageOptionsState>) => {
|
||||
if (readOnly) return;
|
||||
setOptions((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
void persistOptions(patch);
|
||||
return next;
|
||||
dispatchPageClientState({
|
||||
type: "patch_page_options",
|
||||
patch,
|
||||
});
|
||||
void persistOptions(patch);
|
||||
},
|
||||
[persistOptions, readOnly],
|
||||
);
|
||||
|
||||
const closeToc = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
setOptions((prev) => {
|
||||
if (!prev.showToc) return prev;
|
||||
const next = { ...prev, showToc: false };
|
||||
void persistOptions({ showToc: false });
|
||||
return next;
|
||||
if (!options.showToc) return;
|
||||
dispatchPageClientState({
|
||||
type: "patch_page_options",
|
||||
patch: { showToc: false },
|
||||
});
|
||||
}, [persistOptions, readOnly]);
|
||||
void persistOptions({ showToc: false });
|
||||
}, [options.showToc, persistOptions, readOnly]);
|
||||
|
||||
const handleSetPageFont = useCallback(
|
||||
(font: PageFont) => {
|
||||
@@ -854,22 +853,10 @@ export function DocumentContent({
|
||||
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 pageSubtree = useMemo(
|
||||
() => selectPageAggregateClientPageSubtree(pageClientState, pageTitle),
|
||||
[pageClientState, pageTitle],
|
||||
);
|
||||
const readViewTocEntries = useMemo(
|
||||
() =>
|
||||
(pageSubtree?.outline ?? [])
|
||||
@@ -882,16 +869,20 @@ export function DocumentContent({
|
||||
})),
|
||||
[pageSubtree],
|
||||
);
|
||||
const getLatestBlocks = useCallback(() => latestBlocksRef.current, []);
|
||||
const getLatestPageSubtree = useCallback(() => pageSubtree, [pageSubtree]);
|
||||
const getLatestPersistedMeta = useCallback(
|
||||
() => ({
|
||||
workspaceId,
|
||||
revision: contentRevision,
|
||||
conflictDetectionKey,
|
||||
}),
|
||||
[conflictDetectionKey, contentRevision, workspaceId],
|
||||
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) => {
|
||||
@@ -903,7 +894,10 @@ export function DocumentContent({
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
|
||||
setContent(payload.blocks);
|
||||
dispatchPageClientState({
|
||||
type: "apply_local_content_snapshot",
|
||||
content: payload.blocks,
|
||||
});
|
||||
latestBlocksRef.current = payload.blocks;
|
||||
setHistory((prev) => {
|
||||
const now = Date.now();
|
||||
@@ -1162,8 +1156,7 @@ export function DocumentContent({
|
||||
onSnapshot={handleSnapshot}
|
||||
onCloseToc={closeToc}
|
||||
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
|
||||
setContentRevision(meta.revision);
|
||||
setConflictDetectionKey(meta.conflictDetectionKey);
|
||||
handlePersistedMetaChange(meta);
|
||||
}}
|
||||
onHostEvent={handleHostEvent}
|
||||
onRequestFallback={(payload) => {
|
||||
@@ -1184,8 +1177,7 @@ export function DocumentContent({
|
||||
onSnapshot={handleSnapshot}
|
||||
onCloseToc={closeToc}
|
||||
onPersistedMetaChange={(meta: { revision: number | null; conflictDetectionKey: string | null }) => {
|
||||
setContentRevision(meta.revision);
|
||||
setConflictDetectionKey(meta.conflictDetectionKey);
|
||||
handlePersistedMetaChange(meta);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1266,13 +1258,8 @@ export function DocumentContent({
|
||||
<DocumentCommentsDrawer />
|
||||
<DocumentAiAgentPanel
|
||||
documentId={documentId}
|
||||
getLatestBlocks={getLatestBlocks}
|
||||
getLatestPageSubtree={getLatestPageSubtree}
|
||||
getLatestPersistedMeta={getLatestPersistedMeta}
|
||||
onPersistedMetaChange={(meta) => {
|
||||
setContentRevision(meta.revision);
|
||||
setConflictDetectionKey(meta.conflictDetectionKey);
|
||||
}}
|
||||
getLatestPageAggregateSnapshot={getLatestPageAggregateSnapshot}
|
||||
onPersistedMetaChange={handlePersistedMetaChange}
|
||||
onPageHeadTitleChange={handleAiPageHeadTitleChange}
|
||||
/>
|
||||
</ImagePickerProvider>
|
||||
|
||||
Reference in New Issue
Block a user