20260513 mindmap优化01

This commit is contained in:
lix-2026
2026-05-13 22:43:16 +08:00
parent 17c003976b
commit b4a452a8b7
89 changed files with 11557 additions and 707 deletions
@@ -0,0 +1,117 @@
export type DocumentVisibilityScope = "private" | "shared" | "public" | null | undefined;
export type DocumentVisibilityRecord = {
id: string;
user_id?: string | null;
parent_id?: string | null;
access_scope?: DocumentVisibilityScope;
deleted_at?: string | null;
};
export type DocumentVisibilityShareRecord = {
document_id: string;
include_descendants?: boolean | null;
};
type BuildVisibleDocumentIdsInput = {
userId: string;
docs: readonly DocumentVisibilityRecord[];
directShares: readonly DocumentVisibilityShareRecord[];
directGroupShares: readonly DocumentVisibilityShareRecord[];
includeDeletedDocuments?: boolean;
};
function normalizeDocumentId(value: string | null | undefined): string | null {
const trimmed = String(value ?? "").trim();
return trimmed.length > 0 ? trimmed : null;
}
function buildInheritedShareMap(
shares: readonly DocumentVisibilityShareRecord[],
): Map<string, { includeDescendants: boolean }> {
const result = new Map<string, { includeDescendants: boolean }>();
for (const share of shares) {
const documentId = normalizeDocumentId(share.document_id);
if (!documentId) continue;
const existing = result.get(documentId);
if (!existing) {
result.set(documentId, {
includeDescendants: Boolean(share.include_descendants),
});
continue;
}
existing.includeDescendants = existing.includeDescendants || Boolean(share.include_descendants);
}
return result;
}
function canAccessByInheritedShare(input: {
docId: string;
parentById: Map<string, string | null>;
directShareMap: Map<string, { includeDescendants: boolean }>;
cache: Map<string, boolean>;
}): boolean {
const cached = input.cache.get(input.docId);
if (typeof cached === "boolean") return cached;
if (input.directShareMap.has(input.docId)) {
input.cache.set(input.docId, true);
return true;
}
let parentId = input.parentById.get(input.docId) ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = input.directShareMap.get(parentId);
if (parentShare && parentShare.includeDescendants) {
input.cache.set(input.docId, true);
return true;
}
parentId = input.parentById.get(parentId) ?? null;
}
input.cache.set(input.docId, false);
return false;
}
export function buildVisibleDocumentIds(input: BuildVisibleDocumentIdsInput): Set<string> {
const candidateDocs = input.includeDeletedDocuments
? [...input.docs]
: input.docs.filter((doc) => doc.deleted_at == null);
const parentById = new Map<string, string | null>();
for (const doc of candidateDocs) {
parentById.set(doc.id, normalizeDocumentId(doc.parent_id) ?? null);
}
const directShares = buildInheritedShareMap(input.directShares);
const directGroupShares = buildInheritedShareMap(input.directGroupShares);
const shareAccessCache = new Map<string, boolean>();
const groupAccessCache = new Map<string, boolean>();
const visible = new Set<string>();
for (const doc of candidateDocs) {
if (String(doc.user_id ?? "") === input.userId) {
visible.add(doc.id);
continue;
}
if (doc.access_scope === "public") {
visible.add(doc.id);
continue;
}
if (
canAccessByInheritedShare({
docId: doc.id,
parentById,
directShareMap: directShares,
cache: shareAccessCache,
}) ||
canAccessByInheritedShare({
docId: doc.id,
parentById,
directShareMap: directGroupShares,
cache: groupAccessCache,
})
) {
visible.add(doc.id);
}
}
return visible;
}
+89 -14
View File
@@ -4,13 +4,21 @@ import { requireUserId } from "./_utils/auth";
import { nowIso } from "./_utils/time";
import { enqueueIngestMindmapJob } from "./_utils/ingestJobs";
import { internal } from "./_generated/api";
import { requireCanonicalOwnedDocument } from "./_utils/documentRecord";
import { getCanonicalDocumentByBusinessId, requireCanonicalOwnedDocument } from "./_utils/documentRecord";
import { buildVisibleDocumentIds } from "./_utils/documentVisibility";
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
const bridgeArtifactArgs = {
streamDeltaHint: v.optional(v.any()),
domainEventHint: v.optional(v.any()),
domainEventPlan: v.optional(v.any()),
domainEventPlans: v.optional(v.any()),
};
function resolveGraceSeconds(): number {
const raw = process.env.DELETE_GRACE_SECONDS ?? process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? "600";
const parsed = Number(raw);
@@ -31,12 +39,77 @@ async function requireOwnedDocument(ctx: any, userId: string, docId: string) {
return await requireCanonicalOwnedDocument<any>(ctx, docId, userId);
}
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
.first();
if (!membership) {
throw new Error("无权操作该工作空间");
}
}
async function readWorkspaceVisibilityContext(ctx: any, workspaceId: string, userId: string) {
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q: any) => q.eq("workspace_id", workspaceId))
.collect();
const directShares = await ctx.db
.query("document_shares")
.withIndex("by_workspace_shared_with", (q: any) =>
q.eq("workspace_id", workspaceId).eq("shared_with_user_id", userId),
)
.collect();
const groupMemberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
.collect();
const groupIds = new Set<string>(groupMemberships.map((item: any) => String(item.group_id)));
const directGroupShares: Array<{ document_id: string; include_descendants: boolean | null }> = [];
for (const groupId of groupIds) {
const rows = await ctx.db
.query("document_group_shares")
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", workspaceId).eq("group_id", groupId))
.collect();
for (const row of rows) {
directGroupShares.push({
document_id: row.document_id,
include_descendants: row.include_descendants,
});
}
}
return { docs, directShares, directGroupShares };
}
async function requireReadableDocument(ctx: any, userId: string, docId: string) {
const doc = await getCanonicalDocumentByBusinessId<any>(ctx, docId);
if (!doc) {
throw new Error("页面不存在");
}
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
const visibility = await readWorkspaceVisibilityContext(ctx, doc.workspace_id, userId);
const visibleDocumentIds = buildVisibleDocumentIds({
userId,
docs: visibility.docs,
directShares: visibility.directShares,
directGroupShares: visibility.directGroupShares,
});
if (!visibleDocumentIds.has(doc.id)) {
throw new Error("无权限");
}
return doc;
}
export const get = query({
args: { docId: v.string(), mindmapId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await requireOwnedDocument(ctx, userId, args.docId);
const doc = await requireReadableDocument(ctx, userId, args.docId);
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const row = await ctx.db
@@ -122,6 +195,7 @@ export const put = mutation({
mindmapId: v.string(),
data: v.any(),
createOnly: v.optional(v.boolean()),
...bridgeArtifactArgs,
},
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -195,7 +269,7 @@ export const put = mutation({
});
export const softDelete = mutation({
args: { docId: v.string(), mindmapId: v.string() },
args: { docId: v.string(), mindmapId: v.string(), ...bridgeArtifactArgs },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -299,7 +373,7 @@ export const purgeIfExpired = internalMutation({
});
export const restore = mutation({
args: { docId: v.string(), mindmapId: v.string() },
args: { docId: v.string(), mindmapId: v.string(), ...bridgeArtifactArgs },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -328,7 +402,7 @@ export const restore = mutation({
});
export const purge = mutation({
args: { docId: v.string(), mindmapId: v.string() },
args: { docId: v.string(), mindmapId: v.string(), ...bridgeArtifactArgs },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
@@ -359,14 +433,15 @@ export const listByWorkspace = query({
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// 说明:阶段 6 先按 membership 存在即可,避免引入复杂权限模型。
const membership = await ctx.db
.query("workspace_members")
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
.first();
if (!membership) {
throw new Error("无权操作该工作空间");
}
await requireWorkspaceMember(ctx, args.workspaceId, userId);
const visibility = await readWorkspaceVisibilityContext(ctx, args.workspaceId, userId);
const visibleDocumentIds = buildVisibleDocumentIds({
userId,
docs: visibility.docs,
directShares: visibility.directShares,
directGroupShares: visibility.directGroupShares,
includeDeletedDocuments: true,
});
const rows = await ctx.db
.query("mindmaps")
@@ -376,7 +451,7 @@ export const listByWorkspace = query({
const includeDeleted = Boolean(args.includeDeleted);
return rows
.filter((r) => r.user_id === userId)
.filter((r) => visibleDocumentIds.has(r.document_id))
.filter((r) => (includeDeleted ? true : r.deleted_at == null))
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))
.map((r) => ({
@@ -11,15 +11,34 @@ import {
import {
executeRustBridgeMutationTransport,
executeRustBridgeQueryTransport,
recordRustBridgeCommandArtifacts,
resolveRustBridgeCommandPlan,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import {
applyMetadataOnlyMindmapCommands,
isMetadataOnlyMindmapCommandSet,
} from "@/lib/mindmap/mindmap-command-apply-local";
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
async function recordMindmapCommandSuccess(args: {
context: ReturnType<typeof buildDocumentBridgeContextWithActor>;
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
result: unknown;
}) {
try {
await recordRustBridgeCommandArtifacts(args);
} catch (error) {
console.warn("[mindmap-route] Rust bridge success artifacts skipped:", error);
}
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
@@ -132,6 +151,81 @@ export async function POST(
},
});
const isCommandApply = commandName === "mindmap.command.apply";
const isMetadataOnlyCommandApply = isCommandApply && isMetadataOnlyMindmapCommandSet(Array.isArray(commands) ? commands : []);
if (isMetadataOnlyCommandApply) {
const currentEnvelope = buildDocumentQueryEnvelope({
name: "mindmaps.get",
payload: {
documentId: docId,
mindmapId,
workspaceId: null,
},
});
const currentPlan = await resolveRustBridgeQueryPlan({ context, envelope: currentEnvelope });
const currentResult = await executeRustBridgeQueryTransport<{ data?: unknown }>({
client,
plan: currentPlan,
});
const applied = applyMetadataOnlyMindmapCommands(currentResult?.data ?? defaultMindmapData, commands);
if (applied.errors.length > 0) {
return NextResponse.json(
{
error: "mindmap_command_apply_failed",
details: applied.errors,
},
{ status: 400 },
);
}
const putEnvelope = buildDocumentCommandEnvelope({
name: "mindmaps.put",
payload: {
documentId: docId,
mindmapId,
data: applied.data,
createOnly: false,
},
context,
target: {
workspaceId: null,
pageId: docId,
blockId: mindmapId,
},
reason: "mindmap-route:command-apply-metadata-fallback",
refs: ["task-166", "mindmap-command-bridge", "mindmap-metadata-fallback"],
});
const putPlan = await resolveRustBridgeCommandPlan({ context, envelope: putEnvelope });
const result = await executeRustBridgeMutationTransport<{
ok?: boolean;
workspace_id?: string | null;
updated_at?: string | null;
}>({
client,
plan: putPlan,
});
await recordMindmapCommandSuccess({
context,
envelope: putEnvelope,
client,
plan: putPlan,
result,
});
return NextResponse.json({
...(result ?? { ok: true }),
commandName: "mindmap.command.apply",
applied: applied.applied,
errors: applied.errors,
projectionRevision: typeof projectionRevision === "number" ? projectionRevision : null,
meta: {
...buildMindmapRouteMeta(request, {
workspaceId: result?.workspace_id ?? null,
documentId: docId,
mindmapId,
ownerUserId: auth.userId,
}),
updatedAt: result?.updated_at ?? null,
},
});
}
const envelope = buildDocumentCommandEnvelope({
name: isCommandApply ? "mindmap.command.apply" : "mindmaps.put",
payload: isCommandApply
@@ -166,6 +260,13 @@ export async function POST(
client,
plan,
});
await recordMindmapCommandSuccess({
context,
envelope,
client,
plan,
result,
});
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
@@ -232,6 +333,13 @@ export async function DELETE(
client,
plan,
});
await recordMindmapCommandSuccess({
context,
envelope,
client,
plan,
result,
});
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
@@ -303,6 +411,13 @@ export async function PATCH(
client,
plan,
});
await recordMindmapCommandSuccess({
context,
envelope,
client,
plan,
result,
});
return NextResponse.json({
...(result ?? { ok: true }),
meta: {
@@ -49,6 +49,7 @@ import {
type MindmapProjection,
type MindmapRouteMeta,
} from "@/lib/mindmap/mindmap-projection";
import { buildInitialMindmapAssetRefreshPayload } from "@/lib/mindmap/mindmap-initial-sync";
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
const loadIconModules = async () => {
@@ -1588,14 +1589,15 @@ const MindmapSurfaceView = ({
}, [persistData]);
const initialSyncDone = useRef(false);
useEffect(() => {
if (!docId || !mindmap || initialSyncDone.current) return;
initialSyncDone.current = true;
const data = canonicalizeMindmapData(
mindmap.getData?.(true) ?? mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData,
);
(async () => {
try {
useEffect(() => {
if (!docId || !mindmap || initialSyncDone.current) return;
initialSyncDone.current = true;
const data = canonicalizeMindmapData(
mindmap.getData?.(true) ?? mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData,
);
(async () => {
let refreshPayload: ReturnType<typeof buildInitialMindmapAssetRefreshPayload> = null;
try {
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -1605,19 +1607,18 @@ const MindmapSurfaceView = ({
} else {
const payload = (await resp.json().catch(() => null)) as { meta?: MindmapRouteMeta } | null;
syncMindmapRouteMeta(payload?.meta);
refreshPayload = buildInitialMindmapAssetRefreshPayload({
ok: true,
docId,
mindmapId,
});
}
} catch {} finally {
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
const fileName = `mindmap-${mindmapId}.json`;
emitAssetsChanged(docId, {
id: mindmapId,
document_id: docId,
asset_type: "mindmap",
file_name: fileName,
file_url: `/documents/${docId}/${fileName}`,
});
}
})();
if (refreshPayload) {
emitAssetsChanged(docId, refreshPayload);
}
}
})();
}, [docId, mindmap, mindmapId, syncMindmapRouteMeta]);
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import path from "node:path";
const ASSET_CONTEXT_MENU_SOURCE = path.join(process.cwd(), "src/components/sidebar/asset-context-menu.tsx");
describe("asset context menu source", () => {
it("删除按钮应把删除语义交给外层,而不是在菜单内硬编码单项 asset id", () => {
const source = fs.readFileSync(ASSET_CONTEXT_MENU_SOURCE, "utf8");
expect(source).toContain("onDelete: () => void;");
expect(source).toContain("onClick={() => onDelete()}");
expect(source).not.toContain("onDelete: (assetIds: string[]) => void;");
expect(source).not.toContain("onClick={() => onDelete([asset.id])}");
});
});
@@ -1,22 +1,21 @@
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
import type { MediaAsset } from "@/types/media";
import { cn } from "@/lib/utils";
interface AssetContextMenuProps {
asset: MediaAsset;
position: { x: number; y: number };
onClose: () => void;
"use client";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Copy, Download, Hash, Link as LinkIcon, Move, PenLine, Trash2 } from "lucide-react";
import type { MediaAsset } from "@/types/media";
interface AssetContextMenuProps {
asset: MediaAsset;
position: { x: number; y: number };
onClose: () => void;
onOpen: (asset: MediaAsset) => void;
onCopyLink: (asset: MediaAsset) => void;
onCopyPath: (asset: MediaAsset) => void;
onRename: (asset: MediaAsset) => void;
onMove: (asset: MediaAsset) => void;
onDelete: (assetIds: string[]) => void;
onDownload: (asset: MediaAsset) => void;
}
onCopyLink: (asset: MediaAsset) => void;
onCopyPath: (asset: MediaAsset) => void;
onRename: (asset: MediaAsset) => void;
onMove: (asset: MediaAsset) => void;
onDelete: () => void;
onDownload: (asset: MediaAsset) => void;
}
export function AssetContextMenu({
asset,
@@ -93,14 +92,14 @@ export function AssetContextMenu({
<Move className="h-4 w-4 text-gray-500" />
<span>...</span>
</button>
<button
type="button"
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
onClick={() => onDelete([asset.id])}
>
<Trash2 className="h-4 w-4" />
<span></span>
</button>
<button
type="button"
className="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm text-red-500 hover:bg-red-50"
onClick={() => onDelete()}
>
<Trash2 className="h-4 w-4" />
<span></span>
</button>
</div>
);
}
@@ -12,4 +12,20 @@ describe("sidebar file tree delete preflight source", () => {
expect(source).toContain("buildFileTreeShellDeletePreflightPayload(");
expect(source).not.toContain("computeFileTreeShellDeleteTargets(");
});
it("filetree DOM host 删除回调应直连 sidebar 删除入口", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
expect(source).toContain("const handleFileTreeShellDeleteSelection = useCallback(");
expect(source).toContain("void handleDeleteResourceSelection(payload);");
expect(source).toContain("onFileTreeDeleteSelection={handleFileTreeShellDeleteSelection}");
});
it("附件右键菜单删除应复用当前文件树 selection 删除入口", () => {
const source = fs.readFileSync(SIDEBAR_SOURCE, "utf8");
expect(source).toContain("const handleDeleteFromAssetContextMenu = useCallback(");
expect(source).toContain('if (viewMode === "filesystem") {');
expect(source).toContain("await handleDeleteResourceSelection();");
});
});
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { buildSidebarDocumentOpenTarget } from "./sidebar-navigation";
import {
buildSidebarDocumentOpenTarget,
buildSidebarMindmapOpenTarget,
} from "./sidebar-navigation";
describe("sidebar-navigation", () => {
it("页面树普通打开应留在当前窗口", () => {
@@ -15,4 +18,18 @@ describe("sidebar-navigation", () => {
url: "http://127.0.0.1:3000/documents/doc_1?preview=sidebar",
});
});
it("文件树里的 mindmap 主打开应进入 mindmap 对象编辑页", () => {
expect(buildSidebarMindmapOpenTarget("doc_1", "mind_1", "main", "http://127.0.0.1:3000")).toEqual({
kind: "same-window",
path: "/mindmap/doc_1/mind_1",
});
});
it("显式新标签打开 mindmap 也应使用同一对象编辑页", () => {
expect(buildSidebarMindmapOpenTarget("doc_1", "mind_1", "sidebar", "http://127.0.0.1:3000")).toEqual({
kind: "new-window",
url: "http://127.0.0.1:3000/mindmap/doc_1/mind_1",
});
});
});
@@ -17,3 +17,18 @@ export function buildSidebarDocumentOpenTarget(
const base = origin ? `${origin}${path}` : path;
return { kind: "new-window", url: `${base}?preview=sidebar` };
}
export function buildSidebarMindmapOpenTarget(
documentId: string,
mindmapId: string,
mode: SidebarDocumentOpenMode,
origin?: string | null,
): SidebarDocumentOpenTarget {
const path = `/mindmap/${documentId}/${mindmapId}`;
if (mode === "main") {
return { kind: "same-window", path };
}
const url = origin ? `${origin}${path}` : path;
return { kind: "new-window", url };
}
@@ -107,6 +107,7 @@ import {
} from "@/components/sidebar/tree-pane-bindings";
import {
buildSidebarDocumentOpenTarget,
buildSidebarMindmapOpenTarget,
type SidebarDocumentOpenMode,
} from "@/components/sidebar/sidebar-navigation";
import type { MediaAsset } from "@/types/media";
@@ -290,6 +291,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const mindmapAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.mindmapAssets ?? []));
const tableAssetsSyncKeyRef = useRef<string>(buildMediaAssetListSyncKey(sidebarData.tableAssets ?? []));
const pageTreeFocusedDocumentIdRef = useRef<string | null>(activeId || null);
const resourceRendererSelectionRef = useRef<TreePaneSelectionState>(
createEmptyFileTreeSelectionState(),
);
const resourcePaneContainerRef = useRef<HTMLDivElement>(null);
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
@@ -870,10 +874,18 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
});
}, []);
const handleOpenAsset = useCallback((asset: MediaAsset) => {
const handleOpenAssetInMain = useCallback((asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
router.push(`/mindmap/${asset.document_id}/${asset.id}`);
setOpen(false);
const target = buildSidebarMindmapOpenTarget(
asset.document_id,
asset.id,
"main",
typeof window !== "undefined" ? window.location.origin : null,
);
if (target.kind === "same-window") {
router.push(target.path);
setOpen(false);
}
return;
}
if (asset.asset_type === "luckysheet") {
@@ -949,6 +961,32 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
})();
}, [activeId, editorBridge, router, setOpen]);
const handleOpenAssetInNewTab = useCallback((asset: MediaAsset) => {
if (asset.asset_type === "mindmap") {
const target = buildSidebarMindmapOpenTarget(
asset.document_id,
asset.id,
"sidebar",
typeof window !== "undefined" ? window.location.origin : null,
);
if (target.kind === "new-window" && typeof window !== "undefined") {
window.open(target.url, "_blank", "noopener,noreferrer");
setOpen(false);
}
return;
}
if (asset.asset_type === "luckysheet") {
if (typeof window !== "undefined") {
window.open(buildTableUrl(asset.id), "_blank", "noopener,noreferrer");
}
setOpen(false);
return;
}
void handleOpenAssetInMain(asset);
}, [handleOpenAssetInMain, setOpen]);
const handleResourcePaneBlankMouseDown = useCallback(() => {
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
}, []);
@@ -998,12 +1036,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const handleResourceRowDoubleClick = useCallback(
(row: TreePaneRow, _event?: React.MouseEvent) => {
if (row.kind === "asset" || row.kind === "asset-folder") {
handleOpenAsset(row.asset);
handleOpenAssetInMain(row.asset);
return;
}
handleOpenDocument(row.docId, "main");
},
[handleOpenAsset, handleOpenDocument],
[handleOpenAssetInMain, handleOpenDocument],
);
const handleResourceRowContextMenu = useCallback(
@@ -1129,12 +1167,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
anchorRowId: string | null;
focusedRowId: string | null;
}) => {
setResourceRendererSelection(
materializeRendererSelectionSnapshot({
payload,
hasRowId: (rowId) => resourceShellRowById.has(rowId),
}),
);
const nextSelection = materializeRendererSelectionSnapshot({
payload,
hasRowId: (rowId) => resourceShellRowById.has(rowId),
});
resourceRendererSelectionRef.current = nextSelection;
setResourceRendererSelection(nextSelection);
},
[resourceShellRowById],
);
@@ -1145,9 +1183,9 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
if (!asset) {
return;
}
handleOpenAsset(asset);
handleOpenAssetInMain(asset);
},
[assetById, handleOpenAsset],
[assetById, handleOpenAssetInMain],
);
const handleTreeShellMutation = useCallback(() => {
@@ -1581,8 +1619,22 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[mediaAssets, mindmapAssets, refreshTree, tableAssets],
);
const handleDeleteResourceSelection = useCallback(async () => {
const selectedRowIds = Array.from(resourceSelection.selectedRowIds);
const handleDeleteResourceSelection = useCallback(async (selectionOverride?: {
selectedRowIds: string[];
anchorRowId: string | null;
focusedRowId: string | null;
}) => {
const effectiveSelection =
selectionOverride
? {
selectedRowIds: new Set(selectionOverride.selectedRowIds),
anchorRowId: selectionOverride.anchorRowId,
focusedRowId: selectionOverride.focusedRowId,
}
: isRustFamilyTreeRenderer
? resourceRendererSelectionRef.current
: resourceSelection;
const selectedRowIds = Array.from(effectiveSelection.selectedRowIds);
let shellDeleteTargets: {
docIds: string[];
assetIds: string[];
@@ -1614,7 +1666,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const legacyDeleteTargets = !isRustFamilyTreeRenderer
? computeTreePaneDeleteTargets({
visibleRows: resourceRows,
selectedRowIds: resourceSelection.selectedRowIds,
selectedRowIds: effectiveSelection.selectedRowIds,
parentById: docParentById,
})
: null;
@@ -1636,10 +1688,10 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
const selectedAssetHints =
shellDeleteTargets?.assetHints ??
Array.from(
Array.from(
new Map(
resourceRows
.filter((row) => resourceSelection.selectedRowIds.has(row.rowId))
.filter((row) => effectiveSelection.selectedRowIds.has(row.rowId))
.filter(isAssetRow)
.map((row) => [row.asset.id, row.asset] as const),
).values(),
@@ -1706,13 +1758,24 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
isRustFamilyTreeRenderer,
resourceRows,
resourceShellRowById,
resourceSelection.selectedRowIds,
resourceSelection,
handleDeleteAssets,
refreshTree,
router,
sidebarData.activeWorkspaceId,
]);
const handleFileTreeShellDeleteSelection = useCallback(
(payload: {
selectedRowIds: string[];
anchorRowId: string | null;
focusedRowId: string | null;
}) => {
void handleDeleteResourceSelection(payload);
},
[handleDeleteResourceSelection],
);
const handleResizeStart = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
@@ -2116,43 +2179,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
[handleDelete, handleDeleteResourceSelection, viewMode],
);
const handleDeleteFromAssetContextMenu = useCallback(
async (assetIds: string[], assetHint?: MediaAsset) => {
const uniqueAssetIds = Array.from(new Set(assetIds));
if (uniqueAssetIds.length === 0) return;
const assets = uniqueAssetIds
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
.filter(Boolean) as MediaAsset[];
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
assets.unshift(assetHint);
}
const mindmapCount = assets.filter((item) => item.asset_type === "mindmap").length;
const tableCount = assets.filter((item) => item.asset_type === "luckysheet").length;
const fileCount = assets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
const unknownCount = Math.max(0, uniqueAssetIds.length - mindmapCount - tableCount - fileCount);
const parts: string[] = [];
if (fileCount > 0) parts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
if (mindmapCount > 0) parts.push(`${mindmapCount} 个思维导图(删除)`);
if (tableCount > 0) parts.push(`${tableCount} 个在线表格(删除)`);
if (unknownCount > 0) parts.push(`${unknownCount} 个对象(删除)`);
const ok = window.confirm(`确认删除选中的 ${parts.join(" + ")} 吗?`);
if (!ok) return;
try {
await handleDeleteAssets(uniqueAssetIds, assetHint);
if (!isRustFamilyTreeRenderer) {
setLegacyResourceSelection((prev) => reduceTreePaneSelection(prev, { type: "clear" }));
}
} catch (error) {
window.alert(error instanceof Error ? error.message : "删除失败");
}
},
[handleDeleteAssets, isRustFamilyTreeRenderer, mediaAssets, mindmapAssets, tableAssets],
);
const handleDeleteFromAssetContextMenu = useCallback(async () => {
if (viewMode === "filesystem") {
await handleDeleteResourceSelection();
return;
}
}, [handleDeleteResourceSelection, viewMode]);
const handleConvertToChild = useCallback(
async (documentId: string) => {
@@ -2889,6 +2921,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
onNavigate={handleFileTreeShellNavigate}
onFileTreeContextMenu={handleFileTreeShellContextMenu}
onFileTreeSelectionChange={handleFileTreeShellSelectionChange}
onFileTreeDeleteSelection={handleFileTreeShellDeleteSelection}
onAssetOpen={handleFileTreeShellAssetOpen}
onTreeMutation={handleTreeShellMutation}
/>
@@ -3029,12 +3062,12 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
asset={assetMenu.asset}
position={{ x: assetMenu.x, y: assetMenu.y }}
onClose={() => setAssetMenu(null)}
onOpen={handleOpenAsset}
onOpen={handleOpenAssetInNewTab}
onCopyLink={handleCopyAssetLink}
onCopyPath={handleCopyAssetPath}
onRename={handleRenameAsset}
onMove={handleMoveAsset}
onDelete={(assetIds) => void handleDeleteFromAssetContextMenu(assetIds, assetMenu.asset)}
onDelete={() => void handleDeleteFromAssetContextMenu()}
onDownload={handleDownloadAsset}
/>
)}
@@ -12,11 +12,16 @@ import {
type TreeShellPickerItem,
} from "@/components/sidebar/tree-shell-dom-model";
import type {
FileTreeShellDeleteSelectionPayload,
FileTreeShellExternalDropPayload,
FileTreeShellInternalDropPayload,
TreeShellHostMode,
TreeShellPickerCommand,
} from "@/components/sidebar/tree-shell-host";
import {
normalizeFileTreeSelectionForVisibleRows as normalizeVisibleFileTreeSelection,
reduceFileTreeSelection,
} from "@/lib/file-tree/selection";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
import { cn } from "@/lib/utils";
@@ -91,6 +96,7 @@ type TreeShellRustDomShellHostProps = {
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
@@ -201,6 +207,35 @@ function normalizeRuntimeState<T>(result: TreeShellRuntimeResult | null, mode: "
return result.state.state as T;
}
function areFileTreeSelectionsEqual(
left: FileTreeRuntimeState["selection"],
right: FileTreeRuntimeState["selection"],
) {
if (left.anchorRowId !== right.anchorRowId || left.focusedRowId !== right.focusedRowId) {
return false;
}
if (left.selectedRowIds.length !== right.selectedRowIds.length) {
return false;
}
return left.selectedRowIds.every((rowId, index) => rowId === right.selectedRowIds[index]);
}
function toLocalSelectionState(selection: FileTreeRuntimeState["selection"]) {
return {
selectedRowIds: new Set(selection.selectedRowIds),
anchorRowId: selection.anchorRowId,
focusedRowId: selection.focusedRowId,
};
}
function fromLocalSelectionState(selection: ReturnType<typeof toLocalSelectionState>) {
return {
selectedRowIds: Array.from(selection.selectedRowIds),
anchorRowId: selection.anchorRowId,
focusedRowId: selection.focusedRowId,
};
}
async function loadTreeShellRuntimeWasm() {
if (!treeShellRuntimeWasmPromise) {
treeShellRuntimeWasmPromise = (async () => {
@@ -271,6 +306,7 @@ export function TreeShellRustDomShellHost({
onPageFocusChange,
onFileTreeContextMenu,
onFileTreeSelectionChange,
onFileTreeDeleteSelection,
onInternalDrop,
onDropFiles,
onAssetOpen,
@@ -295,6 +331,9 @@ export function TreeShellRustDomShellHost({
activeItemKey: activePickerItemKey,
}));
const pickerCommandSeqRef = useRef<number | null>(null);
const fileTreeStateRef = useRef(fileTreeState);
const fileTreeSelectionVersionRef = useRef(0);
const fileTreeSelectionInitializedRef = useRef(false);
const pageItems = useMemo(() => {
if (mode !== "page") return [];
@@ -319,10 +358,7 @@ export function TreeShellRustDomShellHost({
() => new Map(items.map((item) => [item.nodeId, item])),
[items],
);
const filetreeItemByRowId = useMemo(
() => new Map(fileTreeItems.map((item) => [toRowId(item), item])),
[fileTreeItems],
);
const visibleFileTreeRowIds = useMemo(() => fileTreeItems.map(toRowId), [fileTreeItems]);
const visiblePageItems = useMemo(() => {
const expanded = new Set(pageState.expandedIds);
const visible: TreeShellDomProjectionItem[] = [];
@@ -426,8 +462,55 @@ export function TreeShellRustDomShellHost({
[childrenByParent, pageItems, pageState, reduceRuntime, visiblePageItems],
);
useEffect(() => {
fileTreeStateRef.current = fileTreeState;
}, [fileTreeState]);
const updateFileTreeState = useCallback(
(updater: (prev: FileTreeRuntimeState) => FileTreeRuntimeState) => {
let nextStateSnapshot = fileTreeStateRef.current;
setFileTreeState((prev) => {
const nextState = updater(prev);
nextStateSnapshot = nextState;
fileTreeStateRef.current = nextState;
return nextState;
});
return nextStateSnapshot;
},
[],
);
const commitFileTreeSelection = useCallback(
(nextSelection: FileTreeRuntimeState["selection"]) => {
let changed = false;
const nextState = updateFileTreeState((prev) => {
if (areFileTreeSelectionsEqual(prev.selection, nextSelection)) {
return prev;
}
changed = true;
return {
...prev,
selection: nextSelection,
};
});
fileTreeSelectionInitializedRef.current = true;
if (changed) {
fileTreeSelectionVersionRef.current += 1;
onFileTreeSelectionChange?.(nextSelection);
}
return nextState;
},
[onFileTreeSelectionChange, updateFileTreeState],
);
const readFileTreeState = useCallback(() => fileTreeStateRef.current, []);
const reduceFileTreeAction = useCallback(
async (action: Record<string, unknown>, stateSnapshot: FileTreeRuntimeState = fileTreeState) => {
async (
action: Record<string, unknown>,
stateSnapshot: FileTreeRuntimeState = readFileTreeState(),
options?: { selectionVersion?: number },
) => {
const requestId = `filetree-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const result = await reduceRuntime({
mode: "fileTree",
@@ -446,13 +529,20 @@ export function TreeShellRustDomShellHost({
});
const nextState = normalizeRuntimeState<FileTreeRuntimeState>(result, "fileTree");
if (nextState) {
setFileTreeState(nextState);
onFileTreeSelectionChange?.(nextState.selection);
return { result, state: nextState };
const shouldApplySelection =
options?.selectionVersion === undefined || options.selectionVersion === fileTreeSelectionVersionRef.current;
const mergedState = updateFileTreeState((prev) => ({
...nextState,
selection: shouldApplySelection ? nextState.selection : prev.selection,
}));
if (shouldApplySelection && !areFileTreeSelectionsEqual(stateSnapshot.selection, mergedState.selection)) {
onFileTreeSelectionChange?.(mergedState.selection);
}
return { result, state: mergedState };
}
return { result: null, state: stateSnapshot };
},
[fileTreeItems, fileTreeState, onFileTreeSelectionChange, reduceRuntime],
[fileTreeItems, onFileTreeSelectionChange, readFileTreeState, reduceRuntime, updateFileTreeState],
);
const reducePickerAction = useCallback(
@@ -645,15 +735,25 @@ export function TreeShellRustDomShellHost({
useEffect(() => {
if (mode !== "filetree") return;
const selection = buildTreeShellDomFiletreeSelection(activeDocumentId);
setFileTreeState({
const shouldBackfillSelection =
!fileTreeSelectionInitializedRef.current || readFileTreeState().selection.selectedRowIds.length === 0;
if (shouldBackfillSelection) {
commitFileTreeSelection(selection);
}
updateFileTreeState((prev) => ({
...prev,
activeRowId: activeDocumentId ? `index:${activeDocumentId}` : null,
selection,
dragRowIds: [],
dragEffect: null,
dropTargetRowId: null,
});
onFileTreeSelectionChange?.(selection);
}, [activeDocumentId, mode, onFileTreeSelectionChange]);
selection: shouldBackfillSelection ? selection : prev.selection,
}));
}, [activeDocumentId, commitFileTreeSelection, mode, readFileTreeState, updateFileTreeState]);
useEffect(() => {
if (mode !== "filetree") return;
const normalizedSelection = fromLocalSelectionState(
normalizeVisibleFileTreeSelection(toLocalSelectionState(readFileTreeState().selection), visibleFileTreeRowIds),
);
commitFileTreeSelection(normalizedSelection);
}, [commitFileTreeSelection, mode, readFileTreeState, visibleFileTreeRowIds]);
useEffect(() => {
if (mode !== "picker") return;
@@ -837,8 +937,17 @@ export function TreeShellRustDomShellHost({
async (item: TreeShellDomProjectionItem, event: MouseEvent) => {
event.preventDefault();
const rowId = toRowId(item);
const { state } = await reduceFileTreeAction({ kind: "selectContextRow", rowId });
const { result } = await reduceFileTreeAction({ kind: "contextMenuRow", rowId }, state);
const state = commitFileTreeSelection(
fromLocalSelectionState(
reduceFileTreeSelection(toLocalSelectionState(readFileTreeState().selection), {
type: "contextmenu",
rowId,
}),
),
);
const selectionVersion = fileTreeSelectionVersionRef.current;
void reduceFileTreeAction({ kind: "selectContextRow", rowId }, state, { selectionVersion });
const { result } = await reduceFileTreeAction({ kind: "contextMenuRow", rowId }, state, { selectionVersion });
applyFileTreeHostEvents(result?.hostEvents, {
rowId,
rowKind: toShellRowKind(item),
@@ -846,13 +955,28 @@ export function TreeShellRustDomShellHost({
y: event.clientY,
});
},
[applyFileTreeHostEvents, reduceFileTreeAction],
[applyFileTreeHostEvents, commitFileTreeSelection, readFileTreeState, reduceFileTreeAction],
);
const handleFileTreeSelect = useCallback(
async (item: TreeShellDomProjectionItem, event: MouseEvent) => {
(item: TreeShellDomProjectionItem, event: MouseEvent) => {
const rowId = toRowId(item);
await reduceFileTreeAction({
const state = commitFileTreeSelection(
fromLocalSelectionState(
reduceFileTreeSelection(toLocalSelectionState(readFileTreeState().selection), {
type: "click",
rowId,
visibleRowIds: visibleFileTreeRowIds,
modifiers: {
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
},
}),
),
);
const selectionVersion = fileTreeSelectionVersionRef.current;
void reduceFileTreeAction({
kind: "selectRow",
rowId,
modifiers: {
@@ -860,9 +984,9 @@ export function TreeShellRustDomShellHost({
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
},
});
}, state, { selectionVersion });
},
[reduceFileTreeAction],
[commitFileTreeSelection, readFileTreeState, reduceFileTreeAction, visibleFileTreeRowIds],
);
const handleFileTreeKeyDown = useCallback(
@@ -889,11 +1013,16 @@ export function TreeShellRustDomShellHost({
if (!action) return;
event.preventDefault();
const rowId = toRowId(item);
const currentFileTreeState = readFileTreeState();
if (action.kind === "deleteSelection") {
onFileTreeDeleteSelection?.(currentFileTreeState.selection);
return;
}
const { result } = await reduceFileTreeAction(action, {
...fileTreeState,
...currentFileTreeState,
selection: {
...fileTreeState.selection,
focusedRowId: fileTreeState.selection.focusedRowId ?? rowId,
...currentFileTreeState.selection,
focusedRowId: currentFileTreeState.selection.focusedRowId ?? rowId,
},
});
applyFileTreeHostEvents(result?.hostEvents, {
@@ -901,7 +1030,7 @@ export function TreeShellRustDomShellHost({
rowKind: toShellRowKind(item),
});
},
[applyFileTreeHostEvents, fileTreeState, reduceFileTreeAction],
[applyFileTreeHostEvents, onFileTreeDeleteSelection, readFileTreeState, reduceFileTreeAction],
);
const handleFileTreeDrop = useCallback(
@@ -1095,7 +1224,11 @@ export function TreeShellRustDomShellHost({
data-testid="filetree-action-menu"
aria-label="更多操作"
className="rounded-md px-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
onClick={(event) => void handleFileTreeContextMenu(item, event)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleFileTreeContextMenu(item, event);
}}
>
</button>
@@ -1,14 +1,23 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TreeShellRustDomShellHost } from "./tree-shell-dom-host";
import { TreeShellHost } from "./tree-shell-host";
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type PendingRuntimeRequest = {
request: Record<string, unknown>;
resolve: (runtimeResult?: Record<string, unknown> | null) => void;
};
describe("tree-shell-host", () => {
let container: HTMLDivElement;
let root: Root;
let previousLegacyFlag: string | undefined;
let originalFetch: typeof fetch | undefined;
let pendingRuntimeRequests: PendingRuntimeRequest[];
beforeEach(() => {
previousLegacyFlag = process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
@@ -16,13 +25,35 @@ describe("tree-shell-host", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
originalFetch = global.fetch;
pendingRuntimeRequests = [];
global.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
const request = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
return new Promise((resolve) => {
pendingRuntimeRequests.push({
request,
resolve: (runtimeResult = null) => {
resolve({
ok: true,
json: async () => runtimeResult,
} as Response);
},
});
});
}) as typeof fetch;
});
afterEach(() => {
pendingRuntimeRequests.splice(0).forEach(({ resolve }) => resolve(null));
act(() => {
root.unmount();
});
container.remove();
if (originalFetch) {
global.fetch = originalFetch;
} else {
delete (globalThis as typeof globalThis & { fetch?: typeof fetch }).fetch;
}
if (previousLegacyFlag === undefined) {
delete process.env.NEXT_PUBLIC_TREE_SHELL_LEGACY_IFRAME_HOST;
} else {
@@ -30,6 +61,109 @@ describe("tree-shell-host", () => {
}
});
const fileTreeItems: KernelFileTreeProjectionItem[] = [
{
projectionKind: "file_tree",
rowId: "index:doc_1",
rowKind: "index",
nodeId: "doc_1:index",
parentNodeId: null,
title: "Doc 1 索引",
depth: 0,
childCount: 0,
position: 0,
expandedByDefault: false,
iconHint: "page",
capabilities: ["select"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_1",
workspaceId: "ws_1",
iconHint: "page",
},
},
{
projectionKind: "file_tree",
rowId: "index:doc_2",
rowKind: "index",
nodeId: "doc_2:index",
parentNodeId: null,
title: "Doc 2 索引",
depth: 0,
childCount: 0,
position: 1,
expandedByDefault: false,
iconHint: "page",
capabilities: ["select"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_2",
workspaceId: "ws_1",
iconHint: "page",
},
},
{
projectionKind: "file_tree",
rowId: "index:doc_3",
rowKind: "index",
nodeId: "doc_3:index",
parentNodeId: null,
title: "Doc 3 索引",
depth: 0,
childCount: 0,
position: 2,
expandedByDefault: false,
iconHint: "page",
capabilities: ["select"],
resourceMeta: {
resourceKind: "document",
documentId: "doc_3",
workspaceId: "ws_1",
iconHint: "page",
},
},
];
function queryFileTreeRow(rowId: string) {
return container.querySelector(`[data-row-id="${rowId}"]`) as HTMLElement | null;
}
function expectSelectedRowIds(rowIds: string[]) {
const selected = Array.from(container.querySelectorAll('[data-shell-mode="filetree"][data-selected="true"]'))
.map((element) => element.getAttribute("data-row-id"))
.filter((rowId): rowId is string => Boolean(rowId));
expect(selected).toEqual(rowIds);
}
function renderDomHost(input: {
activeDocumentId?: string | null;
onFileTreeDeleteSelection?: (payload: {
selectedRowIds: string[];
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
} = {}) {
act(() => {
root.render(
<TreeShellRustDomShellHost
mode="filetree"
surfaceTestId="sidebar-file-tree-shell"
workspaceId="ws_1"
activeDocumentId={input.activeDocumentId ?? null}
inlineFileTreeItems={fileTreeItems}
onFileTreeDeleteSelection={input.onFileTreeDeleteSelection}
/>,
);
});
}
async function flushRuntimeDispatch() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function renderHost() {
act(() => {
root.render(
@@ -79,4 +213,158 @@ describe("tree-shell-host", () => {
expect(iframe?.getAttribute("data-tree-browser-bridge")).toBe("iframe_srcdoc");
expect(iframe?.getAttribute("srcdoc")).toContain('"browserBridge":"iframe_srcdoc"');
});
it("filetree DOM host 应先本地提交 Ctrl/Shift selection,再异步等 runtime 对账", async () => {
renderDomHost();
const row2 = queryFileTreeRow("index:doc_2");
const row3 = queryFileTreeRow("index:doc_3");
expect(row2).not.toBeNull();
expect(row3).not.toBeNull();
act(() => {
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
expectSelectedRowIds(["index:doc_2"]);
act(() => {
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true }));
});
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
});
it("旧 runtime selection 回写不应覆盖更新后的多选,Delete 应使用当前稳定 selection", async () => {
const handleDeleteSelection = vi.fn();
renderDomHost({ onFileTreeDeleteSelection: handleDeleteSelection });
const row2 = queryFileTreeRow("index:doc_2");
const row3 = queryFileTreeRow("index:doc_3");
expect(row2).not.toBeNull();
expect(row3).not.toBeNull();
act(() => {
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
await flushRuntimeDispatch();
act(() => {
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
await flushRuntimeDispatch();
act(() => {
pendingRuntimeRequests[0]?.resolve({
requestId: String(pendingRuntimeRequests[0]?.request.requestId ?? "filetree-stale"),
mode: "fileTree",
state: {
mode: "fileTree",
state: {
activeRowId: null,
selection: {
selectedRowIds: ["index:doc_2"],
anchorRowId: "index:doc_2",
focusedRowId: "index:doc_2",
},
dragRowIds: [],
dragEffect: null,
dropTargetRowId: null,
},
},
});
});
await flushRuntimeDispatch();
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
act(() => {
row3?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete" }));
});
await flushRuntimeDispatch();
expect(handleDeleteSelection).toHaveBeenCalledTimes(1);
expect(handleDeleteSelection.mock.calls[0]?.[0]).toMatchObject({
selectedRowIds: ["index:doc_2", "index:doc_3"],
anchorRowId: "index:doc_3",
focusedRowId: "index:doc_3",
});
});
it("Backspace 也应复用当前稳定多选并触发删除回调", async () => {
const handleDeleteSelection = vi.fn();
renderDomHost({ onFileTreeDeleteSelection: handleDeleteSelection });
const row2 = queryFileTreeRow("index:doc_2");
const row3 = queryFileTreeRow("index:doc_3");
expect(row2).not.toBeNull();
expect(row3).not.toBeNull();
act(() => {
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
await flushRuntimeDispatch();
act(() => {
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
act(() => {
row3?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Backspace" }));
});
await flushRuntimeDispatch();
expect(handleDeleteSelection).toHaveBeenCalledTimes(1);
expect(handleDeleteSelection.mock.calls[0]?.[0]).toMatchObject({
selectedRowIds: ["index:doc_2", "index:doc_3"],
anchorRowId: "index:doc_3",
focusedRowId: "index:doc_3",
});
});
it("点击行内更多操作时不应因为事件冒泡把多选压成单选", async () => {
renderDomHost();
const row2 = queryFileTreeRow("index:doc_2");
const row3 = queryFileTreeRow("index:doc_3");
const row3Menu = container.querySelector(
'[data-row-id="index:doc_3"] [data-testid="filetree-action-menu"]',
) as HTMLElement | null;
expect(row2).not.toBeNull();
expect(row3).not.toBeNull();
expect(row3Menu).not.toBeNull();
act(() => {
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
act(() => {
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
act(() => {
row3Menu?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushRuntimeDispatch();
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
});
it("activeDocumentId 变化时,已有 filetree selection 不应被无条件重置", () => {
renderDomHost();
const row2 = queryFileTreeRow("index:doc_2");
const row3 = queryFileTreeRow("index:doc_3");
expect(row2).not.toBeNull();
expect(row3).not.toBeNull();
act(() => {
row2?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
act(() => {
row3?.dispatchEvent(new MouseEvent("click", { bubbles: true, ctrlKey: true }));
});
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
renderDomHost({ activeDocumentId: "doc_1" });
expectSelectedRowIds(["index:doc_2", "index:doc_3"]);
});
});
@@ -40,6 +40,12 @@ export type FileTreeShellExternalDropPayload = {
files: FileList | File[];
};
export type FileTreeShellDeleteSelectionPayload = {
selectedRowIds: string[];
anchorRowId: string | null;
focusedRowId: string | null;
};
type TreeShellHostProps = {
mode: TreeShellHostMode;
surfaceTestId: string;
@@ -79,6 +85,7 @@ type TreeShellHostProps = {
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onInternalDrop?: (payload: FileTreeShellInternalDropPayload) => void;
onDropFiles?: (payload: FileTreeShellExternalDropPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
@@ -114,6 +121,7 @@ export function TreeShellHost({
onPageFocusChange,
onFileTreeContextMenu,
onFileTreeSelectionChange,
onFileTreeDeleteSelection,
onInternalDrop,
onDropFiles,
onAssetOpen,
@@ -181,6 +189,7 @@ export function TreeShellHost({
onPageFocusChange={onPageFocusChange}
onFileTreeContextMenu={onFileTreeContextMenu}
onFileTreeSelectionChange={onFileTreeSelectionChange}
onFileTreeDeleteSelection={onFileTreeDeleteSelection}
onInternalDrop={onInternalDrop}
onDropFiles={onDropFiles}
onAssetOpen={onAssetOpen}
@@ -211,6 +220,7 @@ export function TreeShellHost({
onPageFocusChange={onPageFocusChange}
onFileTreeContextMenu={onFileTreeContextMenu}
onFileTreeSelectionChange={onFileTreeSelectionChange}
onFileTreeDeleteSelection={onFileTreeDeleteSelection}
onInternalDrop={onInternalDrop}
onDropFiles={onDropFiles}
onAssetOpen={onAssetOpen}
@@ -3,6 +3,7 @@
import type { DragEvent, MouseEvent } from "react";
import {
TreeShellHost,
type FileTreeShellDeleteSelectionPayload,
type FileTreeShellExternalDropPayload,
type FileTreeShellInternalDropPayload,
type TreeShellPickerCommand,
@@ -71,6 +72,7 @@ type SidebarFileTreeSurfaceProps = {
anchorRowId: string | null;
focusedRowId: string | null;
}) => void;
onFileTreeDeleteSelection?: (payload: FileTreeShellDeleteSelectionPayload) => void;
onAssetOpen?: (payload: { assetId: string; documentId: string | null }) => void;
onTreeMutation?: (payload: { type: string; documentId: string | null }) => void;
};
@@ -153,6 +155,7 @@ export function SidebarTreeSurface(props: SidebarTreeSurfaceProps) {
onPageFocusChange={props.mode === "page" ? props.onPageFocusChange : undefined}
onFileTreeContextMenu={props.mode === "filetree" ? props.onFileTreeContextMenu : undefined}
onFileTreeSelectionChange={props.mode === "filetree" ? props.onFileTreeSelectionChange : undefined}
onFileTreeDeleteSelection={props.mode === "filetree" ? props.onFileTreeDeleteSelection : undefined}
onInternalDrop={props.mode === "filetree" ? props.onInternalDrop : undefined}
onDropFiles={props.mode === "filetree" ? props.onDropFiles : undefined}
onAssetOpen={props.mode === "filetree" ? props.onAssetOpen : undefined}
@@ -72,7 +72,7 @@ describe("usePreferredSidebarSnapshot", () => {
container.remove();
});
it("tree stream live 时即使 query 更新也应继续优先使用 stream 快照", async () => {
it("tree stream 仍停在 initial 时,query 新快照不应被旧 stream 压住", async () => {
const initialData = buildSidebarData([buildDocument({ title: "标题 A" })]);
const staleTreeStream = buildSidebarData([buildDocument({ title: "标题 A" })]);
const refreshedQuery = buildSidebarData([
@@ -81,6 +81,12 @@ describe("usePreferredSidebarSnapshot", () => {
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
const caughtUpTreeStream = buildSidebarData([
buildDocument({
title: "标题 B",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
@@ -112,10 +118,29 @@ describe("usePreferredSidebarSnapshot", () => {
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "query",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
}),
});
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={refreshedQuery}
treeStreamData={caughtUpTreeStream}
treeStreamStatus="live"
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "tree_stream",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "标题 A" })],
kernelSidebarTree: [expect.objectContaining({ title: "标题 B" })],
}),
});
});
@@ -20,8 +20,16 @@ export function usePreferredSidebarSnapshot(input: {
);
const initialSyncKey = useMemo(() => buildSidebarDataSyncKey(input.initialData), [input.initialData]);
const streamIsPreferred = input.treeStreamStatus !== "fallback";
const queryHasFreshSnapshot =
input.sidebarQueryData != null &&
querySyncKey != null &&
querySyncKey !== initialSyncKey &&
treeStreamSyncKey === initialSyncKey;
const source = useMemo<PreferredSidebarSnapshotSource>(() => {
if (queryHasFreshSnapshot) {
return "query";
}
if (input.treeStreamData && streamIsPreferred) {
return "tree_stream";
}
@@ -32,6 +40,7 @@ export function usePreferredSidebarSnapshot(input: {
}, [
input.sidebarQueryData,
input.treeStreamData,
queryHasFreshSnapshot,
streamIsPreferred,
]);
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { buildVisibleDocumentIds } from "../../../convex/_utils/documentVisibility";
describe("buildVisibleDocumentIds", () => {
const docs = [
{ id: "owner-root", user_id: "u1", parent_id: null, access_scope: "private", deleted_at: null },
{ id: "public-root", user_id: "u2", parent_id: null, access_scope: "public", deleted_at: null },
{ id: "shared-root", user_id: "u2", parent_id: null, access_scope: "private", deleted_at: null },
{ id: "shared-child", user_id: "u2", parent_id: "shared-root", access_scope: "private", deleted_at: null },
{ id: "group-root", user_id: "u3", parent_id: null, access_scope: "private", deleted_at: null },
{ id: "group-child", user_id: "u3", parent_id: "group-root", access_scope: "private", deleted_at: null },
{ id: "deleted-public", user_id: "u4", parent_id: null, access_scope: "public", deleted_at: "2026-05-12T00:00:00.000Z" },
] as const;
it("允许 owner、public、直接分享与继承分享的文档可见", () => {
const visible = buildVisibleDocumentIds({
userId: "u1",
docs,
directShares: [{ document_id: "shared-root", include_descendants: true }],
directGroupShares: [],
});
expect(Array.from(visible).sort()).toEqual([
"owner-root",
"public-root",
"shared-child",
"shared-root",
]);
});
it("允许群组分享向下继承,但不会包含已删除文档", () => {
const visible = buildVisibleDocumentIds({
userId: "u9",
docs,
directShares: [],
directGroupShares: [{ document_id: "group-root", include_descendants: true }],
includeDeletedDocuments: false,
});
expect(Array.from(visible).sort()).toEqual([
"group-child",
"group-root",
"public-root",
]);
expect(visible.has("deleted-public")).toBe(false);
});
});
@@ -130,6 +130,58 @@ describe("buildDocumentSavePayload", () => {
});
});
it("保留 mindmap 引用骨架,不把图数据保存成文档块真源", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
workspaceId: "ws_1",
tiptapDocument: {
type: "doc",
content: [
{
type: "paragraph",
attrs: {
blockId: "mind_block_1",
mindmapId: "mindmap_1",
mnoteBlockType: "mindmap",
projectionVersion: 1,
rootNodeId: "root",
},
},
],
},
});
expect(payload.editorDocument).toEqual({
documentId: "doc_1",
rootBlockIds: ["mind_block_1"],
blocks: [
{
blockId: "mind_block_1",
blockType: "mindmap",
props: {
mindmapId: "mindmap_1",
rootNodeId: "root",
projectionVersion: 1,
},
contentNodes: [],
childBlockIds: [],
},
],
});
expect(payload.content).toEqual([
{
id: "mind_block_1",
type: "mindmap",
props: {
mindmapId: "mindmap_1",
rootNodeId: "root",
projectionVersion: 1,
},
content: "",
},
]);
});
it("保留正文快照采集元数据", () => {
const payload = buildDocumentSavePayload({
documentId: "doc_1",
@@ -219,7 +219,7 @@ describe("tiptap-content-converter", () => {
]);
});
it("保留 slash 插入的 mindmap placeholder 骨架为 mindmap legacy block", () => {
it("保留 mindmap 引用骨架为 mindmap legacy block,不把图数据写回文档块", () => {
const tiptapDoc = {
type: "doc" as const,
content: [
@@ -228,6 +228,9 @@ describe("tiptap-content-converter", () => {
attrs: {
blockId: "mind_1",
mnoteBlockType: "mindmap",
mindmapId: "mindmap_1",
rootNodeId: "root",
projectionVersion: 1,
mnoteMindmapData: {
data: { uid: "root", text: "KMIND", generalization: { text: "概要" } },
children: [
@@ -251,19 +254,48 @@ describe("tiptap-content-converter", () => {
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: [] },
],
},
],
mindmapId: "mindmap_1",
rootNodeId: "root",
projectionVersion: 1,
},
content: "",
},
]);
});
it("读取旧 mindmap block 时只迁移引用字段,丢弃 props.data 作为块真源", () => {
const tiptapDoc = tiptapDocFromBlocks([
{
id: "mind_legacy",
type: "mindmap",
props: {
data: { id: "mindmap_from_legacy_data", rootNodeId: "root_from_data" },
},
},
] as never);
expect(tiptapDoc).toEqual({
type: "doc",
content: [
{
type: "paragraph",
attrs: {
blockId: "mind_legacy",
mnoteBlockType: "mindmap",
mindmapId: "mindmap_from_legacy_data",
rootNodeId: "root_from_data",
},
},
],
});
expect(blocksFromTiptapDoc(tiptapDoc)).toEqual([
{
id: "mind_legacy",
type: "mindmap",
props: {
mindmapId: "mindmap_from_legacy_data",
rootNodeId: "root_from_data",
},
content: "",
},
]);
@@ -47,7 +47,9 @@ export type EditorBlock = {
headingLevel?: number | null;
checked?: boolean | null;
language?: string | null;
data?: unknown;
mindmapId?: string | null;
rootNodeId?: string | null;
projectionVersion?: number | null;
};
contentNodes?: EditorContentNode[];
childBlockIds?: string[];
@@ -178,6 +180,39 @@ function normalizeBlockId(value: unknown, fallback: string): string {
return raw || fallback;
}
function optionalText(value: unknown): string | null {
const raw = typeof value === "string" ? value.trim() : "";
return raw || null;
}
function mindmapReferenceProps(
attrsOrProps: Record<string, unknown> | undefined,
fallbackMindmapId: string,
): NonNullable<EditorBlock["props"]> {
const data = attrsOrProps?.data && typeof attrsOrProps.data === "object"
? attrsOrProps.data as Record<string, unknown>
: {};
const mindmapId =
optionalText(attrsOrProps?.mindmapId) ??
optionalText(attrsOrProps?.mindmap_id) ??
optionalText(data.mindmapId) ??
optionalText(data.mindmap_id) ??
optionalText(data.id) ??
fallbackMindmapId;
const rootNodeId =
optionalText(attrsOrProps?.rootNodeId) ??
optionalText(attrsOrProps?.root_node_id) ??
optionalText(data.rootNodeId) ??
optionalText(data.root_node_id) ??
"root";
const projectionVersion = Number(attrsOrProps?.projectionVersion ?? attrsOrProps?.projection_version);
return {
mindmapId,
rootNodeId,
...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}),
};
}
function normalizeEditorContentNodes(input: unknown): EditorContentNode[] {
if (Array.isArray(input)) {
const nodes = input.flatMap((item) => {
@@ -252,7 +287,7 @@ function normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBloc
return {
blockId,
blockType: "mindmap",
props: { data: props.data ?? block.content },
props: mindmapReferenceProps(props, blockId),
contentNodes: [],
childBlockIds: [],
};
@@ -378,9 +413,8 @@ function blockToTiptapNode(block: EditorBlock): TiptapNode {
attrs: {
...commonAttrs,
mnoteBlockType: "mindmap",
mnoteMindmapData: block.props?.data ?? null,
...mindmapReferenceProps(block.props, block.blockId),
},
content: textNodesToInline(block.contentNodes),
};
case "paragraph":
default:
@@ -433,7 +467,7 @@ function tiptapNodeToBlock(node: TiptapNode, index: number): EditorBlock | null
return {
blockId,
blockType: "mindmap",
props: { data: node.attrs.mnoteMindmapData },
props: mindmapReferenceProps(node.attrs, blockId),
contentNodes: [],
childBlockIds: [],
};
@@ -623,7 +657,7 @@ export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocumen
: block.blockType === "code_block"
? { language: block.props?.language ?? null }
: block.blockType === "mindmap"
? { data: block.props?.data ?? null }
? mindmapReferenceProps(block.props, block.blockId)
: undefined,
content: block.blockType === "mindmap" ? "" : legacyContentFromNodes(block.contentNodes),
}));
@@ -4,6 +4,7 @@ import {
createMindmapCommandApplyEndpoint,
executeMindmapCommandApply,
executeMindmapCommandApplyAndRefreshProjection,
executeMindmapDataPutAndRefreshProjection,
isMindmapAdapterProjection,
requestMindmapAdapterProjection,
} from "./leptos-mindmap-adapter";
@@ -87,6 +88,23 @@ describe("leptos-mindmap adapter projection", () => {
commands: [{ type: "deleteNode", mindmapId: "mind_1", nodeId: "node_1" }],
fetcher,
}),
).rejects.toMatchObject({ code: "command_failed", status: 409 });
).rejects.toMatchObject({ code: "command_failed", status: 409, message: "command_failed:409:revision_conflict" });
});
it("data put 失败时透出 route 错误细节", async () => {
const fetcher = vi.fn(async () => ({
ok: false,
status: 400,
json: async () => ({ error: "mindmap_put_failed", details: ["missing_root_uid"] }),
})) as unknown as typeof fetch;
await expect(
executeMindmapDataPutAndRefreshProjection({
documentId: "doc_1",
mindmapId: "mind_1",
data: { data: { text: "中心主题" }, children: [] },
fetcher,
}),
).rejects.toMatchObject({ code: "command_failed", status: 400, message: "command_failed:400:mindmap_put_failed" });
});
});
@@ -38,6 +38,15 @@ export type MindmapCommandApplyInput = {
fetcher?: typeof fetch;
};
export type MindmapDataPutInput = {
documentId: string;
mindmapId: string;
data: unknown;
projectionEndpoint?: string;
endpoint?: string;
fetcher?: typeof fetch;
};
export type MindmapCommandApplyResult = {
ok: true;
kernelRevision: number | null;
@@ -59,6 +68,32 @@ export class MindmapCommandBridgeError extends Error {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const readErrorDetailFromPayload = (raw: unknown): string => {
if (!isRecord(raw)) return "";
const detail = [raw.error, raw.message, raw.details]
.map((value) => {
if (typeof value === "string") return value.trim();
if (Array.isArray(value) && value.length > 0) return value.join("|");
return "";
})
.find(Boolean);
return detail ? `:${detail}` : "";
};
const readErrorDetail = async (response: Response): Promise<string> => {
if (typeof response.text !== "function") {
const raw = (await response.json().catch(() => null)) as unknown;
return readErrorDetailFromPayload(raw);
}
const rawText = await response.text().catch(() => "");
if (!rawText.trim()) return "";
try {
return readErrorDetailFromPayload(JSON.parse(rawText) as unknown) || `:${rawText.trim()}`;
} catch {
return `:${rawText.trim()}`;
}
};
export const isMindmapAdapterProjection = (value: unknown): value is MindmapAdapterProjection => {
if (!isRecord(value)) return false;
return (
@@ -122,23 +157,29 @@ export const executeMindmapCommandApply = async (
}
const fetcher = input.fetcher ?? fetch;
const endpoint = input.endpoint ?? createMindmapCommandApplyEndpoint(input.documentId, input.mindmapId);
const requestBody = JSON.stringify({
commandName: "mindmap.command.apply",
documentId: input.documentId,
mindmapId: input.mindmapId,
commands: input.commands,
projectionRevision: input.projectionRevision ?? null,
});
const response = await fetcher(endpoint, {
method: "POST",
keepalive: requestBody.length <= 60_000,
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,
}),
body: requestBody,
});
const raw = (await response.json().catch(() => null)) as unknown;
if (!response.ok) {
throw new MindmapCommandBridgeError(`command_failed:${response.status}`, response.status);
const detail = readErrorDetailFromPayload(raw);
throw new MindmapCommandBridgeError(
`command_failed:${response.status}${detail}`,
response.status,
);
}
return {
ok: true,
@@ -159,6 +200,35 @@ export const executeMindmapCommandApplyAndRefreshProjection = async (
});
};
export const executeMindmapDataPutAndRefreshProjection = async (
input: MindmapDataPutInput,
): Promise<MindmapAdapterProjection> => {
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({
data: input.data,
createOnly: false,
}),
});
if (!response.ok) {
const detail = await readErrorDetail(response);
throw new MindmapCommandBridgeError(`command_failed:${response.status}${detail}`, response.status);
}
await response.json().catch(() => null);
return requestMindmapAdapterProjection({
documentId: input.documentId,
mindmapId: input.mindmapId,
endpoint: input.projectionEndpoint,
fetcher,
});
};
export const createLeptosMindmapAdapter = async (
input: LeptosMindmapAdapterOptions,
): Promise<SimpleMindMapBridge> => {
@@ -28,7 +28,7 @@ describe("mindmap action map", () => {
},
});
expect(mapMindmapActionToCommand({ actionId: "deleteNode", mindmapId: "mind_1", activeNodeId: "node_1" })).toEqual({
runtimeCommand: "DELETE_NODE",
runtimeCommand: "REMOVE_NODE",
command: { type: "deleteNode", mindmapId: "mind_1", nodeId: "node_1" },
});
});
@@ -38,6 +38,21 @@ describe("mindmap action map", () => {
expect(getMindmapActionMapping("zoomIn")).toMatchObject({ target: "localView", runtimeMethod: "zoomIn" });
expect(getMindmapActionMapping("zoomOut")).toMatchObject({ target: "localView", runtimeMethod: "zoomOut" });
expect(getMindmapActionMapping("fitView")).toMatchObject({ target: "localView", runtimeMethod: "fitView" });
expect(getMindmapActionMapping("fullscreenCanvas")).toMatchObject({ target: "localView", requiresActiveNode: false });
expect(getMindmapActionMapping("fullscreenPage")).toMatchObject({ target: "localView", requiresActiveNode: false });
expect(getMindmapActionMapping("exitFullscreen")).toMatchObject({ target: "localView", requiresActiveNode: false });
expect(getMindmapActionMapping("showMenu")).toMatchObject({ target: "localView", requiresActiveNode: false });
});
it("全屏动作不产生 kernel commandreadonly 下仍可使用", () => {
expect(mapMindmapActionToCommand({ actionId: "fullscreenCanvas", mindmapId: "mind_1" })).toEqual({});
expect(mapMindmapActionToCommand({ actionId: "fullscreenPage", mindmapId: "mind_1" })).toEqual({});
expect(mapMindmapActionToCommand({ actionId: "exitFullscreen", mindmapId: "mind_1" })).toEqual({});
expect(mapMindmapActionToCommand({ actionId: "showMenu", mindmapId: "mind_1" })).toEqual({});
expect(getMindmapActionMapping("fullscreenCanvas")).toMatchObject({ readonlyAllowed: true });
expect(getMindmapActionMapping("fullscreenPage")).toMatchObject({ readonlyAllowed: true });
expect(getMindmapActionMapping("exitFullscreen")).toMatchObject({ readonlyAllowed: true });
expect(getMindmapActionMapping("showMenu")).toMatchObject({ readonlyAllowed: true });
});
it("把主题、结构和扩展字段映射到 kernel/compat", () => {
@@ -46,11 +46,11 @@ const nodeDataPath = (field: string) => (activeNodeId: string | null): string |
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: "editNode", target: "localView", 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: "deleteNode", target: "runtimeCommand", runtimeCommand: "REMOVE_NODE", requiresActiveNode: true, readonlyAllowed: false },
{ actionId: "summary", target: "runtimeCommand", 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 },
@@ -67,7 +67,11 @@ const mappings: MindmapActionMapping[] = [
{ 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: "fullscreenCanvas", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "fullscreenPage", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "exitFullscreen", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "search", target: "localView", requiresActiveNode: false, readonlyAllowed: true },
{ actionId: "showMenu", 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 },
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import {
applyMindmapCommandsLocally,
applyMetadataOnlyMindmapCommands,
isLocallyApplicableMindmapCommandSet,
isMetadataOnlyMindmapCommandSet,
} from "./mindmap-command-apply-local";
describe("mindmap command apply local", () => {
it("识别 metadata-only 命令集合", () => {
expect(
isMetadataOnlyMindmapCommandSet([
{ type: "setLayout", mindmapId: "mind_1", layout: "mindMap" },
{ type: "setTheme", mindmapId: "mind_1", theme: "dark" },
{ type: "patchView", mindmapId: "mind_1", patch: { state: { scale: 1.2 } } },
{ type: "compatPayloadPatch", mindmapId: "mind_1", path: "root.data.fillColor", value: "#dbeafe" },
]),
).toBe(true);
expect(
isMetadataOnlyMindmapCommandSet([
{ type: "insertChild", mindmapId: "mind_1", parentNodeId: "root", node: { text: "新节点" } },
]),
).toBe(false);
});
it("把 layout/theme/view/compat patch 合并回完整 mindmap blob", () => {
const result = applyMetadataOnlyMindmapCommands(
{
data: { uid: "root", text: "KMIND" },
children: [{ data: { uid: "node_1", text: "子节点" }, children: [] }],
layout: "logicalStructure",
theme: "classic",
themeConfig: { lineColor: "#60a5fa" },
view: { state: { scale: 1 } },
compatPayload: { source: "compat-blob" },
},
[
{ type: "setLayout", mindmapId: "mind_1", layout: "mindMap" },
{ type: "setTheme", mindmapId: "mind_1", theme: "dark" },
{ type: "patchView", mindmapId: "mind_1", patch: { state: { scale: 1.25, x: 10 } } },
{ type: "compatPayloadPatch", mindmapId: "mind_1", path: "nodes.node_1.data.fillColor", value: "#dbeafe" },
{ type: "compatPayloadPatch", mindmapId: "mind_1", path: "style.map.backgroundColor", value: "#0f172a" },
],
);
expect(result.errors).toEqual([]);
expect(result.applied).toBe(5);
expect(result.data.layout).toBe("mindMap");
expect(result.data.theme).toBe("dark");
expect(result.data.view).toEqual({ state: { scale: 1.25, x: 10 } });
expect(result.data.children[0].data.fillColor).toBe("#dbeafe");
expect(result.data.compatPayload).toEqual({
source: "compat-blob",
style: {
map: {
backgroundColor: "#0f172a",
},
},
});
});
it("识别可本地应用的结构命令集合", () => {
expect(
isLocallyApplicableMindmapCommandSet([
{ type: "updateText", mindmapId: "mind_1", nodeId: "node_1", text: "改名" },
{ type: "insertChild", mindmapId: "mind_1", parentNodeId: "node_1", node: { uid: "node_2", text: "子节点" } },
{ type: "insertSiblingAfter", mindmapId: "mind_1", targetNodeId: "node_1", node: { uid: "node_3", text: "同级" } },
{ type: "deleteNode", mindmapId: "mind_1", nodeId: "node_3" },
]),
).toBe(true);
expect(
isLocallyApplicableMindmapCommandSet([
{ type: "moveNode", mindmapId: "mind_1", nodeId: "node_1", newParentNodeId: "root" },
]),
).toBe(false);
});
it("可本地应用 update/insert/delete 到 mindmap tree,避免每次都整块重挂载", () => {
const result = applyMindmapCommandsLocally(
{
data: { uid: "root", text: "KMIND" },
children: [
{
data: { uid: "node_1", text: "节点 1" },
children: [],
},
{
data: { uid: "node_2", text: "节点 2" },
children: [],
},
],
layout: "logicalStructure",
theme: "default",
},
[
{ type: "updateText", mindmapId: "mind_1", nodeId: "node_1", text: "节点 1A" },
{
type: "insertChild",
mindmapId: "mind_1",
parentNodeId: "node_1",
node: { uid: "node_1_child", text: "子节点 A" },
},
{
type: "insertSiblingAfter",
mindmapId: "mind_1",
targetNodeId: "node_1",
node: { uid: "node_1_sibling", text: "同级 A" },
},
{ type: "deleteNode", mindmapId: "mind_1", nodeId: "node_2" },
],
);
expect(result.errors).toEqual([]);
expect(result.applied).toBe(4);
expect(result.data.children).toHaveLength(2);
expect(result.data.children[0].data.text).toBe("节点 1A");
expect(result.data.children[0].children[0].data.uid).toBe("node_1_child");
expect(result.data.children[1].data.uid).toBe("node_1_sibling");
expect(result.data.children[1].data.text).toBe("同级 A");
});
});
@@ -0,0 +1,279 @@
import type { MindmapCompatPayloadPatch, MindmapKernelCommand } from "./mindmap-command-diff";
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
type MetadataOnlyMindmapCommand =
| Extract<MindmapKernelCommand, { type: "setLayout" | "setTheme" | "patchView" }>
| MindmapCompatPayloadPatch;
type LocallyApplicableMindmapCommand =
| Exclude<MindmapKernelCommand, { type: "moveNode" }>
| MindmapCompatPayloadPatch;
type ApplyMetadataOnlyMindmapCommandsResult = {
applied: number;
data: Record<string, unknown>;
errors: string[];
};
type ApplyMindmapCommandsLocallyResult = ApplyMetadataOnlyMindmapCommandsResult;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
const ensureMindmapBlob = (value: unknown): Record<string, unknown> => {
const next = isRecord(value) ? cloneJson(value) : cloneJson(defaultMindmapData);
if (!isRecord(next.data)) next.data = { text: "中心主题" };
if (!Array.isArray(next.children)) next.children = [];
return next;
};
const mergeJsonObject = (base: unknown, patch: unknown): unknown => {
if (!isRecord(base)) return cloneJson(patch);
if (!isRecord(patch)) return cloneJson(patch);
const next = { ...base };
Object.entries(patch).forEach(([key, value]) => {
if (value === null) {
delete next[key];
return;
}
next[key] = isRecord(next[key]) && isRecord(value) ? mergeJsonObject(next[key], value) : cloneJson(value);
});
return next;
};
const setNestedPath = (root: Record<string, unknown>, path: string, value: unknown): boolean => {
const segments = path.split(".").filter(Boolean);
if (segments.length === 0) return false;
let current: Record<string, unknown> = root;
for (const segment of segments.slice(0, -1)) {
if (!isRecord(current[segment])) current[segment] = {};
current = current[segment] as Record<string, unknown>;
}
current[segments[segments.length - 1]] = cloneJson(value);
return true;
};
const findMindmapNodeByUid = (node: unknown, uid: string): Record<string, unknown> | null => {
if (!isRecord(node)) return null;
const data = isRecord(node.data) ? node.data : null;
if (typeof data?.uid === "string" && data.uid === uid) return node;
const children = Array.isArray(node.children) ? node.children : [];
for (const child of children) {
const found = findMindmapNodeByUid(child, uid);
if (found) return found;
}
return null;
};
const findMindmapNodeContainerByUid = (
nodes: unknown[],
uid: string,
): { parentChildren: Record<string, unknown>[]; index: number } | null => {
for (let index = 0; index < nodes.length; index += 1) {
const candidate = nodes[index];
if (!isRecord(candidate)) continue;
const data = isRecord(candidate.data) ? candidate.data : null;
if (typeof data?.uid === "string" && data.uid === uid) {
return {
parentChildren: nodes as Record<string, unknown>[],
index,
};
}
const children = Array.isArray(candidate.children) ? candidate.children : [];
const found = findMindmapNodeContainerByUid(children, uid);
if (found) return found;
}
return null;
};
const createMindmapNodeRecord = (node: {
uid?: string;
text: string;
hyperlink?: string;
note?: string;
refs?: unknown[];
}): Record<string, unknown> => ({
data: {
uid: node.uid ?? `node_${Date.now().toString(36)}`,
text: node.text,
...(typeof node.hyperlink === "string" ? { hyperlink: node.hyperlink } : {}),
...(typeof node.note === "string" ? { note: node.note } : {}),
...(Array.isArray(node.refs) ? { refs: cloneJson(node.refs) } : {}),
},
children: [],
});
const applyCompatPayloadPatch = (root: Record<string, unknown>, command: MindmapCompatPayloadPatch): boolean => {
const path = String(command.path || "").trim();
if (!path) return false;
if (path.startsWith("root.data.")) {
const field = path.slice("root.data.".length);
const rootData = isRecord(root.data) ? root.data : (root.data = {});
return setNestedPath(rootData as Record<string, unknown>, field, command.value);
}
if (path.startsWith("nodes.")) {
const [, uid, scope, ...rest] = path.split(".");
if (!uid || scope !== "data" || rest.length === 0) return false;
const node = findMindmapNodeByUid(root, uid);
if (!node) return false;
const nodeData = isRecord(node.data) ? node.data : (node.data = {});
return setNestedPath(nodeData as Record<string, unknown>, rest.join("."), command.value);
}
const compatPayload = isRecord(root.compatPayload) ? root.compatPayload : (root.compatPayload = {});
return setNestedPath(compatPayload as Record<string, unknown>, path, command.value);
};
export const isMetadataOnlyMindmapCommandSet = (commands: unknown[]): commands is MetadataOnlyMindmapCommand[] =>
Array.isArray(commands) &&
commands.every((command) => {
if (!isRecord(command) || typeof command.type !== "string") return false;
return ["setLayout", "setTheme", "patchView", "compatPayloadPatch"].includes(command.type);
});
export const isLocallyApplicableMindmapCommandSet = (
commands: unknown[],
): commands is LocallyApplicableMindmapCommand[] =>
Array.isArray(commands) &&
commands.every((command) => {
if (!isRecord(command) || typeof command.type !== "string") return false;
return [
"updateText",
"insertChild",
"insertSiblingAfter",
"deleteNode",
"setLayout",
"setTheme",
"patchView",
"compatPayloadPatch",
].includes(command.type);
});
export const applyMetadataOnlyMindmapCommands = (
currentData: unknown,
commands: MetadataOnlyMindmapCommand[],
): ApplyMetadataOnlyMindmapCommandsResult => {
const nextData = ensureMindmapBlob(currentData);
const errors: string[] = [];
let applied = 0;
commands.forEach((command) => {
if (command.type === "setLayout") {
nextData.layout = cloneJson(command.layout);
applied += 1;
return;
}
if (command.type === "setTheme") {
nextData.theme = cloneJson(command.theme);
if (command.themeConfig !== undefined && command.themeConfig !== null) {
nextData.themeConfig = cloneJson(command.themeConfig);
}
applied += 1;
return;
}
if (command.type === "patchView") {
nextData.view = mergeJsonObject(nextData.view ?? {}, command.patch) as Record<string, unknown>;
applied += 1;
return;
}
if (command.type === "compatPayloadPatch") {
if (applyCompatPayloadPatch(nextData, command)) {
applied += 1;
} else {
errors.push(`无法应用 compatPayloadPatch:${command.path}`);
}
}
});
return {
applied,
data: nextData,
errors,
};
};
export const applyMindmapCommandsLocally = (
currentData: unknown,
commands: LocallyApplicableMindmapCommand[],
): ApplyMindmapCommandsLocallyResult => {
const nextData = ensureMindmapBlob(currentData);
const errors: string[] = [];
let applied = 0;
commands.forEach((command) => {
if (command.type === "setLayout" || command.type === "setTheme" || command.type === "patchView" || command.type === "compatPayloadPatch") {
const result = applyMetadataOnlyMindmapCommands(nextData, [command]);
Object.assign(nextData, result.data);
applied += result.applied;
errors.push(...result.errors);
return;
}
if (command.type === "updateText") {
const node = findMindmapNodeByUid(nextData, command.nodeId);
if (!node) {
errors.push(`未找到节点:${command.nodeId}`);
return;
}
const data = isRecord(node.data) ? node.data : (node.data = {});
data.text = command.text;
applied += 1;
return;
}
if (command.type === "insertChild") {
const parent = findMindmapNodeByUid(nextData, command.parentNodeId);
if (!parent) {
errors.push(`未找到父节点:${command.parentNodeId}`);
return;
}
const children = Array.isArray(parent.children) ? parent.children : (parent.children = []);
children.push(createMindmapNodeRecord(command.node));
applied += 1;
return;
}
if (command.type === "insertSiblingAfter") {
const container = findMindmapNodeContainerByUid([nextData], command.targetNodeId);
if (!container) {
errors.push(`未找到同级节点:${command.targetNodeId}`);
return;
}
if (container.parentChildren[container.index] === nextData) {
errors.push("根节点不支持插入同级节点");
return;
}
container.parentChildren.splice(container.index + 1, 0, createMindmapNodeRecord(command.node));
applied += 1;
return;
}
if (command.type === "deleteNode") {
const container = findMindmapNodeContainerByUid([nextData], command.nodeId);
if (!container) {
errors.push(`未找到删除节点:${command.nodeId}`);
return;
}
if (container.parentChildren[container.index] === nextData) {
errors.push("根节点不支持删除");
return;
}
container.parentChildren.splice(container.index, 1);
applied += 1;
}
});
return {
applied,
data: nextData,
errors,
};
};
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { buildInitialMindmapAssetRefreshPayload } from "./mindmap-initial-sync";
describe("buildInitialMindmapAssetRefreshPayload", () => {
it("远端 createOnly 成功时返回文件树刷新 payload", () => {
expect(
buildInitialMindmapAssetRefreshPayload({
ok: true,
docId: "doc-1",
mindmapId: "mind-1",
}),
).toEqual({
id: "mind-1",
document_id: "doc-1",
asset_type: "mindmap",
file_name: "mindmap-mind-1.json",
file_url: "/documents/doc-1/mindmap-mind-1.json",
});
});
it("远端 createOnly 失败时不应伪造文件树刷新事件", () => {
expect(
buildInitialMindmapAssetRefreshPayload({
ok: false,
docId: "doc-1",
mindmapId: "mind-1",
}),
).toBeNull();
});
});
@@ -0,0 +1,25 @@
export type InitialMindmapAssetRefreshPayload = {
id: string;
document_id: string;
asset_type: "mindmap";
file_name: string;
file_url: string;
};
export function buildInitialMindmapAssetRefreshPayload(input: {
ok: boolean;
docId: string;
mindmapId: string;
}): InitialMindmapAssetRefreshPayload | null {
if (!input.ok) {
return null;
}
const fileName = `mindmap-${input.mindmapId}.json`;
return {
id: input.mindmapId,
document_id: input.docId,
asset_type: "mindmap",
file_name: fileName,
file_url: `/documents/${input.docId}/${fileName}`,
};
}
@@ -95,6 +95,46 @@ export const defaultMindmapData: MindMapData = {
children: [],
};
export const DEFAULT_MINDMAP_LAYOUT = "logicalStructure";
export const DEFAULT_MINDMAP_THEME = "default";
export const createDefaultMindmapThemeConfig = (): Record<string, unknown> => ({
lineColor: "#7aa2ff",
lineStyle: "curve",
rootLineKeepSameInCurve: true,
rootLineStartPositionKeepSameInCurve: true,
generalizationLineColor: "#ef6a5b",
backgroundColor: "#f6f8fc",
root: {
fillColor: "#e25563",
color: "#ffffff",
fontWeight: "bold",
borderColor: "transparent",
borderWidth: 0,
borderRadius: 8,
},
second: {
fillColor: "#4f7df3",
color: "#ffffff",
borderColor: "transparent",
borderWidth: 0,
borderRadius: 8,
},
node: {
fillColor: "transparent",
color: "#315aa9",
borderColor: "transparent",
borderWidth: 0,
},
generalization: {
fillColor: "#ffffff",
color: "#ef6a5b",
borderColor: "#ef6a5b",
borderWidth: 1,
borderRadius: 8,
},
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -299,9 +339,9 @@ export const buildMindmapSimpleMindMapScene = (input: {
mindmapId: input.mindmapId,
rootNodeId,
root,
layout: "logicalStructure",
theme: "classic",
themeConfig: {},
layout: DEFAULT_MINDMAP_LAYOUT,
theme: DEFAULT_MINDMAP_THEME,
themeConfig: createDefaultMindmapThemeConfig(),
view: { x: 0, y: 0, scale: 1 },
config: {},
compatPayload: {},
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
resolveMindmapShortcutAction,
shouldInterceptMindmapShortcut,
} from "./mindmap-shortcuts";
describe("mindmap shortcuts", () => {
it("把基础快捷键解析为导图动作", () => {
expect(resolveMindmapShortcutAction({ key: "Enter" })).toBe("insertSiblingAfter");
expect(resolveMindmapShortcutAction({ key: "Tab" })).toBe("insertChild");
expect(resolveMindmapShortcutAction({ key: "Delete" })).toBe("deleteNode");
expect(resolveMindmapShortcutAction({ key: "F2" })).toBe("editNode");
expect(resolveMindmapShortcutAction({ key: "Enter", ctrlKey: true })).toBeNull();
});
it("已进入 mindmap 时即使暂时没有 active node,也要拦截危险按键,避免 ProseMirror 误删整个 block", () => {
expect(
shouldInterceptMindmapShortcut({
debugChromeEnabled: false,
bridgeReady: true,
readonly: false,
isComposing: false,
isEditableTarget: false,
targetInsideRoot: false,
keyboardShortcutArmed: true,
}),
).toBe(true);
expect(
shouldInterceptMindmapShortcut({
debugChromeEnabled: false,
bridgeReady: true,
readonly: false,
isComposing: false,
isEditableTarget: false,
targetInsideRoot: false,
keyboardShortcutArmed: false,
}),
).toBe(false);
});
});
@@ -0,0 +1,40 @@
import type { MindmapUiActionId } from "./mindmap-ui-schema";
type MindmapShortcutEventLike = {
key: string;
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
};
export type MindmapShortcutGateInput = {
debugChromeEnabled: boolean;
bridgeReady: boolean;
readonly: boolean;
isComposing: boolean;
isEditableTarget: boolean;
targetInsideRoot: boolean;
keyboardShortcutArmed: boolean;
};
export const resolveMindmapShortcutAction = (
event: MindmapShortcutEventLike,
): MindmapUiActionId | null => {
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
if (event.key === "Enter") return "insertSiblingAfter";
if (event.key === "Tab" || event.key === "Insert") return "insertChild";
if (event.key === "Delete" || event.key === "Backspace") return "deleteNode";
if (event.key === "F2") return "editNode";
return null;
};
export const shouldInterceptMindmapShortcut = (
input: MindmapShortcutGateInput,
): boolean => {
if (input.debugChromeEnabled) return false;
if (!input.bridgeReady || input.readonly || input.isComposing) return false;
if (input.isEditableTarget) return false;
if (input.targetInsideRoot) return true;
return input.keyboardShortcutArmed;
};
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import {
MINDMAP_TOOLBAR_MORE_ACTION_ID,
MINDMAP_DEBUG_CHROME_QUERY_PARAM,
isMindmapDebugChromeEnabled,
mindmapDefaultUiSchema,
mindmapToolbarFileActionOrder,
mindmapToolbarPrimaryActionOrder,
} from "./mindmap-ui-schema";
import { getMindmapActionMapping } from "./mindmap-action-map";
@@ -12,6 +15,7 @@ describe("mindmap UI schema", () => {
"history",
"node",
"insert",
"file",
"view",
]);
expect(mindmapDefaultUiSchema.sidebarPanels.map((panel) => panel.id)).toEqual([
@@ -20,6 +24,8 @@ describe("mindmap UI schema", () => {
"theme",
"structure",
"outline",
"shortcutKey",
"settings",
]);
expect(mindmapDefaultUiSchema.navigatorItems.map((item) => item.id)).toEqual([
"stats",
@@ -28,6 +34,7 @@ describe("mindmap UI schema", () => {
"zoomOut",
"zoom",
"zoomIn",
"fullscreen",
"readonly",
]);
});
@@ -48,6 +55,41 @@ describe("mindmap UI schema", () => {
expect(new Set(actionIds).size).toBe(actionIds.length);
});
it("单行 toolbar 主按钮顺序对齐 lx-doc/KMind", () => {
expect([...mindmapToolbarPrimaryActionOrder, MINDMAP_TOOLBAR_MORE_ACTION_ID]).toEqual([
"undo",
"redo",
"editNode",
"insertSiblingAfter",
"deleteNode",
"insertChild",
"tag",
"hyperlink",
"note",
"image",
"icon",
"summary",
"associativeLine",
"formula",
"more",
]);
});
it("toolbar 元信息包含图标、短标签、长标签、优先级和溢出归类", () => {
const primaryMeta = mindmapToolbarPrimaryActionOrder.map((actionId) => mindmapDefaultUiSchema.toolbarActionMeta[actionId]);
expect(primaryMeta.map((meta) => meta.priority)).toEqual([...primaryMeta.map((meta) => meta.priority)].sort((a, b) => a - b));
expect(primaryMeta.every((meta) => meta.cluster === "main")).toBe(true);
expect(primaryMeta.every((meta) => meta.iconKey && meta.shortLabel && meta.longLabel && meta.overflowGroup)).toBe(true);
});
it("导入导出属于右侧独立 toolbar cluster", () => {
expect([...mindmapToolbarFileActionOrder]).toEqual(["import", "export"]);
expect(mindmapToolbarFileActionOrder.map((actionId) => mindmapDefaultUiSchema.toolbarActionMeta[actionId].cluster)).toEqual([
"file",
"file",
]);
});
it("默认 schema 中所有 action 都有 action map 映射", () => {
const actionIds = [
...mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions),
@@ -58,6 +100,16 @@ describe("mindmap UI schema", () => {
expect(actionIds.filter((actionId) => getMindmapActionMapping(actionId) === null)).toEqual([]);
});
it("toolbar 真实可见 action 都有 action mapmore 仅作为 shell 虚拟按钮", () => {
const visibleActionIds = [
...mindmapToolbarPrimaryActionOrder,
...mindmapToolbarFileActionOrder,
];
expect(visibleActionIds.filter((actionId) => getMindmapActionMapping(actionId) === null)).toEqual([]);
expect(mindmapDefaultUiSchema.toolbarActionMeta).not.toHaveProperty(MINDMAP_TOOLBAR_MORE_ACTION_ID);
});
it("第一阶段 context menu 覆盖节点和画布动作", () => {
expect(mindmapDefaultUiSchema.contextMenuItems.filter((item) => item.requiresNode).map((item) => item.actionId)).toEqual([
"insertChild",
@@ -73,27 +125,53 @@ describe("mindmap UI schema", () => {
"fitView",
"search",
"readonly",
"showMenu",
]);
});
it("第一阶段 sidebar panel 数量受控", () => {
expect(mindmapDefaultUiSchema.sidebarPanels).toHaveLength(5);
expect(mindmapDefaultUiSchema.sidebarPanels).toHaveLength(7);
});
it("第一阶段 sidebar 暴露主题、结构和 compat patch 选项", () => {
const themePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "theme");
expect(themePanel?.options.map((option) => option.value)).toEqual(["classic", "classic4"]);
expect(themePanel?.options.map((option) => option.value)).toEqual(["classic", "classic4", "simple", "dark"]);
const structurePanel = mindmapDefaultUiSchema.sidebarPanels.find((panel) => panel.id === "structure");
expect(structurePanel?.options.map((option) => option.value)).toEqual([
"logicalStructure",
"mindMap",
"organizationStructure",
"catalogOrganization",
"timeline",
"fishbone",
]);
expect(structurePanel?.options.every((option) => option.controlType === "layoutCard")).toBe(true);
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);
});
it("每个 sidebar option 要么可写,要么显式标记只读", () => {
const options = mindmapDefaultUiSchema.sidebarPanels.flatMap((panel) => panel.options);
expect(options.length).toBeGreaterThan(0);
expect(options.every((option) => option.actionId !== null || option.readonly === true)).toBe(true);
});
it("可写 sidebar option 必须能映射到 compat patch 或 kernel command", () => {
const writableOptions = mindmapDefaultUiSchema.sidebarPanels.flatMap((panel) =>
panel.options.filter((option) => option.readonly !== true),
);
expect(writableOptions.length).toBeGreaterThan(0);
expect(
writableOptions.every((option) => {
if (option.compatPath) return true;
if (!option.actionId) return false;
const mapping = getMindmapActionMapping(option.actionId);
return mapping?.target === "kernelCommand" || mapping?.target === "compatPatch";
}),
).toBe(true);
});
});
@@ -24,20 +24,45 @@ export type MindmapUiActionId =
| "zoomOut"
| "fitView"
| "centerRoot"
| "fullscreenCanvas"
| "fullscreenPage"
| "exitFullscreen"
| "search"
| "showMenu"
| "expandCollapse"
| "copyNodeText"
| "readonly";
export const MINDMAP_TOOLBAR_MORE_ACTION_ID = "more" as const;
export type MindmapToolbarVirtualActionId = typeof MINDMAP_TOOLBAR_MORE_ACTION_ID;
export type MindmapToolbarActionId = MindmapUiActionId | MindmapToolbarVirtualActionId;
export type MindmapToolbarActionCluster = "main" | "file" | "view";
export type MindmapToolbarOverflowGroup = "history" | "node" | "insert" | "file" | "view";
export type MindmapToolbarActionMeta = {
id: MindmapUiActionId;
iconKey: string;
shortLabel: string;
longLabel: string;
priority: number;
overflowGroup: MindmapToolbarOverflowGroup;
cluster: MindmapToolbarActionCluster;
};
export type MindmapToolbarGroup = {
id: "history" | "node" | "insert" | "view";
id: MindmapToolbarOverflowGroup;
label: string;
actions: MindmapUiActionId[];
collapsePriority: number;
};
export type MindmapSidebarPanel = {
id: "nodeStyle" | "baseStyle" | "theme" | "structure" | "outline";
id: MindmapSidebarPanelId;
kind: MindmapSidebarPanelId;
label: string;
icon: string;
runtimeCapability: string;
@@ -45,16 +70,40 @@ export type MindmapSidebarPanel = {
options: MindmapSidebarOption[];
};
export type MindmapSidebarPanelId =
| "nodeStyle"
| "baseStyle"
| "theme"
| "structure"
| "outline"
| "shortcutKey"
| "settings";
export type MindmapSidebarOptionControlType =
| "button"
| "swatch"
| "segmented"
| "slider"
| "numberInput"
| "select"
| "layoutCard"
| "treeItem"
| "toggle";
export type MindmapSidebarOption = {
id: string;
label: string;
actionId: MindmapUiActionId | null;
value: unknown;
controlType: MindmapSidebarOptionControlType;
preview?: string;
description?: string;
readonly?: boolean;
compatPath?: string;
};
export type MindmapNavigatorItem = {
id: "stats" | "centerRoot" | "search" | "zoomOut" | "zoom" | "zoomIn" | "readonly";
id: "stats" | "centerRoot" | "search" | "zoomOut" | "zoom" | "zoomIn" | "fullscreen" | "readonly";
label: string;
actionId: MindmapUiActionId | null;
readOnly: boolean;
@@ -71,11 +120,49 @@ export type MindmapContextMenuItem = {
export type MindmapUiSchema = {
toolbarGroups: MindmapToolbarGroup[];
toolbarActionMeta: Record<MindmapUiActionId, MindmapToolbarActionMeta>;
sidebarPanels: MindmapSidebarPanel[];
navigatorItems: MindmapNavigatorItem[];
contextMenuItems: MindmapContextMenuItem[];
};
export const mindmapToolbarPrimaryActionOrder = [
"undo",
"redo",
"editNode",
"insertSiblingAfter",
"deleteNode",
"insertChild",
"tag",
"hyperlink",
"note",
"image",
"icon",
"summary",
"associativeLine",
"formula",
] as const satisfies readonly MindmapUiActionId[];
export const mindmapToolbarFileActionOrder = ["import", "export"] as const satisfies readonly MindmapUiActionId[];
const createToolbarActionMeta = (
id: MindmapUiActionId,
iconKey: string,
shortLabel: string,
longLabel: string,
priority: number,
overflowGroup: MindmapToolbarOverflowGroup,
cluster: MindmapToolbarActionCluster,
): MindmapToolbarActionMeta => ({
id,
iconKey,
shortLabel,
longLabel,
priority,
overflowGroup,
cluster,
});
export const mindmapDefaultUiSchema: MindmapUiSchema = {
toolbarGroups: [
{
@@ -87,84 +174,177 @@ export const mindmapDefaultUiSchema: MindmapUiSchema = {
{
id: "node",
label: "节点",
actions: ["editNode", "insertSiblingAfter", "insertChild", "deleteNode"],
actions: ["editNode", "insertSiblingAfter", "deleteNode", "insertChild"],
collapsePriority: 1,
},
{
id: "insert",
label: "插入",
actions: ["tag", "hyperlink", "note", "image", "icon", "summary", "associativeLine", "formula", "painter", "import", "export"],
actions: ["tag", "hyperlink", "note", "image", "icon", "summary", "associativeLine", "formula"],
collapsePriority: 2,
},
{
id: "file",
label: "文件",
actions: ["import", "export"],
collapsePriority: 5,
},
{
id: "view",
label: "视图",
actions: ["centerRoot", "zoomOut", "zoomIn", "search", "readonly"],
actions: ["painter", "centerRoot", "zoomOut", "zoomIn", "search", "readonly"],
collapsePriority: 3,
},
],
toolbarActionMeta: {
undo: createToolbarActionMeta("undo", "undo", "撤销", "撤销", 10, "history", "main"),
redo: createToolbarActionMeta("redo", "redo", "重做", "重做", 20, "history", "main"),
editNode: createToolbarActionMeta("editNode", "type", "编辑", "编辑节点", 30, "node", "main"),
insertSiblingAfter: createToolbarActionMeta("insertSiblingAfter", "sibling", "同级", "插入同级节点", 40, "node", "main"),
deleteNode: createToolbarActionMeta("deleteNode", "trash", "删除", "删除节点", 50, "node", "main"),
insertChild: createToolbarActionMeta("insertChild", "child", "子级", "插入子节点", 60, "node", "main"),
tag: createToolbarActionMeta("tag", "tag", "标签", "标签", 70, "insert", "main"),
hyperlink: createToolbarActionMeta("hyperlink", "link", "链接", "超链接", 80, "insert", "main"),
note: createToolbarActionMeta("note", "note", "备注", "备注", 90, "insert", "main"),
image: createToolbarActionMeta("image", "image", "图片", "图片", 100, "insert", "main"),
icon: createToolbarActionMeta("icon", "smile", "图标", "图标", 110, "insert", "main"),
summary: createToolbarActionMeta("summary", "summary", "概要", "概要", 120, "insert", "main"),
associativeLine: createToolbarActionMeta("associativeLine", "route", "关联", "关联线", 130, "insert", "main"),
formula: createToolbarActionMeta("formula", "formula", "公式", "公式", 140, "insert", "main"),
painter: createToolbarActionMeta("painter", "paintbrush", "格式", "格式刷", 210, "view", "view"),
import: createToolbarActionMeta("import", "import", "导入", "导入", 310, "file", "file"),
export: createToolbarActionMeta("export", "export", "导出", "导出", 320, "file", "file"),
setTheme: createToolbarActionMeta("setTheme", "palette", "主题", "主题", 410, "view", "view"),
setLayout: createToolbarActionMeta("setLayout", "layout", "结构", "结构", 420, "view", "view"),
zoomIn: createToolbarActionMeta("zoomIn", "zoom-in", "放大", "放大", 510, "view", "view"),
zoomOut: createToolbarActionMeta("zoomOut", "zoom-out", "缩小", "缩小", 500, "view", "view"),
fitView: createToolbarActionMeta("fitView", "fit", "适应", "适应画布", 520, "view", "view"),
centerRoot: createToolbarActionMeta("centerRoot", "target", "回根", "回到根节点", 490, "view", "view"),
fullscreenCanvas: createToolbarActionMeta("fullscreenCanvas", "fullscreen", "全屏", "全屏查看", 550, "view", "view"),
fullscreenPage: createToolbarActionMeta("fullscreenPage", "fullscreen-page", "全页", "全屏编辑", 560, "view", "view"),
exitFullscreen: createToolbarActionMeta("exitFullscreen", "exit-fullscreen", "退出", "退出全屏", 570, "view", "view"),
search: createToolbarActionMeta("search", "search", "搜索", "搜索", 530, "view", "view"),
showMenu: createToolbarActionMeta("showMenu", "menu", "菜单", "显示菜单", 580, "view", "view"),
expandCollapse: createToolbarActionMeta("expandCollapse", "expand", "展开", "展开/收起", 610, "view", "view"),
copyNodeText: createToolbarActionMeta("copyNodeText", "copy", "复制", "复制文本", 620, "view", "view"),
readonly: createToolbarActionMeta("readonly", "lock", "只读", "只读", 540, "view", "view"),
},
sidebarPanels: [
{
id: "nodeStyle",
kind: "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: "node-fill-blue", label: "蓝", actionId: "painter", value: "#dbeafe", controlType: "swatch", preview: "#dbeafe", compatPath: "nodes.$active.data.fillColor" },
{ id: "node-fill-green", label: "薄荷", actionId: "painter", value: "#dcfce7", controlType: "swatch", preview: "#dcfce7", compatPath: "nodes.$active.data.fillColor" },
{ id: "node-fill-amber", label: "暖黄", actionId: "painter", value: "#fef3c7", controlType: "swatch", preview: "#fef3c7", compatPath: "nodes.$active.data.fillColor" },
{ id: "node-text-dark", label: "深色文字", actionId: "painter", value: "#0f172a", controlType: "swatch", preview: "#0f172a", compatPath: "nodes.$active.data.color" },
{ id: "node-text-blue", label: "蓝色文字", actionId: "painter", value: "#1d4ed8", controlType: "swatch", preview: "#1d4ed8", compatPath: "nodes.$active.data.color" },
{ id: "node-font-14", label: "14 px", actionId: "painter", value: 14, controlType: "segmented", compatPath: "nodes.$active.data.fontSize" },
{ id: "node-font-18", label: "18 px", actionId: "painter", value: 18, controlType: "segmented", compatPath: "nodes.$active.data.fontSize" },
{ id: "node-font-bold", label: "加粗", actionId: "painter", value: true, controlType: "toggle", compatPath: "nodes.$active.data.fontWeight" },
{ id: "node-font-italic", label: "斜体", actionId: "painter", value: true, controlType: "toggle", compatPath: "nodes.$active.data.fontStyle" },
{ id: "node-shape-round", label: "圆角矩形", actionId: "painter", value: "roundedRectangle", controlType: "button", compatPath: "nodes.$active.data.shape" },
{ id: "node-shape-rect", label: "矩形", actionId: "painter", value: "rectangle", controlType: "button", compatPath: "nodes.$active.data.shape" },
{ id: "node-border-blue", label: "蓝色边框", actionId: "painter", value: "#60a5fa", controlType: "swatch", preview: "#60a5fa", compatPath: "nodes.$active.data.borderColor" },
{ id: "node-line-teal", label: "青色分支线", actionId: "painter", value: "#14b8a6", controlType: "swatch", preview: "#14b8a6", compatPath: "nodes.$active.data.lineColor" },
{ id: "node-line-width-2", label: "边线 2", actionId: "painter", value: 2, controlType: "segmented", compatPath: "nodes.$active.data.lineWidth" },
],
},
{
id: "baseStyle",
kind: "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: "base-curve-line", label: "曲线", actionId: "painter", value: "curve", controlType: "segmented", compatPath: "style.map.lineStyle" },
{ id: "base-direct-line", label: "直线", actionId: "painter", value: "straight", controlType: "segmented", compatPath: "style.map.lineStyle" },
{ id: "base-rainbow-lines", label: "彩虹线条", actionId: "painter", value: { enabled: true }, controlType: "toggle", compatPath: "style.map.rainbowLines" },
{ id: "base-line-width-2", label: "线宽 2", actionId: "painter", value: 2, controlType: "segmented", compatPath: "style.map.lineWidth" },
{ id: "base-line-width-4", label: "线宽 4", actionId: "painter", value: 4, controlType: "segmented", compatPath: "style.map.lineWidth" },
{ id: "base-background-light", label: "浅色背景", actionId: "painter", value: "#f8fafc", controlType: "swatch", preview: "#f8fafc", compatPath: "style.map.backgroundColor" },
{ id: "base-node-spacing-36", label: "节点间距 36", actionId: "painter", value: 36, controlType: "numberInput", compatPath: "style.map.nodeSpacing" },
{ id: "base-summary-bracket", label: "括号概要", actionId: "painter", value: "bracket", controlType: "select", compatPath: "style.map.summaryStyle" },
],
},
{
id: "theme",
kind: "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: "theme-classic", label: "Classic", actionId: "setTheme", value: "classic", controlType: "swatch", preview: "#60a5fa", description: "默认主题" },
{ id: "theme-classic4", label: "KMind", actionId: "setTheme", value: "classic4", controlType: "swatch", preview: "#22c55e", description: "KMind-like" },
{ id: "theme-simple", label: "Simple", actionId: "setTheme", value: "simple", controlType: "swatch", preview: "#f59e0b", description: "轻量主题" },
{ id: "theme-dark", label: "Dark", actionId: "setTheme", value: "dark", controlType: "swatch", preview: "#334155", description: "深色主题" },
],
},
{
id: "structure",
kind: "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: "layout-logical", label: "逻辑结构", actionId: "setLayout", value: "logicalStructure", controlType: "layoutCard", preview: "logicalStructure" },
{ id: "layout-mind-map", label: "思维导图", actionId: "setLayout", value: "mindMap", controlType: "layoutCard", preview: "mindMap" },
{ id: "layout-organization", label: "组织结构", actionId: "setLayout", value: "organizationStructure", controlType: "layoutCard", preview: "organizationStructure" },
{ id: "layout-catalog", label: "目录组织图", actionId: "setLayout", value: "catalogOrganization", controlType: "layoutCard", preview: "catalogOrganization" },
{ id: "layout-timeline", label: "时间轴", actionId: "setLayout", value: "timeline", controlType: "layoutCard", preview: "timeline" },
{ id: "layout-fishbone", label: "鱼骨图", actionId: "setLayout", value: "fishbone", controlType: "layoutCard", preview: "fishbone" },
],
},
{
id: "outline",
kind: "outline",
label: "大纲",
icon: "list-tree",
runtimeCapability: "outline",
phase: 1,
options: [],
},
{
id: "shortcutKey",
kind: "shortcutKey",
label: "快捷键",
icon: "sparkles",
runtimeCapability: "shortcut-key",
phase: 1,
options: [
{ id: "shortcut-insert-child", label: "Tab", description: "插入子节点", actionId: null, value: null, controlType: "treeItem", readonly: true },
{ id: "shortcut-insert-sibling", label: "Enter", description: "插入同级节点", actionId: null, value: null, controlType: "treeItem", readonly: true },
{ id: "shortcut-delete", label: "Delete", description: "删除节点", actionId: null, value: null, controlType: "treeItem", readonly: true },
],
},
{
id: "settings",
kind: "settings",
label: "设置",
icon: "hexagon",
runtimeCapability: "settings",
phase: 1,
options: [
{ id: "settings-readonly-hint", label: "只读模式", description: "导航栏切换", actionId: null, value: null, controlType: "toggle", readonly: true },
{ id: "settings-mouse", label: "鼠标行为", description: "左键选中,右键拖拽", actionId: null, value: "leftSelectRightDrag", controlType: "select", readonly: true },
],
},
],
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: "search", label: "搜索", actionId: "search", readOnly: true, displayMode: "button" },
{ id: "zoomOut", label: "缩小", actionId: "zoomOut", readOnly: true, displayMode: "button" },
{ id: "zoom", label: "缩放", actionId: null, readOnly: true, displayMode: "text" },
{ id: "zoom", label: "缩放", actionId: null, readOnly: true, displayMode: "input" },
{ id: "zoomIn", label: "放大", actionId: "zoomIn", readOnly: true, displayMode: "button" },
{ id: "fullscreen", label: "全屏", actionId: "fullscreenCanvas", readOnly: true, displayMode: "button" },
{ id: "readonly", label: "只读", actionId: "readonly", readOnly: true, displayMode: "button" },
],
contextMenuItems: [
@@ -179,6 +359,7 @@ export const mindmapDefaultUiSchema: MindmapUiSchema = {
{ 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 },
{ id: "showMenu", label: "显示菜单", actionId: "showMenu", requiresNode: false, phase: 1 },
],
};
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { deriveMindmapUiState } from "./mindmap-ui-state";
import {
createDefaultMindmapShellInteractionState,
deriveMindmapUiState,
reduceMindmapChromeVisibility,
} from "./mindmap-ui-state";
describe("mindmap UI state", () => {
it("无 active node 时禁用节点编辑动作,但保留视图动作", () => {
@@ -11,6 +15,8 @@ describe("mindmap UI state", () => {
expect(state.disabledActions.expandCollapse).toBe(true);
expect(state.disabledActions.zoomIn).toBe(false);
expect(state.disabledActions.centerRoot).toBe(false);
expect(state.disabledActions.fullscreenCanvas).toBe(false);
expect(state.disabledActions.exitFullscreen).toBe(false);
});
it("readonly 时禁用编辑动作,保留搜索和视图动作", () => {
@@ -23,6 +29,8 @@ describe("mindmap UI state", () => {
expect(state.disabledActions.copyNodeText).toBe(false);
expect(state.disabledActions.search).toBe(false);
expect(state.disabledActions.zoomOut).toBe(false);
expect(state.disabledActions.fullscreenCanvas).toBe(false);
expect(state.disabledActions.fullscreenPage).toBe(false);
});
it("缺少 runtime capability 时禁用对应 action", () => {
@@ -35,4 +43,97 @@ describe("mindmap UI state", () => {
expect(state.disabledActions.note).toBe(true);
expect(state.disabledActions.insertChild).toBe(false);
});
it("派生默认 shell state,给 Leptos shell 提供稳定合同", () => {
const state = deriveMindmapUiState({
activeNodeId: "node_1",
readonly: true,
shell: {
chromeVisibility: "hiddenByPointerLeave",
toolbarOverflow: {
availableWidth: 640,
visibleActionIds: ["undo", "redo"],
overflowActionIds: ["image"],
moreOpen: true,
},
fullscreen: {
mode: "canvas",
isFullscreen: true,
apiAvailable: false,
},
sidebar: {
triggerVisible: false,
panelOpen: false,
activePanelId: "structure",
collapsedByToggle: true,
},
navigator: {
searchOpen: true,
minimapOpen: true,
zoomPercent: 138.4,
},
},
});
expect(state.shell.chromeVisibility).toBe("hiddenByPointerLeave");
expect(state.shell.toolbarOverflow).toMatchObject({
availableWidth: 640,
visibleActionIds: ["undo", "redo"],
overflowActionIds: ["image"],
moreOpen: true,
});
expect(state.shell.fullscreen).toMatchObject({
mode: "canvas",
isFullscreen: true,
target: "mindmap-root",
apiAvailable: false,
});
expect(state.shell.sidebar).toMatchObject({
triggerVisible: false,
panelOpen: false,
activePanelId: "structure",
drawerWidth: 300,
collapsedByToggle: true,
});
expect(state.shell.navigator).toMatchObject({
searchOpen: true,
minimapOpen: true,
readonly: true,
zoomPercent: 138,
mouseBehavior: "leftSelectRightDrag",
});
});
it("鼠标移出隐藏 chrome,鼠标移入不自动恢复,点击才恢复", () => {
const hidden = reduceMindmapChromeVisibility("visible", "pointerLeave");
expect(hidden).toBe("hiddenByPointerLeave");
expect(reduceMindmapChromeVisibility(hidden, "pointerEnter")).toBe("hiddenByPointerLeave");
expect(reduceMindmapChromeVisibility(hidden, "restoreClick")).toBe("visible");
});
it("sidebar 隐藏触发条后保留最近 active panel", () => {
const state = createDefaultMindmapShellInteractionState({
sidebar: {
triggerVisible: false,
panelOpen: false,
activePanelId: "structure",
collapsedByToggle: true,
},
});
expect(state.sidebar.triggerVisible).toBe(false);
expect(state.sidebar.panelOpen).toBe(false);
expect(state.sidebar.activePanelId).toBe("structure");
expect(state.sidebar.collapsedByToggle).toBe(true);
const restored = createDefaultMindmapShellInteractionState({
sidebar: {
...state.sidebar,
triggerVisible: true,
panelOpen: true,
collapsedByToggle: false,
},
});
expect(restored.sidebar.activePanelId).toBe("structure");
});
});
@@ -3,18 +3,81 @@ import { mindmapDefaultUiSchema, type MindmapUiActionId } from "./mindmap-ui-sch
export type MindmapRuntimeCapability = MindmapActionTarget;
export type MindmapChromeVisibilityState =
| "visible"
| "hiddenByPointerLeave"
| "hiddenByToggle"
| "hiddenByFullscreen";
export type MindmapToolbarOverflowState = {
availableWidth: number | null;
visibleActionIds: MindmapUiActionId[];
overflowActionIds: MindmapUiActionId[];
moreOpen: boolean;
};
export type MindmapFullscreenState = {
mode: "none" | "canvas" | "page";
isFullscreen: boolean;
target: "mindmap-root" | "document-body";
apiAvailable: boolean;
};
export type MindmapSidebarState = {
triggerVisible: boolean;
panelOpen: boolean;
activePanelId: string | null;
drawerWidth: number;
collapsedByToggle: boolean;
};
export type MindmapNavigatorState = {
searchOpen: boolean;
minimapOpen: boolean;
readonly: boolean;
zoomPercent: number;
mouseBehavior: "leftSelectRightDrag" | "leftDragRightMenu";
};
export type MindmapShellInteractionState = {
chromeVisibility: MindmapChromeVisibilityState;
toolbarOverflow: MindmapToolbarOverflowState;
fullscreen: MindmapFullscreenState;
sidebar: MindmapSidebarState;
navigator: MindmapNavigatorState;
};
export type MindmapUiStateInput = {
activeNodeId?: string | null;
readonly: boolean;
runtimeCapabilities?: MindmapRuntimeCapability[];
shell?: PartialMindmapShellInteractionState;
};
export type MindmapUiState = {
activeNodeId: string | null;
readonly: boolean;
disabledActions: Record<MindmapUiActionId, boolean>;
shell: MindmapShellInteractionState;
};
export type PartialMindmapShellInteractionState = {
chromeVisibility?: MindmapChromeVisibilityState;
toolbarOverflow?: Partial<MindmapToolbarOverflowState>;
fullscreen?: Partial<MindmapFullscreenState>;
sidebar?: Partial<MindmapSidebarState>;
navigator?: Partial<MindmapNavigatorState>;
};
export type MindmapChromeVisibilityEvent =
| "pointerLeave"
| "pointerEnter"
| "restoreClick"
| "hideToggle"
| "showToggle"
| "enterFullscreen"
| "exitFullscreen";
const allActionIds = (): MindmapUiActionId[] => {
const ids = [
...mindmapDefaultUiSchema.toolbarGroups.flatMap((group) => group.actions),
@@ -30,10 +93,80 @@ const normalizeActiveNodeId = (value: string | null | undefined): string | null
return trimmed ? trimmed : null;
};
const normalizeZoomPercent = (value: number | undefined): number => {
if (typeof value !== "number" || !Number.isFinite(value)) return 100;
return Math.max(10, Math.min(500, Math.round(value)));
};
const unsupportedActionIds = new Set<MindmapUiActionId>([
"tag",
"hyperlink",
"note",
"image",
"icon",
"associativeLine",
"formula",
"import",
"export",
]);
export const createDefaultMindmapShellInteractionState = (
input: PartialMindmapShellInteractionState = {},
): MindmapShellInteractionState => ({
chromeVisibility: input.chromeVisibility ?? "visible",
toolbarOverflow: {
availableWidth: input.toolbarOverflow?.availableWidth ?? null,
visibleActionIds: input.toolbarOverflow?.visibleActionIds ?? [],
overflowActionIds: input.toolbarOverflow?.overflowActionIds ?? [],
moreOpen: input.toolbarOverflow?.moreOpen ?? false,
},
fullscreen: {
mode: input.fullscreen?.mode ?? "none",
isFullscreen: input.fullscreen?.isFullscreen ?? false,
target: input.fullscreen?.target ?? "mindmap-root",
apiAvailable: input.fullscreen?.apiAvailable ?? true,
},
sidebar: {
triggerVisible: input.sidebar?.triggerVisible ?? true,
panelOpen: input.sidebar?.panelOpen ?? true,
activePanelId: input.sidebar?.activePanelId ?? mindmapDefaultUiSchema.sidebarPanels[0]?.id ?? null,
drawerWidth: input.sidebar?.drawerWidth ?? 300,
collapsedByToggle: input.sidebar?.collapsedByToggle ?? false,
},
navigator: {
searchOpen: input.navigator?.searchOpen ?? false,
minimapOpen: input.navigator?.minimapOpen ?? false,
readonly: input.navigator?.readonly ?? false,
zoomPercent: normalizeZoomPercent(input.navigator?.zoomPercent),
mouseBehavior: input.navigator?.mouseBehavior ?? "leftSelectRightDrag",
},
});
export const reduceMindmapChromeVisibility = (
state: MindmapChromeVisibilityState,
event: MindmapChromeVisibilityEvent,
): MindmapChromeVisibilityState => {
if (event === "pointerLeave") return "hiddenByPointerLeave";
if (event === "pointerEnter") return state;
if (event === "restoreClick") return "visible";
if (event === "hideToggle") return "hiddenByToggle";
if (event === "showToggle") return "visible";
if (event === "enterFullscreen") return state === "visible" ? "visible" : "hiddenByFullscreen";
if (event === "exitFullscreen") return state === "hiddenByFullscreen" ? "visible" : state;
return state;
};
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>;
const shell = createDefaultMindmapShellInteractionState({
...input.shell,
navigator: {
...input.shell?.navigator,
readonly: input.readonly,
},
});
allActionIds().forEach((actionId) => {
const mapping = getMindmapActionMapping(actionId);
@@ -53,6 +186,10 @@ export const deriveMindmapUiState = (input: MindmapUiStateInput): MindmapUiState
disabledActions[actionId] = true;
return;
}
if (unsupportedActionIds.has(actionId)) {
disabledActions[actionId] = true;
return;
}
disabledActions[actionId] = false;
});
@@ -60,5 +197,6 @@ export const deriveMindmapUiState = (input: MindmapUiStateInput): MindmapUiState
activeNodeId,
readonly: input.readonly,
disabledActions,
shell,
};
};
@@ -56,6 +56,32 @@ describe("simple-mind-map bridge contract", () => {
expect(options.viewData).toEqual({ state: { scale: 0.8, x: 1, y: 2 }, transform: { scaleX: 0.8, scaleY: 0.8 } });
});
it("fallback projection 默认使用 KMind 风格主题与右向逻辑结构", () => {
const projection = createFallbackMindmapAdapterProjection({
mindmapId: "mind_1",
root: { data: { text: "KMIND" }, children: [] },
});
expect(projection.layout).toBe("logicalStructure");
expect(projection.theme).toBe("default");
expect(projection.themeConfig).toMatchObject({
lineStyle: "curve",
root: {
fillColor: "#e25563",
color: "#ffffff",
fontWeight: "bold",
},
second: {
fillColor: "#4f7df3",
color: "#ffffff",
},
node: {
color: "#315aa9",
},
generalizationLineColor: "#ef6a5b",
});
});
it("保留 runtimeOptions 中的 fit 设置,避免初始导图被裁切", () => {
const projection = createFallbackMindmapAdapterProjection({
mindmapId: "mind_1",
@@ -1,5 +1,8 @@
import {
canonicalizeMindmapData,
createDefaultMindmapThemeConfig,
DEFAULT_MINDMAP_LAYOUT,
DEFAULT_MINDMAP_THEME,
defaultMindmapData,
type MindMapData,
} from "./mindmap-projection";
@@ -21,18 +24,40 @@ export type SimpleMindMapInstance = {
execCommand?: (command: string, ...args: unknown[]) => unknown;
destroy?: () => void;
getData?: (withConfig?: boolean) => unknown;
getLayout?: () => unknown;
getTheme?: () => unknown;
getThemeConfig?: (prop?: unknown) => unknown;
on?: (event: string, handler: (...args: unknown[]) => void) => void;
off?: (event: string, handler: (...args: unknown[]) => void) => void;
setData?: (data: unknown) => void;
setLayout?: (layout: unknown, notRender?: boolean) => void;
setMode?: (mode: "edit" | "readonly") => void;
setTheme?: (theme: unknown, notRender?: boolean) => void;
setThemeConfig?: (config: Record<string, unknown>, notRender?: boolean) => void;
updateData?: (data: unknown) => void;
updateConfig?: (config?: Record<string, unknown>) => void;
view?: {
scale?: number;
enlarge?: () => void;
narrow?: () => void;
getTransformData?: () => unknown;
setScale?: (scale: number, cx: number, cy: number) => void;
fit?: () => void;
};
renderer?: {
setRootNodeCenter?: () => void;
textEdit?: {
hideEditTextBox?: () => void;
isShowTextEdit?: () => boolean;
};
clearActiveNodeList?: () => void;
addNodeToActiveList?: (node: unknown, skipBeforeEvent?: boolean) => void;
emitNodeActiveEvent?: (node?: unknown, activeNodeList?: unknown[]) => void;
findNodeByUid?: (uid: string) => unknown;
activeNodeList?: unknown[];
lastActiveNodeList?: unknown[];
root?: unknown;
renderTree?: { _node?: unknown };
};
};
@@ -62,6 +87,7 @@ export type SimpleMindMapBridge = {
instance: SimpleMindMapInstance;
execCommand: (command: string, ...args: unknown[]) => SimpleMindMapSafeCommandResult;
getSnapshot: () => unknown;
refreshProjection?: (reason?: string) => Promise<void>;
destroy: () => void;
};
@@ -125,6 +151,11 @@ const readRecord = (value: unknown): Record<string, unknown> => {
return {};
};
const readThemeConfig = (value: unknown): Record<string, unknown> => {
if (!isRecord(value) || Object.keys(value).length === 0) return createDefaultMindmapThemeConfig();
return value;
};
const readViewData = (value: unknown): Record<string, unknown> | null => {
if (!isRecord(value) || !isRecord(value.state)) return null;
return {
@@ -153,9 +184,9 @@ export const buildSimpleMindMapOptions = (input: {
: typeof config.fit === "boolean"
? config.fit
: true,
layout: readString(input.projection.layout, "logicalStructure"),
theme: readString(input.projection.theme, "classic"),
themeConfig: readRecord(input.projection.themeConfig),
layout: readString(input.projection.layout, DEFAULT_MINDMAP_LAYOUT),
theme: readString(input.projection.theme, DEFAULT_MINDMAP_THEME),
themeConfig: readThemeConfig(input.projection.themeConfig),
viewData: readViewData(input.projection.view),
initRootNodePosition: ["center", "center"],
};
@@ -241,7 +272,9 @@ export const attachSimpleMindMapEventListeners = (input: {
SIMPLE_MIND_MAP_BRIDGE_EVENTS.forEach((eventName) => {
const handler = (...args: unknown[]) => {
const snapshot =
eventName === "data_change" || eventName === "view_data_change"
eventName === "data_change"
? input.instance.getData?.()
: eventName === "view_data_change"
? input.instance.getData?.(true) ?? input.instance.getData?.()
: undefined;
input.onEvent?.({
@@ -266,12 +299,14 @@ export const createSimpleMindMapBridge = async (input: {
onEvent?: (event: SimpleMindMapBridgeEvent) => void;
}): Promise<SimpleMindMapBridge> => {
const runtime = await loadSimpleMindMapRuntime(input.pluginNames);
const themeConfig = readThemeConfig(input.projection.themeConfig);
const options = buildSimpleMindMapOptions({
el: input.el,
projection: input.projection,
runtimeOptions: input.runtimeOptions,
});
const instance = new runtime.MindMap(options);
instance.setThemeConfig?.(themeConfig);
if (input.mode) instance.setMode?.(input.mode);
const detachEvents = attachSimpleMindMapEventListeners({
@@ -286,6 +321,7 @@ export const createSimpleMindMapBridge = async (input: {
execCommand,
getSnapshot: () => instance.getData?.(true) ?? instance.getData?.(),
destroy: () => {
instance.renderer?.textEdit?.hideEditTextBox?.();
detachEvents();
instance.destroy?.();
},
@@ -300,9 +336,9 @@ export const createFallbackMindmapAdapterProjection = (input: {
schema: "mnote.mindmap.simple_mind_map_scene.v1",
runtime: "simple-mind-map",
root: input.root ?? defaultMindmapData,
layout: "logicalStructure",
theme: "classic",
themeConfig: {},
layout: DEFAULT_MINDMAP_LAYOUT,
theme: DEFAULT_MINDMAP_THEME,
themeConfig: createDefaultMindmapThemeConfig(),
view: {},
config: {},
compatPayload: { source: "frontend-fallback", mindmapId: input.mindmapId },
@@ -14,14 +14,37 @@ describe("onlyoffice client session helpers", () => {
expect(docTypeFromExt("docx")).toBe("word");
expect(docTypeFromExt("xlsx")).toBe("cell");
expect(docTypeFromExt("pptx")).toBe("slide");
expect(docTypeFromExt("pdf")).toBe("pdf");
expect(docTypeFromExt("pdf")).toBe("word");
});
it("inferOnlyOfficeFileType detects Office assets from file name and MIME", () => {
it("inferOnlyOfficeFileType detects only true Office assets from file name and MIME", () => {
expect(inferOnlyOfficeFileType("demo.pptx", null)).toBe("pptx");
expect(inferOnlyOfficeFileType("demo", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBe("docx");
expect(inferOnlyOfficeFileType("demo", "application/pdf")).toBe("pdf");
expect(inferOnlyOfficeFileType("demo", "application/pdf")).toBeNull();
expect(inferOnlyOfficeFileType("demo.png", "image/png")).toBeNull();
expect(inferOnlyOfficeFileType("demo.pdf", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.toml", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.json", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.yaml", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.yml", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.md", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.txt", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.ts", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.js", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.py", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.rs", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.go", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.html", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.css", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.vue", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.svelte", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.proto", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.graphql", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.gradle", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.tf", null)).toBeNull();
expect(inferOnlyOfficeFileType("demo.pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBeNull();
expect(inferOnlyOfficeFileType("Dockerfile", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBeNull();
expect(inferOnlyOfficeFileType(".gitignore", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")).toBeNull();
});
it("buildOnlyOfficeAssetOpenUrl keeps attachment identity for callback writeback", () => {
@@ -22,11 +22,9 @@ export const docTypeFromExt = (ext: string) => {
const word = ["doc", "docx", "odt", "rtf"];
const slide = ["ppt", "pptx", "odp"];
const sheet = ["xls", "xlsx", "ods", "csv"];
const pdf = ["pdf"];
if (word.includes(ext)) return "word";
if (slide.includes(ext)) return "slide";
if (sheet.includes(ext)) return "cell";
if (pdf.includes(ext)) return "pdf";
return "word";
};
@@ -34,14 +32,84 @@ export const inferOnlyOfficeFileType = (fileName: string | null | undefined, mim
const name = (fileName ?? "").trim().toLowerCase();
const mt = (mimeType ?? "").trim().toLowerCase();
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
if (["doc", "docx", "odt", "rtf"].includes(ext)) return ext;
if (["ppt", "pptx", "odp"].includes(ext)) return ext;
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return ext;
if (ext === "pdf") return ext;
const officeExts = ["doc", "docx", "odt", "rtf", "ppt", "pptx", "odp", "xls", "xlsx", "ods", "csv"];
const nonOfficeFileNames = [
".dockerignore",
".editorconfig",
".env",
".eslintrc",
".gitattributes",
".gitignore",
".npmrc",
".prettierrc",
"cmakelists.txt",
"dockerfile",
"gemfile",
"makefile",
"procfile",
"rakefile",
];
const nonOfficeExts = [
"pdf",
"toml",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"md",
"markdown",
"txt",
"ini",
"env",
"xml",
"ts",
"tsx",
"js",
"jsx",
"mjs",
"cjs",
"py",
"rs",
"go",
"html",
"htm",
"css",
"scss",
"less",
"vue",
"svelte",
"astro",
"java",
"c",
"cpp",
"h",
"hpp",
"cs",
"php",
"rb",
"sh",
"bash",
"zsh",
"sql",
"lock",
"log",
"proto",
"graphql",
"gql",
"prisma",
"tf",
"tfvars",
"hcl",
"nix",
"gradle",
];
if (officeExts.includes(ext)) return ext;
if (nonOfficeFileNames.includes(name)) return null;
if (nonOfficeExts.includes(ext)) return null;
if (mt.includes("wordprocessingml")) return "docx";
if (mt.includes("presentationml")) return "pptx";
if (mt.includes("spreadsheetml")) return "xlsx";
if (mt.includes("pdf")) return "pdf";
return null;
};