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