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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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>
);
}
@@ -0,0 +1,615 @@
"use client";
import Link from "next/link";
import type { CSSProperties, ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { TocEntry } from "@/components/editor/document-toc";
import {
buildPageSubtreeProjection,
clampHeadingLevel,
extractPageBlocks,
getInlineText,
getPageBlockChildren,
type PageOutlineEntry,
type PageSubtreeBlock,
type PageSubtreeInlineNode,
type PageSubtreeProjection,
} from "@/lib/documents/page-subtree";
import type { PageOptionsState } from "@/types/page-options";
interface DocumentReadViewProps {
content: unknown;
documentId: string;
options: PageOptionsState;
pageSubtree?: PageSubtreeProjection | null;
className?: string;
}
const TODO_STATUS_LABELS: Record<string, string> = {
todo: "未开始",
doing: "进行中",
done: "已完成",
cancelled: "已取消",
};
const LIST_INDENT_CLASS = [
"",
"ml-5",
"ml-9",
"ml-12",
"ml-16",
];
const toInlineNodes = (value: unknown): PageSubtreeInlineNode[] => {
if (!Array.isArray(value)) {
return [];
}
return value as PageSubtreeInlineNode[];
};
const buildTextStyle = (styles: Record<string, unknown> | undefined): CSSProperties => {
const style: CSSProperties = {};
const textColor = styles?.textColor;
const backgroundColor = styles?.backgroundColor;
if (typeof textColor === "string" && textColor.trim()) {
style.color = textColor;
}
if (typeof backgroundColor === "string" && backgroundColor.trim()) {
style.backgroundColor = backgroundColor;
}
return style;
};
const applyInlineMarks = (
content: ReactNode,
styles: Record<string, unknown> | undefined,
key: string,
): ReactNode => {
let node = content;
if (styles?.bold) {
node = <strong key={`${key}-bold`}>{node}</strong>;
}
if (styles?.italic) {
node = <em key={`${key}-italic`}>{node}</em>;
}
if (styles?.underline) {
node = <u key={`${key}-underline`}>{node}</u>;
}
if (styles?.strike) {
node = <s key={`${key}-strike`}>{node}</s>;
}
if (styles?.code) {
node = (
<code
key={`${key}-code`}
className="rounded bg-[#f4f4f5] px-1 py-0.5 font-mono text-[0.92em] text-[#d97706]"
>
{node}
</code>
);
}
const style = buildTextStyle(styles);
if (Object.keys(style).length > 0) {
node = (
<span key={`${key}-style`} style={style}>
{node}
</span>
);
}
return node;
};
const renderInlineNode = (node: unknown, key: string): ReactNode => {
if (typeof node === "string") {
return node;
}
if (!node || typeof node !== "object") {
return null;
}
const typedNode = node as PageSubtreeInlineNode;
if (typedNode.type === "link") {
const href = typeof typedNode.href === "string" && typedNode.href.trim() ? typedNode.href : "#";
const textContent = renderInlineNodes(typedNode.content, `${key}-content`);
return (
<a
key={key}
href={href}
target={href.startsWith("/") ? undefined : "_blank"}
rel={href.startsWith("/") ? undefined : "noreferrer"}
className="text-[#2563eb] underline underline-offset-2"
>
{textContent.length > 0 ? textContent : href}
</a>
);
}
const text = typeof typedNode.text === "string" ? typedNode.text : "";
return (
<span key={key}>
{applyInlineMarks(text, typedNode.styles, key)}
</span>
);
};
const renderInlineNodes = (nodes: unknown, keyPrefix: string): ReactNode[] => {
return toInlineNodes(nodes).map((node, index) => renderInlineNode(node, `${keyPrefix}-${index}`));
};
const buildHeadingNumberingMap = (blocks: PageSubtreeBlock[]): Map<string, string> => {
const counters = [0, 0, 0, 0, 0];
const numberingById = new Map<string, string>();
const walk = (targetBlocks: PageSubtreeBlock[]) => {
targetBlocks.forEach((block) => {
if (block.type === "heading") {
const level = clampHeadingLevel(block.props?.level);
counters[level - 1] += 1;
for (let index = level; index < counters.length; index += 1) {
counters[index] = 0;
}
if (block.id) {
numberingById.set(
block.id,
counters
.slice(0, level)
.filter((value) => value > 0)
.join("."),
);
}
}
const children = getPageBlockChildren(block.children);
if (children.length > 0) {
walk(children);
}
});
};
walk(blocks);
return numberingById;
};
const buildHeadingNumberingMapFromOutline = (outline: PageOutlineEntry[]): Map<string, string> => {
return new Map(
outline
.filter((entry) => entry.anchorBlockId)
.map((entry) => [entry.anchorBlockId as string, entry.numbering]),
);
};
export const extractReadViewBlocks = (content: unknown): PageSubtreeBlock[] => extractPageBlocks(content);
export const buildReadViewTocEntries = (blocks: PageSubtreeBlock[]): TocEntry[] => {
return buildPageSubtreeProjection({
documentId: "preview",
title: "预览",
content: blocks,
}).outline.map(({ id, level, numbering, title }) => ({
id,
level,
numbering,
title,
}));
};
const renderChildren = (
block: PageSubtreeBlock,
options: PageOptionsState,
documentId: string,
headingNumberingById: Map<string, string>,
depth: number,
) => {
const children = getPageBlockChildren(block.children);
if (children.length === 0) {
return null;
}
return (
<div className={cn("mt-2 space-y-1", LIST_INDENT_CLASS[Math.min(depth + 1, LIST_INDENT_CLASS.length - 1)])}>
{renderBlocks(children, options, documentId, headingNumberingById, depth + 1)}
</div>
);
};
const renderMediaBlock = (block: PageSubtreeBlock) => {
const props = block.props ?? {};
const assetType = String(props.assetType ?? "image");
const fileUrl = typeof props.fileUrl === "string" ? props.fileUrl : "";
const thumbnailUrl =
typeof props.thumbnailUrl === "string" && props.thumbnailUrl.trim() ? props.thumbnailUrl : fileUrl;
const fileName =
typeof props.fileName === "string" && props.fileName.trim() ? props.fileName : "未命名资源";
const caption = typeof props.caption === "string" ? props.caption.trim() : "";
if (!fileUrl) {
return (
<div className="rounded-2xl border border-dashed border-[#d4d4d8] bg-[#fafafa] px-4 py-6 text-sm text-[#71717a]">
</div>
);
}
if (assetType === "image") {
return (
<figure className="space-y-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={thumbnailUrl}
alt={caption || fileName}
className="max-h-[560px] w-auto max-w-full rounded-2xl border border-[#f1f5f9] object-contain shadow-sm"
/>
{(caption || fileName) && (
<figcaption className="text-sm text-[#71717a]">{caption || fileName}</figcaption>
)}
</figure>
);
}
if (assetType === "video") {
return (
<figure className="space-y-3">
<video controls className="max-h-[560px] w-full rounded-2xl border border-[#f1f5f9] bg-black">
<source src={fileUrl} />
</video>
{(caption || fileName) && (
<figcaption className="text-sm text-[#71717a]">{caption || fileName}</figcaption>
)}
</figure>
);
}
if (assetType === "audio") {
return (
<div className="space-y-3 rounded-2xl border border-[#e4e4e7] bg-[#fafafa] p-4">
<div className="text-sm font-medium text-[#27272a]">{caption || fileName}</div>
<audio controls className="w-full">
<source src={fileUrl} />
</audio>
</div>
);
}
return (
<a
href={fileUrl}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between rounded-2xl border border-[#e4e4e7] bg-[#fafafa] px-4 py-3 text-sm text-[#27272a] transition hover:border-[#cbd5e1] hover:bg-white"
>
<span className="truncate">{caption || fileName}</span>
<span className="ml-4 shrink-0 text-xs text-[#71717a]"></span>
</a>
);
};
const renderBlock = (
block: PageSubtreeBlock,
options: PageOptionsState,
documentId: string,
headingNumberingById: Map<string, string>,
depth: number,
orderedIndex: number,
): ReactNode => {
const key = block.id ?? `${block.type ?? "block"}-${depth}-${orderedIndex}`;
const props = block.props ?? {};
const inlineContent = renderInlineNodes(block.content, key);
const plainText = getInlineText(block.content).trim();
const children = renderChildren(block, options, documentId, headingNumberingById, depth);
if (block.type === "pageReference" && Boolean(props.asChildPage) && options.hideChildPages) {
return null;
}
switch (block.type) {
case "heading": {
const level = clampHeadingLevel(props.level);
const numbering = block.id ? headingNumberingById.get(block.id) ?? "" : "";
const HeadingTag = (`h${level}` as "h1" | "h2" | "h3" | "h4" | "h5");
return (
<div key={key} className="space-y-2">
<HeadingTag
data-id={block.id}
id={block.id}
className={cn(
"scroll-mt-24 font-semibold tracking-tight text-[#18181b]",
level === 1 && "text-[2rem]",
level === 2 && "text-[1.6rem]",
level === 3 && "text-[1.3rem]",
level >= 4 && "text-[1.08rem]",
)}
>
{options.showHeadingNumbers && numbering ? (
<span className="mr-2 text-[#94a3b8]">{numbering}</span>
) : null}
{inlineContent.length > 0 ? inlineContent : "未命名标题"}
</HeadingTag>
{children}
</div>
);
}
case "bulletListItem":
return (
<div key={key} className={cn("flex gap-3", LIST_INDENT_CLASS[Math.min(depth, LIST_INDENT_CLASS.length - 1)])}>
<span className="mt-2 text-sm text-[#64748b]"></span>
<div className="min-w-0 flex-1 space-y-2">
<div className="leading-7 text-[#27272a]">{inlineContent}</div>
{children}
</div>
</div>
);
case "numberedListItem":
return (
<div key={key} className={cn("flex gap-3", LIST_INDENT_CLASS[Math.min(depth, LIST_INDENT_CLASS.length - 1)])}>
<span className="mt-1.5 min-w-5 text-right text-sm font-medium text-[#64748b]">{orderedIndex}.</span>
<div className="min-w-0 flex-1 space-y-2">
<div className="leading-7 text-[#27272a]">{inlineContent}</div>
{children}
</div>
</div>
);
case "checkListItem": {
const checked = Boolean(props.checked);
return (
<div key={key} className={cn("flex gap-3", LIST_INDENT_CLASS[Math.min(depth, LIST_INDENT_CLASS.length - 1)])}>
<span className="mt-1.5 text-lg leading-none text-[#2563eb]">{checked ? "☑" : "☐"}</span>
<div className={cn("min-w-0 flex-1 space-y-2 leading-7 text-[#27272a]", checked && "text-[#71717a] line-through")}>
<div>{inlineContent}</div>
{children}
</div>
</div>
);
}
case "quote":
return (
<blockquote
key={key}
className="border-l-4 border-[#dbeafe] bg-[#f8fbff] px-4 py-3 text-[#334155]"
>
<div className="leading-7">{inlineContent}</div>
{children}
</blockquote>
);
case "codeBlock":
return (
<div key={key} className="space-y-2">
<pre className="overflow-x-auto rounded-2xl bg-[#0f172a] p-4 text-sm text-[#e2e8f0]">
<code>{plainText}</code>
</pre>
{children}
</div>
);
case "pageReference": {
const pageId = typeof props.pageId === "string" ? props.pageId : "";
const title = typeof props.title === "string" && props.title.trim() ? props.title : "未命名页面";
return (
<div key={key} className="space-y-2">
<Link
href={pageId ? `/documents/${pageId}` : "#"}
className="inline-flex items-center gap-2 rounded-xl border border-[#e4e4e7] bg-[#fafafa] px-3 py-2 text-sm font-medium text-[#27272a] transition hover:border-[#cbd5e1] hover:bg-white"
>
<span className="text-[#94a3b8]"></span>
<span>{title}</span>
</Link>
{children}
</div>
);
}
case "blockReference": {
const sourceDocumentId = typeof props.sourceDocumentId === "string" ? props.sourceDocumentId : "";
const targetBlockId = typeof props.targetBlockId === "string" ? props.targetBlockId : "";
const href = sourceDocumentId ? `/documents/${sourceDocumentId}${targetBlockId ? `#${targetBlockId}` : ""}` : "#";
return (
<div key={key} className="space-y-2 rounded-2xl border border-dashed border-[#cbd5e1] bg-[#fafafa] px-4 py-3">
<Link href={href} className="text-sm font-medium text-[#2563eb] underline underline-offset-2">
</Link>
{children}
</div>
);
}
case "advancedTodo": {
const status = String(props.status ?? "todo");
const faded = status === "done" || status === "cancelled";
return (
<div key={key} className="space-y-2 rounded-2xl border border-[#e4e4e7] bg-white px-4 py-3">
<div className="flex items-start gap-3">
<span
className={cn(
"rounded-full px-2.5 py-1 text-xs font-medium",
status === "done" && "bg-[#dcfce7] text-[#166534]",
status === "doing" && "bg-[#dbeafe] text-[#1d4ed8]",
status === "cancelled" && "bg-[#f3f4f6] text-[#6b7280]",
status === "todo" && "bg-[#fef3c7] text-[#92400e]",
)}
>
{TODO_STATUS_LABELS[status] ?? "未开始"}
</span>
<div className={cn("min-w-0 flex-1 leading-7 text-[#27272a]", faded && "text-[#71717a] line-through")}>
{inlineContent}
</div>
</div>
{children}
</div>
);
}
case "progressMeter": {
const percent = Math.min(100, Math.max(0, Number(props.percent ?? 0) || 0));
const summary = typeof props.summary === "string" && props.summary.trim() ? props.summary : "暂无条目";
return (
<div key={key} className="space-y-3 rounded-2xl border border-[#dbeafe] bg-[#f8fbff] px-4 py-4">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-[#1e3a8a]">{summary}</span>
<span className="text-[#64748b]">{percent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[#dbeafe]">
<div className="h-full rounded-full bg-[#2563eb]" style={{ width: `${percent}%` }} />
</div>
{children}
</div>
);
}
case "media":
return (
<div key={key} className="space-y-2">
{renderMediaBlock(block)}
{children}
</div>
);
case "onlineTable": {
const tableId = typeof props.tableId === "string" ? props.tableId : "";
return (
<div key={key} className="space-y-2 rounded-2xl border border-[#e4e4e7] bg-[#fafafa] px-4 py-4">
<div className="text-sm font-medium text-[#27272a]">
{typeof props.title === "string" && props.title.trim() ? props.title : "在线表格"}
</div>
<Link
href={tableId ? `/tables/${tableId}/view` : "#"}
className="inline-flex items-center text-sm text-[#2563eb] underline underline-offset-2"
>
</Link>
{children}
</div>
);
}
case "mindmap": {
const mindmapId = typeof block.id === "string" ? block.id : "";
const docId = typeof props.docId === "string" && props.docId.trim() ? props.docId : documentId;
return (
<div key={key} className="space-y-2 rounded-2xl border border-[#e4e4e7] bg-[#fafafa] px-4 py-4">
<div className="text-sm font-medium text-[#27272a]"></div>
<Link
href={docId && mindmapId ? `/mindmap/${docId}/${mindmapId}` : "#"}
className="inline-flex items-center text-sm text-[#2563eb] underline underline-offset-2"
>
</Link>
{children}
</div>
);
}
case "paragraph":
return (
<div key={key} className="space-y-2">
<p className="min-h-7 whitespace-pre-wrap break-words leading-7 text-[#27272a]">
{inlineContent.length > 0 ? inlineContent : <span className="text-[#d4d4d8]"> </span>}
</p>
{children}
</div>
);
default:
if (inlineContent.length === 0 && !children) {
return null;
}
return (
<div key={key} className="space-y-2">
{inlineContent.length > 0 ? (
<div className="whitespace-pre-wrap break-words leading-7 text-[#27272a]">{inlineContent}</div>
) : null}
{children}
</div>
);
}
};
const renderBlocks = (
blocks: PageSubtreeBlock[],
options: PageOptionsState,
documentId: string,
headingNumberingById: Map<string, string>,
depth: number,
) => {
let orderedIndex = 0;
return blocks.map((block, index) => {
orderedIndex = block.type === "numberedListItem" ? orderedIndex + 1 : 0;
return renderBlock(block, options, documentId, headingNumberingById, depth, orderedIndex || index + 1);
});
};
function DocumentReadStructurePanel({ pageSubtree }: { pageSubtree: PageSubtreeProjection }) {
const outlineEntries = pageSubtree.outline.slice(0, 10);
const evidenceEntries = pageSubtree.evidence.slice(0, 5);
return (
<section className="rounded-2xl border border-[#dbe4f0] bg-[#f8fbff] p-4">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-semibold text-[#1e293b]"> / Kernel Outline</div>
<span className="rounded-full bg-white px-2 py-1 text-[11px] text-[#64748b]">
{pageSubtree.subtree.nodes.length}
</span>
<span className="rounded-full bg-white px-2 py-1 text-[11px] text-[#64748b]">
{pageSubtree.stats.headingCount}
</span>
<span className="rounded-full bg-white px-2 py-1 text-[11px] text-[#64748b]">
{pageSubtree.stats.evidenceCount}
</span>
</div>
<div className="mt-3 grid gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)]">
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-[#64748b]"></div>
{outlineEntries.length > 0 ? (
<ul className="space-y-1.5 text-sm text-[#334155]">
{outlineEntries.map((entry) => (
<li key={entry.nodeId} className={cn(entry.level > 1 && "pl-4", entry.level > 2 && "pl-7", entry.level > 3 && "pl-10")}>
<span className="mr-2 font-mono text-[11px] text-[#94a3b8]">{entry.numbering}</span>
<span>{entry.title || "未命名标题"}</span>
</li>
))}
</ul>
) : (
<div className="text-sm text-[#64748b]"> subtree</div>
)}
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.18em] text-[#64748b]"></div>
{evidenceEntries.length > 0 ? (
<ul className="space-y-2">
{evidenceEntries.map((entry) => (
<li key={entry.id} className="rounded-xl border border-white/70 bg-white/80 px-3 py-2 text-sm text-[#334155]">
<div className="text-[11px] uppercase tracking-[0.12em] text-[#94a3b8]">{entry.kind}</div>
<div className="mt-1 line-clamp-2">{entry.snippet}</div>
</li>
))}
</ul>
) : (
<div className="text-sm text-[#64748b]"></div>
)}
</div>
</div>
</section>
);
}
export function DocumentReadView({ content, documentId, options, pageSubtree, className }: DocumentReadViewProps) {
const blocks = extractReadViewBlocks(content);
const resolvedPageSubtree =
pageSubtree ??
buildPageSubtreeProjection({
documentId,
title: null,
content,
});
const headingNumberingById =
resolvedPageSubtree.outline.length > 0
? buildHeadingNumberingMapFromOutline(resolvedPageSubtree.outline)
: buildHeadingNumberingMap(blocks);
if (blocks.length === 0) {
return (
<div className={cn("space-y-4", className)}>
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null}
<div className="flex min-h-[40vh] items-center justify-center rounded-2xl border border-dashed border-[#e4e4e7] bg-[#fafafa] px-6 py-10 text-sm text-[#71717a]">
</div>
</div>
);
}
return (
<div className={cn("space-y-4", className)}>
{options.showStructure ? <DocumentReadStructurePanel pageSubtree={resolvedPageSubtree} /> : null}
{renderBlocks(blocks, options, documentId, headingNumberingById, 0)}
</div>
);
}
@@ -1,37 +1,8 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import type { DocumentContentProps } from "@/components/editor/document-content";
const DocumentContent = dynamic(
() => import("@/components/editor/document-content").then((mod) => mod.DocumentContent),
{
ssr: false,
loading: () => (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
...
</div>
),
},
);
import { DocumentContent } from "@/components/editor/document-content";
export function DocumentShell(props: DocumentContentProps) {
const [mounted, setMounted] = useState(false);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
setMounted(true);
}, []);
/* eslint-enable react-hooks/set-state-in-effect */
if (!mounted) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm text-gray-500">
...
</div>
);
}
return <DocumentContent {...props} />;
}