feat(kernel): complete tree-first graph tasks 074-080
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user