feat: 收口 mindmap Phase 6 Leptos UI shell
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user