20260513 mindmap优化01
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 command,readonly 下仍可使用", () => {
|
||||
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 map,more 仅作为 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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user