feat(kernel): complete tree-first graph tasks 074-080

This commit is contained in:
lix-2026
2026-04-16 22:01:51 +08:00
parent 2ff10fa86c
commit b1d5d97142
65 changed files with 11579 additions and 4606 deletions
@@ -21,6 +21,10 @@ 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 { buildPageSubtreeProjection, extractPageBlocks, type PageSubtreeProjection } from "@/lib/documents/page-subtree";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -40,6 +44,7 @@ export interface DocumentContentProps {
initialContent: unknown;
initialContentRevision?: number | null;
initialConflictDetectionKey?: string | null;
initialPageSubtree?: PageSubtreeProjection | null;
initialOptions: PageOptionsState;
initialStats: DocumentStats | null;
openTableId?: string | null;
@@ -64,6 +69,7 @@ const defaultOptions: PageOptionsState = {
embedDefaultBlockId: null,
};
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
const EDITOR_UNMOUNT_GRACE_MS = 1000;
export function DocumentContent({
documentId,
@@ -73,6 +79,7 @@ export function DocumentContent({
initialContent,
initialContentRevision = null,
initialConflictDetectionKey = null,
initialPageSubtree = null,
initialOptions,
initialStats,
openTableId,
@@ -82,6 +89,7 @@ export function DocumentContent({
}: DocumentContentProps) {
const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent);
const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch);
const canEditDocument = !readOnly;
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
@@ -100,10 +108,15 @@ export function DocumentContent({
const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0);
const [showContentLoadingIndicator, setShowContentLoadingIndicator] = useState(false);
const [isEditing, setIsEditing] = useState(() => Boolean((openTableId ?? "").trim()) && !readOnly);
const [keepEditorMounted, setKeepEditorMounted] = useState(() => Boolean((openTableId ?? "").trim()) && !readOnly);
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);
useEffect(() => {
@@ -116,20 +129,22 @@ export function DocumentContent({
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 =
anchor && "nodeType" in anchor && anchor.nodeType === Node.TEXT_NODE
? anchor.parentElement
: (anchor as any as Element | null);
const focusEl =
focus && "nodeType" in focus && focus.nodeType === Node.TEXT_NODE
? focus.parentElement
: (focus as any as Element | null);
const anchorEl = toElement(anchor);
const focusEl = toElement(focus);
return Boolean((anchorEl && root.contains(anchorEl)) || (focusEl && root.contains(focusEl)));
};
@@ -218,7 +233,43 @@ export function DocumentContent({
useEffect(() => {
setConflictDetectionKey(initialConflictDetectionKey);
}, [initialConflictDetectionKey]);
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;
@@ -321,14 +372,14 @@ export function DocumentContent({
}, 600);
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (readOnly) return;
if (!canEditDocument) return;
const value = event.target.value;
setPageTitle(value);
debouncedPersistTitle(value);
};
const handleTitleBlur = () => {
if (readOnly) return;
if (!canEditDocument) return;
void persistTitle(pageTitle);
};
@@ -409,7 +460,11 @@ export function DocumentContent({
);
const handleSetEmbedDefaultToCursor = useCallback(() => {
if (readOnly) return;
if (!canEditDocument) return;
if (!isEditing) {
setIsEditing(true);
return;
}
const blockId = editorBridge?.getCursorBlockId?.() ?? null;
if (!blockId) {
window.alert("未找到当前光标所在块,请先在编辑器中点击任意块");
@@ -417,7 +472,7 @@ export function DocumentContent({
}
setOptionPatch({ embedDefaultBlockId: blockId });
window.alert("已设置“嵌入默认位置”");
}, [editorBridge, readOnly, setOptionPatch]);
}, [canEditDocument, editorBridge, isEditing, setOptionPatch]);
const handleClearEmbedDefault = useCallback(() => {
if (readOnly) return;
@@ -492,12 +547,20 @@ export function DocumentContent({
);
const handleUndo = useCallback(() => {
if (!isEditing) {
setIsEditing(true);
return;
}
editorBridge?.undo?.();
}, [editorBridge]);
}, [editorBridge, isEditing]);
const handleRedo = useCallback(() => {
if (!isEditing) {
setIsEditing(true);
return;
}
editorBridge?.redo?.();
}, [editorBridge]);
}, [editorBridge, isEditing]);
const handleDeletePage = useCallback(async () => {
if (readOnly) return;
@@ -586,16 +649,17 @@ export function DocumentContent({
return;
}
const latest = history[0];
if (!latest) {
const exportBlocks = latest?.blocks ?? latestBlocksRef.current;
if (!exportBlocks) {
window.alert("暂无可导出的内容");
return;
}
const payload = JSON.stringify(latest.blocks, null, 2);
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 = `${title ?? "未命名页面"}-${new Date(latest.timestamp).toISOString()}.json`;
anchor.download = `${title ?? "未命名页面"}-${new Date().toISOString()}.json`;
anchor.click();
URL.revokeObjectURL(url);
}, [disableDownload, history, title]);
@@ -609,8 +673,40 @@ export function DocumentContent({
options.smallText && "wolai-small-text",
options.hideChildPages && "wolai-hide-child-pages",
);
const pageSubtree = useMemo(() => {
if (initialPageSubtree && content === initialContent && pageTitle === (title ?? "无标题")) {
return initialPageSubtree;
}
return buildPageSubtreeProjection({
documentId,
title: pageTitle,
content,
});
}, [content, documentId, initialContent, initialPageSubtree, pageTitle, title]);
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 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 }) => {
setContent(payload.blocks);
latestBlocksRef.current = payload.blocks;
setHistory((prev) => {
const now = Date.now();
@@ -647,42 +743,86 @@ export function DocumentContent({
const handleRestoreSnapshot = useCallback(
(snapshot: DocumentSnapshot) => {
if (!editorBridge) {
window.alert("编辑器尚未准备好,无法恢复历史版本");
if (!isEditing || !editorBridge) {
pendingRestoreSnapshotRef.current = snapshot;
setIsEditing(true);
return;
}
editorBridge.replaceWithSnapshot(snapshot.blocks);
setHistoryOpen(false);
},
[editorBridge],
[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]);
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="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 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>
) : !isEditing && canEditDocument ? (
<p className="mt-1 text-sm text-gray-500"></p>
) : null}
<p className="text-sm text-wolai-text-secondary">{formattedUpdatedAt}</p>
</div>
{canEditDocument && (
<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>
{readOnly ? (
<p className="mt-1 text-sm text-gray-500"></p>
) : options.protectEditing ? (
<p className="mt-1 text-sm text-[#b91c1c]"></p>
) : null}
<p className="text-sm text-wolai-text-secondary">{formattedUpdatedAt}</p>
</div>
<div className="flex-1 overflow-y-auto px-12 py-6">
<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">
@@ -707,22 +847,45 @@ export function DocumentContent({
</button>
</div>
) : (
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => {
setContentRevision(revision);
setConflictDetectionKey(nextConflictDetectionKey);
}}
/>
<div className="relative">
{keepEditorMounted && (
<div className={cn(!isEditing && "pointer-events-none absolute inset-0 opacity-0")} aria-hidden={!isEditing}>
<BlockNoteEditor
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => {
setContentRevision(revision);
setConflictDetectionKey(nextConflictDetectionKey);
}}
/>
</div>
)}
{!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"
@@ -740,18 +903,18 @@ export function DocumentContent({
onToggle={toggleOption}
onSetPageFont={handleSetPageFont}
onSetLayoutDensity={handleSetLayoutDensity}
onSetEmbedDefaultToCursor={handleSetEmbedDefaultToCursor}
onSetEmbedDefaultToCursor={inspectorCanUseEditorBridge ? handleSetEmbedDefaultToCursor : undefined}
onClearEmbedDefault={handleClearEmbedDefault}
onExport={handleExport}
onOpenHistory={() => setHistoryOpen(true)}
onOpenComments={() => openCommentsForPage({ workspaceId, documentId })}
onUndo={handleUndo}
onRedo={handleRedo}
onDeletePage={handleDeletePage}
onOpenMoveEmbedPicker={handleOpenMoveEmbed}
onUndo={canEditDocument ? handleUndo : undefined}
onRedo={canEditDocument ? handleRedo : undefined}
onDeletePage={canEditDocument ? handleDeletePage : undefined}
onOpenMoveEmbedPicker={canEditDocument ? handleOpenMoveEmbed : undefined}
onCopyPageLink={handleCopyPageLink}
onCopyPageReference={handleCopyPageReference}
onAddToTemplates={handleAddToTemplates}
onAddToTemplates={canEditDocument ? handleAddToTemplates : undefined}
/>
)}
</div>
@@ -762,7 +925,11 @@ export function DocumentContent({
onRestore={handleRestoreSnapshot}
/>
<DocumentCommentsDrawer />
<DocumentAiAgentPanel documentId={documentId} getLatestBlocks={() => latestBlocksRef.current} />
<DocumentAiAgentPanel
documentId={documentId}
getLatestBlocks={() => latestBlocksRef.current}
getLatestPageSubtree={() => pageSubtree}
/>
</ImagePickerProvider>
);
}