feat: 收口 mindmap Phase 6 Leptos UI shell

This commit is contained in:
lix-2026
2026-05-11 12:27:40 +08:00
parent b7dddd2a66
commit b5eb27fabc
51 changed files with 8669 additions and 1313 deletions
@@ -25,6 +25,14 @@ export async function GET(
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
const url = new URL(request.url);
const view = url.searchParams.get("view");
const queryName =
view === "simple_mind_map_scene" || url.searchParams.get("queryName") === "mindmap.simple_mind_map_scene.get"
? "mindmap.simple_mind_map_scene.get"
: view === "editor_scene"
? "mindmap.editor_scene.get"
: "mindmaps.get";
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
@@ -43,11 +51,12 @@ export async function GET(
},
});
const envelope = buildDocumentQueryEnvelope({
name: "mindmaps.get",
name: queryName,
payload: {
documentId: docId,
mindmapId,
workspaceId: null,
rootNodeId: url.searchParams.get("rootNodeId"),
},
});
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
@@ -65,6 +74,9 @@ export async function GET(
client,
plan,
});
if (queryName === "mindmap.simple_mind_map_scene.get" || queryName === "mindmap.editor_scene.get") {
return NextResponse.json(res);
}
return NextResponse.json({
data: res?.data ?? defaultMindmapData,
source: "convex",
@@ -97,9 +109,12 @@ export async function POST(
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
const { data, createOnly, commandName, commands, projectionRevision } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown;
createOnly?: boolean;
commandName?: string;
commands?: unknown[];
projectionRevision?: number | null;
};
try {
@@ -116,22 +131,31 @@ export async function POST(
client: "wolai-frontend",
},
});
const isCommandApply = commandName === "mindmap.command.apply";
const envelope = buildDocumentCommandEnvelope({
name: "mindmaps.put",
payload: {
documentId: docId,
mindmapId,
data: data ?? defaultMindmapData,
createOnly: typeof createOnly === "boolean" ? createOnly : false,
},
name: isCommandApply ? "mindmap.command.apply" : "mindmaps.put",
payload: isCommandApply
? {
documentId: docId,
mindmapId,
workspaceId: null,
commands: Array.isArray(commands) ? commands : [],
projectionRevision: typeof projectionRevision === "number" ? projectionRevision : null,
}
: {
documentId: docId,
mindmapId,
data: data ?? defaultMindmapData,
createOnly: typeof createOnly === "boolean" ? createOnly : false,
},
context,
target: {
workspaceId: null,
pageId: docId,
blockId: mindmapId,
},
reason: "mindmap-route:put",
refs: ["task-032", "mindmap-route"],
reason: isCommandApply ? "mindmap-route:command-apply" : "mindmap-route:put",
refs: isCommandApply ? ["task-166", "mindmap-command-bridge"] : ["task-032", "mindmap-route"],
});
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
const result = await executeRustBridgeMutationTransport<{
@@ -1,30 +0,0 @@
"use client";
import { MindmapBlockView, defaultMindmapData } from "@/components/editor/blocks/MindmapBlock";
import type { BlockNoteEditor } from "@blocknote/core";
import type { CustomBlockSchema } from "@/components/editor/schema";
const stubBlock = {
id: "dev-mindmap",
type: "mindmap",
props: {
docId: "dev",
data: defaultMindmapData,
},
content: [],
children: [],
} as any;
const editorStub = {
updateBlock: () => {
/* 开发沙盒中跳过持久化 */
},
} as unknown as BlockNoteEditor<CustomBlockSchema>;
export default function MindmapDevPage() {
return (
<div className="fixed inset-0 bg-white">
<MindmapBlockView block={stubBlock} editor={editorStub} fullscreen />
</div>
);
}
@@ -7,31 +7,41 @@ import {
buildMindmapProjection,
defaultMindmapData,
type MindmapProjection,
type MindmapSimpleMindMapScene,
} from "@/lib/mindmap/mindmap-projection";
import MindmapPageClient from "./mindmap-page-client";
type MindmapRouteQueryResult = {
data?: unknown;
meta?: unknown;
};
export const MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT = {
shellOwner: "mnote-web",
runtimeRole: "legacy_compat_island",
island: "mindmap_runtime",
runtimeRole: "simple_mind_map_adapter",
island: "leptos_mindmap_adapter",
projectionQueryName: "mindmap.simple_mind_map_scene.get",
commandName: "mindmap.command.apply",
} as const;
async function fetchMindmapProjectionOnServer(input: {
async function fetchMindmapAdapterProjectionOnServer(input: {
docId: string;
mindmapId: string;
}): Promise<MindmapProjection | null> {
}): Promise<MindmapSimpleMindMapScene | null> {
if (!isConvexEnabled()) {
return buildMindmapProjection({
return {
schema: "mnote.mindmap.simple_mind_map_scene.v1",
runtime: "simple-mind-map",
documentId: input.docId,
mindmapId: input.mindmapId,
data: defaultMindmapData,
rootNodeId: null,
root: defaultMindmapData,
layout: "logicalStructure",
theme: "classic",
themeConfig: {},
view: {},
config: {},
compatPayload: { source: "compat-blob", mindmapId: input.mindmapId },
kernelRevision: 1,
source: "compat-blob",
owner: "rust-kernel",
meta: null,
});
};
}
try {
@@ -63,7 +73,7 @@ async function fetchMindmapProjectionOnServer(input: {
workspaceId: null,
});
const envelope = buildDocumentQueryEnvelope({
name: "mindmaps.get",
name: MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.projectionQueryName,
payload: {
documentId: input.docId,
mindmapId: input.mindmapId,
@@ -74,43 +84,70 @@ async function fetchMindmapProjectionOnServer(input: {
context,
envelope,
});
const result = await executeRustBridgeQueryTransport<MindmapRouteQueryResult | null>({
const result = await executeRustBridgeQueryTransport<MindmapSimpleMindMapScene | null>({
client,
plan,
});
return buildMindmapProjection({
documentId: input.docId,
mindmapId: input.mindmapId,
data: result?.data ?? defaultMindmapData,
meta: result?.meta ?? {
requestId: context.requestId,
traceId: context.traceId,
workspaceId: context.workspaceId,
documentId: input.docId,
pageId: input.docId,
mindmapId: input.mindmapId,
attachmentId: input.mindmapId,
},
});
return result
? {
...result,
source: result.source ?? "compat-blob",
owner: result.owner ?? "rust-kernel",
meta: result.meta ?? {
requestId: context.requestId,
traceId: context.traceId,
workspaceId: context.workspaceId,
documentId: input.docId,
pageId: input.docId,
mindmapId: input.mindmapId,
attachmentId: input.mindmapId,
},
}
: null;
} catch {
return null;
}
}
function buildStandaloneProjectionFromAdapter(input: {
docId: string;
mindmapId: string;
adapterProjection: MindmapSimpleMindMapScene | null;
}): MindmapProjection | null {
if (!input.adapterProjection) return null;
return buildMindmapProjection({
documentId: input.docId,
mindmapId: input.mindmapId,
data: input.adapterProjection.root ?? defaultMindmapData,
source: input.adapterProjection.source ?? "compat-blob",
owner: input.adapterProjection.owner ?? "rust-kernel",
meta: input.adapterProjection.meta,
});
}
export default async function MindmapFullscreenPage({
params,
}: {
params: Promise<{ docId: string; mindmapId: string }>;
}) {
const { docId, mindmapId } = await params;
const initialProjection = await fetchMindmapProjectionOnServer({ docId, mindmapId });
const initialAdapterProjection = await fetchMindmapAdapterProjectionOnServer({ docId, mindmapId });
const initialProjection = buildStandaloneProjectionFromAdapter({
docId,
mindmapId,
adapterProjection: initialAdapterProjection,
});
return (
<main
data-react-island={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.island}
data-leptos-mindmap-island={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.island}
data-runtime-role={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.runtimeRole}
data-shell-owner={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.shellOwner}
data-projection-query={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.projectionQueryName}
data-command-name={MINDMAP_PAGE_LEGACY_COMPAT_CONTRACT.commandName}
data-adapter-source={initialAdapterProjection?.source ?? "compat-blob"}
data-kernel-revision={initialAdapterProjection?.kernelRevision ?? 1}
>
<MindmapPageClient
docId={docId}
@@ -1,6 +1,9 @@
"use client";
import "simple-mind-map/dist/simpleMindMap.esm.css";
"use client";
// 说明:本文件仅保留为 legacy/compat/reference 实现,用于独立导图页、
// 历史 BlockNote 导图块和 blob import/export 对照;3000 文档页 Phase 6
// 默认主链是 leptos-tiptap NodeView + leptos-mindmap adapter。
import "simple-mind-map/dist/simpleMindMap.esm.css";
import React, {
useCallback,
useEffect,
@@ -1,7 +1,10 @@
"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import { createPortal } from "react-dom";
import type { MindMapNode } from "./mindmapTypes";
// 菜单项配置
@@ -1,6 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useState } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
type CountProps = {
mindmap: any;
};
@@ -3,6 +3,9 @@
/* eslint-disable @next/next/no-img-element */
import React, { useEffect, useRef, useState } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
type MiniMapProps = {
mindmap: any;
show: boolean;
@@ -1,5 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useState, useEffect } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import { Minus, Plus, Maximize, Map, MapPin, Minimize, Eye, EyeOff } from "lucide-react";
type NavigatorProps = {
@@ -1,6 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useState } from "react";
import Image from "next/image";
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { useEffect, useMemo, useState } from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import Image from "next/image";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Toggle } from "@/components/ui/toggle";
@@ -1,4 +1,7 @@
import React from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import { ChevronRight } from "lucide-react";
import { sidebarTriggers, type SidebarPanel } from "./mindmapSidebarConfig";
@@ -1,5 +1,8 @@
import React from "react";
import {
import React from "react";
// 说明:legacy/reference/compat 组件,仅作为 Phase 6 UI shell 迁移素材。
// 3000 文档页默认 mindmap 主链是 leptos-tiptap NodeView + Leptos/Rust shell。
import {
fileToolbarMeta,
fileToolbarOrder,
nodeToolbarMeta,
@@ -5,6 +5,12 @@ import {
} from "@/components/editor/editor-host-config";
import { describe, expect, it } from "vitest";
const phase6MindmapSuccessSelectors = [
'[data-testid="mnote-mindmap-editor-root"]',
'[data-testid="simple-mind-map-runtime"]',
'[data-testid="mindmap-rust-shell"]',
];
describe("editor-host-config", () => {
it("保持 island 为默认正式 host", () => {
expect(DEFAULT_EDITOR_HOST_KIND).toBe("leptos_tiptap_island");
@@ -16,22 +22,34 @@ describe("editor-host-config", () => {
expect(normalizeEditorHostKind("leptos_tiptap")).toBe("leptos_tiptap_island");
});
it("保留 iframe debug 作为显式调试 host", () => {
expect(normalizeEditorHostKind("leptos_tiptap_iframe_debug")).toBe(
"leptos_tiptap_iframe_debug",
);
expect(normalizeEditorHostKind("iframe_debug")).toBe("leptos_tiptap_iframe_debug");
expect(normalizeEditorHostKind("leptos_tiptap_debug")).toBe(
"leptos_tiptap_iframe_debug",
);
});
it("优先尊重 query override 的 debug host 选择", () => {
it(" iframe debug host 配置应折返到正式 island host", () => {
expect(normalizeEditorHostKind("leptos_tiptap_iframe_debug")).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("iframe_debug")).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("leptos_tiptap_debug")).toBe("leptos_tiptap_island");
expect(
resolveEditorHostKind({
override: "leptos_tiptap_iframe_debug",
runtimeDefault: "leptos_tiptap_island",
}),
).toBe("leptos_tiptap_iframe_debug");
).toBe("leptos_tiptap_island");
});
it("不再允许通过 query 或 runtime 默认值切回 blocknote", () => {
expect(
resolveEditorHostKind({
override: "blocknote",
runtimeDefault: "leptos_tiptap_island",
}),
).toBe("leptos_tiptap_island");
expect(normalizeEditorHostKind("blocknote")).toBe("leptos_tiptap_island");
});
it("Phase 6 mindmap 成功条件不应依赖旧 React runtime 标记", () => {
expect(phase6MindmapSuccessSelectors).not.toContain(
".mnote-mindmap-react-mount",
);
expect(phase6MindmapSuccessSelectors).not.toContain(
"window.__mindmapInstance",
);
});
});
@@ -1,7 +1,4 @@
export type EditorHostKind =
| "blocknote"
| "leptos_tiptap_island"
| "leptos_tiptap_iframe_debug";
export type EditorHostKind = "leptos_tiptap_island";
export interface EditorHostConfig {
kind: EditorHostKind;
@@ -15,7 +12,7 @@ export function normalizeEditorHostKind(
): EditorHostKind {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "blocknote") {
return "blocknote";
return fallback;
}
if (
normalized === "leptos_tiptap_runtime" ||
@@ -24,15 +21,7 @@ export function normalizeEditorHostKind(
) {
return "leptos_tiptap_island";
}
// 说明:iframe 只保留给显式调试桥,不再混入正式 runtime 主链
if (
normalized === "leptos_tiptap_iframe_debug" ||
normalized === "leptos_tiptap_debug" ||
normalized === "iframe_debug"
) {
return "leptos_tiptap_iframe_debug";
}
// 说明:保留历史 query 值兼容;未显式声明 debug 时,一律回到正式 runtime host。
// 说明:历史 debug host 已退出文档页主路径,旧 query/runtime 值统一折返正式 island host
return fallback;
}
@@ -53,7 +42,7 @@ export function resolveEditorHostKind(input: {
}
export function isLeptosTiptapHostKind(kind: EditorHostKind): boolean {
return kind !== "blocknote";
return kind === "leptos_tiptap_island";
}
export function getEditorHostKindFromEnv(value?: unknown): EditorHostKind {
@@ -17,19 +17,6 @@ const LeptosTiptapIslandEditor = dynamic(
},
) as ComponentType<DocumentEditorHostProps>;
const LeptosTiptapIframeDebugEditor = dynamic(
() => import("@/components/editor/leptos-tiptap-editor-host").then((mod) => mod.LeptosTiptapEditorHost),
{
ssr: false,
loading: () => (
<div className="flex h-64 items-center justify-center text-sm text-gray-400">...</div>
),
},
) as ComponentType<DocumentEditorHostProps>;
export function EditorHost(props: DocumentEditorHostProps) {
if (props.hostKind === "leptos_tiptap_iframe_debug") {
return <LeptosTiptapIframeDebugEditor {...props} />;
}
return <LeptosTiptapIslandEditor {...props} />;
}
@@ -1,6 +1,7 @@
import { act, useState } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { readFileSync } from "node:fs";
import { LeptosTiptapIslandEditorHost } from "@/components/editor/leptos-tiptap-island-editor-host";
import type { DocumentEditorHostProps } from "@/components/editor/editor-host-types";
@@ -135,6 +136,18 @@ describe("leptos-tiptap-island-editor-host", () => {
let container: HTMLDivElement;
let root: Root;
it("默认 island host 不加载旧 React mindmap 主链", () => {
const source = readFileSync("src/components/editor/leptos-tiptap-island-editor-host.tsx", "utf8");
const smokeSource = readFileSync("../scripts/task166-mindmap-phase6-block-smoke.js", "utf8");
expect(source).not.toContain("MindmapBlockView");
expect(source).not.toContain("mnote-mindmap-react-mount");
expect(source).not.toContain("mnoteMindmapData");
expect(source).not.toContain("__mindmapInstance");
expect(smokeSource).not.toContain("mnote-mindmap-react-mount");
expect(smokeSource).not.toContain("__mindmapInstance");
});
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
@@ -39,8 +39,21 @@ type IslandRuntimeModule = {
default: (input?: RequestInfo | URL | Response | BufferSource | WebAssembly.Module) => Promise<unknown>;
mount: (container: Element, options: unknown) => number;
unmount: (mountId: number) => void;
mount_mindmap_shell?: (container: Element, options: unknown) => number;
unmount_mindmap_shell?: (mountId: number) => void;
};
type MindmapRustShellBridge = {
mount: (container: Element, options: unknown) => number;
unmount: (mountId: number) => void;
};
declare global {
interface Window {
__MNOTE_MINDMAP_RUST_SHELL__?: MindmapRustShellBridge;
}
}
type RuntimeEnvelope<T = unknown> = {
protocol?: string;
runtime?: string;
@@ -251,6 +264,15 @@ async function loadIslandRuntime(): Promise<{
throw new Error("island entry 缺少 unmount 导出");
}
await runtimeModule.default(wasmAssetUrl ?? undefined);
if (
typeof runtimeModule.mount_mindmap_shell === "function" &&
typeof runtimeModule.unmount_mindmap_shell === "function"
) {
window.__MNOTE_MINDMAP_RUST_SHELL__ = {
mount: runtimeModule.mount_mindmap_shell,
unmount: runtimeModule.unmount_mindmap_shell,
};
}
return {
runtimeModule,
entryAssetUrl,
@@ -778,9 +800,6 @@ export function LeptosTiptapIslandEditorHost(props: DocumentEditorHostProps) {
);
}
},
requestFallbackToBlockNote: () => {
requestFallback("explicit_fallback", "通过页面壳显式切回 BlockNote");
},
};
useEditorBridgeStore.getState().registerBridge(editorBridge);
return () => {
@@ -219,4 +219,54 @@ describe("tiptap-content-converter", () => {
]);
});
it("保留 slash 插入的 mindmap placeholder 骨架为 mindmap legacy block", () => {
const tiptapDoc = {
type: "doc" as const,
content: [
{
type: "paragraph",
attrs: {
blockId: "mind_1",
mnoteBlockType: "mindmap",
mnoteMindmapData: {
data: { uid: "root", text: "KMIND", generalization: { text: "概要" } },
children: [
{
data: { uid: "topic", text: "二级节点" },
children: [
{ data: { uid: "branch-1", text: "分支主题" }, children: [] },
{ data: { uid: "branch-2", text: "分支主题" }, children: [] },
],
},
],
},
},
content: [{ type: "text", text: "KMIND / 二级节点 / 分支主题 / 分支主题 / 概要" }],
},
],
};
expect(blocksFromTiptapDoc(tiptapDoc)).toEqual([
{
id: "mind_1",
type: "mindmap",
props: {
data: {
data: { uid: "root", text: "KMIND", generalization: { text: "概要" } },
children: [
{
data: { uid: "topic", text: "二级节点" },
children: [
{ data: { uid: "branch-1", text: "分支主题" }, children: [] },
{ data: { uid: "branch-2", text: "分支主题" }, children: [] },
],
},
],
},
},
content: "",
},
]);
});
});
@@ -31,7 +31,8 @@ export type EditorBlockType =
| "numbered_list_item"
| "todo"
| "quote"
| "code_block";
| "code_block"
| "mindmap";
export type EditorContentNode = {
type: "text";
@@ -46,6 +47,7 @@ export type EditorBlock = {
headingLevel?: number | null;
checked?: boolean | null;
language?: string | null;
data?: unknown;
};
contentNodes?: EditorContentNode[];
childBlockIds?: string[];
@@ -246,6 +248,15 @@ function normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBloc
contentNodes,
childBlockIds: [],
};
case "mindmap": {
return {
blockId,
blockType: "mindmap",
props: { data: props.data ?? block.content },
contentNodes: [],
childBlockIds: [],
};
}
case "media": {
const sourcePath = firstNonEmptyText(props.sourcePath, props.url, props.src);
const name = firstNonEmptyText(props.name, props.fileName, props.title, sourcePath);
@@ -361,6 +372,16 @@ function blockToTiptapNode(block: EditorBlock): TiptapNode {
attrs: { ...commonAttrs, language: block.props?.language ?? null },
content: textNodesToInline(block.contentNodes),
};
case "mindmap":
return {
type: "paragraph",
attrs: {
...commonAttrs,
mnoteBlockType: "mindmap",
mnoteMindmapData: block.props?.data ?? null,
},
content: textNodesToInline(block.contentNodes),
};
case "paragraph":
default:
return {
@@ -408,6 +429,15 @@ function nodeBlockId(node: TiptapNode, index: number): string {
function tiptapNodeToBlock(node: TiptapNode, index: number): EditorBlock | null {
const blockId = nodeBlockId(node, index);
if (node.attrs?.mnoteBlockType === "mindmap") {
return {
blockId,
blockType: "mindmap",
props: { data: node.attrs.mnoteMindmapData },
contentNodes: [],
childBlockIds: [],
};
}
switch (node.type) {
case "paragraph":
return { blockId, blockType: "paragraph", props: {}, contentNodes: extractInlineContent(node), childBlockIds: [] };
@@ -592,8 +622,10 @@ export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocumen
? { checked: Boolean(block.props?.checked) }
: block.blockType === "code_block"
? { language: block.props?.language ?? null }
: block.blockType === "mindmap"
? { data: block.props?.data ?? null }
: undefined,
content: legacyContentFromNodes(block.contentNodes),
content: block.blockType === "mindmap" ? "" : legacyContentFromNodes(block.contentNodes),
}));
}
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import {
createMindmapAdapterProjectionEndpoint,
createMindmapCommandApplyEndpoint,
executeMindmapCommandApply,
executeMindmapCommandApplyAndRefreshProjection,
isMindmapAdapterProjection,
requestMindmapAdapterProjection,
} from "./leptos-mindmap-adapter";
import { createFallbackMindmapAdapterProjection } from "./simple-mind-map-bridge";
describe("leptos-mindmap adapter projection", () => {
it("识别 adapter projection contract", () => {
const projection = createFallbackMindmapAdapterProjection({ mindmapId: "mind_1" });
expect(isMindmapAdapterProjection(projection)).toBe(true);
expect(isMindmapAdapterProjection({ ...projection, runtime: "other" })).toBe(false);
});
it("构建 simple_mind_map_scene 查询 endpoint", () => {
expect(createMindmapAdapterProjectionEndpoint("doc 1", "mind/1")).toBe(
"/api/mindmap/doc%201/mind%2F1?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get",
);
expect(createMindmapCommandApplyEndpoint("doc 1", "mind/1")).toBe(
"/api/mindmap/doc%201/mind%2F1",
);
});
it("从 result wrapper 中读取 projection", async () => {
const projection = createFallbackMindmapAdapterProjection({ mindmapId: "mind_1" });
const fetcher = vi.fn(async () => ({
ok: true,
json: async () => ({ result: projection }),
})) as unknown as typeof fetch;
await expect(
requestMindmapAdapterProjection({
documentId: "doc_1",
mindmapId: "mind_1",
fetcher,
}),
).resolves.toEqual(projection);
});
it("command apply 成功后可刷新 adapter projection", async () => {
const projection = createFallbackMindmapAdapterProjection({ mindmapId: "mind_1", kernelRevision: 12 });
const fetcher = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ ok: true, kernelRevision: 11 }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: projection }),
}) as unknown as typeof fetch;
await expect(
executeMindmapCommandApplyAndRefreshProjection({
documentId: "doc_1",
mindmapId: "mind_1",
commands: [{ type: "updateText", mindmapId: "mind_1", nodeId: "root", text: "新标题" }],
projectionRevision: 10,
fetcher,
}),
).resolves.toEqual(projection);
expect(fetcher).toHaveBeenNthCalledWith(
1,
"/api/mindmap/doc_1/mind_1",
expect.objectContaining({
method: "POST",
body: expect.stringContaining('"commandName":"mindmap.command.apply"'),
}),
);
});
it("command apply 失败时抛出可定位 command_failed 错误", async () => {
const fetcher = vi.fn(async () => ({
ok: false,
status: 409,
json: async () => ({ error: "revision_conflict" }),
})) as unknown as typeof fetch;
await expect(
executeMindmapCommandApply({
documentId: "doc_1",
mindmapId: "mind_1",
commands: [{ type: "deleteNode", mindmapId: "mind_1", nodeId: "node_1" }],
fetcher,
}),
).rejects.toMatchObject({ code: "command_failed", status: 409 });
});
});
@@ -0,0 +1,169 @@
import {
createSimpleMindMapBridge,
type MindmapAdapterProjection,
type SimpleMindMapBridge,
type SimpleMindMapBridgeEvent,
type SimpleMindMapPluginName,
} from "./simple-mind-map-bridge";
import type { MindmapCompatPayloadPatch, MindmapKernelCommand } from "./mindmap-command-diff";
export type {
MindmapAdapterProjection,
SimpleMindMapBridge,
SimpleMindMapBridgeEvent,
} from "./simple-mind-map-bridge";
export type LeptosMindmapAdapterOptions = {
el: HTMLElement;
projection: MindmapAdapterProjection;
mode?: "edit" | "readonly";
pluginNames?: SimpleMindMapPluginName[];
runtimeOptions?: Record<string, unknown>;
onEvent?: (event: SimpleMindMapBridgeEvent) => void;
};
export type RequestMindmapAdapterProjectionInput = {
documentId: string;
mindmapId: string;
endpoint?: string;
fetcher?: typeof fetch;
};
export type MindmapCommandApplyInput = {
documentId: string;
mindmapId: string;
commands: Array<MindmapKernelCommand | MindmapCompatPayloadPatch>;
projectionRevision?: number | null;
endpoint?: string;
fetcher?: typeof fetch;
};
export type MindmapCommandApplyResult = {
ok: true;
kernelRevision: number | null;
raw: unknown;
};
export class MindmapCommandBridgeError extends Error {
readonly code: "command_failed";
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "MindmapCommandBridgeError";
this.code = "command_failed";
this.status = status;
}
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const isMindmapAdapterProjection = (value: unknown): value is MindmapAdapterProjection => {
if (!isRecord(value)) return false;
return (
value.schema === "mnote.mindmap.simple_mind_map_scene.v1" &&
value.runtime === "simple-mind-map" &&
"root" in value &&
typeof value.kernelRevision === "number"
);
};
export const createMindmapAdapterProjectionEndpoint = (documentId: string, mindmapId: string) => {
const doc = encodeURIComponent(documentId);
const map = encodeURIComponent(mindmapId);
return `/api/mindmap/${doc}/${map}?view=simple_mind_map_scene&queryName=mindmap.simple_mind_map_scene.get`;
};
export const createMindmapCommandApplyEndpoint = (documentId: string, mindmapId: string) => {
const doc = encodeURIComponent(documentId);
const map = encodeURIComponent(mindmapId);
return `/api/mindmap/${doc}/${map}`;
};
export const requestMindmapAdapterProjection = async (
input: RequestMindmapAdapterProjectionInput,
): Promise<MindmapAdapterProjection> => {
const fetcher = input.fetcher ?? fetch;
const endpoint = input.endpoint ?? createMindmapAdapterProjectionEndpoint(input.documentId, input.mindmapId);
const response = await fetcher(endpoint, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`projection_load_failed:${response.status}`);
}
const payload = (await response.json()) as unknown;
const projection = isRecord(payload) && "result" in payload ? payload.result : payload;
if (!isMindmapAdapterProjection(projection)) {
throw new Error("projection_load_failed:invalid_adapter_projection");
}
return projection;
};
const readKernelRevision = (value: unknown): number | null => {
if (!isRecord(value)) return null;
const candidates = [
value.kernelRevision,
value.projectionRevision,
isRecord(value.result) ? value.result.kernelRevision : null,
isRecord(value.result) ? value.result.projectionRevision : null,
];
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
}
return null;
};
export const executeMindmapCommandApply = async (
input: MindmapCommandApplyInput,
): Promise<MindmapCommandApplyResult> => {
if (input.commands.length === 0) {
throw new MindmapCommandBridgeError("command_failed:empty_commands");
}
const fetcher = input.fetcher ?? fetch;
const endpoint = input.endpoint ?? createMindmapCommandApplyEndpoint(input.documentId, input.mindmapId);
const response = await fetcher(endpoint, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
commandName: "mindmap.command.apply",
documentId: input.documentId,
mindmapId: input.mindmapId,
commands: input.commands,
projectionRevision: input.projectionRevision ?? null,
}),
});
const raw = (await response.json().catch(() => null)) as unknown;
if (!response.ok) {
throw new MindmapCommandBridgeError(`command_failed:${response.status}`, response.status);
}
return {
ok: true,
kernelRevision: readKernelRevision(raw),
raw,
};
};
export const executeMindmapCommandApplyAndRefreshProjection = async (
input: MindmapCommandApplyInput & { projectionEndpoint?: string },
): Promise<MindmapAdapterProjection> => {
await executeMindmapCommandApply(input);
return requestMindmapAdapterProjection({
documentId: input.documentId,
mindmapId: input.mindmapId,
endpoint: input.projectionEndpoint,
fetcher: input.fetcher,
});
};
export const createLeptosMindmapAdapter = async (
input: LeptosMindmapAdapterOptions,
): Promise<SimpleMindMapBridge> => {
if (!isMindmapAdapterProjection(input.projection)) {
throw new Error("adapter_init_failed:invalid_projection");
}
return createSimpleMindMapBridge(input);
};
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
getMindmapActionMapping,
listMindmapActionMappings,
mapMindmapActionToCommand,
} from "./mindmap-action-map";
describe("mindmap action map", () => {
it("把核心节点动作映射到 runtime command 和 kernel command", () => {
expect(mapMindmapActionToCommand({ actionId: "insertChild", mindmapId: "mind_1", activeNodeId: "node_1" })).toEqual({
runtimeCommand: "INSERT_CHILD_NODE",
command: {
type: "insertChild",
mindmapId: "mind_1",
parentNodeId: "node_1",
node: { text: "新节点" },
},
});
expect(
mapMindmapActionToCommand({ actionId: "insertSiblingAfter", mindmapId: "mind_1", activeNodeId: "node_1" }),
).toEqual({
runtimeCommand: "INSERT_NODE",
command: {
type: "insertSiblingAfter",
mindmapId: "mind_1",
targetNodeId: "node_1",
node: { text: "新节点" },
},
});
expect(mapMindmapActionToCommand({ actionId: "deleteNode", mindmapId: "mind_1", activeNodeId: "node_1" })).toEqual({
runtimeCommand: "DELETE_NODE",
command: { type: "deleteNode", mindmapId: "mind_1", nodeId: "node_1" },
});
});
it("把视图动作映射到 localView,不依赖 active node", () => {
expect(getMindmapActionMapping("centerRoot")).toMatchObject({ target: "localView", runtimeMethod: "centerRoot" });
expect(getMindmapActionMapping("zoomIn")).toMatchObject({ target: "localView", runtimeMethod: "zoomIn" });
expect(getMindmapActionMapping("zoomOut")).toMatchObject({ target: "localView", runtimeMethod: "zoomOut" });
expect(getMindmapActionMapping("fitView")).toMatchObject({ target: "localView", runtimeMethod: "fitView" });
});
it("把主题、结构和扩展字段映射到 kernel/compat", () => {
expect(mapMindmapActionToCommand({ actionId: "setTheme", mindmapId: "mind_1", value: "classic4" })).toEqual({
command: { type: "setTheme", mindmapId: "mind_1", theme: "classic4" },
});
expect(mapMindmapActionToCommand({ actionId: "setLayout", mindmapId: "mind_1", value: "logicalStructure" })).toEqual({
command: { type: "setLayout", mindmapId: "mind_1", layout: "logicalStructure" },
});
expect(mapMindmapActionToCommand({ actionId: "note", mindmapId: "mind_1", activeNodeId: "node_1", value: "备注" })).toEqual({
command: {
type: "compatPayloadPatch",
mindmapId: "mind_1",
path: "nodes.node_1.data.note",
value: "备注",
source: "toolbar",
actionId: "note",
},
});
expect(
mapMindmapActionToCommand({
actionId: "painter",
mindmapId: "mind_1",
activeNodeId: "node_1",
value: { fillColor: "#dbeafe" },
source: "sidebar",
}),
).toEqual({
command: {
type: "compatPayloadPatch",
mindmapId: "mind_1",
path: "nodes.node_1.data.style",
value: { fillColor: "#dbeafe" },
source: "sidebar",
actionId: "painter",
},
});
});
it("所有 action id 映射唯一", () => {
const mappings = listMindmapActionMappings();
const ids = mappings.map((mapping) => mapping.actionId);
expect(new Set(ids).size).toBe(ids.length);
});
});
@@ -0,0 +1,141 @@
import {
createCompatPayloadPatch,
createLayoutCommand,
createThemeCommand,
createToolbarKernelCommand,
type MindmapCompatPayloadPatch,
type MindmapKernelCommand,
type MindmapNodeInput,
} from "./mindmap-command-diff";
import type { MindmapUiActionId } from "./mindmap-ui-schema";
export type MindmapActionTarget = "runtimeCommand" | "kernelCommand" | "compatPatch" | "localView";
export type MindmapRuntimeViewMethod = "centerRoot" | "zoomIn" | "zoomOut" | "fitView";
export type MindmapActionMapping = {
actionId: MindmapUiActionId;
target: MindmapActionTarget;
runtimeCommand?: string;
runtimeMethod?: MindmapRuntimeViewMethod;
requiresActiveNode: boolean;
readonlyAllowed: boolean;
compatPath?: (activeNodeId: string | null) => string | null;
};
export type MindmapActionCommandInput = {
actionId: MindmapUiActionId;
mindmapId: string;
activeNodeId?: string | null;
value?: unknown;
node?: MindmapNodeInput;
themeConfig?: unknown;
source?: MindmapCompatPayloadPatch["source"];
runtimeRevision?: number | null;
kernelRevision?: number | null;
};
export type MindmapActionCommandResult = {
runtimeCommand?: string;
command?: MindmapKernelCommand | MindmapCompatPayloadPatch;
};
const nodeDataPath = (field: string) => (activeNodeId: string | null): string | null =>
activeNodeId ? `nodes.${activeNodeId}.data.${field}` : null;
const mappings: MindmapActionMapping[] = [
{ actionId: "undo", target: "runtimeCommand", runtimeCommand: "BACK", requiresActiveNode: false, readonlyAllowed: false },
{ actionId: "redo", target: "runtimeCommand", runtimeCommand: "FORWARD", requiresActiveNode: false, readonlyAllowed: false },
{ actionId: "editNode", target: "kernelCommand", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "insertSiblingAfter", target: "runtimeCommand", runtimeCommand: "INSERT_NODE", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "insertChild", target: "runtimeCommand", runtimeCommand: "INSERT_CHILD_NODE", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "deleteNode", target: "runtimeCommand", runtimeCommand: "DELETE_NODE", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "summary", target: "compatPatch", runtimeCommand: "ADD_GENERALIZATION", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "associativeLine", target: "compatPatch", runtimeCommand: "ADD_ASSOCIATIVE_LINE", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "setTheme", target: "kernelCommand", requiresActiveNode: false, readonlyAllowed: false },
{ actionId: "setLayout", target: "kernelCommand", requiresActiveNode: false, readonlyAllowed: false },
{ actionId: "tag", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("tag") },
{ actionId: "hyperlink", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("hyperlink") },
{ actionId: "note", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("note") },
{ actionId: "image", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("image") },
{ actionId: "icon", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("icon") },
{ actionId: "formula", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("formula") },
{ actionId: "painter", target: "compatPatch", requiresActiveNode: true, readonlyAllowed: false, compatPath: nodeDataPath("style") },
{ actionId: "import", target: "compatPatch", requiresActiveNode: false, readonlyAllowed: false, compatPath: () => "import" },
{ actionId: "export", target: "runtimeCommand", runtimeCommand: "EXPORT", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "centerRoot", target: "localView", runtimeMethod: "centerRoot", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "zoomIn", target: "localView", runtimeMethod: "zoomIn", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "zoomOut", target: "localView", runtimeMethod: "zoomOut", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "fitView", target: "localView", runtimeMethod: "fitView", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "search", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "expandCollapse", target: "localView", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "copyNodeText", target: "localView", requiresActiveNode: true, readonlyAllowed: true },
{ actionId: "readonly", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
];
export const listMindmapActionMappings = (): MindmapActionMapping[] => mappings;
export const getMindmapActionMapping = (actionId: MindmapUiActionId): MindmapActionMapping | null =>
mappings.find((mapping) => mapping.actionId === actionId) ?? null;
const readActiveNodeId = (value: string | null | undefined): string | null => {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
};
export const mapMindmapActionToCommand = (input: MindmapActionCommandInput): MindmapActionCommandResult | null => {
const mapping = getMindmapActionMapping(input.actionId);
if (!mapping) return null;
const activeNodeId = readActiveNodeId(input.activeNodeId);
if (mapping.requiresActiveNode && !activeNodeId) return null;
if (input.actionId === "setTheme") {
return { command: createThemeCommand(input.mindmapId, input.value, input.themeConfig) };
}
if (input.actionId === "setLayout") {
return { command: createLayoutCommand(input.mindmapId, input.value) };
}
if (input.actionId === "editNode") {
return {
command: {
type: "updateText",
mindmapId: input.mindmapId,
nodeId: activeNodeId ?? "root",
text: typeof input.value === "string" ? input.value : "",
},
};
}
if (mapping.runtimeCommand && ["INSERT_CHILD_NODE", "INSERT_NODE", "DELETE_NODE", "REMOVE_NODE"].includes(mapping.runtimeCommand)) {
const command = createToolbarKernelCommand({
mindmapId: input.mindmapId,
runtimeCommand: mapping.runtimeCommand,
activeNodeId,
newNode: input.node,
});
return command ? { runtimeCommand: mapping.runtimeCommand, command } : { runtimeCommand: mapping.runtimeCommand };
}
if (mapping.compatPath) {
const path = mapping.compatPath(activeNodeId);
if (!path) return null;
return {
command: createCompatPayloadPatch({
mindmapId: input.mindmapId,
path,
value: input.value ?? true,
source: input.source ?? "toolbar",
actionId: input.actionId,
runtimeRevision: input.runtimeRevision,
kernelRevision: input.kernelRevision,
}),
};
}
if (mapping.runtimeCommand) {
return { runtimeCommand: mapping.runtimeCommand };
}
return {};
};
@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import {
createCompatPayloadPatch,
createLayoutCommand,
createThemeCommand,
createToolbarKernelCommand,
createViewPatchCommand,
diffMindmapRuntimeDataToKernelCommands,
} from "./mindmap-command-diff";
describe("mindmap command bridge mapping", () => {
it("把明确 toolbar 动作映射到 kernel command", () => {
expect(
createToolbarKernelCommand({
mindmapId: "mind_1",
runtimeCommand: "INSERT_CHILD_NODE",
activeNodeId: "node_1",
}),
).toEqual({
type: "insertChild",
mindmapId: "mind_1",
parentNodeId: "node_1",
node: { text: "新节点" },
});
expect(
createToolbarKernelCommand({
mindmapId: "mind_1",
runtimeCommand: "REMOVE_NODE",
activeNodeId: "node_2",
}),
).toEqual({
type: "deleteNode",
mindmapId: "mind_1",
nodeId: "node_2",
});
});
it("把暂未语义化的 toolbar 动作落入 compatPayload patch", () => {
expect(
createToolbarKernelCommand({
mindmapId: "mind_1",
runtimeCommand: "ADD_GENERALIZATION",
activeNodeId: "node_1",
}),
).toEqual({
type: "compatPayloadPatch",
mindmapId: "mind_1",
path: "root.data.generalization",
value: { text: "概要" },
source: "toolbar",
});
});
it("构建 view/theme/layout 和通用 compat patch command", () => {
expect(createViewPatchCommand("mind_1", { scale: 0.9 })).toEqual({
type: "patchView",
mindmapId: "mind_1",
patch: { scale: 0.9 },
});
expect(createThemeCommand("mind_1", "classic4", { root: true })).toEqual({
type: "setTheme",
mindmapId: "mind_1",
theme: "classic4",
themeConfig: { root: true },
});
expect(createLayoutCommand("mind_1", "logicalStructure")).toEqual({
type: "setLayout",
mindmapId: "mind_1",
layout: "logicalStructure",
});
expect(
createCompatPayloadPatch({
mindmapId: "mind_1",
path: "root.data.image",
value: "asset:img_1",
source: "sidebar",
actionId: "image",
runtimeRevision: 7,
kernelRevision: 3,
}),
).toEqual({
type: "compatPayloadPatch",
mindmapId: "mind_1",
path: "root.data.image",
value: "asset:img_1",
source: "sidebar",
actionId: "image",
runtimeRevision: 7,
kernelRevision: 3,
});
});
it("从 data_change diff 识别文本、插入、删除、移动和 compat 字段", () => {
const previous = {
data: { uid: "root", text: "KMIND" },
children: [
{ data: { uid: "a", text: "A", image: "asset:old" }, children: [] },
{ data: { uid: "b", text: "B" }, children: [] },
],
};
const next = {
data: { uid: "root", text: "KMIND" },
children: [
{
data: { uid: "a", text: "A2", image: "asset:new" },
children: [{ data: { uid: "c", text: "C" }, children: [] }],
},
],
};
expect(
diffMindmapRuntimeDataToKernelCommands({
mindmapId: "mind_1",
previous,
next,
}),
).toEqual({
commands: [
{ type: "updateText", mindmapId: "mind_1", nodeId: "a", text: "A2" },
{
type: "insertChild",
mindmapId: "mind_1",
parentNodeId: "a",
node: { uid: "c", text: "C" },
},
{ type: "deleteNode", mindmapId: "mind_1", nodeId: "b" },
],
compatPatches: [
{
type: "compatPayloadPatch",
mindmapId: "mind_1",
path: "nodes.a.data.image",
value: "asset:new",
source: "adapter-diff",
},
],
});
});
it("从 data_change diff 识别节点移动", () => {
const previous = {
data: { uid: "root", text: "KMIND" },
children: [
{ data: { uid: "a", text: "A" }, children: [] },
{ data: { uid: "b", text: "B" }, children: [] },
],
};
const next = {
data: { uid: "root", text: "KMIND" },
children: [{ data: { uid: "a", text: "A" }, children: [{ data: { uid: "b", text: "B" }, children: [] }] }],
};
expect(
diffMindmapRuntimeDataToKernelCommands({
mindmapId: "mind_1",
previous,
next,
}).commands,
).toContainEqual({
type: "moveNode",
mindmapId: "mind_1",
nodeId: "b",
newParentNodeId: "a",
order: 0,
});
});
});
@@ -0,0 +1,292 @@
import { canonicalizeMindmapData, type MindMapData } from "./mindmap-projection";
export type MindmapNodeInput = {
uid?: string;
text: string;
hyperlink?: string;
note?: string;
refs?: unknown[];
};
export type MindmapKernelCommand =
| { type: "updateText"; mindmapId: string; nodeId: string; text: string }
| { type: "insertChild"; mindmapId: string; parentNodeId: string; node: MindmapNodeInput }
| { type: "insertSiblingAfter"; mindmapId: string; targetNodeId: string; node: MindmapNodeInput }
| { type: "deleteNode"; mindmapId: string; nodeId: string }
| { type: "moveNode"; mindmapId: string; nodeId: string; newParentNodeId: string; order?: number }
| { type: "patchView"; mindmapId: string; patch: Record<string, unknown> }
| { type: "setLayout"; mindmapId: string; layout: unknown }
| { type: "setTheme"; mindmapId: string; theme: unknown; themeConfig?: unknown };
export type MindmapCompatPayloadPatch = {
type: "compatPayloadPatch";
mindmapId: string;
path: string;
value: unknown;
source: "toolbar" | "sidebar" | "runtime-event" | "adapter-diff";
actionId?: string;
runtimeRevision?: number | null;
kernelRevision?: number | null;
};
export type MindmapCommandDiffResult = {
commands: MindmapKernelCommand[];
compatPatches: MindmapCompatPayloadPatch[];
};
export type ToolbarCommandInput = {
mindmapId: string;
runtimeCommand: string;
activeNodeId?: string | null;
newNode?: MindmapNodeInput;
layout?: unknown;
theme?: unknown;
themeConfig?: unknown;
};
const defaultNode = (node?: MindmapNodeInput): MindmapNodeInput => ({
text: node?.text?.trim() || "新节点",
...(node?.uid ? { uid: node.uid } : {}),
...(node?.hyperlink ? { hyperlink: node.hyperlink } : {}),
...(node?.note ? { note: node.note } : {}),
...(node?.refs ? { refs: node.refs } : {}),
});
export const createToolbarKernelCommand = (
input: ToolbarCommandInput,
): MindmapKernelCommand | MindmapCompatPayloadPatch | null => {
const activeNodeId = input.activeNodeId?.trim();
switch (input.runtimeCommand) {
case "INSERT_CHILD_NODE":
if (!activeNodeId) return null;
return {
type: "insertChild",
mindmapId: input.mindmapId,
parentNodeId: activeNodeId,
node: defaultNode(input.newNode),
};
case "INSERT_NODE":
if (!activeNodeId) return null;
return {
type: "insertSiblingAfter",
mindmapId: input.mindmapId,
targetNodeId: activeNodeId,
node: defaultNode(input.newNode),
};
case "REMOVE_NODE":
case "DELETE_NODE":
if (!activeNodeId) return null;
return { type: "deleteNode", mindmapId: input.mindmapId, nodeId: activeNodeId };
case "ADD_GENERALIZATION":
return {
type: "compatPayloadPatch",
mindmapId: input.mindmapId,
path: "root.data.generalization",
value: { text: "概要" },
source: "toolbar",
};
case "ADD_OUTER_FRAME":
return {
type: "compatPayloadPatch",
mindmapId: input.mindmapId,
path: "outerFrame",
value: { enabled: true, activeNodeId },
source: "toolbar",
};
default:
return null;
}
};
export const createViewPatchCommand = (
mindmapId: string,
patch: Record<string, unknown>,
): MindmapKernelCommand => ({
type: "patchView",
mindmapId,
patch,
});
export const createThemeCommand = (
mindmapId: string,
theme: unknown,
themeConfig?: unknown,
): MindmapKernelCommand => ({
type: "setTheme",
mindmapId,
theme,
themeConfig,
});
export const createLayoutCommand = (mindmapId: string, layout: unknown): MindmapKernelCommand => ({
type: "setLayout",
mindmapId,
layout,
});
export const createCompatPayloadPatch = (input: {
mindmapId: string;
path: string;
value: unknown;
source: MindmapCompatPayloadPatch["source"];
actionId?: string;
runtimeRevision?: number | null;
kernelRevision?: number | null;
}): MindmapCompatPayloadPatch => ({
type: "compatPayloadPatch",
mindmapId: input.mindmapId,
path: input.path,
value: input.value,
source: input.source,
...(input.actionId ? { actionId: input.actionId } : {}),
...(typeof input.runtimeRevision === "number" ? { runtimeRevision: input.runtimeRevision } : {}),
...(typeof input.kernelRevision === "number" ? { kernelRevision: input.kernelRevision } : {}),
});
type IndexedRuntimeNode = {
uid: string;
parentUid: string | null;
order: number;
text: string;
data: Record<string, unknown>;
node: MindMapData;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const readNodeUid = (node: MindMapData, fallback: string): string => {
const uid = node.data?.uid;
return typeof uid === "string" && uid.trim() ? uid.trim() : fallback;
};
const readNodeText = (node: MindMapData): string => {
const text = node.data?.text;
return typeof text === "string" ? text : String(text ?? "");
};
const indexMindmapRuntimeTree = (input: unknown): Map<string, IndexedRuntimeNode> => {
const root = canonicalizeMindmapData(input);
const index = new Map<string, IndexedRuntimeNode>();
const visit = (node: MindMapData, parentUid: string | null, order: number, path: string) => {
const uid = readNodeUid(node, path);
const data = isRecord(node.data) ? node.data : {};
index.set(uid, {
uid,
parentUid,
order,
text: readNodeText(node),
data,
node,
});
const children = Array.isArray(node.children) ? node.children : [];
children.forEach((child, childIndex) => {
visit(canonicalizeMindmapData(child), uid, childIndex, `${path}.${childIndex}`);
});
};
visit(root, null, 0, "root");
return index;
};
const serializeComparable = (value: unknown): string => JSON.stringify(value ?? null);
const buildNodeInputFromRuntimeNode = (node: IndexedRuntimeNode): MindmapNodeInput => ({
uid: node.uid,
text: node.text || "新节点",
...(typeof node.data.hyperlink === "string" ? { hyperlink: node.data.hyperlink } : {}),
...(typeof node.data.note === "string" ? { note: node.data.note } : {}),
...(Array.isArray(node.data.refs) ? { refs: node.data.refs } : {}),
});
const pushCompatDataPatches = (
mindmapId: string,
prev: IndexedRuntimeNode,
next: IndexedRuntimeNode,
compatPatches: MindmapCompatPayloadPatch[],
) => {
const semanticKeys = new Set(["uid", "text", "refs"]);
const keys = new Set([...Object.keys(prev.data), ...Object.keys(next.data)]);
keys.forEach((key) => {
if (semanticKeys.has(key)) return;
if (serializeComparable(prev.data[key]) === serializeComparable(next.data[key])) return;
compatPatches.push(
createCompatPayloadPatch({
mindmapId,
path: `nodes.${next.uid}.data.${key}`,
value: next.data[key],
source: "adapter-diff",
}),
);
});
};
export const diffMindmapRuntimeDataToKernelCommands = (input: {
mindmapId: string;
previous: unknown;
next: unknown;
}): MindmapCommandDiffResult => {
const previousIndex = indexMindmapRuntimeTree(input.previous);
const nextIndex = indexMindmapRuntimeTree(input.next);
const commands: MindmapKernelCommand[] = [];
const compatPatches: MindmapCompatPayloadPatch[] = [];
nextIndex.forEach((nextNode, uid) => {
const previousNode = previousIndex.get(uid);
if (!previousNode) {
const previousSibling = Array.from(nextIndex.values()).find(
(candidate) =>
candidate.parentUid === nextNode.parentUid &&
candidate.order === nextNode.order - 1 &&
previousIndex.has(candidate.uid),
);
if (previousSibling) {
commands.push({
type: "insertSiblingAfter",
mindmapId: input.mindmapId,
targetNodeId: previousSibling.uid,
node: buildNodeInputFromRuntimeNode(nextNode),
});
} else if (nextNode.parentUid) {
commands.push({
type: "insertChild",
mindmapId: input.mindmapId,
parentNodeId: nextNode.parentUid,
node: buildNodeInputFromRuntimeNode(nextNode),
});
}
return;
}
if (previousNode.text !== nextNode.text) {
commands.push({
type: "updateText",
mindmapId: input.mindmapId,
nodeId: uid,
text: nextNode.text,
});
}
if (
previousNode.parentUid !== nextNode.parentUid ||
previousNode.order !== nextNode.order
) {
commands.push({
type: "moveNode",
mindmapId: input.mindmapId,
nodeId: uid,
newParentNodeId: nextNode.parentUid ?? "",
order: nextNode.order,
});
}
pushCompatDataPatches(input.mindmapId, previousNode, nextNode, compatPatches);
});
previousIndex.forEach((previousNode, uid) => {
if (!nextIndex.has(uid) && previousNode.parentUid) {
commands.push({ type: "deleteNode", mindmapId: input.mindmapId, nodeId: uid });
}
});
return { commands, compatPatches };
};
@@ -1,3 +1,6 @@
// 说明:这里保留的是 legacy/blob compat projection 工具,服务导图导入、
// 旧 React mindmap 参考实现和独立页兼容。Phase 6 文档页默认编辑主链
// 使用 Rust kernel projection 与 simple-mind-map adapter projection。
export type MindMapData = {
data: Record<string, unknown>;
children?: unknown[];
@@ -22,8 +25,11 @@ export type MindmapProjectionNode = {
};
export type MindmapProjection = {
schema: "mnote.mindmap_projection.v1";
projectionId: string;
projection: "mindmap_subtree";
source: string;
owner: string;
documentId: string;
mindmapId: string;
rootNodeId: string | null;
@@ -34,6 +40,56 @@ export type MindmapProjection = {
meta: MindmapRouteMeta | null;
};
export type MindmapEditorSceneNode = MindmapProjectionNode & {
id: string;
parentId: string | null;
};
export type MindmapEditorSceneEdge = {
id: string;
source: string;
target: string;
};
export type MindmapEditorScene = {
schema: "mnote.mindmap_editor_scene.v1";
source: string;
owner: string;
documentId: string;
mindmapId: string;
rootNodeId: string | null;
title: string;
nodes: MindmapEditorSceneNode[];
edges: MindmapEditorSceneEdge[];
capabilities: {
canEditText: boolean;
canAddChild: boolean;
canAddSiblingAfter: boolean;
canDeleteNode: boolean;
};
data: MindMapData;
meta: MindmapRouteMeta | null;
};
export type MindmapSimpleMindMapScene = {
schema: "mnote.mindmap.simple_mind_map_scene.v1";
runtime: "simple-mind-map";
documentId: string;
mindmapId: string;
rootNodeId: string | null;
root: MindMapData;
layout: unknown;
theme: unknown;
themeConfig: unknown;
view: unknown;
config: unknown;
compatPayload: unknown;
kernelRevision: number;
source: string;
owner: string;
meta: MindmapRouteMeta | null;
};
export const defaultMindmapData: MindMapData = {
data: { text: "中心主题" },
children: [],
@@ -49,13 +105,12 @@ export const normalizeMindmapData = (input: unknown): unknown => {
const walk = (node: unknown) => {
if (!isRecord(node)) return;
if (!isRecord(node.data)) {
node.data = {};
}
const rawText = node.data.text;
node.data.text = typeof rawText === "string" ? rawText : String(rawText ?? "");
const data = isRecord(node.data) ? node.data : {};
node.data = data;
const rawText = data.text;
data.text = typeof rawText === "string" ? rawText : String(rawText ?? "");
const gen = node.data.generalization;
const gen = data.generalization;
const fixGen = (value: unknown) => {
if (!isRecord(value)) return;
const text = value.text;
@@ -128,6 +183,8 @@ export const buildMindmapProjection = (input: {
documentId: string;
mindmapId: string;
data: unknown;
source?: string;
owner?: string;
meta?: unknown;
}): MindmapProjection => {
const data = canonicalizeMindmapData(input.data);
@@ -136,8 +193,11 @@ export const buildMindmapProjection = (input: {
const rootNodeId = nodes[0]?.uid ?? null;
return {
schema: "mnote.mindmap_projection.v1",
projectionId: `mindmap_projection:${input.documentId}:${input.mindmapId}`,
projection: "mindmap_subtree",
source: input.source ?? "rust-kernel",
owner: input.owner ?? "rust-kernel",
documentId: input.documentId,
mindmapId: input.mindmapId,
rootNodeId,
@@ -148,3 +208,106 @@ export const buildMindmapProjection = (input: {
meta,
};
};
export const buildMindmapEditorScene = (input: {
documentId: string;
mindmapId: string;
rootNodeId?: string | null;
data: unknown;
source?: string;
owner?: string;
meta?: unknown;
}): MindmapEditorScene => {
const data = canonicalizeMindmapData(input.data);
const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
const nodes: MindmapEditorSceneNode[] = [];
const edges: MindmapEditorSceneEdge[] = [];
const walk = (node: MindMapData, depth: number, parentId: string | null) => {
const uidRaw = node?.data?.uid;
const textRaw = node?.data?.text;
const id =
typeof uidRaw === "string" && uidRaw.trim()
? uidRaw.trim()
: `depth:${depth}:index:${nodes.length}`;
const children = Array.isArray(node?.children) ? node.children : [];
nodes.push({
id,
uid: id,
text: typeof textRaw === "string" && textRaw.trim() ? textRaw.trim() : "未命名节点",
depth,
childCount: children.length,
parentId,
});
if (parentId) {
edges.push({
id: `${parentId}->${id}`,
source: parentId,
target: id,
});
}
children.forEach((child) => {
walk(canonicalizeMindmapData(child), depth + 1, id);
});
};
walk(data, 0, null);
const fallbackRootId = nodes[0]?.id ?? null;
const rootNodeId = input.rootNodeId && input.rootNodeId.trim() ? input.rootNodeId.trim() : fallbackRootId;
return {
schema: "mnote.mindmap_editor_scene.v1",
source: input.source ?? "rust-kernel",
owner: input.owner ?? "rust-kernel",
documentId: input.documentId,
mindmapId: input.mindmapId,
rootNodeId,
title: extractMindmapTitle(data),
nodes,
edges,
capabilities: {
canEditText: true,
canAddChild: true,
canAddSiblingAfter: true,
canDeleteNode: true,
},
data,
meta,
};
};
export const buildMindmapSimpleMindMapScene = (input: {
documentId: string;
mindmapId: string;
data: unknown;
source?: string;
owner?: string;
meta?: unknown;
}): MindmapSimpleMindMapScene => {
const root = canonicalizeMindmapData(input.data);
const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
const rootNodeIdRaw = root?.data?.uid;
const rootNodeId = typeof rootNodeIdRaw === "string" && rootNodeIdRaw.trim() ? rootNodeIdRaw.trim() : "root";
if (!root.data.uid) {
root.data.uid = rootNodeId;
}
return {
schema: "mnote.mindmap.simple_mind_map_scene.v1",
runtime: "simple-mind-map",
documentId: input.documentId,
mindmapId: input.mindmapId,
rootNodeId,
root,
layout: "logicalStructure",
theme: "classic",
themeConfig: {},
view: { x: 0, y: 0, scale: 1 },
config: {},
compatPayload: {},
kernelRevision: 1,
source: input.source ?? "rust-kernel",
owner: input.owner ?? "rust-kernel",
meta,
};
};
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import {
MINDMAP_DEBUG_CHROME_QUERY_PARAM,
isMindmapDebugChromeEnabled,
mindmapDefaultUiSchema,
} from "./mindmap-ui-schema";
import { getMindmapActionMapping } from "./mindmap-action-map";
describe("mindmap UI schema", () => {
it("默认 schema 暴露第一阶段 UI shell 分区", () => {
expect(mindmapDefaultUiSchema.toolbarGroups.map((group) => group.id)).toEqual([
"history",
"node",
"insert",
"view",
]);
expect(mindmapDefaultUiSchema.sidebarPanels.map((panel) => panel.id)).toEqual([
"nodeStyle",
"baseStyle",
"theme",
"structure",
"outline",
]);
expect(mindmapDefaultUiSchema.navigatorItems.map((item) => item.id)).toEqual([
"stats",
"centerRoot",
"search",
"zoomOut",
"zoom",
"zoomIn",
"readonly",
]);
});
it("默认不启用旧手写 chrome,只有显式 debug 参数才启用", () => {
expect(MINDMAP_DEBUG_CHROME_QUERY_PARAM).toBe("mnoteMindmapDebugChrome");
expect(isMindmapDebugChromeEnabled("http://127.0.0.1:3000/documents/doc_1")).toBe(false);
expect(
isMindmapDebugChromeEnabled("http://127.0.0.1:3000/documents/doc_1?mnoteMindmapDebugChrome=1"),
).toBe(true);
expect(
isMindmapDebugChromeEnabled("http://127.0.0.1:3000/documents/doc_1?mnoteMindmapDebugChrome=true"),
).toBe(true);
});
it("toolbar action id 不重复", () => {
const actionIds = mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions);
expect(new Set(actionIds).size).toBe(actionIds.length);
});
it("默认 schema 中所有 action 都有 action map 映射", () => {
const actionIds = [
...mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions),
...mindmapDefaultUiSchema.navigatorItems.flatMap((item) => (item.actionId ? [item.actionId] : [])),
...mindmapDefaultUiSchema.contextMenuItems.map((item) => item.actionId),
];
expect(actionIds.filter((actionId) => getMindmapActionMapping(actionId) === null)).toEqual([]);
});
it("第一阶段 context menu 覆盖节点和画布动作", () => {
expect(mindmapDefaultUiSchema.contextMenuItems.filter((item) => item.requiresNode).map((item) => item.actionId)).toEqual([
"insertChild",
"insertSiblingAfter",
"deleteNode",
"summary",
"associativeLine",
"expandCollapse",
"copyNodeText",
]);
expect(mindmapDefaultUiSchema.contextMenuItems.filter((item) => !item.requiresNode).map((item) => item.actionId)).toEqual([
"centerRoot",
"fitView",
"search",
"readonly",
]);
});
it("第一阶段 sidebar panel 数量受控", () => {
expect(mindmapDefaultUiSchema.sidebarPanels).toHaveLength(5);
});
it("第一阶段 sidebar 暴露主题、结构和 compat patch 选项", () => {
const themePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "theme");
expect(themePanel?.options.map((option) => option.value)).toEqual(["classic", "classic4"]);
const structurePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "structure");
expect(structurePanel?.options.map((option) => option.value)).toEqual([
"logicalStructure",
"mindMap",
"fishbone",
]);
const nodeStylePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "nodeStyle");
const baseStylePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "baseStyle");
expect(nodeStylePanel?.options.every((option) => Boolean(option.compatPath))).toBe(true);
expect(baseStylePanel?.options.every((option) => Boolean(option.compatPath))).toBe(true);
});
});
@@ -0,0 +1,192 @@
export const MINDMAP_DEBUG_CHROME_QUERY_PARAM = "mnoteMindmapDebugChrome";
export type MindmapUiActionId =
| "undo"
| "redo"
| "editNode"
| "insertSiblingAfter"
| "insertChild"
| "deleteNode"
| "tag"
| "hyperlink"
| "note"
| "image"
| "icon"
| "summary"
| "associativeLine"
| "formula"
| "painter"
| "import"
| "export"
| "setTheme"
| "setLayout"
| "zoomIn"
| "zoomOut"
| "fitView"
| "centerRoot"
| "search"
| "expandCollapse"
| "copyNodeText"
| "readonly";
export type MindmapToolbarGroup = {
id: "history" | "node" | "insert" | "view";
label: string;
actions: MindmapUiActionId[];
collapsePriority: number;
};
export type MindmapSidebarPanel = {
id: "nodeStyle" | "baseStyle" | "theme" | "structure" | "outline";
label: string;
icon: string;
runtimeCapability: string;
phase: 1;
options: MindmapSidebarOption[];
};
export type MindmapSidebarOption = {
id: string;
label: string;
actionId: MindmapUiActionId | null;
value: unknown;
compatPath?: string;
};
export type MindmapNavigatorItem = {
id: "stats" | "centerRoot" | "search" | "zoomOut" | "zoom" | "zoomIn" | "readonly";
label: string;
actionId: MindmapUiActionId | null;
readOnly: boolean;
displayMode: "text" | "button" | "input";
};
export type MindmapContextMenuItem = {
id: string;
label: string;
actionId: MindmapUiActionId;
requiresNode: boolean;
phase: 1;
};
export type MindmapUiSchema = {
toolbarGroups: MindmapToolbarGroup[];
sidebarPanels: MindmapSidebarPanel[];
navigatorItems: MindmapNavigatorItem[];
contextMenuItems: MindmapContextMenuItem[];
};
export const mindmapDefaultUiSchema: MindmapUiSchema = {
toolbarGroups: [
{
id: "history",
label: "历史",
actions: ["undo", "redo"],
collapsePriority: 4,
},
{
id: "node",
label: "节点",
actions: ["editNode", "insertSiblingAfter", "insertChild", "deleteNode"],
collapsePriority: 1,
},
{
id: "insert",
label: "插入",
actions: ["tag", "hyperlink", "note", "image", "icon", "summary", "associativeLine", "formula", "painter", "import", "export"],
collapsePriority: 2,
},
{
id: "view",
label: "视图",
actions: ["centerRoot", "zoomOut", "zoomIn", "search", "readonly"],
collapsePriority: 3,
},
],
sidebarPanels: [
{
id: "nodeStyle",
label: "节点样式",
icon: "palette",
runtimeCapability: "node-style",
phase: 1,
options: [
{ id: "node-fill-blue", label: "蓝色节点", actionId: "painter", value: "#dbeafe", compatPath: "nodes.$active.data.fillColor" },
{ id: "node-round", label: "圆角节点", actionId: "painter", value: "roundedRectangle", compatPath: "nodes.$active.data.shape" },
],
},
{
id: "baseStyle",
label: "导图样式",
icon: "sliders",
runtimeCapability: "base-style",
phase: 1,
options: [
{ id: "base-curve-line", label: "曲线连线", actionId: "painter", value: "curve", compatPath: "style.map.lineStyle" },
{ id: "base-rainbow-lines", label: "彩虹线条", actionId: "painter", value: { enabled: true }, compatPath: "style.map.rainbowLines" },
],
},
{
id: "theme",
label: "主题",
icon: "swatch",
runtimeCapability: "theme",
phase: 1,
options: [
{ id: "theme-classic", label: "默认主题", actionId: "setTheme", value: "classic" },
{ id: "theme-classic4", label: "KMind-like", actionId: "setTheme", value: "classic4" },
],
},
{
id: "structure",
label: "结构",
icon: "layout",
runtimeCapability: "layout",
phase: 1,
options: [
{ id: "layout-logical", label: "逻辑结构", actionId: "setLayout", value: "logicalStructure" },
{ id: "layout-mind-map", label: "右侧结构", actionId: "setLayout", value: "mindMap" },
{ id: "layout-fishbone", label: "鱼骨结构", actionId: "setLayout", value: "fishbone" },
],
},
{
id: "outline",
label: "大纲",
icon: "list-tree",
runtimeCapability: "outline",
phase: 1,
options: [],
},
],
navigatorItems: [
{ id: "stats", label: "统计", actionId: null, readOnly: true, displayMode: "text" },
{ id: "centerRoot", label: "回根节点", actionId: "centerRoot", readOnly: true, displayMode: "button" },
{ id: "search", label: "搜索", actionId: "search", readOnly: true, displayMode: "input" },
{ id: "zoomOut", label: "缩小", actionId: "zoomOut", readOnly: true, displayMode: "button" },
{ id: "zoom", label: "缩放", actionId: null, readOnly: true, displayMode: "text" },
{ id: "zoomIn", label: "放大", actionId: "zoomIn", readOnly: true, displayMode: "button" },
{ id: "readonly", label: "只读", actionId: "readonly", readOnly: true, displayMode: "button" },
],
contextMenuItems: [
{ id: "insertChild", label: "插入子节点", actionId: "insertChild", requiresNode: true, phase: 1 },
{ id: "insertSiblingAfter", label: "插入同级节点", actionId: "insertSiblingAfter", requiresNode: true, phase: 1 },
{ id: "deleteNode", label: "删除节点", actionId: "deleteNode", requiresNode: true, phase: 1 },
{ id: "summary", label: "概要", actionId: "summary", requiresNode: true, phase: 1 },
{ id: "associativeLine", label: "关联线", actionId: "associativeLine", requiresNode: true, phase: 1 },
{ id: "expandCollapse", label: "展开/收起", actionId: "expandCollapse", requiresNode: true, phase: 1 },
{ id: "copyNodeText", label: "复制文本", actionId: "copyNodeText", requiresNode: true, phase: 1 },
{ id: "centerRoot", label: "回根节点", actionId: "centerRoot", requiresNode: false, phase: 1 },
{ id: "fitView", label: "适应画布", actionId: "fitView", requiresNode: false, phase: 1 },
{ id: "search", label: "搜索", actionId: "search", requiresNode: false, phase: 1 },
{ id: "readonly", label: "只读切换", actionId: "readonly", requiresNode: false, phase: 1 },
],
};
export const isMindmapDebugChromeEnabled = (href: string): boolean => {
try {
const value = new URL(href).searchParams.get(MINDMAP_DEBUG_CHROME_QUERY_PARAM);
return value === "1" || value === "true";
} catch {
return false;
}
};
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { deriveMindmapUiState } from "./mindmap-ui-state";
describe("mindmap UI state", () => {
it("无 active node 时禁用节点编辑动作,但保留视图动作", () => {
const state = deriveMindmapUiState({ activeNodeId: null, readonly: false });
expect(state.disabledActions.insertChild).toBe(true);
expect(state.disabledActions.insertSiblingAfter).toBe(true);
expect(state.disabledActions.deleteNode).toBe(true);
expect(state.disabledActions.expandCollapse).toBe(true);
expect(state.disabledActions.zoomIn).toBe(false);
expect(state.disabledActions.centerRoot).toBe(false);
});
it("readonly 时禁用编辑动作,保留搜索和视图动作", () => {
const state = deriveMindmapUiState({ activeNodeId: "node_1", readonly: true });
expect(state.disabledActions.insertChild).toBe(true);
expect(state.disabledActions.note).toBe(true);
expect(state.disabledActions.setTheme).toBe(true);
expect(state.disabledActions.expandCollapse).toBe(true);
expect(state.disabledActions.copyNodeText).toBe(false);
expect(state.disabledActions.search).toBe(false);
expect(state.disabledActions.zoomOut).toBe(false);
});
it("缺少 runtime capability 时禁用对应 action", () => {
const state = deriveMindmapUiState({
activeNodeId: "node_1",
readonly: false,
runtimeCapabilities: ["runtimeCommand", "kernelCommand"],
});
expect(state.disabledActions.note).toBe(true);
expect(state.disabledActions.insertChild).toBe(false);
});
});
@@ -0,0 +1,64 @@
import { getMindmapActionMapping, listMindmapActionMappings, type MindmapActionTarget } from "./mindmap-action-map";
import { mindmapDefaultUiSchema, type MindmapUiActionId } from "./mindmap-ui-schema";
export type MindmapRuntimeCapability = MindmapActionTarget;
export type MindmapUiStateInput = {
activeNodeId?: string | null;
readonly: boolean;
runtimeCapabilities?: MindmapRuntimeCapability[];
};
export type MindmapUiState = {
activeNodeId: string | null;
readonly: boolean;
disabledActions: Record<MindmapUiActionId, boolean>;
};
const allActionIds = (): MindmapUiActionId[] => {
const ids = [
...mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions),
...mindmapDefaultUiSchema.navigatorItems.flatMap((item) => (item.actionId ? [item.actionId] : [])),
...mindmapDefaultUiSchema.contextMenuItems.map((item) => item.actionId),
...listMindmapActionMappings().map((mapping) => mapping.actionId),
];
return [...new Set(ids)];
};
const normalizeActiveNodeId = (value: string | null | undefined): string | null => {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
};
export const deriveMindmapUiState = (input: MindmapUiStateInput): MindmapUiState => {
const activeNodeId = normalizeActiveNodeId(input.activeNodeId);
const capabilities = input.runtimeCapabilities ? new Set<MindmapRuntimeCapability>(input.runtimeCapabilities) : null;
const disabledActions = {} as Record<MindmapUiActionId, boolean>;
allActionIds().forEach((actionId) => {
const mapping = getMindmapActionMapping(actionId);
if (!mapping) {
disabledActions[actionId] = true;
return;
}
if (mapping.requiresActiveNode && !activeNodeId) {
disabledActions[actionId] = true;
return;
}
if (input.readonly && !mapping.readonlyAllowed) {
disabledActions[actionId] = true;
return;
}
if (capabilities && !capabilities.has(mapping.target)) {
disabledActions[actionId] = true;
return;
}
disabledActions[actionId] = false;
});
return {
activeNodeId,
readonly: input.readonly,
disabledActions,
};
};
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
SIMPLE_MIND_MAP_BRIDGE_EVENTS,
SIMPLE_MIND_MAP_REQUIRED_PLUGINS,
buildSimpleMindMapOptions,
createFallbackMindmapAdapterProjection,
createSafeMindmapCommandExecutor,
defaultSimpleMindMapPluginNames,
} from "./simple-mind-map-bridge";
describe("simple-mind-map bridge contract", () => {
it("登记 Phase 6 第一阶段必需插件和事件", () => {
expect(SIMPLE_MIND_MAP_REQUIRED_PLUGINS).toEqual([
"Drag",
"KeyboardNavigation",
"Export",
"Select",
"AssociativeLine",
"Search",
"OuterFrame",
]);
expect(defaultSimpleMindMapPluginNames()).toEqual(
expect.arrayContaining(["Scrollbar", "MiniMap", "Painter", "Formula"]),
);
expect(defaultSimpleMindMapPluginNames()).not.toContain("RichText");
expect(SIMPLE_MIND_MAP_BRIDGE_EVENTS).toEqual([
"data_change",
"view_data_change",
"node_active",
"back_forward",
"scale",
"translate",
]);
});
it("从 adapter projection 构建 simple-mind-map 初始化参数", () => {
const projection = createFallbackMindmapAdapterProjection({
mindmapId: "mind_1",
root: { data: { text: "KMIND" }, children: [] },
kernelRevision: 7,
});
const options = buildSimpleMindMapOptions({
el: {} as HTMLElement,
projection: {
...projection,
theme: { template: "classic4" },
themeConfig: { root: { fillColor: "#c62828" } },
view: { state: { scale: 0.8, x: 1, y: 2 }, transform: { scaleX: 0.8, scaleY: 0.8 } },
},
});
expect(options.data).toEqual({ data: { text: "KMIND" }, children: [] });
expect(options.layout).toBe("logicalStructure");
expect(options.theme).toBe("classic4");
expect(options.themeConfig).toEqual({ root: { fillColor: "#c62828" } });
expect(options.viewData).toEqual({ state: { scale: 0.8, x: 1, y: 2 }, transform: { scaleX: 0.8, scaleY: 0.8 } });
});
it("保留 runtimeOptions 中的 fit 设置,避免初始导图被裁切", () => {
const projection = createFallbackMindmapAdapterProjection({
mindmapId: "mind_1",
root: { data: { text: "KMIND" }, children: [] },
kernelRevision: 7,
});
expect(
buildSimpleMindMapOptions({
el: {} as HTMLElement,
projection,
}).fit,
).toBe(true);
expect(
buildSimpleMindMapOptions({
el: {} as HTMLElement,
projection,
runtimeOptions: { fit: false },
}).fit,
).toBe(false);
});
it("忽略无效 viewData,避免 simple-mind-map 读取 viewData.state 时崩溃", () => {
const projection = createFallbackMindmapAdapterProjection({
mindmapId: "mind_1",
root: { data: { text: "KMIND" }, children: [] },
kernelRevision: 7,
});
const options = buildSimpleMindMapOptions({
el: {} as HTMLElement,
projection: { ...projection, view: {} },
});
expect(options.viewData).toBeNull();
});
it("安全命令包装只允许受控 runtime command", () => {
const calls: unknown[][] = [];
const exec = createSafeMindmapCommandExecutor({
execCommand: (...args) => calls.push(args),
});
expect(exec("INSERT_CHILD_NODE", "node_1")).toMatchObject({ ok: true });
expect(exec("SAVE_FULL_BLOB", { data: {} })).toEqual({
ok: false,
command: "SAVE_FULL_BLOB",
error: "unsupported_command",
});
expect(calls).toEqual([["INSERT_CHILD_NODE", "node_1"]]);
});
});
@@ -0,0 +1,310 @@
import {
canonicalizeMindmapData,
defaultMindmapData,
type MindMapData,
} from "./mindmap-projection";
export type MindmapAdapterProjection = {
schema: "mnote.mindmap.simple_mind_map_scene.v1";
runtime: "simple-mind-map" | string;
root: unknown;
layout?: unknown;
theme?: unknown;
themeConfig?: unknown;
view?: unknown;
config?: unknown;
compatPayload?: unknown;
kernelRevision: number;
};
export type SimpleMindMapInstance = {
execCommand?: (command: string, ...args: unknown[]) => unknown;
destroy?: () => void;
getData?: (withConfig?: boolean) => unknown;
on?: (event: string, handler: (...args: unknown[]) => void) => void;
off?: (event: string, handler: (...args: unknown[]) => void) => void;
setMode?: (mode: "edit" | "readonly") => void;
view?: {
scale?: number;
enlarge?: () => void;
narrow?: () => void;
getTransformData?: () => unknown;
setScale?: (scale: number, cx: number, cy: number) => void;
};
renderer?: {
setRootNodeCenter?: () => void;
};
};
export type SimpleMindMapCtor = new (options: Record<string, unknown>) => SimpleMindMapInstance;
export type SimpleMindMapRuntime = {
MindMap: SimpleMindMapCtor;
registeredPlugins: string[];
};
export type SimpleMindMapBridgeEventName =
| "data_change"
| "view_data_change"
| "node_active"
| "back_forward"
| "scale"
| "translate";
export type SimpleMindMapBridgeEvent = {
type: SimpleMindMapBridgeEventName;
args: unknown[];
snapshot?: unknown;
kernelRevision: number;
};
export type SimpleMindMapBridge = {
instance: SimpleMindMapInstance;
execCommand: (command: string, ...args: unknown[]) => SimpleMindMapSafeCommandResult;
getSnapshot: () => unknown;
destroy: () => void;
};
export type SimpleMindMapSafeCommandResult =
| { ok: true; command: string; result: unknown }
| { ok: false; command: string; error: "unsupported_command" | "runtime_unavailable" | "command_failed" };
export const SIMPLE_MIND_MAP_REQUIRED_PLUGINS = [
"Drag",
"KeyboardNavigation",
"Export",
"Select",
"AssociativeLine",
"Search",
"OuterFrame",
] as const;
export const SIMPLE_MIND_MAP_OPTIONAL_PLUGINS = [
// RichText 会把纯文本节点转成 HTML 字符串;Phase 6.1 先保护 kernel 文本语义。
"Scrollbar",
"MiniMap",
"Painter",
"Formula",
] as const;
export const SIMPLE_MIND_MAP_BRIDGE_EVENTS: SimpleMindMapBridgeEventName[] = [
"data_change",
"view_data_change",
"node_active",
"back_forward",
"scale",
"translate",
];
export const SIMPLE_MIND_MAP_SAFE_COMMANDS = new Set([
"BACK",
"FORWARD",
"INSERT_NODE",
"INSERT_CHILD_NODE",
"REMOVE_NODE",
"DELETE_NODE",
"ADD_GENERALIZATION",
"ADD_OUTER_FRAME",
"SET_NOTATION",
]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const readString = (value: unknown, fallback: string): string => {
if (typeof value === "string" && value.trim()) return value.trim();
if (isRecord(value)) {
const template = value.template;
if (typeof template === "string" && template.trim()) return template.trim();
}
return fallback;
};
const readRecord = (value: unknown): Record<string, unknown> => {
if (isRecord(value)) return value;
return {};
};
const readViewData = (value: unknown): Record<string, unknown> | null => {
if (!isRecord(value) || !isRecord(value.state)) return null;
return {
...value,
state: value.state,
transform: isRecord(value.transform) ? value.transform : {},
};
};
export const buildSimpleMindMapOptions = (input: {
el: HTMLElement;
projection: MindmapAdapterProjection;
runtimeOptions?: Record<string, unknown>;
}): Record<string, unknown> => {
const root = canonicalizeMindmapData(input.projection.root ?? defaultMindmapData);
const config = readRecord(input.projection.config);
const runtimeOptions = readRecord(input.runtimeOptions);
return {
...config,
...runtimeOptions,
el: input.el,
data: root,
fit:
typeof runtimeOptions.fit === "boolean"
? runtimeOptions.fit
: typeof config.fit === "boolean"
? config.fit
: true,
layout: readString(input.projection.layout, "logicalStructure"),
theme: readString(input.projection.theme, "classic"),
themeConfig: readRecord(input.projection.themeConfig),
viewData: readViewData(input.projection.view),
initRootNodePosition: ["center", "center"],
};
};
const pluginImporters = {
Drag: () => import("simple-mind-map/src/plugins/Drag.js"),
KeyboardNavigation: () => import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
Export: () => import("simple-mind-map/src/plugins/Export.js"),
Select: () => import("simple-mind-map/src/plugins/Select.js"),
AssociativeLine: () => import("simple-mind-map/src/plugins/AssociativeLine.js"),
Search: () => import("simple-mind-map/src/plugins/Search.js"),
OuterFrame: () => import("simple-mind-map/src/plugins/OuterFrame.js"),
Scrollbar: () => import("simple-mind-map/src/plugins/Scrollbar.js"),
MiniMap: () => import("simple-mind-map/src/plugins/MiniMap.js"),
Painter: () => import("simple-mind-map/src/plugins/Painter.js"),
Formula: () => import("simple-mind-map/src/plugins/Formula.js"),
} satisfies Record<string, () => Promise<{ default: unknown }>>;
export type SimpleMindMapPluginName = keyof typeof pluginImporters;
export const defaultSimpleMindMapPluginNames = (): SimpleMindMapPluginName[] => [
...SIMPLE_MIND_MAP_REQUIRED_PLUGINS,
...SIMPLE_MIND_MAP_OPTIONAL_PLUGINS,
];
export const loadSimpleMindMapRuntime = async (
pluginNames: SimpleMindMapPluginName[] = defaultSimpleMindMapPluginNames(),
): Promise<SimpleMindMapRuntime> => {
if (typeof window === "undefined" || typeof document === "undefined") {
throw new Error("simple_mind_map_browser_required");
}
const [{ default: MindMap }, pluginModules] = await Promise.all([
import("simple-mind-map"),
Promise.all(pluginNames.map(async (name) => [name, (await pluginImporters[name]()).default] as const)),
]);
const MindMapCtor = MindMap as unknown as SimpleMindMapCtor & {
hasPlugin?: (plugin: unknown) => number;
usePlugin?: (plugin: unknown) => unknown;
};
const registeredPlugins: string[] = [];
pluginModules.forEach(([name, plugin]) => {
if (!plugin || typeof MindMapCtor.usePlugin !== "function") return;
const registered =
typeof MindMapCtor.hasPlugin === "function" ? MindMapCtor.hasPlugin(plugin) !== -1 : false;
if (!registered) {
MindMapCtor.usePlugin(plugin);
}
registeredPlugins.push(name);
});
return {
MindMap: MindMapCtor,
registeredPlugins,
};
};
export const createSafeMindmapCommandExecutor =
(instance: SimpleMindMapInstance) =>
(command: string, ...args: unknown[]): SimpleMindMapSafeCommandResult => {
if (!SIMPLE_MIND_MAP_SAFE_COMMANDS.has(command)) {
return { ok: false, command, error: "unsupported_command" };
}
if (typeof instance.execCommand !== "function") {
return { ok: false, command, error: "runtime_unavailable" };
}
try {
return { ok: true, command, result: instance.execCommand(command, ...args) };
} catch {
return { ok: false, command, error: "command_failed" };
}
};
export const attachSimpleMindMapEventListeners = (input: {
instance: SimpleMindMapInstance;
projection: MindmapAdapterProjection;
onEvent?: (event: SimpleMindMapBridgeEvent) => void;
}): (() => void) => {
const cleanup: Array<() => void> = [];
SIMPLE_MIND_MAP_BRIDGE_EVENTS.forEach((eventName) => {
const handler = (...args: unknown[]) => {
const snapshot =
eventName === "data_change" || eventName === "view_data_change"
? input.instance.getData?.(true) ?? input.instance.getData?.()
: undefined;
input.onEvent?.({
type: eventName,
args,
snapshot,
kernelRevision: input.projection.kernelRevision,
});
};
input.instance.on?.(eventName, handler);
cleanup.push(() => input.instance.off?.(eventName, handler));
});
return () => cleanup.splice(0).forEach((dispose) => dispose());
};
export const createSimpleMindMapBridge = async (input: {
el: HTMLElement;
projection: MindmapAdapterProjection;
pluginNames?: SimpleMindMapPluginName[];
runtimeOptions?: Record<string, unknown>;
mode?: "edit" | "readonly";
onEvent?: (event: SimpleMindMapBridgeEvent) => void;
}): Promise<SimpleMindMapBridge> => {
const runtime = await loadSimpleMindMapRuntime(input.pluginNames);
const options = buildSimpleMindMapOptions({
el: input.el,
projection: input.projection,
runtimeOptions: input.runtimeOptions,
});
const instance = new runtime.MindMap(options);
if (input.mode) instance.setMode?.(input.mode);
const detachEvents = attachSimpleMindMapEventListeners({
instance,
projection: input.projection,
onEvent: input.onEvent,
});
const execCommand = createSafeMindmapCommandExecutor(instance);
return {
instance,
execCommand,
getSnapshot: () => instance.getData?.(true) ?? instance.getData?.(),
destroy: () => {
detachEvents();
instance.destroy?.();
},
};
};
export const createFallbackMindmapAdapterProjection = (input: {
mindmapId: string;
root?: MindMapData;
kernelRevision?: number;
}): MindmapAdapterProjection => ({
schema: "mnote.mindmap.simple_mind_map_scene.v1",
runtime: "simple-mind-map",
root: input.root ?? defaultMindmapData,
layout: "logicalStructure",
theme: "classic",
themeConfig: {},
view: {},
config: {},
compatPayload: { source: "frontend-fallback", mindmapId: input.mindmapId },
kernelRevision: input.kernelRevision ?? 1,
});
@@ -22,6 +22,10 @@ declare module "simple-mind-map/src/plugins/AssociativeLine.js" {
const plugin: unknown;
export default plugin;
}
declare module "simple-mind-map/src/plugins/Search.js" {
const plugin: unknown;
export default plugin;
}
declare module "simple-mind-map/src/plugins/OuterFrame.js" {
const plugin: unknown;
export default plugin;