feat: 接入 mnote web tree shell 与主页链路整理

- 增加 mnote-web tree/command 支持与前端 MnoteWebTreeShell 集成
- 调整 sidebar、documents、runtime config 与 dev/prod server 配套逻辑
- 补充 homepage/tree shell smoke 脚本并更新 harness 进度文件
This commit is contained in:
lix-2026
2026-04-17 23:36:24 +08:00
parent cfc3af8984
commit d8de820d93
40 changed files with 4668 additions and 4143 deletions
@@ -21,7 +21,6 @@ import type { MediaAsset } from "@/types/media";
import { customBlockSchema, type CustomBlockSchema } from "./schema";
import { CustomSideMenu } from "./menus/CustomSideMenu";
import { CustomSlashMenu } from "./menus/CustomSlashMenu";
import { MoveEmbedPickerHost } from "@/components/documents/move-embed-picker-host";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DocumentToc, type TocEntry } from "@/components/editor/document-toc";
import { useSearchPaletteStore } from "@/store/search-palette";
@@ -36,6 +35,7 @@ import { useCommentsUiStore } from "@/store/comments-ui";
import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
import type { EditorReferenceBridge } from "@/store/editor-bridge";
interface BlockNoteEditorProps {
documentId: string;
@@ -197,6 +197,10 @@ export function BlockNoteEditor({
const isFullScreenTableOpen = fullScreenTableId !== null;
const revisionRef = useRef<number | null>(initialRevision);
const conflictDetectionKeyRef = useRef<string | null>(initialConflictDetectionKey);
const onStatsChangeRef = useRef(onStatsChange);
const onSnapshotRef = useRef(onSnapshot);
const onPersistedMetaChangeRef = useRef(onPersistedMetaChange);
const stableInitialContentRef = useRef<{ documentId: string; content: Json | undefined } | null>(null);
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
@@ -226,6 +230,16 @@ export function BlockNoteEditor({
[initialContent],
);
if (
!stableInitialContentRef.current ||
stableInitialContentRef.current.documentId !== documentId
) {
stableInitialContentRef.current = {
documentId,
content: normalizedInitialContent,
};
}
const collaboration = useMemo(() => {
const url = process.env.NEXT_PUBLIC_HOCUSPOCUS_URL;
if (!url) return null;
@@ -240,7 +254,7 @@ export function BlockNoteEditor({
const editor = useCreateBlockNote(
{
initialContent: normalizedInitialContent as never,
initialContent: stableInitialContentRef.current?.content as never,
schema: customBlockSchema,
placeholders: {
default: "输入'/'选择,按 空格 打开AI...",
@@ -257,7 +271,7 @@ export function BlockNoteEditor({
}
: undefined,
},
[documentId, normalizedInitialContent],
[documentId],
);
useEffect(
@@ -268,6 +282,20 @@ export function BlockNoteEditor({
[collaboration],
);
const editorRef = useRef(editor);
const insertMediaAssetRef = useRef<(asset: MediaAsset) => void>(() => {});
const insertMindmapRef = useRef<(args: { documentId: string; mindmapId: string }) => void>(() => {});
const insertOnlineTableRef = useRef<(args: { documentId: string; tableId: string }) => void>(() => {});
const setFullScreenTableIdRef = useRef(setFullScreenTableId);
useEffect(() => {
editorRef.current = editor;
}, [editor]);
useEffect(() => {
setFullScreenTableIdRef.current = setFullScreenTableId;
}, [setFullScreenTableId]);
useEffect(() => {
revisionRef.current = initialRevision;
}, [initialRevision]);
@@ -276,6 +304,18 @@ export function BlockNoteEditor({
conflictDetectionKeyRef.current = initialConflictDetectionKey;
}, [initialConflictDetectionKey]);
useEffect(() => {
onStatsChangeRef.current = onStatsChange;
}, [onStatsChange]);
useEffect(() => {
onSnapshotRef.current = onSnapshot;
}, [onSnapshot]);
useEffect(() => {
onPersistedMetaChangeRef.current = onPersistedMetaChange;
}, [onPersistedMetaChange]);
const saveContent = useCallback(
async (content: Json) => {
setIsSaving(true);
@@ -324,7 +364,7 @@ export function BlockNoteEditor({
: conflictDetectionKeyRef.current;
revisionRef.current = nextRevision ?? null;
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
onPersistedMetaChange?.({
onPersistedMetaChangeRef.current?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
});
@@ -337,7 +377,7 @@ export function BlockNoteEditor({
setIsSaving(false);
}
},
[documentId, onPersistedMetaChange, workspaceId],
[documentId, workspaceId],
);
const debouncedSave = useDebouncedCallback(saveContent, 800);
@@ -705,8 +745,8 @@ export function BlockNoteEditor({
setTocEntries(buildHeadingToc(typedBlocks));
syncProgressMeters(editor);
const stats = computeDocumentStats(typedBlocks);
onStatsChange?.(stats);
onSnapshot?.({ blocks: blocks as Json, stats });
onStatsChangeRef.current?.(stats);
onSnapshotRef.current?.({ blocks: blocks as Json, stats });
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
const { assetIds, mindmapBlockIds, onlineTableIds } = collectAssets(typedBlocks);
@@ -745,7 +785,7 @@ export function BlockNoteEditor({
disposed = true;
unsubscribe?.();
};
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, onSnapshot, onStatsChange, restoreOnlineTableIfNeeded]);
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, restoreOnlineTableIfNeeded]);
const jumpToHeading = useCallback((headingId: string) => {
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
@@ -1003,6 +1043,18 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
[documentId, editor],
);
useEffect(() => {
insertMediaAssetRef.current = insertMediaAssetBlock;
}, [insertMediaAssetBlock]);
useEffect(() => {
insertMindmapRef.current = insertMindmapBlock;
}, [insertMindmapBlock]);
useEffect(() => {
insertOnlineTableRef.current = insertOnlineTableBlock;
}, [insertOnlineTableBlock]);
useEffect(() => {
if (!editor) return undefined;
@@ -1090,46 +1142,48 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
[documentId, workspaceId],
);
useEffect(() => {
if (!editor) {
registerEditorBridge(null);
return;
}
const bridge = {
const stableEditorBridge = useMemo<EditorReferenceBridge>(
() => ({
undo: () => {
try {
editor.focus();
editor.undo();
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.focus();
currentEditor.undo();
} catch {
// ignore
}
},
redo: () => {
try {
editor.focus();
editor.redo();
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.focus();
currentEditor.redo();
} catch {
// ignore
}
},
openTableFullScreen: (tableId: string) => {
setFullScreenTableId(tableId);
setFullScreenTableIdRef.current(tableId);
},
insertMediaAsset: (asset: MediaAsset) => {
insertMediaAssetBlock(asset);
insertMediaAssetRef.current(asset);
},
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
insertMindmapBlock(args);
insertMindmapRef.current(args);
},
insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => {
insertOnlineTableBlock(args);
insertOnlineTableRef.current(args);
},
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
const currentEditor = editorRef.current;
if (!currentEditor) return { blockId: null };
currentEditor.focus();
const cursor = currentEditor.getTextCursorPosition();
const blockId = cursor?.block?.id ?? null;
const text = aliasText || target.title || "无标题";
editor.insertInlineContent([
currentEditor.insertInlineContent([
{
type: "link",
href: buildDocumentPath(target.id),
@@ -1140,12 +1194,14 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
return { blockId };
},
insertEmbedReference: (target: ReferenceTarget) => {
editor.focus();
const cursor = editor.getTextCursorPosition();
const currentEditor = editorRef.current;
if (!currentEditor) return { blockId: null };
currentEditor.focus();
const cursor = currentEditor.getTextCursorPosition();
const referenceBlock =
cursor?.block ?? editor.topLevelBlocks[editor.topLevelBlocks.length - 1];
cursor?.block ?? currentEditor.topLevelBlocks[currentEditor.topLevelBlocks.length - 1];
const blockId = generateBlockId();
editor.insertBlocks(
currentEditor.insertBlocks(
[
{
id: blockId,
@@ -1162,7 +1218,9 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
return { blockId };
},
replaceWithSnapshot: (payload: Json) => {
editor.focus();
const currentEditor = editorRef.current;
if (!currentEditor) return;
currentEditor.focus();
const nextBlocks = Array.isArray(payload)
? payload
: Array.isArray((payload as { blocks?: Json }).blocks)
@@ -1171,20 +1229,29 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
if (!Array.isArray(nextBlocks)) {
return;
}
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
currentEditor.replaceBlocks(currentEditor.topLevelBlocks, nextBlocks as never);
},
getCursorBlockId: () => {
try {
const cursor = editor.getTextCursorPosition();
const currentEditor = editorRef.current;
if (!currentEditor) return null;
const cursor = currentEditor.getTextCursorPosition();
return cursor?.block?.id ?? null;
} catch {
return null;
}
},
};
registerEditorBridge(bridge);
}),
[],
);
useEffect(() => {
registerEditorBridge(editor ? stableEditorBridge : null);
}, [editor, registerEditorBridge, stableEditorBridge]);
useEffect(() => {
return () => registerEditorBridge(null);
}, [editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock, registerEditorBridge]);
}, [registerEditorBridge]);
useEffect(() => {
if (!editor) return;
@@ -1336,9 +1403,6 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
</div>
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
</div>
<MoveEmbedPickerHost />
{/* 全屏表格编辑器 Modal */}
{fullScreenTableId && (
<FullScreenTableEditor
@@ -3,14 +3,10 @@
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options";
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
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 { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
import { DocumentCommentsDrawer } from "@/components/editor/document-comments-drawer";
import type { DocumentSnapshot } from "@/types/document";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { ImagePickerProvider } from "@/components/media/image-picker-context";
@@ -25,6 +21,7 @@ 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";
import { moveDocumentCommand, renameDocumentCommand } from "@/lib/documents/tree-command-client";
const BlockNoteEditor = dynamic(
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
@@ -36,6 +33,45 @@ const BlockNoteEditor = dynamic(
},
);
// 说明:这些组件都不是“进入文档页首屏”所必需。
// 先拆成独立 chunk,避免 /documents/[id] 首次编译时把评论、历史、弹层、检查器等整串依赖一并拉进来。
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 {
documentId: string;
workspaceId: string;
@@ -358,11 +394,11 @@ export function DocumentContent({
async (nextTitle: string) => {
if (readOnly) return;
const payload = nextTitle.trim() || "无标题";
await fetch("/api/documents/title", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, workspaceId, title: payload }),
});
try {
await renameDocumentCommand({ documentId, workspaceId, title: payload });
} catch (error) {
console.error("更新页面标题失败", error);
}
},
[documentId, readOnly, workspaceId],
);
@@ -590,14 +626,15 @@ export function DocumentContent({
excludeIds: [documentId],
onPick: async (mode, targetId) => {
if (mode === "move") {
const resp = await fetch("/api/documents/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, parentId: targetId ?? null, position: 999999 }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "移动失败");
try {
await moveDocumentCommand({
documentId,
parentId: targetId ?? null,
position: 999999,
workspaceId,
});
} catch (error) {
window.alert(error instanceof Error ? error.message : "移动失败");
return;
}
window.alert("移动成功");
@@ -640,7 +677,18 @@ export function DocumentContent({
const formattedUpdatedAt = useMemo(() => {
if (!updatedAt) return "";
return new Date(updatedAt).toLocaleString();
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(() => {
@@ -924,6 +972,7 @@ export function DocumentContent({
history={history}
onRestore={handleRestoreSnapshot}
/>
<MoveEmbedPickerHost />
<DocumentCommentsDrawer />
<DocumentAiAgentPanel
documentId={documentId}
@@ -19,6 +19,7 @@ import { deleteOnlineTable } from "@/lib/online-table";
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
import { useCommentsUiStore } from "@/store/comments-ui";
import { createChildDocumentCommand } from "@/lib/documents/tree-command-client";
type InlineNode = { text?: unknown };
type TableMenuBlock = Parameters<
@@ -196,32 +197,26 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
}, [block, currentDocumentId, editor, removePageReference]);
const turnToPage = useCallback(async () => {
const response = await fetch("/api/documents/create-child", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
try {
const payload = await createChildDocumentCommand({
parentId: currentDocumentId,
title: extractText(block),
blocks: [block],
}),
});
});
if (!response.ok) {
return;
editor.replaceBlocks(
[block.id],
[
{
type: "pageReference",
props: { pageId: payload.pageId, title: payload.title, asChildPage: true },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
} catch (error) {
console.error("块转页面失败", error);
}
const { pageId, title } = await response.json();
editor.replaceBlocks(
[block.id],
[
{
type: "pageReference",
props: { pageId, title, asChildPage: true },
} as PartialBlock<CustomBlockSchema>,
],
);
router.refresh();
}, [block, currentDocumentId, editor, router]);
const handleMoveEmbedPick = useCallback(
@@ -39,15 +39,26 @@ const BacklinkItem = ({ record }: { record: BacklinkRecord }) => (
);
export function PageBacklinksPanel({ workspaceId, documentId, className, defaultCollapsed }: PageBacklinksPanelProps) {
const [isReady, setIsReady] = useState(false);
const { data, isLoading, error, refetch, isFetching } = useBacklinks({
workspaceId,
documentId,
enabled: isReady,
});
const records = useMemo(() => data ?? [], [data]);
// 说明:collapsed 需要可交互;这里用一个轻量的局部状态,但默认值来自 props(用于“自定义页面:折叠引用列表”)。
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed));
useEffect(() => setIsCollapsed(Boolean(defaultCollapsed)), [defaultCollapsed]);
useEffect(() => {
setIsReady(false);
const timer = window.setTimeout(() => {
setIsReady(true);
}, 600);
return () => {
window.clearTimeout(timer);
};
}, [documentId, workspaceId]);
// 避免页面切换时“先出现加载态、随后又消失”的闪烁:
// 当尚未拿到任何记录且正在加载时,直接不渲染面板。
if (!error && records.length === 0 && (isLoading || isFetching)) {