feat: recreate XMind editing and file workflows

This commit is contained in:
134 2026-09-24 16:39:07 +08:00
parent 4ada7eaf38
commit a492d082ed
45 changed files with 8404 additions and 371 deletions

View File

@ -1,4 +1,8 @@
import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema";
import type {
FlowBoardDraft,
FlowBoardSheetSnapshotData,
FlowBoardSnapshotData,
} from "@/lib/db/schema";
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@ -8,6 +12,73 @@ function normalizeRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
const DEFAULT_FLOW_BOARD_CANVAS = {
backgroundColor: "#f7f8fb",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
};
function normalizeCanvasColor(value: unknown, fallback: string) {
return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value)
? value.toLowerCase()
: fallback;
}
function normalizeFlowBoardCanvas(value: unknown): Record<string, unknown> {
const canvas = isRecord(value) ? value : {};
const requestedGridSize =
typeof canvas.gridSize === "number" && Number.isFinite(canvas.gridSize)
? Math.round(canvas.gridSize)
: DEFAULT_FLOW_BOARD_CANVAS.gridSize;
return {
backgroundColor: normalizeCanvasColor(
canvas.backgroundColor,
DEFAULT_FLOW_BOARD_CANVAS.backgroundColor
),
gridColor: normalizeCanvasColor(
canvas.gridColor,
DEFAULT_FLOW_BOARD_CANVAS.gridColor
),
gridEnabled:
typeof canvas.gridEnabled === "boolean"
? canvas.gridEnabled
: DEFAULT_FLOW_BOARD_CANVAS.gridEnabled,
gridSize: Math.min(64, Math.max(8, requestedGridSize)),
};
}
function normalizeSheetId(value: unknown, index: number) {
const id = typeof value === "string" ? value.trim().slice(0, 128) : "";
return id || `sheet-${index + 1}`;
}
function normalizeSheetTitle(value: unknown, index: number) {
const title = typeof value === "string" ? value.trim().slice(0, 128) : "";
return title || `画布 ${index + 1}`;
}
function normalizeFlowBoardSheet(
value: unknown,
index: number
): FlowBoardSheetSnapshotData | null {
if (!isRecord(value)) {
return null;
}
return {
id: normalizeSheetId(value.id, index),
title: normalizeSheetTitle(value.title, index),
children: Array.isArray(value.children)
? value.children.filter(isRecord)
: [],
viewport: normalizeRecord(value.viewport),
theme: normalizeRecord(value.theme),
canvas: normalizeFlowBoardCanvas(value.canvas),
};
}
export function normalizeFlowBoardTitle(value: unknown) {
const title = String(value || "").trim();
return title.slice(0, 256) || "未命名流程白板";
@ -17,15 +88,67 @@ export function normalizeFlowBoardSnapshot(
value: unknown
): FlowBoardSnapshotData {
const raw = isRecord(value) ? value : {};
const legacyChildren = Array.isArray(raw.children)
? raw.children.filter(isRecord)
: [];
const normalizedSheets = Array.isArray(raw.sheets)
? raw.sheets
.map(normalizeFlowBoardSheet)
.filter(
(sheet): sheet is FlowBoardSheetSnapshotData => Boolean(sheet)
)
: [];
const sheets =
normalizedSheets.length > 0
? normalizedSheets
: [
{
id: "sheet-1",
title: "画布 1",
children: legacyChildren,
viewport: normalizeRecord(raw.viewport),
theme: normalizeRecord(raw.theme),
canvas: normalizeFlowBoardCanvas(raw.canvas),
},
];
const requestedActiveSheetId =
typeof raw.activeSheetId === "string" ? raw.activeSheetId.trim() : "";
const activeSheet =
sheets.find((sheet) => sheet.id === requestedActiveSheetId) || sheets[0];
const activeChildren = Array.isArray(raw.children)
? legacyChildren
: activeSheet.children;
const activeViewport = isRecord(raw.viewport)
? raw.viewport
: activeSheet.viewport;
const activeTheme = isRecord(raw.theme) ? raw.theme : activeSheet.theme;
const activeCanvas = isRecord(raw.canvas)
? normalizeFlowBoardCanvas(raw.canvas)
: normalizeFlowBoardCanvas(activeSheet.canvas);
const nextSheets = sheets.map((sheet) =>
sheet.id === activeSheet.id
? {
...sheet,
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
}
: sheet
);
const updatedAt =
typeof raw.updatedAt === "string" && raw.updatedAt.trim()
? raw.updatedAt
: new Date().toISOString();
return {
children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [],
viewport: normalizeRecord(raw.viewport),
theme: normalizeRecord(raw.theme),
version: 2,
activeSheetId: activeSheet.id,
sheets: nextSheets,
children: activeChildren,
viewport: activeViewport,
theme: activeTheme,
canvas: activeCanvas,
updatedAt,
};
}

View File

@ -0,0 +1,17 @@
import { notFound } from "next/navigation";
import { FlowBoardClient } from "@/components/flow-board/flow-board-client";
export default function FlowBoardPreviewPage() {
if (process.env.NODE_ENV === "production") {
notFound();
}
return (
<FlowBoardClient
previewMode
siteLogo={null}
siteLogoDark={null}
siteName="SnapGen"
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ type FlowBoardClientProps = {
siteName: string;
siteLogo: string | null;
siteLogoDark: string | null;
previewMode?: boolean;
};
const DrawnixWorkspace = dynamic(

View File

@ -0,0 +1,206 @@
import {
addSelectedElement,
BoardTransforms,
clearSelectedElement,
PlaitBoard,
type PlaitElement,
PlaitHistoryBoard,
PlaitPointerType,
type Point,
Transforms,
} from "@plait/core";
import {
ArrowLineMarkerType,
ArrowLineShape,
BasicShapes,
createArrowLineElement,
createGeometryElementWithoutText,
} from "@plait/draw";
import { getRectangleByNode, MindElement } from "@plait/mind";
import {
type AnnotatedElement,
getBoundaryPoints,
getRelationshipPoints,
isMindAnnotation,
type MindAnnotation,
type TopicRectangle,
} from "@/lib/flow-board/mind-annotations";
function getTopics(board: PlaitBoard) {
const topics = new Map<
string,
{ element: MindElement; visible: MindElement }
>();
const visit = (elements: PlaitElement[], collapsedAncestor?: MindElement) => {
for (const element of elements) {
if (!MindElement.isMindElement(board, element)) continue;
topics.set(element.id, {
element,
visible: collapsedAncestor || element,
});
visit(
element.children || [],
collapsedAncestor || (element.isCollapsed ? element : undefined)
);
}
};
visit(board.children);
return topics;
}
function rectangle(element: MindElement): TopicRectangle | null {
const node = MindElement.getNode(element);
return node ? getRectangleByNode(node) : null;
}
function annotationPoints(
board: PlaitBoard,
annotation: MindAnnotation
): Point[] | null {
const topics = getTopics(board);
if (annotation.kind === "relationship") {
const source = topics.get(annotation.sourceId);
const target = topics.get(annotation.targetId);
if (!source || !target) return null;
const a = rectangle(source.visible);
const b = rectangle(target.visible);
return a && b ? getRelationshipPoints(a, b) : null;
}
const rectangles: TopicRectangle[] = [];
const seen = new Set<string>();
const collect = (element: MindElement) => {
if (seen.has(element.id)) return;
seen.add(element.id);
const rect = rectangle(element);
if (rect) rectangles.push(rect);
if (!element.isCollapsed) element.children?.forEach(collect);
};
for (const id of annotation.topicIds) {
const topic = topics.get(id);
if (topic) collect(topic.visible);
}
return getBoundaryPoints(rectangles, annotation.padding);
}
export function insertMindAnnotation(
board: PlaitBoard,
annotation: MindAnnotation
) {
const points = annotationPoints(board, annotation);
if (!points) return null;
const endpoints: [Point, Point] = [points[0], points[points.length - 1]];
const label = { type: "paragraph", children: [{ text: "联系" }] };
const shape =
annotation.kind === "relationship"
? createArrowLineElement(
ArrowLineShape.curve,
endpoints,
{ marker: ArrowLineMarkerType.none },
{ marker: ArrowLineMarkerType.arrow },
[{ position: 0.5, text: label }],
{ strokeColor: "#64748b", strokeWidth: 2 }
)
: createGeometryElementWithoutText(
BasicShapes.roundRectangle,
endpoints,
{
fill: "transparent",
strokeColor: "#8b9bb8",
strokeWidth: 2,
}
);
const element: AnnotatedElement = {
...shape,
points,
mindAnnotation: annotation,
};
PlaitHistoryBoard.withNewBatch(board, () => {
// Boundaries sit behind topics, so clicking within them still selects topics.
Transforms.insertNode(board, element, [
annotation.kind === "boundary" ? 0 : board.children.length,
]);
});
BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
clearSelectedElement(board);
addSelectedElement(board, element);
return element;
}
const installed = new WeakSet<PlaitBoard>();
/** Keep native drawable shapes linked to topic IDs, using the existing undo stack. */
export function withMindAnnotations(board: PlaitBoard) {
if (installed.has(board)) return;
installed.add(board);
const { afterChange, undo, redo, apply } = board;
board.apply = (operation) => {
// Plait keeps a hover reference after deleting or collapsing a topic. Clear it
// while the node's DOM still exists, otherwise the next pointerLeave throws.
if (
operation.type === "remove_node" ||
(operation.type === "set_node" &&
("isCollapsed" in operation.newProperties ||
"children" in operation.newProperties))
) {
board.pointerLeave(new PointerEvent("pointerleave"));
}
apply(operation);
};
let pending = false;
let replaying = true;
const synchronize = () => {
pending = false;
const replay = replaying;
replaying = false;
const topics = getTopics(board);
const changes = board.children
.filter(isMindAnnotation)
.flatMap((element) => {
const annotation = element.mindAnnotation;
const missing =
annotation.kind === "relationship"
? !topics.has(annotation.sourceId) ||
!topics.has(annotation.targetId)
: !annotation.topicIds.some((id) => topics.has(id));
const points = missing ? null : annotationPoints(board, annotation);
if (
!missing &&
(!points || JSON.stringify(points) === JSON.stringify(element.points))
)
return [];
return [{ element, points, missing }];
});
if (!changes.length) return;
const update = () => {
for (const { element, points, missing } of changes) {
const index = board.children.findIndex(
(child) => child.id === element.id
);
if (index < 0) continue;
if (missing) Transforms.removeNode(board, [index]);
else if (points) Transforms.setNode(board, { points }, [index]);
}
};
if (replay) PlaitHistoryBoard.withoutSaving(board, update);
else PlaitHistoryBoard.withMerging(board, update);
};
board.undo = () => {
replaying = board.history.undos.length > 0;
undo();
};
board.redo = () => {
replaying = board.history.redos.length > 0;
redo();
};
board.afterChange = () => {
afterChange();
if (
!pending &&
board.operations.some((op) => op.type !== "set_selection")
) {
pending = true;
queueMicrotask(synchronize);
}
};
queueMicrotask(synchronize);
}

View File

@ -0,0 +1,503 @@
import type { PlaitBoard } from "@plait/core";
import {
MindElement,
MindNodeComponent,
PlaitMind,
PlaitMindComponent,
getBranchColorByMindElement,
getBranchWidthByMindElement,
type MindNode,
} from "@plait/mind";
import {
arrangeSpecialMind,
isSpecialMindLayout,
isSummaryNode,
specialNodeRect,
specialSubtreeBounds,
} from "@/lib/flow-board/mind-special-layout";
const ns = "http://www.w3.org/2000/svg";
type TableCell = {
x: number;
y: number;
width: number;
height: number;
depth: number;
branch: number;
};
type TableGrid = {
kind: "spreadsheet";
x: number;
y: number;
width: number;
height: number;
columns: string[];
headerHeight: number;
columnHeaderHeight: number;
rowHeaderWidth: number;
columnWidth: number;
rows: { y: number; height: number; branch: number }[];
};
const tableLayouts = new Set(["treeTable", "treeTableSide", "spreadsheet"]);
const tableTopicState = new WeakMap<
SVGGElement,
{
foreignObject: SVGForeignObjectElement;
x: string | null;
y: string | null;
width: string | null;
height: string | null;
textStyle: string | null;
contentStyle: string | null;
shape: SVGElement | null;
shapeVisibility: string;
}
>();
function ensureTreeTableStyles() {
const id = "xmind-tree-table-topic-styles";
if (document.getElementById(id)) return;
const style = document.createElement("style");
style.id = id;
style.textContent = `
[data-special-table-topic] .plait-text-container {
align-items: center !important;
display: flex !important;
height: 100% !important;
justify-content: center !important;
overflow-wrap: normal !important;
text-align: center !important;
white-space: nowrap !important;
width: 100% !important;
}
[data-special-table-topic] .plait-text-container > [data-slate-node="element"] {
text-align: center !important;
white-space: nowrap !important;
width: 100% !important;
}
[data-special-table-topic="0"] .plait-text-container {
color: #fff !important;
}
`;
document.head.append(style);
}
function frameTreeTableTopic(
component: MindNodeComponent,
node: MindNode,
enabled: boolean
) {
const host = component.getElementG();
const saved = tableTopicState.get(host);
if (!enabled) {
delete host.dataset.specialTableTopic;
if (!saved) return;
for (const [name, value] of [
["x", saved.x],
["y", saved.y],
["width", saved.width],
["height", saved.height],
] as const) {
if (value === null) saved.foreignObject.removeAttribute(name);
else saved.foreignObject.setAttribute(name, value);
}
const text = saved.foreignObject.firstElementChild as HTMLElement | null;
if (text) {
if (saved.textStyle === null) text.removeAttribute("style");
else text.setAttribute("style", saved.textStyle);
const content = text.firstElementChild as HTMLElement | null;
if (content) {
if (saved.contentStyle === null) content.removeAttribute("style");
else content.setAttribute("style", saved.contentStyle);
}
}
if (saved.shape) saved.shape.style.visibility = saved.shapeVisibility;
tableTopicState.delete(host);
return;
}
const cell = (node as MindNode & { tableCell?: TableCell }).tableCell;
const foreignObject = host.querySelector<SVGForeignObjectElement>(
":scope > .text foreignObject"
);
if (!cell || !foreignObject) return;
ensureTreeTableStyles();
host.dataset.specialTableTopic = String(cell.depth);
const currentShape = (
component.nodeShapeGenerator as unknown as { g?: SVGGElement }
).g;
if (!saved) {
tableTopicState.set(host, {
foreignObject,
x: foreignObject.getAttribute("x"),
y: foreignObject.getAttribute("y"),
width: foreignObject.getAttribute("width"),
height: foreignObject.getAttribute("height"),
textStyle: foreignObject.firstElementChild?.getAttribute("style") ?? null,
contentStyle:
foreignObject.firstElementChild?.firstElementChild?.getAttribute(
"style"
) ?? null,
shape: currentShape || null,
shapeVisibility: currentShape?.style.visibility || "",
});
} else if (currentShape && currentShape !== saved.shape) {
saved.shape = currentShape;
saved.shapeVisibility = currentShape.style.visibility || "";
}
if (currentShape) currentShape.style.visibility = "hidden";
foreignObject.setAttribute("x", String(cell.x + 14));
foreignObject.setAttribute("y", String(cell.y + 8));
foreignObject.setAttribute("width", String(Math.max(1, cell.width - 28)));
foreignObject.setAttribute("height", String(Math.max(1, cell.height - 16)));
const text = foreignObject.firstElementChild as HTMLElement | null;
if (text) {
text.style.alignItems = "center";
text.style.color = cell.depth === 0 ? "#ffffff" : "#17252a";
text.style.display = "flex";
text.style.height = "100%";
text.style.justifyContent = "center";
text.style.overflowWrap = "normal";
text.style.textAlign = "center";
text.style.whiteSpace = "nowrap";
text.style.width = "100%";
const content = text.firstElementChild as HTMLElement | null;
if (content) {
content.style.alignItems = "center";
content.style.color = cell.depth === 0 ? "#ffffff" : "#17252a";
content.style.display = "flex";
content.style.height = "100%";
content.style.justifyContent = "center";
content.style.textAlign = "center";
content.style.whiteSpace = "nowrap";
content.style.width = "100%";
}
}
}
function specialLink(component: MindNodeComponent) {
const node = component.node;
let root: MindNode = node;
while (root.parent) root = root.parent;
const kind = root.origin.specialLayout;
const tableLayout = tableLayouts.has(String(kind));
frameTreeTableTopic(component, node, tableLayout);
if (tableLayout)
requestAnimationFrame(() => {
if (component.getElementG().isConnected)
frameTreeTableTopic(component, node, true);
});
if (!isSpecialMindLayout(kind) || isSummaryNode(node)) return false;
if (
kind !== "brace" &&
kind !== "verticalTimeline" &&
!tableLayout &&
node !== root &&
node.parent !== root
)
return false;
component.linkLineG?.remove();
const group = document.createElementNS(ns, "g");
group.dataset.specialStructure = kind;
const color =
getBranchColorByMindElement(component.board, component.element) ||
"#64748b";
const width =
getBranchWidthByMindElement(component.board, component.element) || 2;
const path = (d: string) => {
const shape = document.createElementNS(ns, "path");
shape.setAttribute("d", d);
shape.setAttribute("fill", "none");
shape.setAttribute("stroke", color);
shape.setAttribute("stroke-width", String(width));
group.append(shape);
};
const blendWithWhite = (value: string, amount: number) => {
const match = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (!match) return value;
const channels = match.slice(1).map((channel) => Number.parseInt(channel, 16));
return `#${channels
.map((channel) =>
Math.round(channel + (255 - channel) * amount)
.toString(16)
.padStart(2, "0")
)
.join("")}`;
};
const solidColor = (value: unknown, fallback: string) => {
if (typeof value !== "string") return fallback;
const short = value.match(/^#([\da-f])([\da-f])([\da-f])$/i);
if (short) return `#${short.slice(1).map((channel) => channel + channel).join("")}`;
if (/^#[\da-f]{6}$/i.test(value)) return value;
const rgba = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i);
if (!rgba) return fallback;
const alpha = Number.parseInt(rgba[4], 16) / 255;
return `#${rgba
.slice(1, 4)
.map((channel) =>
Math.round(Number.parseInt(channel, 16) * alpha + 255 * (1 - alpha))
.toString(16)
.padStart(2, "0")
)
.join("")}`;
};
const rootRect = specialNodeRect(root);
const axisY = rootRect.y + rootRect.height / 2;
const branches = root.children.filter((child) => !isSummaryNode(child));
if (kind === "treeTable" || kind === "treeTableSide") {
if (node === root) {
group.setAttribute("pointer-events", "none");
const palette = ["#ffbc9f", "#d8ac8f", "#83c5be", "#f3c78e", "#8ab7d9"];
const tableNodes: MindNode[] = [];
const collect = (item: MindNode) => {
if (!isSummaryNode(item)) tableNodes.push(item);
item.children.forEach(collect);
};
collect(root);
const rootFill = solidColor(root.origin.fill, "#006d77");
tableNodes.forEach((item) => {
const cell = (item as typeof item & { tableCell?: TableCell }).tableCell;
if (!cell) return;
const rectangle = document.createElementNS(ns, "rect");
rectangle.dataset.specialTableCell = item.origin.id || "topic";
rectangle.dataset.depth = String(cell.depth);
rectangle.setAttribute("x", String(cell.x));
rectangle.setAttribute("y", String(cell.y));
rectangle.setAttribute("width", String(cell.width));
rectangle.setAttribute("height", String(cell.height));
const branchColor = palette[cell.branch % palette.length] || palette[0];
const explicit = solidColor(item.origin.fill, "");
rectangle.setAttribute(
"fill",
cell.depth === 0
? rootFill
: explicit ||
blendWithWhite(branchColor, cell.depth === 1 ? 0.08 : 0.72)
);
rectangle.setAttribute("stroke", rootFill);
rectangle.setAttribute("stroke-width", "2");
rectangle.setAttribute("vector-effect", "non-scaling-stroke");
group.append(rectangle);
});
group.dataset.specialTable = kind;
} else {
group.dataset.specialConnector = kind;
}
} else if (kind === "spreadsheet") {
if (node === root) {
group.setAttribute("pointer-events", "none");
const grid = (root as MindNode & { tableGrid?: TableGrid }).tableGrid;
if (grid) {
const palette = ["#ff9d8e", "#f6c75d", "#70c8af", "#68a7dd", "#9c8bd8"];
const rootFill = solidColor(root.origin.fill, "#1f2766");
const rectangle = (
x: number,
y: number,
width: number,
height: number,
fill: string,
dataset?: Record<string, string>
) => {
const rect = document.createElementNS(ns, "rect");
rect.setAttribute("x", String(x));
rect.setAttribute("y", String(y));
rect.setAttribute("width", String(width));
rect.setAttribute("height", String(height));
rect.setAttribute("fill", fill);
rect.setAttribute("stroke", rootFill);
rect.setAttribute("stroke-width", "2");
rect.setAttribute("vector-effect", "non-scaling-stroke");
Object.assign(rect.dataset, dataset);
group.append(rect);
};
rectangle(grid.x, grid.y, grid.width, grid.headerHeight, rootFill, {
specialTableCell: root.origin.id || "topic",
depth: "0",
});
rectangle(
grid.x,
grid.y + grid.headerHeight,
grid.rowHeaderWidth,
grid.columnHeaderHeight,
rootFill
);
grid.columns.forEach((column, index) => {
const x = grid.x + grid.rowHeaderWidth + index * grid.columnWidth;
const y = grid.y + grid.headerHeight;
rectangle(x, y, grid.columnWidth, grid.columnHeaderHeight, rootFill, {
specialMatrixColumn: column,
});
const label = document.createElementNS(ns, "text");
label.dataset.specialMatrixColumnLabel = column;
label.setAttribute("x", String(x + grid.columnWidth / 2));
label.setAttribute("y", String(y + grid.columnHeaderHeight / 2 + 5));
label.setAttribute("fill", "#ffffff");
label.setAttribute("font-size", "14");
label.setAttribute("font-weight", "600");
label.setAttribute("text-anchor", "middle");
label.textContent = column;
group.append(label);
});
grid.rows.forEach((row, rowIndex) => {
const rowNode = branches[rowIndex];
const rowColor = solidColor(
rowNode?.origin.fill,
palette[rowIndex % palette.length]
);
rectangle(grid.x, row.y, grid.rowHeaderWidth, row.height, rowColor, {
specialTableCell: rowNode?.origin.id || `row-${rowIndex}`,
depth: "1",
});
grid.columns.forEach((column, columnIndex) =>
rectangle(
grid.x + grid.rowHeaderWidth + columnIndex * grid.columnWidth,
row.y,
grid.columnWidth,
row.height,
"#ffffff",
{ specialMatrixCell: `${rowIndex}:${columnIndex}` }
)
);
rowNode?.children
.filter((child) => !isSummaryNode(child))
.forEach((entry) => {
const cell = (entry as typeof entry & { tableCell?: TableCell }).tableCell;
if (!cell) return;
rectangle(cell.x, cell.y, cell.width, cell.height, blendWithWhite(rowColor, 0.2), {
specialMatrixEntry: entry.origin.id || "topic",
});
});
});
group.dataset.specialTable = kind;
}
} else group.dataset.specialConnector = kind;
} else if (kind === "verticalTimeline") {
const axisX = rootRect.x + rootRect.width / 2;
if (node === root) {
if (branches.length) {
const end = Math.max(
...branches.map((child) => {
const rect = specialNodeRect(child);
return rect.y + rect.height / 2;
})
);
path(`M${axisX} ${rootRect.y + rootRect.height}V${end}`);
group.dataset.specialAxis = kind;
}
} else {
const rect = specialNodeRect(node);
const right = rect.x > axisX;
const endX = right ? rect.x : rect.x + rect.width;
const endY = rect.y + rect.height / 2;
if (node.parent === root) {
path(`M${axisX} ${endY}H${endX}`);
} else if (node.parent) {
const parent = specialNodeRect(node.parent);
const startX = right ? parent.x + parent.width : parent.x;
const startY = parent.y + parent.height / 2;
const midX = (startX + endX) / 2;
path(
`M${startX} ${startY}C${midX} ${startY} ${midX} ${endY} ${endX} ${endY}`
);
}
group.dataset.specialConnector = kind;
}
} else if (kind === "brace") {
const parent = node.parent;
const siblings = parent?.children.filter((child) => !isSummaryNode(child));
if (parent && siblings?.[0] === node) {
const owner = specialNodeRect(parent);
const rects = siblings.map(specialNodeRect);
const top = Math.min(...rects.map((rect) => rect.y + rect.height / 2));
const bottom = Math.max(...rects.map((rect) => rect.y + rect.height / 2));
const middle = (top + bottom) / 2;
const right = Math.min(...rects.map((rect) => rect.x)) - 10;
const x = right - 18;
const left = Math.max(owner.x + owner.width + 6, x - 18);
const radius = Math.min(18, (bottom - top) / 4);
path(
top === bottom
? `M${left} ${top}H${right}`
: `M${right} ${top}Q${x} ${top} ${x} ${top + radius}V${middle - radius}Q${x} ${middle} ${left} ${middle}Q${x} ${middle} ${x} ${middle + radius}V${bottom - radius}Q${x} ${bottom} ${right} ${bottom}`
);
}
} else if (node === root) {
if (branches.length) {
const end =
Math.max(
...branches.map((child) => {
const rect = specialSubtreeBounds(child);
return rect.x + rect.width;
})
) + 32;
path(`M${rootRect.x + rootRect.width} ${axisY}H${end}`);
group.dataset.specialAxis = kind;
}
} else {
const rect = specialNodeRect(node);
const above = rect.y + rect.height / 2 < axisY;
const endX = rect.x + rect.width / 2;
const startX = endX - (kind === "fishbone" ? 48 : 0);
path(
`M${startX} ${axisY}L${endX} ${above ? rect.y + rect.height : rect.y}`
);
group.dataset.specialConnector = kind;
if (kind === "timeline") {
const circle = document.createElementNS(ns, "circle");
circle.setAttribute("cx", String(startX));
circle.setAttribute("cy", String(axisY));
circle.setAttribute("r", "4");
circle.setAttribute("fill", color);
group.append(circle);
}
}
component.linkLineG = group;
const host = component.getElementG();
if (tableLayout && node === root && host.parentElement)
host.parentElement.insertBefore(group, host.parentElement.firstChild);
else host.append(group);
return true;
}
class SpecialMindRoot extends PlaitMindComponent {
override updateMindLayout(element = this.element) {
super.updateMindLayout(element);
if (isSpecialMindLayout(element.specialLayout))
arrangeSpecialMind(this.root, element.specialLayout);
}
override drawLink() {
if (!specialLink(this)) {
this.linkLineG?.remove();
this.linkLineG = undefined;
super.drawLink();
}
}
}
class SpecialMindNode extends MindNodeComponent {
private lastSpecialLayout: unknown;
private rootLayout() {
let root = this.node;
while (root.parent) root = root.parent;
return root.origin.specialLayout;
}
override onContextChanged(
...args: Parameters<MindNodeComponent["onContextChanged"]>
) {
super.onContextChanged(...args);
// A structure switch can leave descendant positions and parent identity unchanged.
if (this.rootLayout() !== this.lastSpecialLayout) this.drawLink();
}
override drawLink() {
if (!specialLink(this)) super.drawLink();
this.lastSpecialLayout = this.rootLayout();
}
}
const installed = new WeakSet<PlaitBoard>();
export function withSpecialMindLayouts(board: PlaitBoard) {
if (installed.has(board)) return board;
installed.add(board);
const drawElement = board.drawElement;
board.drawElement = (context) => {
if (PlaitMind.isMind(context.element)) return SpecialMindRoot;
if (MindElement.isMindElement(board, context.element))
return SpecialMindNode;
return drawElement(context);
};
return board;
}

View File

@ -0,0 +1,129 @@
"use client";
import { Trash2, X } from "lucide-react";
import type { AnnotatedElement } from "@/lib/flow-board/mind-annotations";
export function XmindAnnotationPanel({
element,
onChange,
onDelete,
onClose,
}: {
element: AnnotatedElement;
onChange: (patch: Record<string, unknown>) => void;
onDelete: () => void;
onClose: () => void;
}) {
const relationship = element.mindAnnotation.kind === "relationship";
const label = (element.texts?.[0]?.text?.children || [])
.map((leaf: { text?: string }) => leaf.text || "")
.join("");
return (
<aside
className="absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
data-flow-board-shell-control="true"
>
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
<h2 className="text-sm font-semibold">
{relationship ? "联系格式" : "外框格式"}
</h2>
<button aria-label="关闭格式面板" onClick={onClose} type="button">
<X size={16} />
</button>
</div>
<div className="space-y-5 p-4 text-xs">
{relationship ? (
<label className="block space-y-2">
<span>联系文字</span>
<input
className="h-9 w-full rounded border border-[#dfe2e7] px-2 text-sm"
value={label}
onChange={(event) =>
onChange({
texts: event.target.value
? [
{
position: 0.5,
text: {
type: "paragraph",
children: [{ text: event.target.value }],
},
},
]
: [],
})
}
/>
</label>
) : (
<label className="flex items-center justify-between">
<span>外框留白</span>
<select
aria-label="外框留白"
className="h-8 rounded border border-[#dfe2e7] px-2"
value={
element.mindAnnotation.kind === "boundary"
? element.mindAnnotation.padding
: 18
}
onChange={(event) =>
onChange({
mindAnnotation: {
...element.mindAnnotation,
padding: Number(event.target.value),
},
})
}
>
{[12, 18, 24, 32].map((value) => (
<option key={value} value={value}>
{value} px
</option>
))}
</select>
</label>
)}
<label className="flex items-center justify-between">
<span>线条颜色</span>
<input
aria-label="联系或外框颜色"
className="h-7 w-12 cursor-pointer"
type="color"
value={element.strokeColor || "#64748b"}
onChange={(event) => onChange({ strokeColor: event.target.value })}
/>
</label>
<label className="flex items-center justify-between">
<span>线条粗细</span>
<select
aria-label="联系或外框粗细"
className="h-8 rounded border border-[#dfe2e7] px-2"
value={element.strokeWidth || 2}
onChange={(event) =>
onChange({ strokeWidth: Number(event.target.value) })
}
>
{[1, 2, 3, 4].map((value) => (
<option key={value} value={value}>
{value} pt
</option>
))}
</select>
</label>
<p className="leading-5 text-[#8b919c]">
{relationship
? "联系线会跟随主题移动。双击线上的文字也可以编辑。"
: "外框包含所选主题及其展开的子主题,随分支布局自动调整。"}
</p>
<button
className="flex h-9 w-full items-center justify-center gap-2 rounded border border-[#e5e7eb] text-[#b44343] hover:bg-red-50"
onClick={onDelete}
type="button"
>
<Trash2 size={14} />
删除{relationship ? "联系" : "外框"}
</button>
</div>
</aside>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,310 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronRight,
GripVertical,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from "lucide-react";
import type { PlaitElement } from "@plait/core";
import {
getTopicDetails,
getTopicEntries,
type TopicEntry,
} from "@/lib/flow-board/topic-details";
import type {
OutlineCommand,
OutlineDrop,
} from "@/lib/flow-board/mind-outline";
type Props = {
zoomPercent: number;
elements: PlaitElement[];
selectedId: string | null;
onSelect: (id: string) => void;
onRename: (id: string, title: string) => void;
onToggle: (id: string) => void;
onCommand: (
id: string | null,
command: OutlineCommand | OutlineDrop
) => string | null;
onUndo: () => void;
onRedo: () => void;
};
function OutlineRow({
entry,
selected,
onSelect,
onRename,
onToggle,
onCommand,
onFocusTopic,
}: {
entry: TopicEntry;
selected: boolean;
onSelect: Props["onSelect"];
onRename: Props["onRename"];
onToggle: Props["onToggle"];
onCommand: Props["onCommand"];
onFocusTopic: (id: string | null, direction?: number) => void;
}) {
const { element, title, depth } = entry;
const [draft, setDraft] = useState(title);
const [drop, setDrop] = useState<OutlineDrop["placement"] | null>(null);
const ref = useRef<HTMLInputElement>(null);
const details = getTopicDetails(element);
useEffect(() => setDraft(title), [title]);
const commit = () => {
if (draft !== title) onRename(element.id, draft);
};
const act = (command: OutlineCommand) => {
commit();
onFocusTopic(onCommand(element.id, command));
};
return (
<div
data-outline-id={element.id}
data-depth={depth}
className={`group flex min-h-11 items-center gap-1 border-b border-[#f1f2f5] px-3 ${selected ? "bg-[#f0f5ff]" : "hover:bg-[#fafbfc]"} ${drop === "child" ? "ring-1 ring-inset ring-blue-400" : drop === "before" ? "border-t-2 border-t-blue-400" : drop === "after" ? "border-b-2 border-b-blue-400" : ""}`}
style={{ paddingLeft: 12 + Math.min(depth, 20) * 24 }}
onDragOver={(event) => {
event.preventDefault();
const box = event.currentTarget.getBoundingClientRect();
const y = (event.clientY - box.top) / box.height;
setDrop(y < 0.25 ? "before" : y > 0.75 ? "after" : "child");
}}
onDragLeave={() => setDrop(null)}
onDrop={(event) => {
event.preventDefault();
const source = event.dataTransfer.getData("application/x-mind-topic");
if (source && drop)
onFocusTopic(
onCommand(source, { targetId: element.id, placement: drop })
);
setDrop(null);
}}
>
<span
draggable
aria-label={`拖动 ${title}`}
className="cursor-grab text-[#9ca3af] opacity-30 group-hover:opacity-100"
onDragStart={(event) => {
event.dataTransfer.setData("application/x-mind-topic", element.id);
event.dataTransfer.effectAllowed = "move";
}}
>
<GripVertical size={13} />
</span>
{element.children?.length ? (
<button
type="button"
className="p-1 text-[#64748b]"
aria-label={`${element.isCollapsed ? "展开" : "折叠"} ${title}`}
onClick={() => onToggle(element.id)}
>
{element.isCollapsed ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
</button>
) : (
<span className="w-[22px] text-center text-[#a2a9b3]">·</span>
)}
<input
ref={ref}
aria-label={`大纲主题 ${title}`}
data-outline-input={element.id}
className={`min-w-0 flex-1 bg-transparent py-2 text-sm outline-none ${depth === 0 ? "font-semibold text-[#253046]" : "text-[#404958]"}`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onFocus={() => onSelect(element.id)}
onBlur={commit}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter") {
event.preventDefault();
act(event.shiftKey || depth === 0 ? "child" : "sibling");
} else if (event.key === "Tab") {
event.preventDefault();
act(event.shiftKey ? "outdent" : "indent");
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
commit();
if (event.altKey) act(event.key === "ArrowUp" ? "up" : "down");
else onFocusTopic(element.id, event.key === "ArrowUp" ? -1 : 1);
} else if (event.key === "Escape") {
event.preventDefault();
setDraft(title);
} else if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "z" &&
draft !== title
) {
event.preventDefault();
event.stopPropagation();
setDraft(title);
} else if (event.key === "Backspace" && !draft) {
event.preventDefault();
onFocusTopic(onCommand(element.id, "delete"));
}
}}
/>
{Number.isInteger(element.start) ? (
<span className="text-[10px] text-[#8d79b2]">概要</span>
) : null}
{details.notes ? (
<span title={details.notes} className="text-[10px] text-[#8a94a5]">
备注
</span>
) : null}
{details.labels.map((label) => (
<span
key={label}
className="hidden rounded bg-[#e8effc] px-1.5 py-0.5 text-[10px] text-[#5879b4] lg:inline"
>
{label}
</span>
))}
<button
type="button"
aria-label={`为 ${title} 添加子主题`}
className="p-1 text-[#8a94a5] opacity-0 focus:opacity-100 group-hover:opacity-100"
onClick={() => act("child")}
>
<Plus size={14} />
</button>
</div>
);
}
export function XmindOutline(props: Props) {
const { elements, selectedId, onCommand } = props;
const container = useRef<HTMLElement>(null);
const [focusId, setFocusId] = useState<string | null>(null);
const entries = getTopicEntries(elements);
const collapsedPaths = entries
.filter(({ element }) => element.isCollapsed)
.map((entry) => entry.path);
const visible = entries.filter(
(entry) =>
!collapsedPaths.some(
(path) =>
path.length < entry.path.length &&
path.every((index, i) => index === entry.path[i])
)
);
useEffect(() => {
if (!focusId) return;
const input = Array.from(
container.current?.querySelectorAll<HTMLInputElement>(
"[data-outline-input]"
) || []
).find((node) => node.dataset.outlineInput === focusId);
if (input) {
input.focus();
input.select();
setFocusId(null);
}
}, [focusId, elements]);
const focusTopic = (id: string | null, direction?: number) => {
if (!id) return;
const target = direction
? visible[
visible.findIndex((entry) => entry.element.id === id) + direction
]?.element.id
: id;
if (target) setFocusId(target);
};
const action = (command: OutlineCommand) =>
focusTopic(onCommand(selectedId, command));
return (
<section
ref={container}
aria-label="大纲视图"
className="h-full overflow-auto bg-white"
data-flow-board-shell-control="true"
onKeyDown={(event) => {
event.stopPropagation();
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "z"
) {
event.preventDefault();
if (event.shiftKey) props.onRedo();
else props.onUndo();
}
}}
>
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-[#e5e8ee] bg-white px-5 py-3">
<h2 className="mr-2 text-sm font-semibold">大纲</h2>
<span className="mr-auto text-xs text-[#949ba7]">
{entries.length} 个主题
</span>
{(
[
{ command: "child", label: "大纲新增子主题", icon: Plus },
{ command: "indent", label: "大纲缩进", icon: IndentIncrease },
{ command: "outdent", label: "大纲提升层级", icon: IndentDecrease },
{ command: "up", label: "大纲上移", icon: ArrowUp },
{ command: "down", label: "大纲下移", icon: ArrowDown },
{ command: "delete", label: "大纲删除主题", icon: Trash2 },
] as const
).map(({ command, label, icon: Icon }) => (
<button
key={command}
type="button"
title={label}
aria-label={label}
className="rounded p-1.5 text-[#64748b] hover:bg-[#edf3ff] disabled:opacity-30"
disabled={
(!selectedId && entries.length > 0) ||
(!selectedId && command !== "child")
}
onClick={() => action(command)}
>
<Icon size={16} />
</button>
))}
</div>
<div
className="mx-auto max-w-4xl px-4 py-6"
style={{ zoom: props.zoomPercent / 100 }}
>
{!entries.length ? (
<button
type="button"
onClick={() => action("child")}
className="w-full rounded border border-dashed border-[#cbd5e1] p-8 text-sm text-[#64748b]"
>
添加中心主题
</button>
) : (
visible.map((entry) => (
<OutlineRow
key={entry.element.id}
entry={entry}
selected={entry.element.id === selectedId}
onSelect={props.onSelect}
onRename={props.onRename}
onToggle={props.onToggle}
onCommand={onCommand}
onFocusTopic={focusTopic}
/>
))
)}
<p className="mt-6 text-xs text-[#a1a8b4]">
Enter 新增同级 · Shift+Enter 新增子主题 · Tab 缩进 · Shift+Tab
提升层级 · Alt+↑↓ 调整顺序
</p>
</div>
</section>
);
}

View File

@ -0,0 +1,329 @@
"use client";
import type { PlaitElement } from "@plait/core";
import {
ExternalLink,
FileDown,
Link2,
StickyNote,
Tag,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
getSafeTopicHref,
getTopicDetails,
getTopicTitle,
type TopicDetails,
type TopicEntry,
} from "@/lib/flow-board/topic-details";
export type TopicDetailSection = "notes" | "links" | "labels";
export function XmindTopicPanel({
topic,
topics,
section,
onPatch,
onJump,
onClose,
}: {
topic: PlaitElement | null;
topics: TopicEntry[];
section: TopicDetailSection;
onPatch: (patch: Partial<TopicDetails>) => void;
onJump: (id: string) => void;
onClose: () => void;
}) {
const details = getTopicDetails(topic);
const [labelDraft, setLabelDraft] = useState("");
const [linkDraft, setLinkDraft] = useState(details.href);
const [attachmentError, setAttachmentError] = useState("");
const [isReadingAttachment, setIsReadingAttachment] = useState(false);
const attachmentRef = useRef<HTMLInputElement>(null);
const noteRef = useRef<HTMLTextAreaElement>(null);
const linkRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLinkDraft(details.href);
}, [topic?.id, details.href]);
useEffect(() => {
setLabelDraft("");
setAttachmentError("");
}, [topic?.id]);
useEffect(() => {
({ notes: noteRef, links: linkRef, labels: labelRef })[
section
].current?.focus();
}, [section]);
const safeHref = getSafeTopicHref(linkDraft);
const invalidHref = Boolean(linkDraft.trim()) && !safeHref;
const addLabel = () => {
const value = labelDraft.trim();
if (value && !details.labels.includes(value))
onPatch({ labels: [...details.labels, value] });
setLabelDraft("");
};
const addAttachment = async (file: File) => {
setAttachmentError("");
if (file.size > 8 * 1024 * 1024) {
setAttachmentError("附件不能超过 8 MB。");
return;
}
setIsReadingAttachment(true);
try {
const url = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("读取失败"));
reader.onload = () => resolve(String(reader.result));
// Always download attachments as files, independent of their source type.
reader.readAsDataURL(
new Blob([file], { type: "application/octet-stream" })
);
});
onPatch({
attachment: {
name: file.name,
url,
mimeType: "application/octet-stream",
},
});
} catch {
setAttachmentError("无法读取附件,请重新选择。");
} finally {
setIsReadingAttachment(false);
}
};
return (
<aside
className="absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
data-flow-board-shell-control="true"
>
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
<h2 className="text-sm font-semibold">主题内容</h2>
<button type="button" aria-label="关闭主题内容" onClick={onClose}>
<X size={16} />
</button>
</div>
{!topic ? (
<p className="p-4 text-xs text-[#8b919c]">
选择一个主题,编辑它的备注、链接和标签。
</p>
) : (
<div className="space-y-6 p-4 text-xs">
<p className="truncate font-medium" title={getTopicTitle(topic)}>
{getTopicTitle(topic)}
</p>
<label className="block space-y-2">
<span className="flex items-center gap-2 font-medium">
<StickyNote size={14} />
备注
</span>
<textarea
aria-label="主题备注"
ref={noteRef}
className="min-h-40 w-full resize-y rounded border border-[#dfe2e7] p-2 text-sm leading-6 outline-none focus:border-blue-400"
placeholder="为这个主题补充说明…"
value={details.notes}
onChange={(event) => onPatch({ notes: event.target.value })}
/>
</label>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium">
<Tag size={14} />
标签
</h3>
<div className="flex flex-wrap gap-1.5">
{details.labels.map((label) => (
<span
key={label}
className="flex items-center gap-1 rounded bg-[#edf3ff] px-2 py-1 text-[#3569e8]"
>
{label}
<button
type="button"
aria-label={`移除标签 ${label}`}
onClick={() =>
onPatch({
labels: details.labels.filter(
(value) => value !== label
),
})
}
>
<X size={12} />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
aria-label="新增主题标签"
ref={labelRef}
className="h-8 min-w-0 flex-1 rounded border border-[#dfe2e7] px-2"
placeholder="输入标签后回车"
value={labelDraft}
onChange={(event) => setLabelDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
event.preventDefault();
addLabel();
}
}}
/>
<button
type="button"
className="rounded border border-[#dfe2e7] px-2"
onClick={addLabel}
>
添加
</button>
</div>
</section>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium">
<Link2 size={14} />
网页或邮件链接
</h3>
<input
aria-label="主题外部链接"
ref={linkRef}
aria-invalid={invalidHref}
className="h-8 w-full rounded border border-[#dfe2e7] px-2"
placeholder="https://…"
value={linkDraft}
onChange={(event) => setLinkDraft(event.target.value)}
onBlur={() => {
if (!invalidHref) onPatch({ href: linkDraft.trim() });
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !invalidHref) {
event.preventDefault();
onPatch({ href: linkDraft.trim() });
}
}}
/>
{invalidHref ? (
<p role="alert" className="text-red-600">
请输入完整的 http、https、mailto 或 tel 链接。
</p>
) : safeHref ? (
<a
className="inline-flex items-center gap-1 text-[#3569e8]"
href={safeHref}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink size={12} />
打开链接
</a>
) : null}
{details.href ? (
<button
type="button"
className="ml-3 text-[#8b919c]"
onClick={() => {
setLinkDraft("");
onPatch({ href: "" });
}}
>
移除外部链接
</button>
) : null}
</section>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium">
<FileDown size={14} />
附件
</h3>
{details.attachment ? (
<>
<a
className="block break-all text-[#3569e8]"
href={details.attachment.url}
download={details.attachment.name}
>
下载 {details.attachment.name}
</a>
<button
type="button"
className="text-[#8b919c]"
onClick={() => onPatch({ attachment: null })}
>
移除附件
</button>
</>
) : null}
<input
ref={attachmentRef}
type="file"
className="hidden"
aria-label="上传主题附件"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void addAttachment(file);
}}
/>
<button
type="button"
className="block text-[#3569e8] disabled:text-gray-400"
disabled={isReadingAttachment}
onClick={() => attachmentRef.current?.click()}
>
{isReadingAttachment
? "正在读取附件…"
: details.attachment
? "替换附件"
: "添加附件"}
</button>
{attachmentError ? (
<p role="alert" className="text-red-600">
{attachmentError}
</p>
) : null}
</section>
<label className="block space-y-2">
<span className="font-medium">链接到当前画布的主题</span>
<select
aria-label="主题链接目标"
className="h-8 w-full rounded border border-[#dfe2e7] px-2"
value={details.topicLinkId}
onChange={(event) => onPatch({ topicLinkId: event.target.value })}
>
<option value="">无主题链接</option>
{details.topicLinkId &&
!topics.some(
({ element }) => element.id === details.topicLinkId
) ? (
<option value={details.topicLinkId}>目标主题已不存在</option>
) : null}
{topics
.filter(({ element }) => element.id !== topic.id)
.map(({ element, title, depth }) => (
<option key={element.id} value={element.id}>
{" ".repeat(Math.min(depth, 5))}
{title}
</option>
))}
</select>
</label>
{details.topicLinkId ? (
<button
type="button"
disabled={
!topics.some(
({ element }) => element.id === details.topicLinkId
)
}
className="text-[#3569e8] disabled:text-gray-400"
onClick={() => onJump(details.topicLinkId)}
>
定位目标主题
</button>
) : null}
</div>
)}
</aside>
);
}

View File

@ -0,0 +1,41 @@
# XMind 26.05 安装包对照记录
用户提供:`/Users/a134/Downloads/Xmind-for-macOS-26.05.01107-202609082317.dmg`。
2026-09-18 只读挂载至 `/Volumes/Xmind`。从 Info.plist 实际读取版本 **26.05.01107**、构建 **2026090823170**、Bundle ID `net.xmind.vana.app`。本机已有版本为 26.04.01337。签名元数据标识为 XMind Ltd.,Team ID `4WV38P2X5K`。
## 可复用的核验入口
```sh
python3 scripts/flow-board/inspect-xmind-package.py /Volumes/Xmind/Xmind.app --samples-dir /tmp/xmind-26.05-reference
XMIND_NATIVE_FIXTURES=/tmp/xmind-26.05-reference pnpm exec playwright test --config playwright.flow-board-ui.config.ts
```
脚本读取安装包的版本和内置样例,输出文件大小、SHA-256、根布局类型。样例仅提取到本机临时目录用于兼容性验证,不作为项目的内置模板提交。
## 样例与布局
安装包含 36 个 `.xmind` 样例,其中基础样例 9 个;这 9 个文件的 SHA-256 与本机 26.04 同名文件相同。
| 样例 | 文件内结构类型 | 当前项目 |
|---|---|---|
| mind_map.xmind / mind_map_legacy.xmind | org.xmind.ui.map.clockwise | 基础思维图 |
| logic_chart.xmind | org.xmind.ui.logic.right | 向右逻辑 |
| org_chart.xmind | org.xmind.ui.org-chart.down | 向下组织 |
| brace_map.xmind | org.xmind.ui.brace.right | 基础括号布局,导出保留结构类型 |
| fishbone.xmind | org.xmind.ui.fishbone.leftHeaded | 基础鱼骨布局,导出保留结构类型 |
| timeline.xmind | org.xmind.ui.timeline.sided.horizontal | 基础水平时间轴,导出保留结构类型 |
| tree_chart.xmind | org.xmind.ui.timeline.vertical | 基础垂直时间轴,导出保留结构类型 |
| tree_table.xmind | org.xmind.ui.treetable.toptitle | 基础顶标题树形表格,导出保留结构类型 |
| coordinating_upcoming_release_1.0.xmind | org.xmind.ui.treetable | 基础侧标题树形表格,导出保留结构类型 |
| mood_tracker.xmind | org.xmind.ui.spreadsheet | 基础矩阵;读取并导出列扩展和单元格标签 |
实际浏览器测试导入这 9 个样例,随后导出,逐工作表比对所有主题标题和数量,全部通过。鱼骨图、水平/垂直时间轴、括号图和树形表格另检查实际特殊结构渲染及导出结构类型。测试不证明所有细节已与原生像素一致,也不证明原生应用打开兼容性。
2026-09-24 追加从安装包提取的结构样例核验:`mood_tracker.xmind`、`coordinating_upcoming_release_1.0.xmind`、`tree_table.xmind`、`film_production.xmind` 均通过导入后再导出的主题标题/数量比对。矩阵的列扩展和标签映射、侧标题表格的根表头与跨行几何另有合成文件浏览器测试。
## 新版功能线索
中文文案字典从 3096 项变为 3167 项:新增 84 项、移除 13 项、修改 1 项。新增内容涉及浮动工具栏显示策略、主题添加按钮、编辑器偏好、默认中日韩字体、默认缩放、一周起始日、快捷输入启动设置、专区和打印选项。
这些是安装包中的功能线索,仍需原生交互核对后才能认定具体行为或版本差异。原生应用界面连接当前超时,未完成界面实测;不以文案存在代替功能验收。

View File

@ -0,0 +1,99 @@
# XMind 复刻功能对照
目标:以用户提供的 XMind 26.05.01107 macOS 安装包为最新参照,结合此前 XMind 26.04 桌面体验,完成全部功能能力的逐项实现与验证。保留项目自有品牌。不以界面相似或少量测试通过宣称完整复刻。安装包核验见 [26.05 对照记录](./XMind-26.05-安装包核验.md)。
参考入口:[官方功能矩阵](https://xmind.app/pricing/_feature_matrix/)、[功能总览](https://xmind.com/features)、[结构](https://xmind.com/structure)、[文件管理](https://xmind.com/user-guide/working-with-files)、[大纲](https://xmind.com/user-guide/outliner-new)。在线版本会变化;原生 XMind 行为和实际文件仍需逐项对照。
状态:基础 = 已有可用实现但未达到完整对等;进行中 = 正在实现;待实现/核验 = 不得视作支持。
| 能力 | 当前状态 | 剩余验收 |
|---|---|---|
| 编辑器布局、结构图标、工作表栏 | 基础 | 逐屏视觉对照、窄屏/高 DPI/暗色模式 |
| 主题/子主题/同级主题、键盘编辑 | 基础 | 完整快捷键矩阵、混合选区、批量操作 |
| 选择、拖动、重排、自由主题 | 底层已有,部分核验 | 跨分支拖拽、拆分合并、对齐、吸附 |
| 撤销/重做 | 基础 | 所有新命令均需单独验证历史边界 |
| 思维图、左右逻辑、上下组织、目录 | 基础 | 子树混合布局、手动布局行为对照 |
| 鱼骨图、时间轴、树形表格、矩阵、括号图 | 部分基础 | 已有左头鱼骨图、双侧水平时间轴、垂直时间轴、顶标题/侧标题树形表格、矩阵和向右括号图;待补方向变体、穿越式时间轴、子树混合布局和更精细的原生视觉对照 |
| 分支/节点/文字格式、主题配色 | 基础 | 全形状、线型、彩虹、字体、富文本、全主题 |
| 联系线 | 基础 | 控制点编辑、箭头、虚线、复杂布局、双向联系 |
| 外框 | 基础 | 标题、全形状、嵌套、填充、范围调整 |
| 概要 | 基础 | 范围调整、多重概要、完整样式 |
| 图标/标记、贴纸 | 部分标记 | 全标记映射、自定义图标、贴纸库 |
| 备注、外链、主题链接、标签 | 基础 | 已验证纯文本备注、标签和外链编辑、撤销、工作表切换、快照序列化;同画布链接可展开折叠分支定位。待补富文本备注、跨画布链接、画布内容标识、真实云端保存重载 |
| 图片、附件、音频、公式 | 部分支持 | 新旧 XMind 内嵌 PNG/JPEG/GIF/WebP 导入;附件新增/替换/下载/移除/撤销。待补 SVG、音频、公式、资源导出和真实样本覆盖 |
| 任务、待办、进度、日期、负责人、甘特图 | 待实现 | 任务模型、视图和筛选,不能用普通图标替代 |
| 查找替换、过滤、聚焦、导航 | 待实现 | 折叠节点和多画布范围、替换撤销 |
| 大纲视图 | 基础 | 已验证画布双向改名、新增/删除、缩进/提升、拖动重排、展开折叠、缩放和撤销;待补混合富文本、批量选区、分支聚焦、大纲专用导出和原生视觉对照 |
| 浮动工具栏、主题添加按钮 | 待新版对照 | 26.05 安装包有新增编辑体验文案,需按原生行为补齐 |
| 编辑器偏好设置 | 待实现 | 默认缩放、默认中日韩字体、一周起始日、快捷输入启动偏好 |
| ZEN、演示/Pitch、分步展示 | 待实现 | 交互、快捷键、导出与演示行为 |
| 多工作表新建/切换/重命名/复制/删除 | 基础 | 跨工作表移动/链接、合并 XMind 文件 |
| 云文件、自动保存 | 基础 | 离线恢复、版本历史、异常恢复与冲突 |
| XMind 新版 JSON / 旧版 XML 导入 | 基础 | 资源、全部样式、结构和扩展内容;真实样本库 |
| MD、OPML、FreeMind 导入 | 基础 | 更多格式与大文件性能 |
| XMind 导出与往返兼容 | 基础 | 已有多工作表 ZIP 导出与项目内往返测试;原生 XMind 打开和保存回传尚未核验,不能仅自读自写认定兼容 |
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | 待逐项核验 | 全图/选区/单页/多页、透明背景、纸张和清晰度 |
| 打印、密码、缓存、文件恢复 | 待实现 | 不得以浏览器存储代替完整文件保护 |
| 模板、主题库、手绘风格 | 待实现/部分底层能力 | 内置模板、自定义、导入与复用 |
| 协作、评论、分享、权限 | 待实现 | 项目已有登录不等于实时协作完成 |
| AI 生成、总结、重组、扩写 | 待实现 | 接入项目模型能力、编辑历史、可靠保存 |
| 无障碍、多语言、性能 | 待专项核验 | 键盘/读屏、长文、1 万主题、资源上限 |
## 已有验证入口
- `pnpm exec playwright test --config playwright.flow-board.config.ts`:快照兼容、工作表隔离、关系/外框几何与选区规则。
- `pnpm exec playwright test --config playwright.flow-board-ui.config.ts`:真实浏览器导入、编辑、撤销、工作表切换。
- `pnpm exec tsc --noEmit --pretty false`:类型检查。
- 开发预览:`/dev/flow-board-preview`,只在开发环境开放。预览内容不等同于云端持久化。
## 2026-09-18 主题内容验收
- 11 项数据测试、7 项浏览器测试、TypeScript 检查通过。
- 新版 JSON 和旧版 XML 的备注、标签、外链、内嵌图片已验证;附件下载内容与原文件一致。
- 修复折叠后代主题没有渲染路径导致链接跳转异常的问题:按数据树路径展开祖先后定位。
- 资源限制:XMind 文件 50 MB、单资源/新增附件 8 MB、导入内嵌资源累计 24 MB;缺失或不支持的图片会给出提示并保留主题。外部图片不自动下载。
- 当前回归使用生成的测试文件。原生 XMind 实际文件库、真实账号云端保存重载和导出往返仍待核验,不能据此认定完全兼容。
## 验收约束
每项至少核验:创建、编辑、删除、保存重载、撤销重做、工作表切换、导入导出兼容;适用时增加原生 XMind 对照。未提供证据的项保持未完成。所有目前实现仍是部分能力,完整目标持续有效。
## 2026-09-18 XMind 导出验收
- 工具栏新增“导出 XMind”。按现代 XMind 文件包写入 content.json、metadata.json、manifest.json 和内嵌资源;文件包结构参考官方模型以及本机 XMind 自带 mind_map.xmind 样本。
- 14 项数据测试、8 项浏览器测试、TypeScript 和本轮新增文件 lint 检查通过。项目内往返覆盖多工作表、当前编辑内容、显式字号/粗体、备注/标签、主题链接、概要、外框、联系线、自由主题、图片和附件。图片与附件字节一致。
- 导出会重新分配主题 ID 并同步引用,避免重复工作表产生 ID 冲突。当前工作表以实时画布内容为准。
- 尚未达到原生无损往返:原生界面连接超时,未完成 XMind 打开/再次保存的实机验证;自动主题配色、混合富文本、部分标记、特殊布局、任意画布元素和联系线控制点仍有差距。同一主题同时含附件和链接时,目前优先附件并明确提示。
- 不支持的内容会显示导出提示;无法读取的资源、没有主题的工作表会明确报错,不生成看似成功的空文件。
## 2026-09-18 大纲与原生样例验收
- 19 项数据测试、10 项浏览器测试通过(浏览器运行设置 `XMIND_NATIVE_FIXTURES=/tmp/xmind-26.05-reference`),另验证大纲缩放、空画布创建中心主题和 Escape 取消改名。类型检查通过。
- 大纲与画布共享主题、备注、历史和工作表数据;支持 Enter 新增同级、Shift+Enter 新增子主题、Tab/Shift+Tab 层级调整、Alt+上下移动,以及拖动到主题前/后/内部。
- 重排保留 ID、备注、图片和联系线引用;概要随保留的主题调整范围。会拆散概要范围的移动暂时明确拒绝,待补原生范围重排行为。
- 用户安装包中的 9 个基础样例均完成“导入 → 导出”主题标题/数量比对,没有缺少主题;此项不证明布局和样式相同。此阶段鱼骨图、括号图、时间轴、树形表格尚未完成;后续进展见下文。
- 原生界面连接两次超时,原生视觉与导出文件打开验证仍未通过;不影响继续开发其他差距。
## 2026-09-18 特殊布局验收
- 新增左头鱼骨图、双侧水平时间轴、向右括号图;结构面板使用独立 SVG 图标。整棵分支参与布局计算,为长标题和后代预留空间,根概要放在分支列之外。
- 新版 XMind 导入识别这三种根结构,导出保留对应 `structureClass`。通过 Drawnix 初始插件入口安装自定义渲染组件,导入首次显示即使用对应布局。
- 23 项数据测试、11 项浏览器测试全部通过,包括安装包 9 个基础样例;TypeScript、五个本轮新文件的 Biome 检查通过。特殊布局测试覆盖实际坐标、连接线、改名、新增子主题、联系线保留、切换、撤销/重做和导出结构。
- 修复新增主题未自动获得键盘焦点的问题,以及括号图切回普通结构时深层连线残留的问题。
- 仍是基础实现:方向变体、垂直时间轴、树形表格、矩阵、子树混合结构、复杂联系线避障和原生视觉精确对照待补;原生应用打开导出文件仍未验证。真实账号保存重载也需另行验收。
## 2026-09-24 垂直时间轴与树形表格验收
- 新增垂直时间轴:根轴向下延伸,一级主题左右交替,整棵子树随所属方向镜像;新增主题、结构切换、撤销、工作表切换和 XMind 往返均有浏览器测试。
- 新增顶标题树形表格:中心主题跨列表头,层级按列排列,父主题纵向跨越全部后代行;表格单元格位于主题内容下层,不拦截选择和编辑。原生 `tree_table.xmind` 的主题配色、表头和网格视觉已用于对照。
- 新版导入和导出保留 `org.xmind.ui.timeline.vertical` 与 `org.xmind.ui.treetable.toptitle`;结构面板新增对应 SVG 图标。
- 25 项数据测试通过;13 项浏览器用例中新增树形表格用例,并再次通过安装包 9 个基础样例。TypeScript 和本轮文件 Biome 检查通过。浏览器预览测试改用 Webpack,避开 Next 16 Turbopack 在迁移后的中文目录中出现的包解析崩溃。
- 该阶段尚未完成侧标题树形表格和矩阵;后续进展见下文。时间轴方向变体、子树混合结构、复杂主题样式和原生应用打开导出文件的实机验证仍待补齐。
## 2026-09-24 侧标题树形表格与矩阵验收
- 新增侧标题树形表格:中心主题成为跨全部叶子行的左侧表头,后续层级按列展开,父主题跨越其后代行。导入、导出保留 `org.xmind.ui.treetable`。
- 新增矩阵:读取 `org.xmind.ui.spreadsheet` 根扩展中的列名,并按子主题 `labels` 放入对应单元格;同一单元格的多个主题纵向堆叠。矩阵列定义和主题标签可随 XMind 导出、再次导入。
- 结构面板增加两枚独立 SVG 图标;表格主题使用整格几何参与命中和画布适配。修复 8 位 XMind 色值、原生圆角主题边框残留和新增长文本裁切。
- 27 项数据测试、15 项浏览器测试和 TypeScript 检查通过;全量浏览器回归再次覆盖安装包的 9 个基础样例。另用安装包中的 `mood_tracker.xmind`、`coordinating_upcoming_release_1.0.xmind`、`tree_table.xmind`、`film_production.xmind` 完成主题完整性导入导出验证。
- 当前矩阵支持 XMind 的列扩展和标签映射;列增删/改名的专用交互、复杂嵌套子树、原生应用打开导出文件和逐像素多主题对照仍待实现。

View File

@ -1343,10 +1343,23 @@ export const aiCadWorkspace = pgTable("AiCadWorkspace", {
export type AiCadWorkspace = InferSelectModel<typeof aiCadWorkspace>;
export type FlowBoardSnapshotData = {
export type FlowBoardSheetSnapshotData = {
id: string;
title: string;
children: Record<string, unknown>[];
viewport?: Record<string, unknown> | null;
theme?: Record<string, unknown> | null;
canvas?: Record<string, unknown> | null;
};
export type FlowBoardSnapshotData = {
version?: number;
activeSheetId?: string | null;
sheets?: FlowBoardSheetSnapshotData[];
children: Record<string, unknown>[];
viewport?: Record<string, unknown> | null;
theme?: Record<string, unknown> | null;
canvas?: Record<string, unknown> | null;
updatedAt?: string | null;
};

View File

@ -0,0 +1,123 @@
import type { PlaitElement, Point } from "@plait/core";
export type MindAnnotation =
| { kind: "relationship"; sourceId: string; targetId: string }
| { kind: "boundary"; topicIds: string[]; padding: number };
export type AnnotatedElement = PlaitElement & {
mindAnnotation: MindAnnotation;
};
export type TopicRectangle = {
x: number;
y: number;
width: number;
height: number;
};
export function isMindAnnotation(
element: PlaitElement
): element is AnnotatedElement {
const value = element.mindAnnotation as MindAnnotation | undefined;
return (
(element.type === "arrow-line" &&
value?.kind === "relationship" &&
typeof value.sourceId === "string" &&
typeof value.targetId === "string") ||
(element.type === "geometry" &&
value?.kind === "boundary" &&
typeof value.padding === "number" &&
Number.isFinite(value.padding) &&
value.padding >= 0 &&
Array.isArray(value.topicIds) &&
value.topicIds.every((id) => typeof id === "string"))
);
}
/** XMind ranges must not silently include unselected, intervening siblings. */
export function validateTopicRange(
paths: number[][],
rightNodeCount?: number
): string | null {
if (paths.length === 0) return "请先选择主题";
const parent = paths[0].slice(0, -1).join("/");
if (paths.some((path) => path.slice(0, -1).join("/") !== parent)) {
return "请选择同一个父主题下的连续主题";
}
const indices = [...new Set(paths.map((path) => path[path.length - 1]))].sort(
(a, b) => a - b
);
if (indices[indices.length - 1] - indices[0] + 1 !== indices.length) {
return "请选择连续的主题,避免包含未选中的分支";
}
if (
rightNodeCount &&
indices[0] < rightNodeCount &&
indices[indices.length - 1] >= rightNodeCount
) {
return "请选择中心主题同一侧的分支";
}
return null;
}
export function getBoundaryPoints(
rectangles: TopicRectangle[],
padding = 18
): [Point, Point] | null {
if (!rectangles.length) return null;
return [
[
Math.min(...rectangles.map((r) => r.x)) - padding,
Math.min(...rectangles.map((r) => r.y)) - padding,
],
[
Math.max(...rectangles.map((r) => r.x + r.width)) + padding,
Math.max(...rectangles.map((r) => r.y + r.height)) + padding,
],
];
}
/** Bow aligned links around topics instead of drawing through intervening siblings. */
export function getRelationshipPoints(
source: TopicRectangle,
target: TopicRectangle
): Point[] {
const a: Point = [source.x + source.width / 2, source.y + source.height / 2];
const b: Point = [target.x + target.width / 2, target.y + target.height / 2];
const dx = b[0] - a[0];
const dy = b[1] - a[1];
if (Math.abs(dx) < (source.width + target.width) / 2) {
return [
[source.x + source.width, a[1]],
[
Math.max(source.x + source.width, target.x + target.width) + 72,
(a[1] + b[1]) / 2,
],
[target.x + target.width, b[1]],
];
}
if (Math.abs(dy) < (source.height + target.height) / 2) {
return [
[a[0], source.y],
[(a[0] + b[0]) / 2, Math.min(source.y, target.y) - 72],
[b[0], target.y],
];
}
if (
Math.abs(dx) / Math.max(1, (source.width + target.width) / 2) >=
Math.abs(dy) / Math.max(1, (source.height + target.height) / 2)
) {
const sign = dx >= 0 ? 1 : -1;
return [
[a[0] + (sign * source.width) / 2, a[1]],
[(a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 48],
[b[0] - (sign * target.width) / 2, b[1]],
];
}
const sign = dy >= 0 ? 1 : -1;
return [
[a[0], a[1] + (sign * source.height) / 2],
[Math.max(a[0], b[0]) + 72, (a[1] + b[1]) / 2],
[b[0], b[1] - (sign * target.height) / 2],
];
}

View File

@ -1,6 +1,13 @@
import type { PlaitElement, Point } from "@plait/core";
import { MindLayoutType } from "@plait/layouts";
import { createMindElement, type MindElement, type PlaitMind } from "@plait/mind";
import {
createMindElement,
type MindElement,
type PlaitMind,
} from "@plait/mind";
import { importXmind, type ImportedMindMap } from "./xmind-import";
export { MindMapImportError } from "./xmind-import";
export const FLOW_BOARD_MIND_MAP_IMPORT_ACCEPT =
".md,.markdown,.mdown,.mkd,.opml,.mm,.xmind,text/markdown,text/plain,text/xml,application/xml";
@ -13,7 +20,9 @@ type OutlineNode = {
};
function normalizeText(value: string | null | undefined) {
return String(value || "").replace(/\s+/g, " ").trim();
return String(value || "")
.replace(/\s+/g, " ")
.trim();
}
export function getMindMapImportTitle(fileName: string) {
@ -75,14 +84,6 @@ function getDirectChildrenByTag(element: Element, tagName: string) {
);
}
function getDirectChildrenByLocalName(element: Element, localName: string) {
const normalizedLocalName = localName.toLowerCase();
return Array.from(element.children).filter(
(child) =>
(child.localName || child.tagName).toLowerCase() === normalizedLocalName
);
}
function createMindFromOutline(root: OutlineNode): PlaitMind {
const build = (node: OutlineNode): MindElement => {
const element = createMindElement(node.text || "未命名节点", {});
@ -108,7 +109,9 @@ function parseOpmlOutlineNode(element: Element): OutlineNode {
return {
text,
children: getDirectChildrenByTag(element, "outline").map(parseOpmlOutlineNode),
children: getDirectChildrenByTag(element, "outline").map(
parseOpmlOutlineNode
),
};
}
@ -119,8 +122,12 @@ function parseOpmlToMind(xmlText: string, fallbackTitle: string): PlaitMind {
throw new Error("OPML body not found");
}
const headTitle = normalizeText(document.getElementsByTagName("title")[0]?.textContent);
const topOutlines = getDirectChildrenByTag(body, "outline").map(parseOpmlOutlineNode);
const headTitle = normalizeText(
document.getElementsByTagName("title")[0]?.textContent
);
const topOutlines = getDirectChildrenByTag(body, "outline").map(
parseOpmlOutlineNode
);
if (topOutlines.length === 0) {
throw new Error("OPML outline not found");
}
@ -172,144 +179,7 @@ function parseFreeMindToMind(xmlText: string): PlaitMind {
return createMindFromOutline(parseFreeMindNode(rootNode));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function getXmindTopicTitle(topic: Record<string, unknown>) {
const title = normalizeText(String(topic.title || topic.text || ""));
return title || "未命名节点";
}
function getXmindJsonChildTopics(topic: Record<string, unknown>) {
const children = topic.children;
if (Array.isArray(children)) {
return children.filter(isRecord);
}
if (!isRecord(children)) {
return [];
}
return Object.values(children).flatMap((value) => {
if (Array.isArray(value)) {
return value.filter(isRecord);
}
if (isRecord(value)) {
return [value];
}
return [];
});
}
function parseXmindJsonTopic(topic: Record<string, unknown>): OutlineNode {
return {
text: getXmindTopicTitle(topic),
children: getXmindJsonChildTopics(topic).map(parseXmindJsonTopic),
};
}
function parseXmindJsonToMind(jsonText: string, fallbackTitle: string) {
const parsed = JSON.parse(jsonText) as unknown;
const sheets = Array.isArray(parsed) ? parsed.filter(isRecord) : [];
const sheetRoots = sheets
.map((sheet) => {
const rootTopic = sheet.rootTopic;
return isRecord(rootTopic) ? parseXmindJsonTopic(rootTopic) : null;
})
.filter((sheet): sheet is OutlineNode => Boolean(sheet));
if (sheetRoots.length === 0) {
throw new Error("XMind content.json rootTopic not found");
}
const root =
sheetRoots.length === 1
? sheetRoots[0]
: {
text: fallbackTitle || "XMind 思维导图",
children: sheetRoots,
};
return createMindFromOutline(root);
}
function getDirectXmindXmlTitle(element: Element) {
return (
normalizeText(getDirectChildrenByLocalName(element, "title")[0]?.textContent) ||
normalizeText(element.getAttribute("title")) ||
normalizeText(element.getAttribute("TEXT")) ||
"未命名节点"
);
}
function getXmindXmlTopicChildren(element: Element) {
const childrenContainers = getDirectChildrenByLocalName(element, "children");
return childrenContainers.flatMap((childrenContainer) =>
getDirectChildrenByLocalName(childrenContainer, "topics").flatMap((topics) =>
getDirectChildrenByLocalName(topics, "topic")
)
);
}
function parseXmindXmlTopic(element: Element): OutlineNode {
return {
text: getDirectXmindXmlTitle(element),
children: getXmindXmlTopicChildren(element).map(parseXmindXmlTopic),
};
}
function parseXmindXmlToMind(xmlText: string, fallbackTitle: string) {
const document = parseXmlDocument(xmlText, "XMind");
const sheets = Array.from(document.getElementsByTagName("*")).filter(
(element) => (element.localName || element.tagName).toLowerCase() === "sheet"
);
const sheetRoots = sheets
.map((sheet) => getDirectChildrenByLocalName(sheet, "topic")[0])
.filter((topic): topic is Element => Boolean(topic))
.map(parseXmindXmlTopic);
if (sheetRoots.length === 0) {
const rootTopic = Array.from(document.getElementsByTagName("*")).find(
(element) => (element.localName || element.tagName).toLowerCase() === "topic"
);
if (!rootTopic) {
throw new Error("XMind content.xml topic not found");
}
return createMindFromOutline(parseXmindXmlTopic(rootTopic));
}
const root =
sheetRoots.length === 1
? sheetRoots[0]
: {
text: fallbackTitle || "XMind 思维导图",
children: sheetRoots,
};
return createMindFromOutline(root);
}
async function parseXmindToMind(file: File, fallbackTitle: string) {
const { default: JSZip } = await import("jszip");
const zip = await JSZip.loadAsync(await file.arrayBuffer());
const contentJsonFile =
zip.file("content.json") ||
zip.file(/(^|\/)content\.json$/i).find((entry) => !entry.dir);
if (contentJsonFile) {
return parseXmindJsonToMind(await contentJsonFile.async("string"), fallbackTitle);
}
const contentXmlFile =
zip.file("content.xml") ||
zip.file(/(^|\/)content\.xml$/i).find((entry) => !entry.dir);
if (contentXmlFile) {
return parseXmindXmlToMind(await contentXmlFile.async("string"), fallbackTitle);
}
throw new Error("XMind content file not found");
}
export async function parseMindMapFileToDrawnixMind(
async function parseOutlineFileToMind(
file: File,
fallbackTitle = getMindMapImportTitle(file.name)
): Promise<PlaitElement & { points?: Point[] }> {
@ -318,11 +188,6 @@ export async function parseMindMapFileToDrawnixMind(
throw new Error("Unsupported mind map import file");
}
if (format === "xmind") {
return parseXmindToMind(file, fallbackTitle) as Promise<
PlaitElement & { points?: Point[] }
>;
}
const fileText = await file.text();
if (!fileText.trim()) {
@ -347,3 +212,25 @@ export async function parseMindMapFileToDrawnixMind(
return parseFreeMindToMind(fileText) as PlaitElement & { points?: Point[] };
}
/** Preserve workbook sheets rather than flattening them into the topic tree. */
export async function parseMindMapFileToDocument(
file: File,
fallbackTitle = getMindMapImportTitle(file.name)
): Promise<ImportedMindMap> {
if (detectMindMapImportFormat(file) === "xmind")
return importXmind(file, fallbackTitle);
const mind = await parseOutlineFileToMind(file, fallbackTitle);
return {
sheets: [
{
id: `import-${crypto.randomUUID()}`,
title: "画布 1",
children: [
{ ...mind, points: mind.points?.length ? mind.points : [[0, 0]] },
],
},
],
warnings: [],
};
}

View File

@ -0,0 +1,183 @@
import type { PlaitElement } from "@plait/core";
import { getTopicEntries } from "./topic-details";
export type OutlineCommand =
| "child"
| "sibling"
| "delete"
| "indent"
| "outdent"
| "up"
| "down";
export type OutlineDrop = {
targetId: string;
placement: "before" | "after" | "child";
};
const isSummary = (node: PlaitElement) =>
Number.isInteger(node.start) && Number.isInteger(node.end);
const regular = (node: PlaitElement) =>
(node.children || []).filter((child) => !isSummary(child));
const newTopic = (root = false): PlaitElement => ({
id: crypto.randomUUID(),
type: root ? "mindmap" : "mind_child",
data: {
topic: {
type: "paragraph",
children: [{ text: root ? "中心主题" : "新主题" }],
},
},
children: [],
...(root
? { isRoot: true, layout: "standard", rightNodeCount: 0, points: [[0, 0]] }
: {}),
});
/** Work on the model, including collapsed nodes that have no renderer path. */
export function applyOutlineCommand(
elements: PlaitElement[],
topicId: string | null,
command: OutlineCommand | OutlineDrop
): { children: PlaitElement[]; selectedId: string } {
const children = structuredClone(elements);
if (!topicId) {
if (command !== "child" || getTopicEntries(children).length)
throw new Error("请先选择一个主题");
const root = newTopic(true);
children.push(root);
return { children, selectedId: root.id };
}
const entries = getTopicEntries(children);
const entry = entries.find(({ element }) => element.id === topicId);
if (!entry) throw new Error("主题已不存在,请重新选择");
const node = entry.element;
const parentOf = (target: PlaitElement) =>
entries.find(({ element }) => element.children?.includes(target))?.element;
const parent = parentOf(node);
const ranges = entries
.filter(({ element }) => isSummary(element))
.map(({ element }) => {
const owner = parentOf(element)!;
return {
element,
owner,
ids: regular(owner)
.slice(Number(element.start), Number(element.end) + 1)
.map((child) => child.id),
};
});
const remove = (target: PlaitElement) => {
const owner = parentOf(target);
const list = owner?.children || children;
const index = list.indexOf(target);
if (
owner?.layout === "standard" &&
!isSummary(target) &&
index < Number(owner.rightNodeCount)
)
owner.rightNodeCount = Number(owner.rightNodeCount) - 1;
list.splice(index, 1);
};
const insert = (owner: PlaitElement, target: PlaitElement, index: number) => {
owner.children ||= [];
const count = regular(owner).length;
const at = Math.min(index, count);
owner.children.splice(at, 0, target);
if (owner.layout === "standard" && at <= Number(owner.rightNodeCount || 0))
owner.rightNodeCount = Number(owner.rightNodeCount || 0) + 1;
owner.isCollapsed = false;
};
let selectedId = node.id;
if (command === "child" || command === "sibling") {
const next = newTopic();
if (command === "child") insert(node, next, regular(node).length);
else {
if (!parent || isSummary(node))
throw new Error("中心主题和概要请使用新增子主题");
insert(parent, next, parent.children!.indexOf(node) + 1);
}
selectedId = next.id;
} else if (command === "delete") {
const siblings = parent
? regular(parent)
: children.filter((item) =>
["mind", "mindmap"].includes(String(item.type))
);
const index = siblings.indexOf(node);
selectedId =
siblings[index - 1]?.id || siblings[index + 1]?.id || parent?.id || "";
remove(node);
} else {
if (!parent || isSummary(node))
throw new Error("中心主题和概要暂不支持此层级调整");
let target: PlaitElement | undefined;
let placement: OutlineDrop["placement"] = "child";
const siblings = regular(parent);
const index = siblings.indexOf(node);
if (typeof command === "object") {
target = entries.find(
({ element }) => element.id === command.targetId
)?.element;
placement = command.placement;
} else if (command === "indent") target = siblings[index - 1];
else if (command === "outdent") {
target = parent;
placement = "after";
} else {
target = siblings[index + (command === "up" ? -1 : 1)];
placement = command === "up" ? "before" : "after";
}
if (!target) throw new Error("此位置无法继续移动主题");
if (isSummary(target)) throw new Error("请移动到普通主题附近");
if (
getTopicEntries([node]).some(({ element }) => element.id === target!.id)
)
throw new Error("不能把主题移入自身或它的子主题");
const destination = placement === "child" ? target : parentOf(target);
if (!destination) throw new Error("一级主题不能再提升层级");
remove(node);
const at =
placement === "child"
? regular(destination).length
: destination.children!.indexOf(target) +
(placement === "after" ? 1 : 0);
insert(destination, node, at);
}
// Keep summaries attached to their surviving topic range, never unrelated gaps.
for (const range of ranges) {
if (!range.owner.children?.includes(range.element)) continue;
const indices = regular(range.owner).flatMap((child, index) =>
range.ids.includes(child.id) ? [index] : []
);
if (!indices.length) {
range.owner.children = range.owner.children.filter(
(child) => child !== range.element
);
continue;
}
const start = indices[0];
const end = indices[indices.length - 1];
const newlyInserted = command === "child" || command === "sibling";
if (!newlyInserted && end - start + 1 !== indices.length)
throw new Error("移动会让概要范围断开,请先调整概要");
range.element.start = start;
range.element.end = end;
}
return { children, selectedId };
}
export function renameOutlineTopic(element: PlaitElement, title: string) {
const data = element.data as Record<string, unknown>;
const topic = data.topic as {
children?: Record<string, unknown>[];
type?: string;
};
return {
...data,
topic: {
...topic,
children: [
{ ...(topic.children?.[0] || {}), text: title.trim() || "未命名主题" },
],
},
};
}

View File

@ -0,0 +1,394 @@
export const SPECIAL_MIND_LAYOUTS = {
fishbone: {
structure: "org.xmind.ui.fishbone.leftHeaded",
base: "right",
label: "鱼骨图",
preview: "fishbone",
},
timeline: {
structure: "org.xmind.ui.timeline.sided.horizontal",
base: "right",
label: "水平时间轴",
preview: "timeline",
},
verticalTimeline: {
structure: "org.xmind.ui.timeline.vertical",
base: "right",
label: "垂直时间轴",
preview: "verticalTimeline",
},
treeTable: {
structure: "org.xmind.ui.treetable.toptitle",
base: "right",
label: "树形表格",
preview: "treeTable",
},
treeTableSide: {
structure: "org.xmind.ui.treetable",
base: "right",
label: "树形表格(侧标题)",
preview: "treeTableSide",
},
spreadsheet: {
structure: "org.xmind.ui.spreadsheet",
base: "right",
label: "矩阵",
preview: "spreadsheet",
},
brace: {
structure: "org.xmind.ui.brace.right",
base: "right",
label: "括号图",
preview: "brace",
},
} as const;
export type SpecialMindLayout = keyof typeof SPECIAL_MIND_LAYOUTS;
export function isSpecialMindLayout(
value: unknown
): value is SpecialMindLayout {
return (
typeof value === "string" && Object.hasOwn(SPECIAL_MIND_LAYOUTS, value)
);
}
export function specialLayoutForStructure(structure: string) {
return (Object.keys(SPECIAL_MIND_LAYOUTS) as SpecialMindLayout[]).find(
(key) => SPECIAL_MIND_LAYOUTS[key].structure === structure
);
}
export type SpecialLayoutNode = {
x: number;
y: number;
width: number;
height: number;
hGap: number;
vGap: number;
origin: {
id?: string;
start?: number;
end?: number;
fill?: string;
spreadsheetColumns?: string[];
xmind?: { labels?: string[] };
};
children: SpecialLayoutNode[];
tableCell?: {
x: number;
y: number;
width: number;
height: number;
depth: number;
branch: number;
};
tableGrid?: {
kind: "spreadsheet";
x: number;
y: number;
width: number;
height: number;
columns: string[];
headerHeight: number;
columnHeaderHeight: number;
rowHeaderWidth: number;
columnWidth: number;
rows: { y: number; height: number; branch: number }[];
};
};
export const specialNodeRect = (node: SpecialLayoutNode) => ({
x: node.x + node.hGap,
y: node.y + node.vGap,
width: node.width - 2 * node.hGap,
height: node.height - 2 * node.vGap,
});
export const isSummaryNode = (node: SpecialLayoutNode) =>
Number.isInteger(node.origin.start) && Number.isInteger(node.origin.end);
export function specialSubtreeBounds(node: SpecialLayoutNode) {
const nodes: SpecialLayoutNode[] = [];
const visit = (item: SpecialLayoutNode) => {
nodes.push(item);
item.children.forEach(visit);
};
visit(node);
const rects = nodes.map(specialNodeRect);
const x = Math.min(...rects.map((rect) => rect.x));
const y = Math.min(...rects.map((rect) => rect.y));
return {
x,
y,
width: Math.max(...rects.map((rect) => rect.x + rect.width)) - x,
height: Math.max(...rects.map((rect) => rect.y + rect.height)) - y,
};
}
function translate(node: SpecialLayoutNode, dx: number, dy: number) {
node.x += dx;
node.y += dy;
node.children.forEach((child) => translate(child, dx, dy));
}
function mirrorHorizontally(node: SpecialLayoutNode, axis: number) {
node.x = 2 * axis - node.x - node.width;
node.children.forEach((child) => mirrorHorizontally(child, axis));
}
function placeInTableCell(
node: SpecialLayoutNode,
cell: NonNullable<SpecialLayoutNode["tableCell"]>
) {
node.x = cell.x - node.hGap;
node.y = cell.y - node.vGap;
node.width = cell.width + node.hGap * 2;
node.height = cell.height + node.vGap * 2;
node.tableCell = cell;
}
function arrangeTreeTable(root: SpecialLayoutNode, title: "top" | "side") {
const branches = root.children.filter((node) => !isSummaryNode(node));
if (!branches.length) return;
let maxDepth = 1;
const visit = (node: SpecialLayoutNode, depth: number) => {
maxDepth = Math.max(maxDepth, depth);
node.children.filter((child) => !isSummaryNode(child)).forEach((child) =>
visit(child, depth + 1)
);
};
branches.forEach((node) => visit(node, 1));
const columnWidths = Array.from({ length: maxDepth }, () => 180);
const measure = (node: SpecialLayoutNode, depth: number) => {
const rect = specialNodeRect(node);
columnWidths[depth - 1] = Math.min(
360,
Math.max(columnWidths[depth - 1], rect.width + 48)
);
node.children.filter((child) => !isSummaryNode(child)).forEach((child) =>
measure(child, depth + 1)
);
};
branches.forEach((node) => measure(node, 1));
const spans = new Map<SpecialLayoutNode, [number, number]>();
let nextRow = 0;
const assignRows = (node: SpecialLayoutNode): [number, number] => {
const children = node.children.filter((child) => !isSummaryNode(child));
if (!children.length) {
const row = nextRow++;
spans.set(node, [row, row]);
return [row, row];
}
const childSpans = children.map(assignRows);
const span: [number, number] = [
childSpans[0][0],
childSpans[childSpans.length - 1][1],
];
spans.set(node, span);
return span;
};
branches.forEach(assignRows);
const sourceRoot = specialNodeRect(root);
const rootWidth = Math.min(420, Math.max(220, sourceRoot.width + 48));
const branchWidth = columnWidths.reduce((sum, width) => sum + width, 0);
const tableWidth = branchWidth + (title === "side" ? rootWidth : 0);
const rowHeight = 78;
const headerHeight = title === "top" ? Math.max(96, sourceRoot.height + 36) : 0;
const tableX = sourceRoot.x;
const tableY = sourceRoot.y;
placeInTableCell(root, {
x: tableX,
y: tableY,
width: title === "top" ? tableWidth : rootWidth,
height: title === "top" ? headerHeight : Math.max(rowHeight, nextRow * rowHeight),
depth: 0,
branch: -1,
});
const position = (
node: SpecialLayoutNode,
depth: number,
branch: number
) => {
const [start, end] = spans.get(node)!;
const x =
tableX + (title === "side" ? rootWidth : 0) +
columnWidths.slice(0, depth - 1).reduce((sum, width) => sum + width, 0);
placeInTableCell(node, {
x,
y: tableY + headerHeight + start * rowHeight,
width: columnWidths[depth - 1],
height: (end - start + 1) * rowHeight,
depth,
branch,
});
node.children.filter((child) => !isSummaryNode(child)).forEach((child) =>
position(child, depth + 1, branch)
);
};
branches.forEach((node, index) => position(node, 1, index));
}
function labelsOf(node: SpecialLayoutNode) {
return Array.isArray(node.origin.xmind?.labels)
? node.origin.xmind.labels.filter((label) => typeof label === "string")
: [];
}
function arrangeSpreadsheet(root: SpecialLayoutNode) {
const rows = root.children.filter((node) => !isSummaryNode(node));
if (!rows.length) return;
const configured = Array.isArray(root.origin.spreadsheetColumns)
? root.origin.spreadsheetColumns.filter(Boolean)
: [];
const derived = rows.flatMap((row) =>
row.children.flatMap((child) => labelsOf(child))
);
const columns = [...new Set(configured.length ? configured : derived)];
if (!columns.length) columns.push("列 1", "列 2", "列 3");
const sourceRoot = specialNodeRect(root);
const headerHeight = Math.max(86, sourceRoot.height + 30);
const columnHeaderHeight = 54;
const rowHeaderWidth = Math.min(
320,
Math.max(180, ...rows.map((row) => specialNodeRect(row).width + 42))
);
const entries = rows.flatMap((row) => row.children.filter((node) => !isSummaryNode(node)));
const columnWidth = Math.min(
300,
Math.max(170, ...entries.map((entry) => specialNodeRect(entry).width + 34))
);
const tableX = sourceRoot.x;
const tableY = sourceRoot.y;
const tableWidth = rowHeaderWidth + columns.length * columnWidth;
const rowGeometry: NonNullable<SpecialLayoutNode["tableGrid"]>["rows"] = [];
let y = tableY + headerHeight + columnHeaderHeight;
placeInTableCell(root, {
x: tableX,
y: tableY,
width: tableWidth,
height: headerHeight,
depth: 0,
branch: -1,
});
rows.forEach((row, branch) => {
const children = row.children.filter((node) => !isSummaryNode(node));
const buckets = columns.map(() => [] as SpecialLayoutNode[]);
let fallback = 0;
for (const child of children) {
const labels = labelsOf(child);
const labelled = labels
.map((label) => columns.indexOf(label))
.find((index) => index >= 0);
const column = labelled ?? fallback++ % columns.length;
buckets[column].push(child);
}
const count = Math.max(1, ...buckets.map((bucket) => bucket.length));
const rowHeight = Math.max(88, 18 + count * 42);
rowGeometry.push({ y, height: rowHeight, branch });
placeInTableCell(row, {
x: tableX,
y,
width: rowHeaderWidth,
height: rowHeight,
depth: 1,
branch,
});
buckets.forEach((bucket, column) => {
bucket.forEach((child, index) => {
placeInTableCell(child, {
x: tableX + rowHeaderWidth + column * columnWidth + 8,
y: y + 8 + index * 42,
width: columnWidth - 16,
height: 34,
depth: 2,
branch,
});
});
});
y += rowHeight;
});
root.tableGrid = {
kind: "spreadsheet",
x: tableX,
y: tableY,
width: tableWidth,
height: y - tableY,
columns,
headerHeight,
columnHeaderHeight,
rowHeaderWidth,
columnWidth,
rows: rowGeometry,
};
}
/** Pack whole subtrees in alternating rows, preserving their native local layout. */
export function arrangeSpecialMind(
root: SpecialLayoutNode,
kind: SpecialMindLayout
) {
if (kind === "brace") return;
if (kind === "treeTable" || kind === "treeTableSide") {
arrangeTreeTable(root, kind === "treeTable" ? "top" : "side");
return;
}
if (kind === "spreadsheet") {
arrangeSpreadsheet(root);
return;
}
const center = specialNodeRect(root);
const axisY = center.y + center.height / 2;
let columnX = center.x + center.width + 110;
const branches = root.children.filter((node) => !isSummaryNode(node));
if (kind === "verticalTimeline") {
const axisX = center.x + center.width / 2;
let rowY = center.y + center.height + 70;
branches.forEach((node, index) => {
const bounds = specialSubtreeBounds(node);
translate(node, axisX + 70 - bounds.x, rowY - bounds.y);
if (index % 2 === 1) mirrorHorizontally(node, axisX);
rowY += bounds.height + 40;
});
} else
for (let i = 0; i < branches.length; i += 2) {
const pair = branches.slice(i, i + 2);
let columnWidth = 0;
pair.forEach((node, side) => {
const bounds = specialSubtreeBounds(node);
const stagger =
side === 1
? kind === "fishbone"
? 48
: Math.max(80, bounds.width * 0.55)
: 0;
const y = side === 0 ? axisY - 76 - bounds.height : axisY + 76;
translate(node, columnX + stagger - bounds.x, y - bounds.y);
columnWidth = Math.max(columnWidth, bounds.width + stagger);
});
columnX += columnWidth + 100;
}
// Root-level summaries follow the full geometry of their covered branches.
let summaryX =
Math.max(
center.x + center.width,
...branches.map((node) => {
const rect = specialSubtreeBounds(node);
return rect.x + rect.width;
})
) + 80;
for (const node of root.children.filter(isSummaryNode)) {
const covered = branches
.slice(node.origin.start, Number(node.origin.end) + 1)
.map(specialSubtreeBounds);
if (!covered.length) continue;
const top = Math.min(...covered.map((rect) => rect.y));
const bottom = Math.max(...covered.map((rect) => rect.y + rect.height));
const rect = specialNodeRect(node);
translate(
node,
summaryX - rect.x,
(top + bottom - rect.height) / 2 - rect.y
);
summaryX += specialSubtreeBounds(node).width + 60;
}
}

View File

@ -0,0 +1,128 @@
import type { PlaitElement } from "@plait/core";
export type TopicAttachment = { name: string; url: string; mimeType: string };
export type TopicDetails = {
notes: string;
labels: string[];
href: string;
topicLinkId: string;
attachment: TopicAttachment | null;
};
export type TopicEntry = {
element: PlaitElement;
title: string;
depth: number;
path: number[];
};
const record = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
export function getTopicTitle(element: PlaitElement): string {
const visit = (value: unknown): string => {
const item = record(value);
if (typeof item.text === "string") return item.text;
return Array.isArray(item.children)
? item.children.map(visit).join("")
: "";
};
return visit(record(element.data).topic) || "未命名主题";
}
export function getTopicEntries(
elements: PlaitElement[],
depth = 0,
parentPath: number[] = []
): TopicEntry[] {
return elements.flatMap((element, index) => {
if (!["mind", "mindmap", "mind_child"].includes(String(element.type)))
return [];
return [
{
element,
title: getTopicTitle(element),
depth,
path: [...parentPath, index],
},
...getTopicEntries(element.children || [], depth + 1, [
...parentPath,
index,
]),
];
});
}
export function getTopicDetails(element: PlaitElement | null): TopicDetails {
const data = record(element?.xmind);
const notes =
typeof data.notes === "string"
? data.notes
: record(record(data.notes).plain).content;
const labels = Array.isArray(data.labels)
? data.labels
: typeof data.labels === "string"
? [data.labels]
: [];
const htmlParagraphs = record(
record(record(data.notes).html).content
).paragraphs;
const htmlText = Array.isArray(htmlParagraphs)
? htmlParagraphs
.map((paragraph) => {
const spans = record(paragraph).spans;
return Array.isArray(spans)
? spans.map((span) => String(record(span).text || "")).join("")
: "";
})
.join("\n")
: "";
const attachment = record(data.attachment);
const safeAttachment =
typeof attachment.url === "string" &&
/^data:[a-z0-9.+/-]+;base64,[a-z0-9+/=]*$/i.test(attachment.url) &&
typeof attachment.name === "string"
? (attachment as TopicAttachment)
: null;
return {
attachment: safeAttachment,
notes: typeof notes === "string" ? notes : htmlText,
labels: [
...new Set(
labels
.filter(
(label): label is string =>
typeof label === "string" && Boolean(label.trim())
)
.map((label) => label.trim())
),
],
href: typeof data.href === "string" ? data.href : "",
topicLinkId: typeof data.topicLinkId === "string" ? data.topicLinkId : "",
};
}
export function patchTopicDetails(
element: PlaitElement,
patch: Partial<TopicDetails>
) {
const data = { ...record(element.xmind) };
for (const [key, value] of Object.entries(patch)) {
if (key === "notes")
data.notes = value ? { plain: { content: value } } : undefined;
else data[key] = value;
}
return data;
}
export function getSafeTopicHref(href: string): string | null {
if (!href.trim()) return null;
try {
const url = new URL(href.trim());
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol)
? url.href
: null;
} catch {
return null;
}
}

View File

@ -0,0 +1,355 @@
import type { PlaitElement } from "@plait/core";
import {
getTopicDetails,
getTopicEntries,
getTopicTitle,
} from "./topic-details";
import { isMindAnnotation } from "./mind-annotations";
import {
SPECIAL_MIND_LAYOUTS,
isSpecialMindLayout,
} from "./mind-special-layout";
type Data = Record<string, unknown>;
type Sheet = { id: string; title: string; children: PlaitElement[] };
export type XmindExportSnapshot = {
activeSheetId?: string;
children: PlaitElement[];
sheets?: Sheet[];
};
const record = (value: unknown): Data =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Data)
: {};
const array = (value: unknown): Data[] =>
Array.isArray(value) ? value.map(record) : [];
const id = () => crypto.randomUUID();
const layouts: Record<string, string> = {
standard: "org.xmind.ui.map.unbalanced",
right: "org.xmind.ui.logic.right",
left: "org.xmind.ui.logic.left",
downward: "org.xmind.ui.org-chart.down",
upward: "org.xmind.ui.org-chart.up",
"right-bottom-indented": "org.xmind.ui.tree.right",
"left-bottom-indented": "org.xmind.ui.tree.left",
};
const markerIds: Record<string, string> = {
"✅": "task-done",
"▶️": "task-start",
"🚩": "flag-red",
"⭐": "star-red",
...Object.fromEntries(
[1, 2, 3, 4, 5, 6, 7].map((n) => [`${n}️⃣`, `priority-${n}`])
),
};
const summary = (element: PlaitElement) =>
Number.isInteger(element.start) && Number.isInteger(element.end);
function textOf(value: unknown): string {
const item = record(value);
return typeof item.text === "string"
? item.text
: array(item.children).map(textOf).join("");
}
/** Modern XMind ZIP model: https://github.com/xmindltd/xmind-model/tree/master/schemas */
export async function exportXmind(
snapshot: XmindExportSnapshot,
title: string
) {
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
const warnings = new Set<string>();
const entries: Record<string, object> = {
"content.json": {},
"metadata.json": {},
};
let resourceNumber = 0;
const embed = async (url: string, name: string) => {
if (!/^(data:|https?:)/i.test(url))
throw new Error(`无法导出资源:${name}`);
const response = await fetch(url);
if (!response.ok)
throw new Error(`无法读取资源:${name},请确认图片仍可访问`);
const bytes = new Uint8Array(await response.arrayBuffer());
const safeName =
name.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_").slice(-120) || "resource";
const path = `resources/${++resourceNumber}/${safeName}`;
zip.file(path, bytes);
entries[path] = {};
return `xap:${path}`;
};
const sourceSheets = snapshot.sheets?.length
? snapshot.sheets
: [{ id: "sheet-1", title, children: snapshot.children }];
const sheets = [];
for (const source of sourceSheets) {
const elements =
source.id === (snapshot.activeSheetId || sourceSheets[0].id)
? snapshot.children
: source.children;
const topics = getTopicEntries(elements);
const ids = new Map(topics.map(({ element }) => [element.id, id()]));
const converted = new Map<string, Data>();
const parents = new Map<string, PlaitElement>();
const build = async (element: PlaitElement): Promise<Data> => {
const data = record(element.data);
const details = getTopicDetails(element);
const metadata = record(element.xmind);
const topic: Data = {
id: ids.get(element.id),
class: "topic",
title: getTopicTitle(element),
};
converted.set(element.id, topic);
if (element.isCollapsed) topic.branch = "folded";
if (element.layout) {
topic.structureClass = layouts[String(element.layout)] || layouts.right;
if (!layouts[String(element.layout)])
warnings.add("部分布局已转换为向右逻辑图");
}
if (isSpecialMindLayout(element.specialLayout))
topic.structureClass =
SPECIAL_MIND_LAYOUTS[element.specialLayout].structure;
if (element.specialLayout === "spreadsheet") {
const configured = Array.isArray(element.spreadsheetColumns)
? element.spreadsheetColumns.filter(
(column): column is string =>
typeof column === "string" && Boolean(column.trim())
)
: [];
const derived = (element.children || []).flatMap(
(child) => getTopicDetails(child).labels
);
const columns = [
...new Set(configured.length ? configured : derived),
];
if (!columns.length) columns.push("列 1", "列 2", "列 3");
topic.extensions = [
{
provider: "org.xmind.ui.spreadsheet",
content: [
{
name: "columns",
content: columns.map((column) => ({
name: "column",
content: column,
})),
},
],
},
];
}
if (
element.layout === "standard" &&
typeof element.rightNodeCount === "number"
)
topic.extensions = [
{
provider: topic.structureClass,
content: [
{ name: "right-number", content: String(element.rightNodeCount) },
],
},
];
const leaves = array(record(data.topic).children);
const leaf = leaves[0] || {};
if (leaves.length > 1) warnings.add("主题内混合文字格式已按首段样式导出");
const properties: Record<string, string> = {};
const copy = (key: string, value: unknown) => {
if (typeof value === "string" || typeof value === "number")
properties[key] = String(value);
};
copy("svg:fill", element.fill);
copy("border-line-color", element.strokeColor);
copy("border-line-width", element.strokeWidth);
copy("line-color", element.branchColor);
copy("line-width", element.branchWidth);
if (element.branchShape)
properties["line-class"] =
element.branchShape === "polyline"
? "org.xmind.branchConnection.elbow"
: "org.xmind.branchConnection.curve";
if (element.shape)
properties["shape-class"] =
element.shape === "underline"
? "org.xmind.topicShape.underline"
: "org.xmind.topicShape.roundedRect";
copy("fo:color", leaf.color);
copy("fo:font-size", leaf["font-size"]);
copy("fo:font-family", leaf["font-family"]);
if (leaf.bold) properties["fo:font-weight"] = "bold";
if (leaf.italic) properties["fo:font-style"] = "italic";
if (leaf.underlined || leaf.underline)
properties["fo:text-decoration"] = "underline";
if (Object.keys(properties).length)
topic.style = { id: id(), properties };
if (details.notes)
topic.notes =
typeof metadata.notes === "object"
? metadata.notes
: { plain: { content: details.notes } };
if (details.labels.length) topic.labels = details.labels;
const markerSet = new Set<string>();
for (const emoji of array(data.emojis)) {
const marker = markerIds[String(emoji.name)];
if (marker) markerSet.add(marker);
else warnings.add("部分自定义图标尚不能写入 XMind 标记");
}
// Retain imported markers that this editor cannot render, but honor removal of supported ones.
for (const marker of array(metadata.markers))
if (
typeof marker.markerId === "string" &&
!Object.values(markerIds).includes(marker.markerId)
)
markerSet.add(marker.markerId);
if (markerSet.size)
topic.markers = [...markerSet].map((markerId) => ({ markerId }));
if (details.topicLinkId && ids.has(details.topicLinkId))
topic.href = `xmind:#${ids.get(details.topicLinkId)}`;
else if (details.href) topic.href = details.href;
if (details.attachment) {
if (topic.href)
warnings.add("同时含附件和链接的主题优先导出附件,其他链接暂不导出");
topic.href = await embed(
details.attachment.url,
details.attachment.name
);
}
const picture = record(data.image);
if (typeof picture.url === "string") {
const mime =
picture.url.match(/^data:image\/(png|jpeg|gif|webp)/)?.[1] || "png";
topic.image = {
src: await embed(picture.url, `image.${mime}`),
width: picture.width,
height: picture.height,
};
}
const attached = (element.children || []).filter(
(child) => !summary(child)
);
const summaries = (element.children || []).filter(summary);
const groups: Data = {};
for (const child of attached) parents.set(child.id, element);
if (attached.length)
groups.attached = await Promise.all(attached.map(build));
if (summaries.length) {
groups.summary = await Promise.all(summaries.map(build));
topic.summaries = summaries.map((child) => ({
id: id(),
topicId: ids.get(child.id),
range: `(${child.start},${child.end})`,
}));
}
if (Object.keys(groups).length) topic.children = groups;
return topic;
};
const roots = elements.filter((element) =>
["mind", "mindmap"].includes(String(element.type))
);
if (!roots.length)
throw new Error(`“${source.title}”没有可导出的思维导图主题`);
const root = await build(roots[0]);
if (roots.length > 1) {
const detached = [];
const origin = Array.isArray(roots[0].points)
? (roots[0].points[0] as number[])
: [0, 0];
for (const element of roots.slice(1)) {
const topic = await build(element);
const point = Array.isArray(element.points)
? (element.points[0] as number[])
: [500, 0];
topic.position = { x: point[0] - origin[0], y: point[1] - origin[1] };
detached.push(topic);
}
root.children = { ...record(root.children), detached };
}
const relationships = [];
for (const element of elements) {
if (roots.includes(element)) continue;
if (!isMindAnnotation(element)) {
warnings.add("自由画布中的图形、文本和独立图片暂不导出到 XMind");
continue;
}
const annotation = element.mindAnnotation;
const style = {
id: id(),
properties: {
"line-color": String(element.strokeColor || "#64748b"),
"line-width": String(element.strokeWidth || 2),
},
};
if (annotation.kind === "relationship") {
if (!ids.has(annotation.sourceId) || !ids.has(annotation.targetId)) {
warnings.add("目标缺失的联系线未导出");
continue;
}
relationships.push({
id: id(),
end1Id: ids.get(annotation.sourceId),
end2Id: ids.get(annotation.targetId),
title: array(element.texts)
.map((item) => textOf(item.text))
.join("\n"),
style,
controlPoints: {
"0": { angle: 0, amount: 0.1 },
"1": { angle: 0, amount: 0.1 },
},
});
} else {
const parent = parents.get(annotation.topicIds[0]);
const siblings =
parent?.children?.filter((child) => !summary(child)) || [];
const indices = annotation.topicIds
.map((topicId) => siblings.findIndex((child) => child.id === topicId))
.sort((a, b) => a - b);
if (
!parent ||
!indices.length ||
indices[0] < 0 ||
indices.at(-1)! - indices[0] + 1 !== indices.length
) {
warnings.add("非连续同级主题的外框暂不导出");
continue;
}
const owner = converted.get(parent.id)!;
owner.boundaries = [
...array(owner.boundaries),
{
id: id(),
range: `(${indices[0]},${indices.at(-1)})`,
title: String(record(element.xmind).title || ""),
style,
},
];
}
}
sheets.push({
id: id(),
class: "sheet",
title: source.title,
rootTopic: root,
relationships,
});
}
zip.file("content.json", JSON.stringify(sheets));
zip.file(
"metadata.json",
JSON.stringify({
dataStructureVersion: "2",
creator: { name: "SnapGen", version: "1.0" },
layoutEngineVersion: "4",
})
);
zip.file("manifest.json", JSON.stringify({ "file-entries": entries }));
return {
blob: await zip.generateAsync({
type: "blob",
compression: "DEFLATE",
mimeType: "application/vnd.xmind.workbook",
}),
warnings: [...warnings],
};
}

View File

@ -0,0 +1,657 @@
import type JSZip from "jszip";
import { getTopicEntries } from "./topic-details";
import {
SPECIAL_MIND_LAYOUTS,
specialLayoutForStructure,
} from "./mind-special-layout";
import type { PlaitElement } from "@plait/core";
import {
ArrowLineMarkerType,
ArrowLineShape,
BasicShapes,
createArrowLineElement,
createGeometryElementWithoutText,
} from "@plait/draw";
import { MindLayoutType } from "@plait/layouts";
import {
createMindElement,
BranchShape,
MindElementShape,
type MindElement,
type PlaitMind,
} from "@plait/mind";
export class MindMapImportError extends Error {}
export type ImportedMindMap = {
sheets: { id: string; title: string; children: PlaitElement[] }[];
warnings: string[];
};
type Data = Record<string, unknown>;
const record = (value: unknown): Data =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Data)
: {};
const list = (value: unknown): Data[] =>
Array.isArray(value)
? value.filter(
(item) => item && typeof item === "object" && !Array.isArray(item)
)
: [];
const text = (value: unknown, fallback = "") =>
typeof value === "string" ? value.trim() || fallback : fallback;
const children = (element: Element, name: string) =>
Array.from(element.children).filter((child) => child.localName === name);
const first = (element: Element, name: string) => children(element, name)[0];
const color = (value: unknown) =>
typeof value === "string" && /^#[\da-f]{3,8}$/i.test(value)
? value
: undefined;
function spreadsheetColumns(topic: Data) {
const extension = list(topic.extensions).find(
(item) => item.provider === "org.xmind.ui.spreadsheet"
);
const result: string[] = [];
const visit = (value: unknown) => {
if (Array.isArray(value)) {
value.forEach(visit);
return;
}
const item = record(value);
if (item.name === "column") {
const value = text(item.content);
if (value) result.push(value);
return;
}
Object.values(item).forEach(visit);
};
visit(extension?.content);
return [...new Set(result)];
}
// XMind's public model: https://github.com/xmindltd/xmind-model/tree/master/schemas
const layouts: Record<string, MindLayoutType> = {
"org.xmind.ui.map.unbalanced": MindLayoutType.standard,
"org.xmind.ui.map.clockwise": MindLayoutType.standard,
"org.xmind.ui.logic.right": MindLayoutType.right,
"org.xmind.ui.logic.left": MindLayoutType.left,
"org.xmind.ui.org-chart.down": MindLayoutType.downward,
"org.xmind.ui.org-chart.up": MindLayoutType.upward,
"org.xmind.ui.tree.right": MindLayoutType.rightBottomIndented,
"org.xmind.ui.tree.left": MindLayoutType.leftBottomIndented,
};
function parseXml(source: string) {
const doc = new DOMParser().parseFromString(source, "application/xml");
if (doc.getElementsByTagName("parsererror").length)
throw new MindMapImportError("XMind 文件中的 XML 内容已损坏");
return doc;
}
function xmlSheets(source: string, stylesSource?: string): Data[] {
const styles = new Map<string, Data>();
if (stylesSource) {
for (const element of Array.from(
parseXml(stylesSource).getElementsByTagNameNS("*", "style")
)) {
const properties: Data = {};
for (const property of Array.from(element.children)) {
for (const attribute of Array.from(property.attributes))
properties[attribute.name] = attribute.value;
}
styles.set(element.getAttribute("id") || "", properties);
}
}
const style = (element: Element) => ({
properties: styles.get(element.getAttribute("style-id") || "") || {},
});
const convert = (element: Element, depth = 0): Data => {
if (depth > 128)
throw new MindMapImportError("主题层级超过 128 层,请拆分后导入");
const childGroups: Data = {};
const container = first(element, "children");
if (container)
for (const group of children(container, "topics")) {
const kind = group.getAttribute("type") || "attached";
childGroups[kind] = children(group, "topic").map((child) =>
convert(child, depth + 1)
);
}
const collection = (containerName: string, itemName: string) => {
const parent = first(element, containerName);
return parent
? children(parent, itemName).map((item) => ({
...Object.fromEntries(
Array.from(item.attributes).map((attr) => [attr.name, attr.value])
),
title: first(item, "title")?.textContent || "",
style: style(item),
topicId: item.getAttribute("topic-id"),
}))
: [];
};
const notes = first(element, "notes");
const plainNote = notes && first(notes, "plain");
const labels = first(element, "labels");
const image =
first(element, "image") ||
Array.from(element.children).find((child) => child.localName === "img");
const position = first(element, "position");
return {
id: element.getAttribute("id"),
title: first(element, "title")?.textContent,
structureClass: element.getAttribute("structure-class"),
branch: element.getAttribute("branch"),
children: childGroups,
style: style(element),
notes: (plainNote || notes)?.textContent?.trim() || undefined,
href:
element.getAttributeNS("http://www.w3.org/1999/xlink", "href") ||
undefined,
labels: labels
? children(labels, "label")
.map((label) => label.textContent || "")
.filter(Boolean)
: undefined,
image: image
? {
src:
image.getAttribute("src") ||
image.getAttributeNS("http://www.w3.org/1999/xlink", "href"),
width: image.getAttribute("width"),
height: image.getAttribute("height"),
}
: undefined,
position: position
? {
x: Number(position.getAttribute("x")),
y: Number(position.getAttribute("y")),
}
: undefined,
summaries: collection("summaries", "summary"),
boundaries: collection("boundaries", "boundary"),
markers: collection("marker-refs", "marker-ref").map((marker) => ({
markerId: record(marker)["marker-id"],
})),
extensions: first(element, "extensions")
? Array.from(first(element, "extensions")!.children).map(
(extension) => {
const extensionItem = (item: Element): Data => ({
name: item.localName,
content: item.children.length
? Array.from(item.children).map(extensionItem)
: item.textContent,
});
return {
provider: extension.getAttribute("provider"),
content: Array.from(extension.children).map(extensionItem),
};
}
)
: [],
};
};
const doc = parseXml(source);
return Array.from(doc.getElementsByTagNameNS("*", "sheet")).map((sheet) => {
const root = first(sheet, "topic");
const relationships = first(sheet, "relationships");
return {
title: first(sheet, "title")?.textContent,
rootTopic: root ? convert(root) : undefined,
relationships: relationships
? children(relationships, "relationship").map((item) => ({
end1Id: item.getAttribute("end1"),
end2Id: item.getAttribute("end2"),
title: first(item, "title")?.textContent,
style: style(item),
}))
: [],
};
});
}
function parseRange(value: unknown, count: number) {
const match = text(value).match(/^\((\d+)\s*,\s*(\d+)\)$/);
if (!match) return null;
const start = Number(match[1]);
const end = Number(match[2]);
return start <= end && end < count ? { start, end } : null;
}
function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
const warnings = new Set<string>();
let nodeCount = 0;
if (
!sheets.length ||
sheets.some((sheet) => !Object.keys(record(sheet.rootTopic)).length)
) {
throw new MindMapImportError("XMind 文件没有有效的工作表或中心主题");
}
return {
sheets: sheets.map((sheet, index) => {
const topicIds = new Map<string, string>();
const floating: PlaitElement[] = [];
const annotations: PlaitElement[] = [];
const theme = record(sheet.theme);
const build = (
topic: Data,
depth: number,
root = false,
floatingRoot = false
): MindElement => {
if (++nodeCount > 10_000 || depth > 128)
throw new MindMapImportError(
"文件超过 10000 个主题或 128 层,请拆分后导入"
);
const element = createMindElement(text(topic.title, "未命名主题"), {});
if (typeof topic.id === "string") topicIds.set(topic.id, element.id);
const groups = Array.isArray(topic.children)
? { attached: topic.children }
: record(topic.children);
const structure = text(topic.structureClass);
const specialLayout = specialLayoutForStructure(structure);
if (specialLayout) element.specialLayout = specialLayout;
if (root && specialLayout === "spreadsheet") {
const columns = spreadsheetColumns(topic);
if (columns.length) element.spreadsheetColumns = columns;
}
if (specialLayout && !root) warnings.add("子树的特殊结构暂未单独排布");
if (structure && !layouts[structure] && !specialLayout)
warnings.add("部分特殊结构已转换为逻辑图");
if (structure || root)
element.layout = specialLayout
? (SPECIAL_MIND_LAYOUTS[specialLayout].base as MindLayoutType)
: layouts[structure] || MindLayoutType.right;
element.isCollapsed = topic.branch === "folded";
const properties = {
...record(
record(
theme[
floatingRoot
? "floatingTopic"
: root
? "centralTopic"
: depth === 1
? "mainTopic"
: "subTopic"
]
).properties
),
...record(record(topic.style).properties),
};
const fill = color(properties["svg:fill"]);
if (fill) element.fill = fill;
const stroke = color(properties["border-line-color"]);
if (stroke) element.strokeColor = stroke;
const branch = color(properties["line-color"]);
if (branch) element.branchColor = branch;
const borderWidth = Number.parseFloat(
String(properties["border-line-width"] || "")
);
if (Number.isFinite(borderWidth))
element.strokeWidth = Math.max(0, Math.min(16, borderWidth));
const branchWidth = Number.parseFloat(
String(properties["line-width"] || "")
);
if (Number.isFinite(branchWidth))
element.branchWidth = Math.max(0, Math.min(16, branchWidth));
if (properties["line-class"] === "org.xmind.branchConnection.elbow")
element.branchShape = BranchShape.polyline;
else if (
properties["line-class"] === "org.xmind.branchConnection.curve"
)
element.branchShape = BranchShape.bight;
const shape = text(properties["shape-class"]);
if (shape.endsWith(".underline"))
element.shape = MindElementShape.underline;
else if (shape.endsWith(".roundedRect"))
element.shape = MindElementShape.roundRectangle;
const fontSize = Number.parseFloat(
String(properties["fo:font-size"] || "")
);
const leaf = {
text: text(topic.title, "未命名主题"),
...(color(properties["fo:color"])
? { color: color(properties["fo:color"]) }
: {}),
...(Number.isFinite(fontSize)
? { "font-size": Math.max(8, Math.min(96, fontSize)) }
: {}),
...(properties["fo:font-weight"] === "bold" ||
Number(properties["fo:font-weight"]) >= 600
? { bold: true }
: {}),
...(properties["fo:font-style"] === "italic" ? { italic: true } : {}),
...(properties["fo:text-decoration"] === "underline"
? { underlined: true }
: {}),
...(typeof properties["fo:font-family"] === "string"
? { "font-family": properties["fo:font-family"] }
: {}),
};
element.data.topic.children = [leaf];
const metadata: Data = {};
for (const key of ["notes", "href", "labels", "markers", "image"])
if (topic[key] !== undefined) metadata[key] = topic[key];
if (Object.keys(metadata).length) element.xmind = metadata;
const emojis: string[] = [];
for (const marker of list(topic.markers)) {
const id = text(marker.markerId);
const priority = id.match(/^priority-([1-7])$/);
const mapped = priority
? `${priority[1]}️⃣`
: (
{
"task-done": "✅",
"task-start": "▶️",
"flag-red": "🚩",
"star-red": "⭐",
} as Record<string, string>
)[id];
if (mapped) emojis.push(mapped);
else warnings.add("部分图标已保留为数据,暂不显示");
}
if (emojis.length)
element.data.emojis = emojis.map((name) => ({ name }));
element.children = list(groups.attached).map((child) =>
build(child, depth + 1)
);
const attachedCount = element.children.length;
if (element.layout === MindLayoutType.standard) {
const extension = list(topic.extensions).find(
(item) => item.provider === structure
);
const right = list(extension?.content).find(
(item) => item.name === "right-number"
);
const n = Number(right?.content ?? Math.ceil(attachedCount / 2));
element.rightNodeCount = Number.isInteger(n)
? Math.max(0, Math.min(attachedCount, n))
: Math.ceil(attachedCount / 2);
}
const usedSummaries = new Set<string>();
for (const summary of list(topic.summaries)) {
const range = parseRange(summary.range, attachedCount);
const summaryTopic = list(groups.summary).find(
(item) => item.id === summary.topicId
);
if (range && summaryTopic) {
const child = build(summaryTopic, depth + 1);
child.start = range.start;
child.end = range.end;
element.children.push(child);
usedSummaries.add(text(summaryTopic.id));
} else warnings.add("部分概要范围无法还原");
}
for (const boundary of list(topic.boundaries)) {
const range = parseRange(boundary.range, attachedCount);
if (!range) {
warnings.add("部分外框范围无法还原");
continue;
}
const props = record(record(boundary.style).properties);
const shape = createGeometryElementWithoutText(
BasicShapes.roundRectangle,
[
[0, 0],
[1, 1],
],
{
fill: "transparent",
strokeColor: color(props["line-color"]) || "#8b9bb8",
strokeWidth: 2,
}
);
annotations.push({
...shape,
mindAnnotation: {
kind: "boundary",
topicIds: element.children
.slice(range.start, range.end + 1)
.map((child) => child.id),
padding: 18,
},
xmind: { title: boundary.title },
});
if (boundary.title) warnings.add("外框标题已保留为数据,暂不显示");
}
const extraTopics = [
...list(groups.detached),
...list(groups.callout),
...list(groups.summary).filter(
(item) => !usedSummaries.has(text(item.id))
),
];
if (list(groups.callout).length)
warnings.add("标注主题已转换为自由主题");
for (const extra of extraTopics) {
const detached = build(extra, depth + 1, true, true) as PlaitMind;
const pos = record(extra.position);
detached.points = [
[
typeof pos.x === "number" && Number.isFinite(pos.x) ? pos.x : 500,
typeof pos.y === "number" && Number.isFinite(pos.y)
? pos.y
: floating.length * 180,
],
];
floating.push(detached);
}
if (root) {
element.type = "mindmap";
element.isRoot = true;
element.points = [[0, 0]];
}
return element;
};
const root = build(record(sheet.rootTopic), 0, true);
for (const { element } of getTopicEntries([root, ...floating])) {
const metadata = record(element.xmind);
if (
typeof metadata.href === "string" &&
metadata.href.startsWith("xmind:#")
) {
const targetId = topicIds.get(metadata.href.slice(7));
if (targetId)
element.xmind = { ...metadata, href: "", topicLinkId: targetId };
else warnings.add("部分主题链接的目标不在当前画布,暂未还原");
}
}
for (const relationship of list(sheet.relationships)) {
const sourceId = topicIds.get(text(relationship.end1Id));
const targetId = topicIds.get(text(relationship.end2Id));
if (!sourceId || !targetId) {
warnings.add("部分联系线的目标主题缺失,未能还原");
continue;
}
const label = {
type: "paragraph",
children: [{ text: text(relationship.title) }],
};
const props = record(record(relationship.style).properties);
const shape = createArrowLineElement(
ArrowLineShape.curve,
[
[0, 0],
[1, 1],
],
{ marker: ArrowLineMarkerType.none },
{ marker: ArrowLineMarkerType.arrow },
relationship.title ? [{ position: 0.5, text: label }] : [],
{
strokeColor: color(props["line-color"]) || "#64748b",
strokeWidth: 2,
}
);
annotations.push({
...shape,
mindAnnotation: { kind: "relationship", sourceId, targetId },
});
}
return {
id: `xmind-${crypto.randomUUID()}`,
title: text(
sheet.title,
sheets.length === 1 ? fallbackTitle : `画布 ${index + 1}`
),
children: [
...annotations.filter((item) => item.type === "geometry"),
root,
...floating,
...annotations.filter((item) => item.type !== "geometry"),
],
};
}),
warnings: [...warnings],
};
}
async function restoreResources(
result: ImportedMindMap,
zip: JSZip
): Promise<ImportedMindMap> {
const warnings = new Set(result.warnings);
const cache = new Map<string, { url: string; bytes: Uint8Array }>();
let totalBytes = 0;
const readResource = async (source: string) => {
const path = decodeURIComponent(
source.replace(/^xap:/, "").replace(/^\//, "")
);
if (/^[a-z]+:/i.test(path) || path.split("/").includes("..")) return null;
const file =
zip.file(path) ||
Object.values(zip.files).find(
(entry) => !entry.dir && entry.name.endsWith(`/${path}`)
);
if (!file) return null;
if (cache.has(file.name)) return cache.get(file.name)!;
const bytes = await file.async("uint8array");
totalBytes += bytes.length;
if (bytes.length > 8 * 1024 * 1024 || totalBytes > 24 * 1024 * 1024) {
warnings.add("部分内嵌资源超过大小限制,暂未导入");
return null;
}
const value = { bytes, url: await file.async("base64") };
cache.set(file.name, value);
return value;
};
for (const sheet of result.sheets)
for (const { element } of getTopicEntries(sheet.children)) {
const metadata = record(element.xmind);
const image = record(metadata.image);
if (typeof image.src === "string") {
try {
const resource = await readResource(image.src);
if (!resource) throw new Error("Missing image");
const b = resource.bytes;
const mime =
b[0] === 137 && b[1] === 80
? "image/png"
: b[0] === 255 && b[1] === 216
? "image/jpeg"
: String.fromCharCode(...b.slice(0, 3)) === "GIF"
? "image/gif"
: String.fromCharCode(...b.slice(0, 4)) === "RIFF" &&
String.fromCharCode(...b.slice(8, 12)) === "WEBP"
? "image/webp"
: null;
if (!mime) throw new Error("Unsupported image");
const bitmap = await createImageBitmap(
new Blob([new Uint8Array(b)], { type: mime })
);
const requested = Number(image.width);
const width = Math.min(
500,
requested > 0 ? requested : Math.min(320, bitmap.width)
);
const height = (width * bitmap.height) / bitmap.width;
bitmap.close();
element.data = {
...record(element.data),
image: {
url: `data:${mime};base64,${resource.url}`,
width,
height,
},
};
} catch {
warnings.add("部分图片缺失、损坏或格式暂不支持");
}
}
if (
typeof metadata.href === "string" &&
metadata.href.startsWith("xap:")
) {
try {
const resource = await readResource(metadata.href);
if (!resource) throw new Error("Missing attachment");
const name = decodeURIComponent(
metadata.href.split("/").pop() || "附件"
);
element.xmind = {
...metadata,
href: "",
attachment: {
name,
mimeType: "application/octet-stream",
url: `data:application/octet-stream;base64,${resource.url}`,
},
};
} catch {
warnings.add("部分附件缺失或超过大小限制,暂未导入");
}
}
}
return { ...result, warnings: [...warnings] };
}
export async function importXmind(
file: File,
fallbackTitle: string
): Promise<ImportedMindMap> {
if (file.size > 50 * 1024 * 1024)
throw new MindMapImportError("XMind 文件超过 50 MB,请拆分后导入");
const { default: JSZip } = await import("jszip");
let zip;
try {
zip = await JSZip.loadAsync(await file.arrayBuffer());
} catch {
throw new MindMapImportError("无法打开 XMind 文件,请确认文件完整且未加密");
}
const entry = (name: string) =>
zip.file(name) ||
Object.values(zip.files).find(
(item) => !item.dir && item.name.toLowerCase().endsWith(`/${name}`)
);
const json = entry("content.json");
const xml = entry("content.xml");
try {
if (json) {
const parsed: unknown = JSON.parse(await json.async("string"));
const sheets = Array.isArray(parsed)
? list(parsed)
: list(record(parsed).sheets);
return await restoreResources(convertSheets(sheets, fallbackTitle), zip);
}
if (xml)
return await restoreResources(
convertSheets(
xmlSheets(
await xml.async("string"),
await entry("styles.xml")?.async("string")
),
fallbackTitle
),
zip
);
} catch (error) {
if (error instanceof MindMapImportError) throw error;
throw new MindMapImportError(
"XMind 内容解析失败,请在 XMind 中重新保存后再导入"
);
}
throw new MindMapImportError(
"文件中未找到 XMind 内容,请选择有效的 .xmind 文件"
);
}

View File

@ -209,7 +209,14 @@
},
"packageManager": "pnpm@9.12.3",
"pnpm": {
"patchedDependencies": {
"@drawnix/drawnix@0.4.0-2": "patches/@drawnix__drawnix@0.4.0-2.patch",
"@plait-board/react-text@0.4.0-2": "patches/@plait-board__react-text@0.4.0-2.patch"
},
"overrides": {
"@plait-board/react-text>slate": "$slate",
"@plait-board/react-text>slate-react": "$slate-react",
"@plait-board/react-text>slate-dom": "$slate-dom",
"sharp": "^0.34.5",
"fast-xml-parser": "^5.5.6",
"protobufjs": "^7.5.6",

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,46 @@
diff --git a/index.js b/index.js
index 9031a9a3b1a614785657dd35b23fed97daabbfe9..839c71d5d54c468a9a1209da21fa1b319a602fed 100644
--- a/index.js
+++ b/index.js
@@ -1,4 +1,4 @@
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const i=require("react/jsx-runtime"),c=require("slate"),g=require("is-hotkey"),f=require("slate-react"),x=require("react"),h=require("slate-history"),u=require("@plait/text-plugins"),C=t=>{const n=t,{insertData:r}=n;return n.insertBreak=()=>{t.insertText(`
`)},n.insertSoftBreak=()=>{t.insertText(`
`)},n.insertData=o=>{let e=o.getData("text/plain");if(!o.getData("application/x-slate-fragment")&&e){e.endsWith(`
-`)&&(e=e.substring(0,e.length-1)),e=e.trim().replace(/\t+/g," "),n.insertText(e);return}r(o)},n},y=()=>i.jsx("span",{contentEditable:!1,style:{fontSize:0},children:String.fromCodePoint(160)}),D=({attributes:t,children:n,element:r})=>i.jsxs("a",{...t,style:{textDecoration:"none",cursor:"inherit"},"data-url":r.url,className:"plait-board-link",children:[i.jsx(y,{}),n,i.jsx(y,{})]}),T=t=>{const{insertData:n,insertText:r,isInline:o}=t;return t.isInline=e=>e.type&&["link"].includes(e.type)||o(e),t.insertText=e=>{e&&u.isUrl(e)?u.LinkEditor.wrapLink(t,e,e):r(e)},t.insertData=e=>{const l=e.getData("text/plain");l&&u.isUrl(l)?u.LinkEditor.wrapLink(t,l,l):n(e)},t},b=t=>{const{text:n,readonly:r,onChange:o,onComposition:e,afterInit:l}=t,d=x.useCallback(s=>i.jsx(w,{...s}),[]),j=[n],a=x.useMemo(()=>{const s=T(C(h.withHistory(f.withReact(c.createEditor()))));return l&&l(s),s},[]);x.useEffect(()=>{n!==a.children[0]&&(a.children=[n],a.onChange())},[n,a]);const k=s=>{const{selection:p}=a;if(p&&c.Range.isCollapsed(p)){const{nativeEvent:m}=s;if(g.isKeyHotkey("left",m)){s.preventDefault(),c.Transforms.move(a,{unit:"offset",reverse:!0});return}if(g.isKeyHotkey("right",m)){s.preventDefault(),c.Transforms.move(a,{unit:"offset"});return}}};return i.jsx(f.Slate,{editor:a,initialValue:j,onChange:s=>{o&&o({newText:a.children[0],operations:a.operations})},children:i.jsx(f.Editable,{className:"slate-editable-container plait-text-container",renderElement:s=>i.jsx(E,{...s}),renderLeaf:d,readOnly:r===void 0?!0:r,onCompositionStart:s=>{e&&e(s)},onCompositionUpdate:s=>{e&&e(s)},onCompositionEnd:s=>{e&&e(s)},onKeyDown:k})})},E=t=>{const{attributes:n,children:r,element:o}=t;switch(o.type){case"link":return i.jsx(D,{...t});default:return i.jsx(v,{...t})}},v=({attributes:t,children:n,element:r})=>{const o={textAlign:r.align};return i.jsx("div",{style:o,...t,children:n})},w=({children:t,leaf:n,attributes:r})=>(n.bold&&(t=i.jsx("strong",{children:t})),n.code&&(t=i.jsx("code",{children:t})),n.italic&&(t=i.jsx("em",{children:t})),n.underlined&&(t=i.jsx("u",{children:t})),i.jsx("span",{style:{color:n.color},...r,children:t}));exports.Text=b;
+`)&&(e=e.substring(0,e.length-1)),e=e.trim().replace(/\t+/g," "),n.insertText(e);return}r(o)},n},y=()=>i.jsx("span",{contentEditable:!1,style:{fontSize:0},children:String.fromCodePoint(160)}),D=({attributes:t,children:n,element:r})=>i.jsxs("a",{...t,style:{textDecoration:"none",cursor:"inherit"},"data-url":r.url,className:"plait-board-link",children:[i.jsx(y,{}),n,i.jsx(y,{})]}),T=t=>{const{insertData:n,insertText:r,isInline:o}=t;return t.isInline=e=>e.type&&["link"].includes(e.type)||o(e),t.insertText=e=>{e&&u.isUrl(e)?u.LinkEditor.wrapLink(t,e,e):r(e)},t.insertData=e=>{const l=e.getData("text/plain");l&&u.isUrl(l)?u.LinkEditor.wrapLink(t,l,l):n(e)},t},b=t=>{const{text:n,readonly:r,onChange:o,onComposition:e,afterInit:l}=t,d=x.useCallback(s=>i.jsx(w,{...s}),[]),j=[n],a=x.useMemo(()=>{const s=T(C(h.withHistory(f.withReact(c.createEditor()))));return l&&l(s),s},[]);x.useEffect(()=>{if(r!==false)return;const frame=requestAnimationFrame(()=>{if(!f.ReactEditor.isReadOnly(a))f.ReactEditor.focus(a)});return()=>cancelAnimationFrame(frame)},[r,a]);x.useEffect(()=>{n!==a.children[0]&&(a.children=[n],a.onChange())},[n,a]);const k=s=>{const{selection:p}=a;if(p&&c.Range.isCollapsed(p)){const{nativeEvent:m}=s;if(g.isKeyHotkey("left",m)){s.preventDefault(),c.Transforms.move(a,{unit:"offset",reverse:!0});return}if(g.isKeyHotkey("right",m)){s.preventDefault(),c.Transforms.move(a,{unit:"offset"});return}}};return i.jsx(f.Slate,{editor:a,initialValue:j,onChange:s=>{o&&o({newText:a.children[0],operations:a.operations})},children:i.jsx(f.Editable,{className:"slate-editable-container plait-text-container",renderElement:s=>i.jsx(E,{...s}),renderLeaf:d,readOnly:r===void 0?!0:r,onCompositionStart:s=>{e&&e(s)},onCompositionUpdate:s=>{e&&e(s)},onCompositionEnd:s=>{e&&e(s)},onKeyDown:k})})},E=t=>{const{attributes:n,children:r,element:o}=t;switch(o.type){case"link":return i.jsx(D,{...t});default:return i.jsx(v,{...t})}},v=({attributes:t,children:n,element:r})=>{const o={textAlign:r.align};return i.jsx("div",{style:o,...t,children:n})},w=({children:t,leaf:n,attributes:r})=>(n.bold&&(t=i.jsx("strong",{children:t})),n.code&&(t=i.jsx("code",{children:t})),n.italic&&(t=i.jsx("em",{children:t})),n.underlined&&(t=i.jsx("u",{children:t})),i.jsx("span",{style:{color:n.color,fontSize:n["font-size"]?`${n["font-size"]}px`:void 0,lineHeight:n["font-size"]?1.5:void 0},...r,children:t}));exports.Text=b;
diff --git a/index.mjs b/index.mjs
index 888a6de26b311566472d986d20a9c7c44277a152..ab92f1215250a90ff58cc91641e228c7a8d2a8fc 100644
--- a/index.mjs
+++ b/index.mjs
@@ -1,7 +1,7 @@
import { jsxs as D, jsx as o } from "react/jsx-runtime";
import { createEditor as h, Range as C, Transforms as u } from "slate";
import { isKeyHotkey as m } from "is-hotkey";
-import { withReact as k, Slate as w, Editable as E } from "slate-react";
+import { withReact as k, Slate as w, Editable as E, ReactEditor as FocusEditor } from "slate-react";
import { useCallback as T, useMemo as b, useEffect as v } from "react";
import { withHistory as L } from "slate-history";
import { isUrl as p, LinkEditor as g } from "@plait/text-plugins";
@@ -60,6 +60,13 @@ const I = (t) => {
);
return l && l(i), i;
}, []);
+ v(() => {
+ if (r !== false) return;
+ const frame = requestAnimationFrame(() => {
+ if (!FocusEditor.isReadOnly(a)) FocusEditor.focus(a);
+ });
+ return () => cancelAnimationFrame(frame);
+ }, [r, a]);
return v(() => {
e !== a.children[0] && (a.children = [e], a.onChange());
}, [e, a]), /* @__PURE__ */ o(
@@ -127,7 +134,7 @@ const I = (t) => {
}) => {
const s = { textAlign: r.align };
return /* @__PURE__ */ o("div", { style: s, ...t, children: e });
-}, H = ({ children: t, leaf: e, attributes: r }) => (e.bold && (t = /* @__PURE__ */ o("strong", { children: t })), e.code && (t = /* @__PURE__ */ o("code", { children: t })), e.italic && (t = /* @__PURE__ */ o("em", { children: t })), e.underlined && (t = /* @__PURE__ */ o("u", { children: t })), /* @__PURE__ */ o("span", { style: { color: e.color }, ...r, children: t }));
+}, H = ({ children: t, leaf: e, attributes: r }) => (e.bold && (t = /* @__PURE__ */ o("strong", { children: t })), e.code && (t = /* @__PURE__ */ o("code", { children: t })), e.italic && (t = /* @__PURE__ */ o("em", { children: t })), e.underlined && (t = /* @__PURE__ */ o("u", { children: t })), /* @__PURE__ */ o("span", { style: { color: e.color, fontSize: e["font-size"] ? `${e["font-size"]}px` : undefined, lineHeight: e["font-size"] ? 1.5 : undefined }, ...r, children: t }));
export {
V as Text
};

35
patches/README.md Normal file
View File

@ -0,0 +1,35 @@
# Flow board dependency patches
## Text rendering and keyboard focus
`@plait-board/react-text@0.4.0-2` measures the Slate `font-size` mark but its
React leaf renderer ignores that mark. The patch applies the size and matching
line height to both ESM and CommonJS renderers.
New topics can mount directly in editable mode. The board renderer only focuses
an editor when it observes a readonly-to-editable transition, so these topics can
leave keyboard focus on the toolbar. The text component now focuses after an
editable render; the animation frame is cancelled on cleanup and readonly editors
are excluded.
The scoped Slate overrides in `package.json` align react-text with the editor's
existing Slate dependencies. Without them, react-text creates editors with
0.116 while react-board tries to focus/blur them with 0.124, causing a missing
DOM-node error when finishing text edits.
## Initial board plugins
`@drawnix/drawnix@0.4.0-2` now accepts an optional `plugins` prop, installed after
the built-in plugins and before the first board render. The special mind-map
layouts need custom root and node components from the first imported render.
Installing them in `afterInit` is too late: the original components already exist.
The patch updates declarations and both ESM/CommonJS builds.
Validate dependency upgrades with:
```
pnpm exec playwright test --config playwright.flow-board-ui.config.ts
```
The regression suite covers imported font sizes, topic editing and creation,
special structures on initial import, structure switches and undo/redo.

View File

@ -0,0 +1,23 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
outputDir: "test-results/flow-board-ui",
testDir: "./tests/flow-board",
testMatch: "*.spec.ts",
workers: 1,
retries: 0,
forbidOnly: !!process.env.CI,
timeout: 60_000,
use: {
baseURL: "http://127.0.0.1:3190",
viewport: { width: 1440, height: 900 },
trace: "retain-on-failure",
},
webServer: {
command:
"NEXT_DIST_DIR=.next/xmind-test pnpm exec next dev --webpack --port 3190",
url: "http://127.0.0.1:3190/dev/flow-board-preview",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

View File

@ -0,0 +1,12 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
outputDir: "test-results/flow-board-data",
testDir: "./tests",
testMatch: /routes\/flow-board-.*\.test\.ts/,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: 0,
workers: 2,
reporter: "list",
});

86
pnpm-lock.yaml generated
View File

@ -5,6 +5,9 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@plait-board/react-text>slate': ^0.124.0
'@plait-board/react-text>slate-react': ^0.124.0
'@plait-board/react-text>slate-dom': ^0.124.0
sharp: ^0.34.5
fast-xml-parser: ^5.5.6
protobufjs: ^7.5.6
@ -18,6 +21,14 @@ overrides:
lodash-es: ^4.18.1
langsmith: ^0.6.0
patchedDependencies:
'@drawnix/drawnix@0.4.0-2':
hash: uhnmjv63wpjkgoivl6d3xdgslu
path: patches/@drawnix__drawnix@0.4.0-2.patch
'@plait-board/react-text@0.4.0-2':
hash: zoloe5ob453p2uq2stpl2d6uya
path: patches/@plait-board__react-text@0.4.0-2.patch
importers:
.:
@ -60,7 +71,7 @@ importers:
version: 6.36.4
'@drawnix/drawnix':
specifier: 0.4.0-2
version: 0.4.0-2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
version: 0.4.0-2(patch_hash=uhnmjv63wpjkgoivl6d3xdgslu)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@fal-ai/client':
specifier: ^1.10.0
version: 1.10.0
@ -93,7 +104,7 @@ importers:
version: 0.4.0-2(immer@11.1.3)(is-hotkey@0.2.0)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@plait-board/react-text':
specifier: 0.4.0-2
version: 0.4.0-2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
version: 0.4.0-2(patch_hash=zoloe5ob453p2uq2stpl2d6uya)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@plait/common':
specifier: ^0.92.1
version: 0.92.3
@ -4860,9 +4871,6 @@ packages:
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.3:
resolution: {integrity: sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==}
@ -6331,11 +6339,6 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
slate-dom@0.116.0:
resolution: {integrity: sha512-ZyyPdT4zY4d/P/gfqMWBaAWWqV3N2BbAiDqEfOtZwPLcy7vvC02PsVvSZLaGun7DvaM2EIqdN7tTq3REbQkYgA==}
peerDependencies:
slate: '>=0.99.0'
slate-dom@0.124.0:
resolution: {integrity: sha512-dPabNlzo67xWRNFT5HtSc0M/F9mfiQSnjJAEx0Uzk7oC++WaRuBy897F+rNzfvM+zH9exMc3hsTMtM7jGDhIiQ==}
peerDependencies:
@ -6346,14 +6349,6 @@ packages:
peerDependencies:
slate: '>=0.114.3'
slate-react@0.116.0:
resolution: {integrity: sha512-wyM41C/qnzfovqOWnjReM5aojLlGxogL/yzMg+ZaZIdEERSJTKjpgt0iIxy4pdIu5GQkF3mwFNNc1/Zy+C/Uwg==}
peerDependencies:
react: '>=18.2.0'
react-dom: '>=18.2.0'
slate: '>=0.114.0'
slate-dom: '>=0.116.0'
slate-react@0.124.0:
resolution: {integrity: sha512-NLN6ME64ChOgJtiVTKwISS1sI/Y8/qN1cwmDTZM9AQJCl+jR3XNCvDsKNrW0kJU+1G3NgIGaYoVWhgIVEIL+Aw==}
peerDependencies:
@ -6362,9 +6357,6 @@ packages:
slate: '>=0.121.0'
slate-dom: '>=0.119.1'
slate@0.116.0:
resolution: {integrity: sha512-CpI6S0PTFDjFsYKah3geR7X5O/38tjxVlwkYGpfQ583yYkSbt/pyEldTB1CJE3MK4htfn2D/wPkwWtDV06ZqWA==}
slate@0.124.0:
resolution: {integrity: sha512-yhtJ0MSV+Z63UTaZtGZDp1goO9XZb5VGER9bmwX+38yJUb7nijPkYEcxD5mpezsSLqL6NedUb4wyvoZa+HDWuQ==}
@ -6529,9 +6521,6 @@ packages:
tiny-invariant@1.3.1:
resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -7876,7 +7865,7 @@ snapshots:
'@dimforge/rapier3d-compat@0.12.0': {}
'@drawnix/drawnix@0.4.0-2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
'@drawnix/drawnix@0.4.0-2(patch_hash=uhnmjv63wpjkgoivl6d3xdgslu)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
'@plait-board/markdown-to-drawnix': 0.0.7
@ -8627,15 +8616,15 @@ snapshots:
- react
- react-dom
'@plait-board/react-text@0.4.0-2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
'@plait-board/react-text@0.4.0-2(patch_hash=zoloe5ob453p2uq2stpl2d6uya)(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)':
dependencies:
'@plait/common': 0.92.3
'@plait/text-plugins': 0.92.3
is-hotkey: 0.2.0
slate: 0.116.0
slate-dom: 0.116.0(slate@0.116.0)
slate-history: 0.115.0(slate@0.116.0)
slate-react: 0.116.0(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(slate-dom@0.116.0(slate@0.116.0))(slate@0.116.0)
slate: 0.124.0
slate-dom: 0.124.0(slate@0.124.0)
slate-history: 0.115.0(slate@0.124.0)
slate-react: 0.124.0(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(slate-dom@0.124.0(slate@0.124.0))(slate@0.124.0)
transitivePeerDependencies:
- react
- react-dom
@ -11772,8 +11761,6 @@ snapshots:
immediate@3.0.6: {}
immer@10.2.0: {}
immer@11.1.3: {}
import-in-the-middle@1.15.0:
@ -13738,17 +13725,6 @@ snapshots:
sisteransi@1.0.5: {}
slate-dom@0.116.0(slate@0.116.0):
dependencies:
'@juggle/resize-observer': 3.4.0
direction: 1.0.4
is-hotkey: 0.2.0
is-plain-object: 5.0.0
lodash: 4.18.1
scroll-into-view-if-needed: 3.1.0
slate: 0.116.0
tiny-invariant: 1.3.1
slate-dom@0.124.0(slate@0.124.0):
dependencies:
'@juggle/resize-observer': 3.4.0
@ -13760,27 +13736,10 @@ snapshots:
slate: 0.124.0
tiny-invariant: 1.3.1
slate-history@0.115.0(slate@0.116.0):
dependencies:
slate: 0.116.0
slate-history@0.115.0(slate@0.124.0):
dependencies:
slate: 0.124.0
slate-react@0.116.0(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(slate-dom@0.116.0(slate@0.116.0))(slate@0.116.0):
dependencies:
'@juggle/resize-observer': 3.4.0
direction: 1.0.4
is-hotkey: 0.2.0
lodash: 4.18.1
react: 19.0.0-rc-45804af1-20241021
react-dom: 19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021)
scroll-into-view-if-needed: 3.1.0
slate: 0.116.0
slate-dom: 0.116.0(slate@0.116.0)
tiny-invariant: 1.3.1
slate-react@0.124.0(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)(slate-dom@0.124.0(slate@0.124.0))(slate@0.124.0):
dependencies:
'@juggle/resize-observer': 3.4.0
@ -13794,11 +13753,6 @@ snapshots:
slate-dom: 0.124.0(slate@0.124.0)
tiny-invariant: 1.3.1
slate@0.116.0:
dependencies:
immer: 10.2.0
tiny-warning: 1.0.3
slate@0.124.0: {}
sonner@1.7.4(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021):
@ -13964,8 +13918,6 @@ snapshots:
tiny-invariant@1.3.1: {}
tiny-warning@1.0.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}

View File

@ -211,6 +211,17 @@ function shouldBypassLocalCanvasE2E(pathname: string, ip: string | null) {
);
}
function shouldBypassLocalFlowBoardPreview(
pathname: string,
ip: string | null
) {
return (
process.env.NODE_ENV !== "production" &&
isLoopbackIp(ip) &&
pathname === "/dev/flow-board-preview"
);
}
async function checkManualIpBlock(
request: NextRequest,
ip: string | null
@ -336,6 +347,10 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
// 🛡️ 安全检查1:标记历史黑名单IP,但不封禁
const clientIP = getClientIP(request);
const bypassLocalCanvasE2E = shouldBypassLocalCanvasE2E(pathname, clientIP);
const bypassLocalFlowBoardPreview = shouldBypassLocalFlowBoardPreview(
pathname,
clientIP
);
const staticBlocklistHit = isBlockedIP(clientIP);
let suspiciousCheck: ReturnType<typeof isSuspiciousRequest> = {
suspicious: false,
@ -385,7 +400,7 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
});
}
const manualBlock = bypassLocalCanvasE2E
const manualBlock = bypassLocalCanvasE2E || bypassLocalFlowBoardPreview
? { blocked: false }
: await checkManualIpBlock(request, clientIP);
if (manualBlock.blocked) {
@ -409,7 +424,7 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
}
// 🛡️ 安全检查2:检测可疑请求模式
suspiciousCheck = bypassLocalCanvasE2E
suspiciousCheck = bypassLocalCanvasE2E || bypassLocalFlowBoardPreview
? { suspicious: false }
: isSuspiciousRequest(request);
if (suspiciousCheck.suspicious) {
@ -418,7 +433,7 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
// 可以在这里添加更严格的检查
}
if (bypassLocalCanvasE2E) {
if (bypassLocalCanvasE2E || bypassLocalFlowBoardPreview) {
return NextResponse.next();
}

View File

@ -0,0 +1,41 @@
"""Read an XMind app bundle's version and sample files without executing its code.
Usage: python3 scripts/flow-board/inspect-xmind-package.py /Volumes/Xmind/Xmind.app --samples-dir /tmp/xmind-native-samples
"""
import argparse
import hashlib
import io
import json
from pathlib import Path
import plistlib
import struct
import zipfile
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("app", type=Path)
parser.add_argument("--samples-dir", type=Path)
args = parser.parse_args()
info = plistlib.loads((args.app / "Contents/Info.plist").read_bytes())
report = {"version": info["CFBundleShortVersionString"], "build": info["CFBundleVersion"], "bundleId": info["CFBundleIdentifier"], "samples": []}
with (args.app / "Contents/Resources/app.asar").open("rb") as archive:
_, header_size, _, string_size = struct.unpack("<4I", archive.read(16))
header = json.loads(archive.read(string_size))
def walk(directory, prefix=""):
for name, item in directory.get("files", {}).items():
path = prefix + "/" + name
if "files" in item:
yield from walk(item, path)
elif name.endswith(".xmind"):
yield path, item
for name, item in walk(header):
archive.seek(8 + header_size + int(item["offset"]))
payload = archive.read(item["size"])
sample = {"path": name, "bytes": len(payload), "sha256": hashlib.sha256(payload).hexdigest()}
with zipfile.ZipFile(io.BytesIO(payload)) as workbook:
if "content.json" in workbook.namelist():
sheets = json.loads(workbook.read("content.json"))
sample["structures"] = [sheet["rootTopic"].get("structureClass") for sheet in sheets]
report["samples"].append(sample)
if args.samples_dir and "/basic/" in name:
args.samples_dir.mkdir(parents=True, exist_ok=True)
(args.samples_dir / Path(name).name).write_bytes(payload)
print(json.dumps(report, ensure_ascii=False, indent=2))

View File

@ -0,0 +1,106 @@
import { expect, type Locator, type Page, test } from "@playwright/test";
async function importMap(page: Page) {
// Exercise the actual Markdown importer with a test-only cloud response.
await page.route("**/api/flow-board/files", async (route) => {
await route.fulfill({
json: {
data: {
...route.request().postDataJSON(),
id: "test-map",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
},
});
});
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
await page.locator("input[type=file][accept*=md]").setInputFiles({
name: "项目计划.md",
mimeType: "text/markdown",
buffer: Buffer.from(
"# 项目计划\n## 产品调研\n### 用户访谈\n### 竞品分析\n## 设计方案\n### 视觉设计\n### 交互设计\n## 开发实现\n### 前端开发\n### 质量验证"
),
});
await expect(page.getByText("用户访谈", { exact: true })).toBeVisible();
}
function bounds(locator: Locator) {
return locator.evaluate((element) => {
const box = (element as unknown as SVGGraphicsElement).getBBox();
return { x: box.x, y: box.y, width: box.width, height: box.height };
});
}
test("annotations follow layout and collapse, persist across sheets, and undo with deleted topics", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await importMap(page);
await page.getByText("用户访谈", { exact: true }).click();
await page.getByRole("button", { name: "联系", exact: true }).click();
await expect(
page.getByRole("status").filter({ hasText: "点击另一个主题" })
).toBeVisible();
await page.getByText("视觉设计", { exact: true }).click();
await page.getByLabel("联系文字", { exact: true }).fill("调研驱动设计");
const relationText = page
.locator(".drawnix")
.getByText("调研驱动设计", { exact: true });
await expect(relationText).toBeVisible();
await expect(page.locator("[plait-arrow-line=true]")).toHaveCount(1);
await page.getByText("产品调研", { exact: true }).click();
await page.getByRole("button", { name: "外框", exact: true }).click();
await page.getByLabel("外框留白").selectOption("24");
const boundary = page.locator("[plait-geometry=true]");
const originalBounds = await bounds(boundary);
await page.locator(".drawnix").getByText("项目计划", { exact: true }).click();
await page.getByRole("button", { name: "向下逻辑", exact: true }).click();
await expect.poll(() => bounds(boundary)).not.toEqual(originalBounds);
await page.getByRole("button", { name: "向右逻辑", exact: true }).click();
await expect.poll(() => bounds(boundary)).toEqual(originalBounds);
await page.getByText("产品调研", { exact: true }).click();
const expanded = await bounds(boundary);
await page.keyboard.press("ControlOrMeta+/");
await expect(page.getByText("用户访谈", { exact: true })).toHaveCount(0);
await expect
.poll(async () => (await bounds(boundary)).width)
.toBeLessThan(expanded.width);
await expect(relationText).toBeVisible();
await page.keyboard.press("ControlOrMeta+/");
await expect(page.getByText("用户访谈", { exact: true })).toBeVisible();
await expect.poll(() => bounds(boundary)).toEqual(expanded);
await page.getByRole("button", { name: "新增画布", exact: true }).click();
await expect(relationText).toHaveCount(0);
await page.getByRole("button", { name: "画布 1", exact: true }).click();
await expect(relationText).toBeVisible();
await expect(boundary).toHaveCount(1);
await page.getByText("视觉设计", { exact: true }).click();
await page.keyboard.press("Backspace");
await expect(relationText).toHaveCount(0);
await page.getByRole("button", { name: "撤销", exact: true }).click();
await expect(relationText).toBeVisible();
await page.getByRole("button", { name: "重做", exact: true }).click();
await expect(relationText).toHaveCount(0);
expect(errors).toEqual([]);
});
test("summary uses selected branches and relationship creation can be cancelled", async ({
page,
}) => {
await importMap(page);
await page.getByText("开发实现", { exact: true }).click();
await page.getByRole("button", { name: "概要", exact: true }).click();
await expect(
page.locator(".drawnix").getByText("摘要", { exact: true })
).toBeVisible();
await page.getByText("用户访谈", { exact: true }).click();
await page.getByRole("button", { name: "联系", exact: true }).click();
await page.keyboard.press("Escape");
await page.getByText("视觉设计", { exact: true }).click();
await expect(page.locator("[plait-arrow-line=true]")).toHaveCount(0);
});

View File

@ -0,0 +1,224 @@
import { expect, type Page, test } from "@playwright/test";
import JSZip from "jszip";
async function upload(page: Page, rootTopic: object, name: string) {
const zip = new JSZip();
zip.file("content.json", JSON.stringify([{ title: name, rootTopic }]));
await page.locator("input[accept*=xmind]").setInputFiles({
name: `${name}.xmind`,
mimeType: "application/octet-stream",
buffer: await zip.generateAsync({ type: "nodebuffer" }),
});
}
test("side-title tree table renders a spanning left header and preserves its native structure", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
await upload(
page,
{
id: "root",
title: "发布协调",
structureClass: "org.xmind.ui.treetable",
style: { properties: { "svg:fill": "#000000FF" } },
children: {
attached: [
{
id: "plan",
title: "规划",
style: { properties: { "svg:fill": "#F7C85FFF" } },
children: {
attached: [
{ id: "scope", title: "范围确认" },
{ id: "date", title: "发布日期" },
],
},
},
{ id: "ship", title: "交付" },
],
},
},
"侧标题树形表格"
);
const canvas = page.locator(".drawnix");
const table = canvas.locator('[data-special-table="treeTableSide"]');
await expect(table).toBeVisible();
await expect(table.locator("[data-special-table-cell]")).toHaveCount(5);
await expect(
table.locator('[data-special-table-cell][data-depth="0"]')
).toHaveAttribute("fill", "#000000");
await page.getByRole("button", { name: "适应画布", exact: true }).click();
const box = async (title: string) =>
(await canvas.getByText(title, { exact: true }).boundingBox())!;
const root = await box("发布协调");
const plan = await box("规划");
const scope = await box("范围确认");
const date = await box("发布日期");
expect(plan.x).toBeGreaterThan(root.x + root.width);
expect(scope.x).toBeGreaterThan(plan.x + plan.width);
expect(date.y).toBeGreaterThan(scope.y);
await canvas.getByText("交付", { exact: true }).click();
await page.getByRole("button", { name: "子主题", exact: true }).click();
await page.keyboard.insertText("上线复盘");
await page
.locator(".flow-board-canvas-shell")
.click({ position: { x: 50, y: 50 } });
await expect(canvas.getByText("上线复盘", { exact: true })).toBeVisible();
await expect(table.locator("[data-special-table-cell]")).toHaveCount(6);
await page.screenshot({
path: test.info().outputPath("side-tree-table-preview.png"),
animations: "disabled",
});
await canvas.getByText("发布协调", { exact: true }).click();
await expect(
page.getByRole("button", { name: "树形表格(侧标题)", exact: true })
).toHaveAttribute("aria-pressed", "true");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 XMind", exact: true }).click();
const stream = await (await downloadPromise).createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(chunk);
const exported = await JSZip.loadAsync(Buffer.concat(chunks));
const content = JSON.parse(
await exported.file("content.json")!.async("string")
);
expect(content[0].rootTopic.structureClass).toBe(
"org.xmind.ui.treetable"
);
expect(errors).toEqual([]);
});
test("spreadsheet imports XMind columns, stacks labelled entries and round-trips the column model", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const columns = ["Monday", "Tuesday", "Friday"];
await upload(
page,
{
id: "root",
title: "Mood Tracker",
structureClass: "org.xmind.ui.spreadsheet",
style: { properties: { "svg:fill": "#1F2766FF" } },
extensions: [
{
provider: "org.xmind.ui.spreadsheet",
content: [
{
name: "columns",
content: columns.map((column) => ({
name: "column",
content: column,
})),
},
],
},
],
children: {
attached: [
{
id: "happy",
title: "Happy",
style: { properties: { "svg:fill": "#FF9D8EFF" } },
children: {
attached: [
{ id: "walk", title: "Morning walk", labels: ["Monday"] },
{ id: "coffee", title: "Coffee", labels: ["Monday"] },
{ id: "music", title: "Music", labels: ["Friday"] },
],
},
},
{
id: "calm",
title: "Calm",
children: {
attached: [
{ id: "read", title: "Read", labels: ["Tuesday"] },
],
},
},
],
},
},
"矩阵"
);
const canvas = page.locator(".drawnix");
const table = canvas.locator('[data-special-table="spreadsheet"]');
await expect(table).toBeVisible();
await expect(table.locator("[data-special-matrix-column]")).toHaveCount(3);
await expect(table.locator("[data-special-matrix-cell]")).toHaveCount(6);
await expect(table.locator("[data-special-matrix-entry]")).toHaveCount(4);
for (const column of columns)
await expect(
table.locator(`[data-special-matrix-column-label="${column}"]`)
).toHaveText(column);
await page.getByRole("button", { name: "适应画布", exact: true }).click();
const box = async (title: string) =>
(await canvas.getByText(title, { exact: true }).boundingBox())!;
const happy = await box("Happy");
const walk = await box("Morning walk");
const coffee = await box("Coffee");
const music = await box("Music");
const read = await box("Read");
expect(Math.abs(walk.x + walk.width / 2 - (coffee.x + coffee.width / 2))).toBeLessThan(1);
expect(coffee.y).toBeGreaterThan(walk.y);
expect(music.x).toBeGreaterThan(walk.x);
expect(read.x).toBeGreaterThan(walk.x);
expect(read.y).toBeGreaterThan(happy.y);
await canvas.getByText("Coffee", { exact: true }).dblclick();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.insertText("Team coffee");
await page
.locator(".flow-board-canvas-shell")
.click({ position: { x: 50, y: 50 } });
await expect(canvas.getByText("Team coffee", { exact: true })).toBeVisible();
await canvas.getByText("Mood Tracker", { exact: true }).click();
await expect(
page.getByLabel("矩阵", { exact: true })
).toHaveAttribute("aria-pressed", "true");
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 XMind", exact: true }).click();
const stream = await (await downloadPromise).createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(chunk);
const buffer = Buffer.concat(chunks);
const exported = await JSZip.loadAsync(buffer);
const content = JSON.parse(
await exported.file("content.json")!.async("string")
);
expect(content[0].rootTopic.structureClass).toBe(
"org.xmind.ui.spreadsheet"
);
expect(
content[0].rootTopic.extensions[0].content[0].content.map(
(column: { content: string }) => column.content
)
).toEqual(columns);
expect(
content[0].rootTopic.children.attached[0].children.attached.find(
(topic: { title: string }) => topic.title === "Team coffee"
).labels
).toEqual(["Monday"]);
await page.locator("input[accept*=xmind]").setInputFiles({
name: "矩阵往返.xmind",
mimeType: "application/octet-stream",
buffer,
});
await expect(table).toBeVisible();
await expect(table.locator("[data-special-matrix-column]")).toHaveCount(3);
await page.getByRole("button", { name: "适应画布", exact: true }).click();
await page.screenshot({
path: test.info().outputPath("spreadsheet-preview.png"),
animations: "disabled",
});
expect(errors).toEqual([]);
});

View File

@ -0,0 +1,158 @@
import { expect, test } from "@playwright/test";
import JSZip from "jszip";
test("outline edits, creates, nests and deletes topics with shared canvas history", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
zip.file(
"content.json",
JSON.stringify([
{
title: "项目",
rootTopic: {
id: "r",
title: "项目计划",
children: {
attached: [
{
id: "a",
title: "市场调研",
notes: { plain: { content: "调研备注" } },
},
{
id: "b",
title: "产品设计",
children: { attached: [{ id: "c", title: "设计评审" }] },
},
],
},
},
},
{ title: "归档", rootTopic: { id: "other", title: "历史记录" } },
])
);
await page.locator("input[accept*=xmind]").setInputFiles({
name: "大纲测试.xmind",
mimeType: "application/octet-stream",
buffer: await zip.generateAsync({ type: "nodebuffer" }),
});
await page.getByRole("button", { name: "切换到大纲", exact: true }).click();
const outline = page.getByRole("region", { name: "大纲视图", exact: true });
await expect(
outline.getByLabel("大纲主题 项目计划", { exact: true })
).toBeVisible();
const research = outline.getByLabel("大纲主题 市场调研", { exact: true });
const initialHeight = (await research.boundingBox())!.height;
await page.getByRole("button", { name: "放大", exact: true }).click();
await expect
.poll(async () => (await research.boundingBox())!.height)
.toBeGreaterThan(initialHeight);
await page.getByRole("button", { name: "适应画布", exact: true }).click();
await research.fill("用户研究");
await research.press("Enter");
await expect(
outline.getByLabel("大纲主题 新主题", { exact: true })
).toBeFocused();
const newTopic = outline.getByLabel("大纲主题 新主题", { exact: true });
await newTopic.fill("竞争分析");
await newTopic.press("Tab");
const competitor = outline.getByLabel("大纲主题 竞争分析", { exact: true });
await expect(competitor.locator("..")).toHaveAttribute("data-depth", "2");
await competitor.press("Shift+Tab");
await expect(competitor.locator("..")).toHaveAttribute("data-depth", "1");
await page.getByRole("button", { name: "撤销", exact: true }).click();
await expect(competitor.locator("..")).toHaveAttribute("data-depth", "2");
await page.getByRole("button", { name: "重做", exact: true }).click();
await expect(competitor.locator("..")).toHaveAttribute("data-depth", "1");
await outline
.getByRole("button", { name: "折叠 产品设计", exact: true })
.click();
await expect(
outline.getByLabel("大纲主题 设计评审", { exact: true })
).toHaveCount(0);
await outline
.getByRole("button", { name: "展开 产品设计", exact: true })
.click();
await expect(
outline.getByLabel("大纲主题 设计评审", { exact: true })
).toBeVisible();
await page
.getByRole("button", { name: "切换到思维导图", exact: true })
.click();
const canvas = page.locator(".drawnix");
await expect(canvas.getByText("用户研究", { exact: true })).toBeVisible();
await expect(canvas.getByText("竞争分析", { exact: true })).toBeVisible();
await canvas.getByText("产品设计", { exact: true }).dblclick();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.insertText("方案设计");
await page
.locator(".flow-board-canvas-shell")
.click({ position: { x: 100, y: 100 } });
await page.getByRole("button", { name: "切换到大纲", exact: true }).click();
await expect(
outline.getByLabel("大纲主题 方案设计", { exact: true })
).toBeVisible();
await outline.getByLabel("大纲主题 竞争分析", { exact: true }).click();
await outline
.getByRole("button", { name: "大纲删除主题", exact: true })
.click();
await expect(
outline.getByLabel("大纲主题 竞争分析", { exact: true })
).toHaveCount(0);
await page.getByRole("button", { name: "撤销", exact: true }).click();
await expect(
outline.getByLabel("大纲主题 竞争分析", { exact: true })
).toBeVisible();
await page.getByRole("button", { name: "归档", exact: true }).click();
await expect(
outline.getByLabel("大纲主题 历史记录", { exact: true })
).toBeVisible();
await page.getByRole("button", { name: "项目", exact: true }).click();
await expect(
outline.getByLabel("大纲主题 用户研究", { exact: true })
).toBeVisible();
const renamedResearch = outline.getByLabel("大纲主题 用户研究", {
exact: true,
});
await renamedResearch.fill("取消这次改名");
await renamedResearch.press("Escape");
await expect(renamedResearch).toHaveValue("用户研究");
await outline.getByLabel("大纲主题 用户研究", { exact: true }).click();
await page.getByRole("button", { name: "备注", exact: true }).click();
await expect(page.getByLabel("主题备注", { exact: true })).toHaveValue(
"调研备注"
);
await outline
.getByLabel("拖动 设计评审", { exact: true })
.dragTo(
outline.getByLabel("大纲主题 用户研究", { exact: true }).locator("..")
);
await expect
.poll(() =>
outline
.locator("[data-outline-input]")
.evaluateAll((nodes) =>
nodes.map((node) => (node as HTMLInputElement).value)
)
)
.toEqual(["项目计划", "用户研究", "设计评审", "竞争分析", "方案设计"]);
await page.screenshot({
path: test.info().outputPath("outline-preview.png"),
});
await page.getByRole("button", { name: "新增画布", exact: true }).click();
await outline
.getByRole("button", { name: "添加中心主题", exact: true })
.click();
await expect(
outline.getByLabel("大纲主题 中心主题", { exact: true })
).toBeVisible();
await outline.getByLabel("大纲主题 中心主题", { exact: true }).press("Enter");
await expect(
outline.getByLabel("大纲主题 新主题", { exact: true })
).toBeFocused();
expect(errors).toEqual([]);
});

View File

@ -0,0 +1,123 @@
import { expect, test } from "@playwright/test";
import JSZip from "jszip";
test("special layouts change geometry and connectors, edit normally and round-trip their structure", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
zip.file(
"content.json",
JSON.stringify([
{
title: "结构试验",
relationships: [{ end1Id: "a", end2Id: "b", title: "关联" }],
rootTopic: {
id: "root",
title: "中心主题",
structureClass: "org.xmind.ui.fishbone.leftHeaded",
children: {
attached: [
{
id: "a",
title: "分支一",
children: {
attached: [
{ id: "a1", title: "细分任务一" },
{ id: "a2", title: "细分任务二" },
],
},
},
{ id: "b", title: "分支二" },
{ id: "c", title: "分支三" },
{ id: "d", title: "分支四" },
],
},
},
},
])
);
await page.locator("input[accept*=xmind]").setInputFiles({
name: "特殊布局.xmind",
mimeType: "application/octet-stream",
buffer: await zip.generateAsync({ type: "nodebuffer" }),
});
const canvas = page.locator(".drawnix");
await expect(page.locator('[plait-arrow-line="true"]')).toHaveCount(1);
await expect(canvas.locator('[data-special-axis="fishbone"]')).toHaveCount(1);
await expect(
canvas.locator('[data-special-connector="fishbone"]')
).toHaveCount(4);
await page.getByRole("button", { name: "适应画布", exact: true }).click();
const rect = async (title: string) =>
(await canvas.getByText(title, { exact: true }).boundingBox())!;
const center = await rect("中心主题");
const a = await rect("分支一");
const b = await rect("分支二");
const c = await rect("分支三");
expect(a.y + a.height).toBeLessThan(center.y);
expect(b.y).toBeGreaterThan(center.y + center.height);
expect(c.x).toBeGreaterThan(a.x + a.width);
await canvas.getByText("中心主题", { exact: true }).click();
await expect(
page.getByRole("button", { name: "鱼骨图", exact: true })
).toHaveAttribute("aria-pressed", "true");
await page.screenshot({
path: test.info().outputPath("fishbone-preview.png"),
animations: "disabled",
});
await page.getByRole("button", { name: "水平时间轴", exact: true }).click();
await expect(canvas.locator('[data-special-axis="timeline"]')).toHaveCount(1);
await expect(
canvas.locator('[data-special-connector="timeline"] circle')
).toHaveCount(4);
await expect(canvas.locator('[data-special-axis="fishbone"]')).toHaveCount(0);
await page.getByRole("button", { name: "撤销", exact: true }).click();
await expect(canvas.locator('[data-special-axis="fishbone"]')).toHaveCount(1);
await page.getByRole("button", { name: "重做", exact: true }).click();
await expect(canvas.locator('[data-special-axis="timeline"]')).toHaveCount(1);
await canvas.getByText("分支二", { exact: true }).dblclick();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.insertText("已编辑的时间节点");
await page
.locator(".flow-board-canvas-shell")
.click({ position: { x: 80, y: 80 } });
await expect(
canvas.getByText("已编辑的时间节点", { exact: true })
).toBeVisible();
await canvas.getByText("分支三", { exact: true }).click();
await page.getByRole("button", { name: "子主题", exact: true }).click();
await expect(canvas.locator('[contenteditable="true"]')).toBeFocused();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.insertText("新增时间任务");
await page
.locator(".flow-board-canvas-shell")
.click({ position: { x: 80, y: 80 } });
await expect(canvas.getByText("新增时间任务", { exact: true })).toBeVisible();
await canvas.getByText("中心主题", { exact: true }).click();
await page.getByRole("button", { name: "括号图", exact: true }).click();
await expect(
canvas.locator('[data-special-structure="brace"] path')
).toHaveCount(3);
await expect(canvas.locator("[data-special-axis]")).toHaveCount(0);
await page.screenshot({
path: test.info().outputPath("brace-preview.png"),
animations: "disabled",
});
const downloading = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 XMind", exact: true }).click();
const download = await downloading;
const stream = await download.createReadStream();
const chunks = [];
for await (const chunk of stream!) chunks.push(chunk);
const exported = await JSZip.loadAsync(Buffer.concat(chunks));
const content = JSON.parse(
await exported.file("content.json")!.async("string")
);
expect(content[0].rootTopic.structureClass).toBe("org.xmind.ui.brace.right");
await page.getByRole("button", { name: "向右逻辑", exact: true }).click();
await expect(canvas.locator("[data-special-structure]")).toHaveCount(0);
expect(errors).toEqual([]);
});

View File

@ -0,0 +1,194 @@
import { expect, test } from "@playwright/test";
import JSZip from "jszip";
const pixel =
"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAO0lEQVR4AezXwQkAMAgDwND9d27rBD4FOdB3wv1yktzJrwI/f+4UIECAAAECBAgQIECAAAEC+wW63f0AAAD//+3aM8YAAAAGSURBVAMABkQgIT13JVIAAAAASUVORK5CYII=";
test("topic content imports, edits, undoes, survives sheet changes and follows internal links", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
zip.file(
"content.json",
JSON.stringify([
{
title: "产品工作表",
rootTopic: {
id: "root",
title: "产品说明",
notes: { plain: { content: "第一行说明\n第二行说明" } },
labels: ["产品", "待审核"],
href: "https://example.com/docs",
image: { src: "xap:resources/pixel.png", width: 48 },
children: {
attached: [
{
id: "branch",
title: "折叠分支",
branch: "folded",
children: {
attached: [
{
id: "target",
title: "目标主题",
notes: { plain: { content: "目标的备注" } },
},
],
},
},
{ id: "link", title: "链接入口", href: "xmind:#target" },
{
id: "attachment",
title: "附加文件",
href: "xap:attachments/spec.txt",
},
],
},
},
},
{ title: "空白工作表", rootTopic: { id: "empty", title: "独立中心" } },
])
);
zip.file("resources/pixel.png", pixel, { base64: true });
zip.file("attachments/spec.txt", "产品需求附件");
await page.locator("input[accept*=xmind]").setInputFiles({
name: "主题内容.xmind",
mimeType: "application/octet-stream",
buffer: await zip.generateAsync({ type: "nodebuffer" }),
});
const canvas = page.locator(".drawnix");
await canvas.getByText("产品说明", { exact: true }).click();
await page.getByRole("button", { name: "备注", exact: true }).click();
await expect(page.getByLabel("主题备注")).toHaveValue(
"第一行说明\n第二行说明"
);
await expect(
page.getByRole("button", { name: "移除标签 产品", exact: true })
).toBeVisible();
await expect(page.getByLabel("主题外部链接")).toHaveValue(
"https://example.com/docs"
);
await expect(
canvas.locator('img[src^="data:image/png"], image[href^="data:image/png"]')
).toHaveCount(1);
await page.screenshot({
path: test.info().outputPath("topic-content-panel.png"),
});
await page.getByLabel("主题备注").fill("更新后的多行备注\n保留换行");
await page.getByLabel("新增主题标签").fill("完成");
await page.getByRole("button", { name: "添加", exact: true }).click();
await expect(
page.getByRole("button", { name: "移除标签 完成", exact: true })
).toBeVisible();
await page.getByRole("button", { name: "撤销", exact: true }).click();
await expect(
page.getByRole("button", { name: "移除标签 完成", exact: true })
).toHaveCount(0);
await expect(page.getByLabel("主题备注")).toHaveValue(
"更新后的多行备注\n保留换行"
);
await page.getByRole("button", { name: "重做", exact: true }).click();
await expect(
page.getByRole("button", { name: "移除标签 完成", exact: true })
).toBeVisible();
await page.getByLabel("主题外部链接").fill("javascript:alert(1)");
await page.getByLabel("主题外部链接").blur();
await expect(page.getByLabel("主题外部链接")).toHaveAttribute(
"aria-invalid",
"true"
);
await page.getByRole("button", { name: "关闭主题内容", exact: true }).click();
await page.getByRole("button", { name: "备注", exact: true }).click();
await expect(page.getByLabel("主题外部链接")).toHaveValue(
"https://example.com/docs"
);
await page.getByRole("button", { name: "空白工作表", exact: true }).click();
await page.getByRole("button", { name: "产品工作表", exact: true }).click();
await canvas.getByText("产品说明", { exact: true }).click();
await expect(page.getByLabel("主题备注")).toHaveValue(
"更新后的多行备注\n保留换行"
);
await canvas.getByText("链接入口", { exact: true }).click();
await page.getByRole("button", { name: "定位目标主题", exact: true }).click();
await expect(canvas.getByText("目标主题", { exact: true })).toBeVisible();
await expect(page.getByLabel("主题备注")).toHaveValue("目标的备注");
await canvas.getByText("附加文件", { exact: true }).click();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("link", { name: "下载 spec.txt", exact: true }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("spec.txt");
const stream = await download.createReadStream();
const chunks = [];
for await (const chunk of stream!) chunks.push(chunk);
expect(Buffer.concat(chunks).toString()).toBe("产品需求附件");
await page
.getByLabel("上传主题附件")
.setInputFiles({
name: "新版需求.txt",
mimeType: "text/plain",
buffer: Buffer.from("替换后的附件"),
});
await expect(
page.getByRole("link", { name: "下载 新版需求.txt", exact: true })
).toHaveAttribute(
"href",
`data:application/octet-stream;base64,${Buffer.from("替换后的附件").toString("base64")}`
);
await page.getByRole("button", { name: "撤销", exact: true }).click();
await expect(
page.getByRole("link", { name: "下载 spec.txt", exact: true })
).toBeVisible();
await page.getByRole("button", { name: "移除附件", exact: true }).click();
await expect(
page.getByRole("link", { name: "下载 spec.txt", exact: true })
).toHaveCount(0);
await page.getByRole("button", { name: "撤销", exact: true }).click();
await expect(
page.getByRole("link", { name: "下载 spec.txt", exact: true })
).toBeVisible();
expect(errors).toEqual([]);
});
test("legacy XML imports plain notes, separate labels and embedded pictures", async ({
page,
}) => {
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
zip.file(
"content.xml",
`<?xml version="1.0"?><xmap-content xmlns="urn:xmind:xmap:xmlns:content:2.0" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xlink="http://www.w3.org/1999/xlink"><sheet id="sheet"><title>旧版内容</title><topic id="root" xlink:href="https://example.com/legacy"><title>旧版资料</title><notes><plain>旧版备注</plain><html>不重复合并的富文本备注</html></notes><labels><label>计划</label><label>资料</label></labels><xhtml:img src="xap:resources/image.png" width="48"/><children><topics type="attached"><topic id="missing"><title>缺失图片仍保留主题</title><xhtml:img src="xap:resources/missing.png"/></topic></topics></children></topic></sheet></xmap-content>`
);
zip.file("resources/image.png", pixel, { base64: true });
await page
.locator("input[accept*=xmind]")
.setInputFiles({
name: "旧版内容.xmind",
mimeType: "application/octet-stream",
buffer: await zip.generateAsync({ type: "nodebuffer" }),
});
const canvas = page.locator(".drawnix");
await canvas.getByText("旧版资料", { exact: true }).click();
await page.getByRole("button", { name: "备注", exact: true }).click();
await expect(page.getByLabel("主题备注")).toHaveValue("旧版备注");
await expect(
page.getByRole("button", { name: "移除标签 计划", exact: true })
).toBeVisible();
await expect(
page.getByRole("button", { name: "移除标签 资料", exact: true })
).toBeVisible();
await expect(page.getByLabel("主题外部链接")).toHaveValue(
"https://example.com/legacy"
);
await expect(
canvas.locator('img[src^="data:image/png"], image[href^="data:image/png"]')
).toHaveCount(1);
await expect(
canvas.getByText("缺失图片仍保留主题", { exact: true })
).toBeVisible();
await expect(
page.getByText("部分图片缺失、损坏或格式暂不支持", { exact: true })
).toBeVisible();
});

View File

@ -0,0 +1,108 @@
import { expect, test } from "@playwright/test";
import JSZip from "jszip";
test("tree table aligns hierarchy cells, edits topics and preserves its XMind structure", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
zip.file(
"content.json",
JSON.stringify([
{
title: "树形表格",
rootTopic: {
id: "root",
title: "项目清单",
structureClass: "org.xmind.ui.treetable.toptitle",
children: {
attached: [
{
id: "a",
title: "产品",
children: {
attached: [
{ id: "a1", title: "需求分析" },
{ id: "a2", title: "原型设计" },
],
},
},
{ id: "b", title: "研发" },
{ id: "c", title: "发布" },
],
},
},
},
])
);
await page.locator("input[accept*=xmind]").setInputFiles({
name: "树形表格.xmind",
mimeType: "application/octet-stream",
buffer: await zip.generateAsync({ type: "nodebuffer" }),
});
const canvas = page.locator(".drawnix");
const table = canvas.locator('[data-special-table="treeTable"]');
await expect(table).toBeVisible();
await expect(table.locator("[data-special-table-cell]")).toHaveCount(6);
await page.getByRole("button", { name: "适应画布", exact: true }).click();
const box = async (title: string) =>
(await canvas.getByText(title, { exact: true }).boundingBox())!;
const root = await box("项目清单");
const product = await box("产品");
const requirement = await box("需求分析");
const prototype = await box("原型设计");
const development = await box("研发");
expect(root.y + root.height).toBeLessThan(product.y);
expect(requirement.x).toBeGreaterThan(product.x + product.width);
expect(prototype.x).toBe(requirement.x);
expect(development.y).toBeGreaterThan(prototype.y);
await canvas.getByText("研发", { exact: true }).click();
await page.getByRole("button", { name: "子主题", exact: true }).click();
await expect(canvas.locator('[contenteditable="true"]')).toBeFocused();
await page.keyboard.insertText("接口联调");
await page
.locator(".flow-board-canvas-shell")
.click({ position: { x: 40, y: 40 } });
await expect(canvas.getByText("接口联调", { exact: true })).toBeVisible();
await expect(table.locator("[data-special-table-cell]")).toHaveCount(7);
await canvas.getByText("项目清单", { exact: true }).click();
await expect(
page.getByLabel("树形表格", { exact: true })
).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: "向右逻辑", exact: true }).click();
await expect(table).toHaveCount(0);
await page.getByRole("button", { name: "撤销", exact: true }).click();
await expect(table).toBeVisible();
const downloading = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 XMind", exact: true }).click();
const stream = await (await downloading).createReadStream();
const chunks = [];
for await (const chunk of stream!) chunks.push(chunk);
const buffer = Buffer.concat(chunks);
const exported = await JSZip.loadAsync(buffer);
const content = JSON.parse(
await exported.file("content.json")!.async("string")
);
expect(content[0].rootTopic.structureClass).toBe(
"org.xmind.ui.treetable.toptitle"
);
await page.locator("input[accept*=xmind]").setInputFiles({
name: "树形表格往返.xmind",
mimeType: "application/octet-stream",
buffer,
});
await expect(table).toBeVisible();
await expect(canvas.getByText("接口联调", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "适应画布", exact: true }).click();
await page.screenshot({
path: test.info().outputPath("tree-table-preview.png"),
animations: "disabled",
});
expect(errors).toEqual([]);
});

View File

@ -0,0 +1,108 @@
import { expect, test } from "@playwright/test";
import JSZip from "jszip";
test("vertical timeline mirrors branches, edits children and survives sheet switching and file round-trip", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
zip.file(
"content.json",
JSON.stringify([
{
title: "纵向计划",
rootTopic: {
id: "root",
title: "年度计划",
structureClass: "org.xmind.ui.timeline.vertical",
children: {
attached: [
{ id: "a", title: "第一阶段" },
{
id: "b",
title: "第二阶段",
children: { attached: [{ id: "b1", title: "已有左侧任务" }] },
},
{ id: "c", title: "第三阶段" },
{ id: "d", title: "第四阶段" },
],
},
},
},
{ title: "另一个画布", rootTopic: { id: "other", title: "备用中心" } },
])
);
await page.locator("input[accept*=xmind]").setInputFiles({
name: "纵向计划.xmind",
mimeType: "application/octet-stream",
buffer: await zip.generateAsync({ type: "nodebuffer" }),
});
const canvas = page.locator(".drawnix");
const axis = canvas.locator('[data-special-axis="verticalTimeline"]');
await expect(axis).toHaveCount(1);
await page.getByRole("button", { name: "适应画布", exact: true }).click();
const box = async (title: string) =>
(await canvas.getByText(title, { exact: true }).boundingBox())!;
const root = await box("年度计划"),
first = await box("第一阶段"),
second = await box("第二阶段"),
third = await box("第三阶段");
expect(first.x).toBeGreaterThan(root.x + root.width / 2);
expect(second.x + second.width).toBeLessThan(root.x + root.width / 2);
expect(second.y).toBeGreaterThan(first.y + first.height);
expect(third.y).toBeGreaterThan(second.y + second.height);
expect((await box("已有左侧任务")).x).toBeLessThan(second.x);
await canvas.getByText("第二阶段", { exact: true }).click();
await page.getByRole("button", { name: "子主题", exact: true }).click();
await expect(canvas.locator('[contenteditable="true"]')).toBeFocused();
await page.keyboard.insertText("新左侧任务");
await page
.locator(".flow-board-canvas-shell")
.click({ position: { x: 40, y: 40 } });
await expect(canvas.getByText("新左侧任务", { exact: true })).toBeVisible();
expect((await box("新左侧任务")).x).toBeLessThan((await box("第二阶段")).x);
await canvas.getByText("年度计划", { exact: true }).click();
await page.getByRole("button", { name: "向右逻辑", exact: true }).click();
await expect(axis).toHaveCount(0);
await page.getByRole("button", { name: "撤销", exact: true }).click();
await expect(axis).toHaveCount(1);
await page.getByRole("button", { name: "另一个画布", exact: true }).click();
await expect(axis).toHaveCount(0);
await page.getByRole("button", { name: "纵向计划", exact: true }).click();
await expect(axis).toHaveCount(1);
const downloading = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 XMind", exact: true }).click();
const stream = await (await downloading).createReadStream();
const chunks = [];
for await (const chunk of stream!) chunks.push(chunk);
const buffer = Buffer.concat(chunks);
const exported = await JSZip.loadAsync(buffer);
const sheets = JSON.parse(
await exported.file("content.json")!.async("string")
);
expect(sheets[0].rootTopic.structureClass).toBe(
"org.xmind.ui.timeline.vertical"
);
expect(
sheets[0].rootTopic.children.attached[1].children.attached.map(
(t: { title: string }) => t.title
)
).toContain("新左侧任务");
await page
.locator("input[accept*=xmind]")
.setInputFiles({
name: "往返.xmind",
mimeType: "application/octet-stream",
buffer,
});
await expect(axis).toHaveCount(1);
await expect(canvas.getByText("新左侧任务", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "适应画布", exact: true }).click();
await page.screenshot({
path: test.info().outputPath("vertical-timeline-preview.png"),
animations: "disabled",
});
expect(errors).toEqual([]);
});

View File

@ -0,0 +1,132 @@
import { expect, test } from "@playwright/test";
import JSZip from "jszip";
const pixel =
"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAO0lEQVR4AezXwQkAMAgDwND9d27rBD4FOdB3wv1yktzJrwI/f+4UIECAAAECBAgQIECAAAEC+wW63f0AAAD//+3aM8YAAAAGSURBVAMABkQgIT13JVIAAAAASUVORK5CYII=";
test("export downloads all sheets and preserves editable topic content after reimport", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const source = new JSZip();
source.file(
"content.json",
JSON.stringify([
{
title: "策划",
rootTopic: {
id: "r",
title: "产品发布",
image: { src: "xap:resources/image.png", width: 48 },
structureClass: "org.xmind.ui.logic.right",
children: {
attached: [
{
id: "a",
title: "设计方案",
notes: { plain: { content: "备注第一行\n备注第二行" } },
labels: ["重点"],
style: {
properties: {
"fo:font-size": "22",
"fo:font-weight": "bold",
"svg:fill": "#fff4cc",
},
},
},
{ id: "b", title: "交付文件", href: "xap:attachments/需求.txt" },
{ id: "c", title: "定位设计", href: "xmind:#a" },
],
summary: [{ id: "summary", title: "准备阶段" }],
detached: [
{ id: "free", title: "待讨论", position: { x: 400, y: 300 } },
],
},
summaries: [{ range: "(0,1)", topicId: "summary" }],
boundaries: [{ range: "(0,1)" }],
},
relationships: [{ end1Id: "a", end2Id: "b", title: "交付" }],
},
{
title: "上线",
rootTopic: {
id: "root2",
title: "验收清单",
structureClass: "org.xmind.ui.org-chart.down",
children: { attached: [{ id: "item", title: "功能验收" }] },
},
},
])
);
source.file("attachments/需求.txt", "附件原文");
source.file("resources/image.png", pixel, { base64: true });
const input = page.locator("input[accept*=xmind]");
await input.setInputFiles({
name: "发布计划.xmind",
mimeType: "application/octet-stream",
buffer: await source.generateAsync({ type: "nodebuffer" }),
});
const canvas = page.locator(".drawnix");
await canvas.getByText("设计方案", { exact: true }).click();
await page.getByRole("button", { name: "备注", exact: true }).click();
await page.getByLabel("主题备注").fill("编辑后的备注");
const downloading = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 XMind", exact: true }).click();
const downloaded = await downloading;
expect(downloaded.suggestedFilename()).toBe("发布计划.xmind");
const stream = await downloaded.createReadStream();
const chunks = [];
for await (const chunk of stream!) chunks.push(chunk);
const bytes = Buffer.concat(chunks);
const exported = await JSZip.loadAsync(bytes);
const content = JSON.parse(
await exported.file("content.json")!.async("string")
);
expect(content).toHaveLength(2);
expect(content[0].rootTopic.children.attached[0].notes.plain.content).toBe(
"编辑后的备注"
);
expect(content[0].relationships[0].title).toBe("交付");
expect(
await exported
.file(content[0].rootTopic.image.src.replace(/^xap:/, ""))!
.async("base64")
).toBe(pixel);
const attachmentPath = content[0].rootTopic.children.attached[1].href.replace(
/^xap:/,
""
);
expect(await exported.file(attachmentPath)!.async("string")).toBe("附件原文");
await downloaded.saveAs(test.info().outputPath("发布计划.xmind"));
await page.getByRole("button", { name: "上线", exact: true }).click();
await expect(canvas.getByText("功能验收", { exact: true })).toBeVisible();
await input.setInputFiles({
name: "往返验证.xmind",
mimeType: "application/octet-stream",
buffer: bytes,
});
await expect(canvas.getByText("设计方案", { exact: true })).toHaveCSS(
"font-size",
"22px"
);
for (const label of ["准备阶段", "待讨论", "交付"])
await expect(canvas.getByText(label, { exact: true })).toBeVisible();
await expect(page.locator("[plait-geometry=true]")).toHaveCount(1);
await expect(page.locator("[plait-arrow-line=true]")).toHaveCount(1);
await expect(
canvas.locator('img[src^="data:image/png"], image[href^="data:image/png"]')
).toHaveCount(1);
await canvas.getByText("设计方案", { exact: true }).click();
await page.getByRole("button", { name: "备注", exact: true }).click();
await expect(page.getByLabel("主题备注")).toHaveValue("编辑后的备注");
await expect(
page.getByRole("button", { name: "移除标签 重点", exact: true })
).toBeVisible();
await canvas.getByText("定位设计", { exact: true }).click();
await page.getByRole("button", { name: "定位目标主题", exact: true }).click();
await expect(page.getByLabel("主题备注")).toHaveValue("编辑后的备注");
await page.getByRole("button", { name: "上线", exact: true }).click();
await expect(canvas.getByText("功能验收", { exact: true })).toBeVisible();
expect(errors).toEqual([]);
});

View File

@ -0,0 +1,72 @@
import { expect, type Page, test } from "@playwright/test";
import JSZip from "jszip";
async function upload(page: Page, files: Record<string, string>, name = "测试导图.xmind") {
const zip = new JSZip();
for (const [path, content] of Object.entries(files)) zip.file(path, content);
await page.locator('input[type=file][accept*=xmind]').setInputFiles({
name, mimeType: "application/octet-stream", buffer: await zip.generateAsync({ type: "nodebuffer" }),
});
}
test("modern XMind preserves sheets, free topics, summaries, boundaries, relationships and style", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const root = { id: "root", title: "新品项目", structureClass: "org.xmind.ui.logic.right", children: {
attached: [{ id: "research", title: "市场调研", style: { properties: { "svg:fill": "#fff4cc", "fo:font-size": 22, "fo:font-weight": "bold" } }, children: { attached: [{ id: "a", title: "用户访谈" }, { id: "b", title: "竞品分析" }] } }, { id: "design", title: "产品设计" }],
summary: [{ id: "summary", title: "启动阶段" }],
detached: [{ id: "free", title: "待讨论", position: { x: 450, y: 300 } }],
}, summaries: [{ range: "(0,1)", topicId: "summary" }], boundaries: [{ range: "(0,1)" }] };
await upload(page, { "content.json": JSON.stringify([
{ title: "产品计划", rootTopic: root, relationships: [{ end1Id: "research", end2Id: "design", title: "输入设计" }] },
{ title: "上线清单", rootTopic: { id: "root2", title: "正式上线", structureClass: "org.xmind.ui.org-chart.down", children: { attached: [{ id: "qa", title: "质量验收" }] } } },
]) });
const canvas = page.locator(".drawnix");
for (const label of ["新品项目", "用户访谈", "竞品分析", "启动阶段", "待讨论", "输入设计"]) await expect(canvas.getByText(label, { exact: true })).toBeVisible();
await expect(page.locator("[plait-geometry=true]")).toHaveCount(1);
await expect(page.locator("[plait-arrow-line=true]")).toHaveCount(1);
await expect(canvas.getByText("市场调研", { exact: true })).toHaveCSS("font-size", "22px");
await canvas.getByText("市场调研", { exact: true }).click();
await expect(page.getByLabel("节点字号")).toHaveValue("22");
await expect(page.getByRole("button", { name: "向右逻辑", exact: true })).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: "上线清单", exact: true }).click();
await expect(canvas.getByText("质量验收", { exact: true })).toBeVisible();
await expect(canvas.getByText("新品项目", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "产品计划", exact: true }).click();
await expect(canvas.getByText("输入设计", { exact: true })).toBeVisible();
await canvas.getByText("产品设计", { exact: true }).dblclick();
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.insertText("设计已更新");
await page.locator('.flow-board-canvas-shell').click({ position: { x: 100, y: 100 } });
await expect(canvas.getByText("设计已更新", { exact: true })).toBeVisible();
expect(errors).toEqual([]);
});
test("legacy XML XMind retains separate sheets and folded branches", async ({ page }) => {
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
await upload(page, { "content.xml": `<?xml version="1.0"?><xmap-content xmlns="urn:xmind:xmap:xmlns:content:2.0">
<sheet id="s1"><title>旧版计划</title><topic id="r1" structure-class="org.xmind.ui.logic.right"><title>旧版中心</title><children><topics type="attached"><topic id="c1" branch="folded" style-id="yellow"><title>折叠分支</title><children><topics type="attached"><topic id="c2"><title>内部任务</title></topic></topics></children></topic></topics></children></topic></sheet>
<sheet id="s2"><title>旧版归档</title><topic id="r2"><title>归档中心</title></topic></sheet></xmap-content>`,
"styles.xml": `<xmap-styles xmlns="urn:xmind:xmap:xmlns:style:2.0" xmlns:svg="http://www.w3.org/2000/svg"><styles><style id="yellow"><topic-properties svg:fill="#fff4cc" /></style></styles></xmap-styles>`,
});
const canvas = page.locator(".drawnix");
await expect(canvas.getByText("折叠分支", { exact: true })).toBeVisible();
await expect(canvas.getByText("内部任务", { exact: true })).toHaveCount(0);
await canvas.getByText("折叠分支", { exact: true }).click();
await page.keyboard.press("ControlOrMeta+/");
await expect(canvas.getByText("内部任务", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "旧版归档", exact: true }).click();
await expect(canvas.getByText("归档中心", { exact: true })).toBeVisible();
});
test("invalid import preserves the existing workbook and gives a readable error", async ({ page }) => {
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
await upload(page, { "content.json": JSON.stringify([{ title: "保留画布", rootTopic: { id: "root", title: "已有内容" } }]) });
await expect(page.locator(".drawnix").getByText("已有内容", { exact: true })).toBeVisible();
await upload(page, { "content.json": "broken json" }, "损坏文件.xmind");
await expect(page.getByText("XMind 内容解析失败,请在 XMind 中重新保存后再导入", { exact: true })).toBeVisible();
await expect(page.locator(".drawnix").getByText("已有内容", { exact: true })).toBeVisible();
await page.locator('input[type=file][accept*=xmind]').setInputFiles({ name: "伪装文件.xmind", mimeType: "application/octet-stream", buffer: Buffer.from("not a zip") });
await expect(page.getByText("无法打开 XMind 文件,请确认文件完整且未加密", { exact: true })).toBeVisible();
});

View File

@ -0,0 +1,101 @@
import { expect, test } from "@playwright/test";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import JSZip from "jszip";
// Optional, locally extracted fixtures; do not redistribute XMind's bundled samples.
const fixtureDirectory = process.env.XMIND_NATIVE_FIXTURES;
test("native XMind basic samples retain every topic through import and export", async ({
page,
}) => {
test.skip(
!fixtureDirectory,
"Set XMIND_NATIVE_FIXTURES to an extracted basic sample directory"
);
test.setTimeout(120_000);
const files = (await readdir(fixtureDirectory!)).filter((name) =>
name.endsWith(".xmind")
);
expect(files.length).toBeGreaterThan(0);
const titles = (topic: any): string[] => [
String(topic.title ?? "").trim() || "未命名主题",
...Object.values(topic.children || {}).flatMap((group) =>
(group as any[]).flatMap(titles)
),
];
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
for (const name of files)
await test.step(name, async () => {
const buffer = await readFile(path.join(fixtureDirectory!, name));
const input = await JSZip.loadAsync(buffer);
const original = JSON.parse(
await input.file("content.json")!.async("string")
);
await page
.locator("input[accept*=xmind]")
.setInputFiles({ name, mimeType: "application/octet-stream", buffer });
await expect(page.getByLabel("白板标题", { exact: true })).toHaveValue(
name.replace(/\.xmind$/, "")
);
const special = (
{
"org.xmind.ui.fishbone.leftHeaded": "fishbone",
"org.xmind.ui.timeline.sided.horizontal": "timeline",
"org.xmind.ui.timeline.vertical": "verticalTimeline",
"org.xmind.ui.treetable.toptitle": "treeTable",
"org.xmind.ui.treetable": "treeTableSide",
"org.xmind.ui.spreadsheet": "spreadsheet",
"org.xmind.ui.brace.right": "brace",
} as Record<string, string>
)[original[0].rootTopic.structureClass];
if (["treeTable", "treeTableSide", "spreadsheet"].includes(special)) {
const table = page.locator(
`.drawnix [data-special-table="${special}"]`
);
await expect(table).toBeVisible();
expect(
await table.locator("[data-special-table-cell]").count()
).toBeGreaterThan(1);
} else if (special === "verticalTimeline") {
// A straight SVG path has a zero-width geometric box despite its visible stroke.
const axis = page.locator(
'.drawnix [data-special-axis="verticalTimeline"] path'
);
await expect(axis).toHaveAttribute("d", /V/);
expect(Number(await axis.getAttribute("stroke-width"))).toBeGreaterThan(
0
);
} else if (special)
await expect(
page
.locator(
`.drawnix [data-special-structure="${special}"] ${special === "timeline" ? "circle" : "path"}`
)
.first()
).toBeVisible();
const downloadPromise = page.waitForEvent("download");
await page
.getByRole("button", { name: "导出 XMind", exact: true })
.click();
const download = await downloadPromise;
const stream = await download.createReadStream();
const chunks = [];
for await (const chunk of stream!) chunks.push(chunk);
const zip = await JSZip.loadAsync(Buffer.concat(chunks));
const output = JSON.parse(
await zip.file("content.json")!.async("string")
);
expect(output).toHaveLength(original.length);
if (special)
expect(output[0].rootTopic.structureClass).toBe(
original[0].rootTopic.structureClass
);
for (let i = 0; i < original.length; i++)
expect(titles(output[i].rootTopic).sort(), name).toEqual(
titles(original[i].rootTopic).sort()
);
expect(errors, name).toEqual([]);
});
});

View File

@ -0,0 +1,139 @@
import type { PlaitElement } from "@plait/core";
import { expect, test } from "@playwright/test";
import { normalizeFlowBoardSnapshot } from "@/app/api/flow-board/_utils";
import {
getBoundaryPoints,
getRelationshipPoints,
isMindAnnotation,
validateTopicRange,
} from "@/lib/flow-board/mind-annotations";
test("summaries and boundaries reject gaps, unrelated parents, and opposite root sides", () => {
expect(
validateTopicRange(
[
[0, 0],
[0, 1],
],
3
)
).toBeNull();
expect(
validateTopicRange([
[0, 0],
[0, 2],
])
).not.toBeNull();
expect(
validateTopicRange([
[0, 0],
[1, 1],
])
).not.toBeNull();
expect(
validateTopicRange(
[
[0, 1],
[0, 2],
],
2
)
).not.toBeNull();
expect(
validateTopicRange(
[
[0, 2],
[0, 3],
],
2
)
).toBeNull();
});
test("boundary encloses every expanded descendant and respects padding", () => {
expect(
getBoundaryPoints(
[
{ x: 100, y: 50, width: 80, height: 30 },
{ x: 260, y: -20, width: 100, height: 32 },
{ x: 260, y: 100, width: 120, height: 32 },
],
24
)
).toEqual([
[76, -44],
[404, 156],
]);
expect(getBoundaryPoints([])).toBeNull();
});
test("relationship curves route outside aligned topics in horizontal and vertical layouts", () => {
const source = { x: 0, y: 0, width: 100, height: 40 };
expect(
getRelationshipPoints(source, { x: 300, y: 0, width: 80, height: 40 })
).toEqual([
[50, 0],
[195, -72],
[340, 0],
]);
expect(
getRelationshipPoints(source, { x: 0, y: 200, width: 100, height: 40 })
).toEqual([
[100, 20],
[172, 120],
[100, 220],
]);
expect(
getRelationshipPoints(source, { x: -300, y: 0, width: 80, height: 40 })
).toEqual([
[50, 0],
[-105, -72],
[-260, 0],
]);
});
test("sheet snapshots preserve topic bindings and edited annotation styles", () => {
const children = [
{
id: "relation",
type: "arrow-line",
points: [
[10, 20],
[30, 40],
],
strokeColor: "#112233",
strokeWidth: 3,
mindAnnotation: {
kind: "relationship",
sourceId: "research",
targetId: "design",
},
},
{
id: "boundary",
type: "geometry",
points: [
[0, 0],
[100, 100],
],
mindAnnotation: { kind: "boundary", topicIds: ["research"], padding: 24 },
},
];
const restored = normalizeFlowBoardSnapshot(
JSON.parse(
JSON.stringify({
activeSheetId: "plan",
sheets: [
{ id: "plan", title: "计划", children },
{ id: "empty", title: "空白", children: [] },
],
})
)
);
expect(restored.children).toEqual(children);
expect(restored.sheets?.[1].children).toEqual([]);
expect((restored.children as PlaitElement[]).every(isMindAnnotation)).toBe(
true
);
expect(isMindAnnotation({ id: "old-arrow", type: "arrow-line" })).toBe(false);
});

View File

@ -0,0 +1,109 @@
import { expect, test } from "@playwright/test";
import type { PlaitElement } from "@plait/core";
import {
applyOutlineCommand,
renameOutlineTopic,
} from "@/lib/flow-board/mind-outline";
import { getTopicEntries } from "@/lib/flow-board/topic-details";
const topic = (id: string, children: PlaitElement[] = []): PlaitElement => ({
id,
type: "mind_child",
data: {
topic: { type: "paragraph", children: [{ text: id, bold: true }] },
image: { url: "picture" },
},
xmind: { notes: "保留备注" },
children,
});
const root = (...children: PlaitElement[]): PlaitElement => ({
...topic("root", children),
type: "mindmap",
layout: "standard",
rightNodeCount: children.length,
points: [[0, 0]],
});
const find = (children: PlaitElement[], id: string) =>
getTopicEntries(children).find((entry) => entry.element.id === id)!;
test("outline nesting preserves IDs and metadata and rejects moving into descendants", () => {
const source = [root(topic("a"), topic("b", [topic("c")]))];
const before = JSON.stringify(source);
const nested = applyOutlineCommand(source, "b", "indent");
expect(find(nested.children, "b").path).toEqual([0, 0, 0]);
expect(find(nested.children, "c").path).toEqual([0, 0, 0, 0]);
expect(find(nested.children, "b").element.xmind).toEqual({
notes: "保留备注",
});
expect(JSON.stringify(source)).toBe(before);
expect(() =>
applyOutlineCommand(nested.children, "a", {
targetId: "c",
placement: "child",
})
).toThrow("不能把主题");
const outdented = applyOutlineCommand(nested.children, "b", "outdent");
expect(find(outdented.children, "b").path).toEqual([0, 1]);
});
test("outline deletion adjusts summary range without assigning unrelated topics", () => {
const source = [
root(topic("a"), topic("b"), topic("c"), {
...topic("summary"),
start: 0,
end: 1,
}),
];
const removed = applyOutlineCommand(source, "a", "delete");
expect(find(removed.children, "summary").element).toMatchObject({
start: 0,
end: 0,
});
expect(() => applyOutlineCommand(source, "b", "down")).toThrow(
"概要范围断开"
);
expect(find(source, "summary").element).toMatchObject({ start: 0, end: 1 });
});
test("outline insertion expands folded parents and includes new topics inside a summary", () => {
const source = [
root(topic("a"), topic("b"), { ...topic("summary"), start: 0, end: 1 }),
];
const inserted = applyOutlineCommand(source, "a", "sibling");
expect(find(inserted.children, "summary").element).toMatchObject({
start: 0,
end: 2,
});
expect(find(inserted.children, inserted.selectedId).path).toEqual([0, 1]);
const folded = [{ ...root(topic("a")), isCollapsed: true }];
expect(
applyOutlineCommand(folded, "root", "child").children[0].isCollapsed
).toBe(false);
});
test("outline drag moves a subtree across branches and retains annotation references", () => {
const relation = {
id: "link",
type: "arrow-line",
mindAnnotation: { kind: "relationship", sourceId: "b", targetId: "c" },
};
const source = [root(topic("a", [topic("b")]), topic("c")), relation];
const moved = applyOutlineCommand(source, "b", {
targetId: "c",
placement: "child",
});
expect(find(moved.children, "b").path).toEqual([0, 1, 0]);
expect(moved.children[1]).toEqual(relation);
});
test("outline creates the first root and renaming preserves image and text marks", () => {
const created = applyOutlineCommand([], null, "child");
expect(created.children[0]).toMatchObject({
type: "mindmap",
id: created.selectedId,
});
const data = renameOutlineTopic(topic("a"), "新名称");
expect(data).toMatchObject({
image: { url: "picture" },
topic: { children: [{ text: "新名称", bold: true }] },
});
});

View File

@ -0,0 +1,160 @@
import { expect, test } from "@playwright/test";
import { normalizeFlowBoardSnapshot } from "@/app/api/flow-board/_utils";
test.describe("flow board snapshot compatibility", () => {
test("upgrades a legacy single-canvas snapshot without losing content", () => {
const legacyChild = { id: "root", type: "mindmap" };
const snapshot = normalizeFlowBoardSnapshot({
children: [legacyChild],
viewport: { zoom: 1.2 },
theme: { themeColorMode: "colorful" },
updatedAt: "2026-09-18T00:00:00.000Z",
});
expect(snapshot.version).toBe(2);
expect(snapshot.activeSheetId).toBe("sheet-1");
expect(snapshot.children).toEqual([legacyChild]);
expect(snapshot.sheets).toEqual([
{
id: "sheet-1",
title: "画布 1",
children: [legacyChild],
viewport: { zoom: 1.2 },
theme: { themeColorMode: "colorful" },
canvas: {
backgroundColor: "#f7f8fb",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
},
},
]);
});
test("mirrors the selected sheet into the legacy top-level fields", () => {
const snapshot = normalizeFlowBoardSnapshot({
activeSheetId: "research",
sheets: [
{
id: "plan",
title: "计划",
children: [{ id: "plan-root" }],
canvas: {
backgroundColor: "#ffffff",
gridColor: "#cad2df",
gridEnabled: true,
gridSize: 32,
},
},
{
id: "research",
title: "调研",
children: [{ id: "research-root" }],
viewport: { zoom: 0.8 },
canvas: {
backgroundColor: "#fff8e7",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
},
},
],
});
expect(snapshot.activeSheetId).toBe("research");
expect(snapshot.children).toEqual([{ id: "research-root" }]);
expect(snapshot.viewport).toEqual({ zoom: 0.8 });
expect(snapshot.canvas).toEqual({
backgroundColor: "#fff8e7",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
});
});
test("updates only the active sheet while preserving the other canvases", () => {
const snapshot = normalizeFlowBoardSnapshot({
activeSheetId: "research",
children: [{ id: "research-root-updated" }],
sheets: [
{
id: "plan",
title: "计划",
children: [{ id: "plan-root" }],
},
{
id: "research",
title: "调研",
children: [{ id: "research-root-old" }],
},
],
});
expect(snapshot.sheets).toEqual([
{
id: "plan",
title: "计划",
children: [{ id: "plan-root" }],
viewport: null,
theme: null,
canvas: {
backgroundColor: "#f7f8fb",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
},
},
{
id: "research",
title: "调研",
children: [{ id: "research-root-updated" }],
viewport: null,
theme: null,
canvas: {
backgroundColor: "#f7f8fb",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
},
},
]);
});
test("keeps canvas settings isolated to the active sheet", () => {
const snapshot = normalizeFlowBoardSnapshot({
activeSheetId: "research",
canvas: {
backgroundColor: "#eef8f1",
gridColor: "#aabbcc",
gridEnabled: true,
gridSize: 40,
},
sheets: [
{
id: "plan",
title: "计划",
children: [],
canvas: {
backgroundColor: "#ffffff",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
},
},
{
id: "research",
title: "调研",
children: [],
},
],
});
expect(snapshot.sheets?.[0]?.canvas).toEqual({
backgroundColor: "#ffffff",
gridColor: "#d8dde8",
gridEnabled: false,
gridSize: 24,
});
expect(snapshot.sheets?.[1]?.canvas).toEqual(snapshot.canvas);
});
});

View File

@ -0,0 +1,167 @@
import { expect, test } from "@playwright/test";
import {
arrangeSpecialMind,
specialNodeRect,
specialSubtreeBounds,
type SpecialLayoutNode,
} from "@/lib/flow-board/mind-special-layout";
const node = (
x: number,
y: number,
width = 100,
height = 40,
children: SpecialLayoutNode[] = []
): SpecialLayoutNode => ({
x,
y,
width,
height,
hGap: 0,
vGap: 0,
children,
origin: {},
});
const tree = () =>
node(0, 0, 120, 60, [
node(200, -100, 100, 40, [
node(400, -150, 180, 40),
node(400, -50, 110, 40),
]),
node(200, 0, 130, 40),
node(200, 100, 200, 40),
node(200, 200, 100, 40),
]);
test("fishbone places whole subtrees above/below a stable spine without overlap", () => {
const root = tree();
arrangeSpecialMind(root, "fishbone");
expect(specialNodeRect(root)).toEqual({ x: 0, y: 0, width: 120, height: 60 });
const a = specialSubtreeBounds(root.children[0]),
b = specialSubtreeBounds(root.children[1]),
c = specialSubtreeBounds(root.children[2]);
expect(a.y + a.height).toBeLessThan(30);
expect(b.y).toBeGreaterThan(30);
expect(c.x).toBeGreaterThan(a.x + a.width);
for (const branch of root.children)
expect(specialNodeRect(branch).x).toBeGreaterThan(120);
});
test("timeline milestones alternate on distinct increasing positions", () => {
const root = tree();
arrangeSpecialMind(root, "timeline");
const rects = root.children.map(specialNodeRect);
for (let i = 1; i < rects.length; i++)
expect(rects[i].x).toBeGreaterThan(rects[i - 1].x);
expect(rects[0].y).toBeLessThan(rects[1].y);
expect(rects[2].y).toBeLessThan(rects[3].y);
});
test("long titles and descendants reserve column space and brace preserves native tree layout", () => {
const root = tree();
root.children[0].children[0].width = 900;
arrangeSpecialMind(root, "timeline");
const first = specialSubtreeBounds(root.children[0]);
expect(specialNodeRect(root.children[2]).x).toBeGreaterThan(
first.x + first.width
);
const brace = tree(),
before = JSON.stringify(brace);
arrangeSpecialMind(brace, "brace");
expect(JSON.stringify(brace)).toBe(before);
});
test("root summaries remain outside the packed branch columns", () => {
const root = tree();
const summary = node(300, 10, 150, 40);
summary.origin = { start: 0, end: 1 };
root.children.push(summary);
arrangeSpecialMind(root, "fishbone");
const right = Math.max(
...root.children.slice(0, 4).map((child) => {
const rect = specialSubtreeBounds(child);
return rect.x + rect.width;
})
);
expect(specialNodeRect(summary).x).toBeGreaterThan(right);
});
test("vertical timeline alternates whole subtrees, keeps row order and mirrors descendants", () => {
const root = tree();
root.children[1].children.push(node(400, 0, 700, 120));
arrangeSpecialMind(root, "verticalTimeline");
const bounds = root.children.map(specialSubtreeBounds);
expect(bounds[0].x).toBeGreaterThan(60);
expect(bounds[1].x + bounds[1].width).toBeLessThan(60);
expect(specialNodeRect(root.children[1].children[0]).x).toBeLessThan(
specialNodeRect(root.children[1]).x
);
for (let i = 1; i < bounds.length; i++)
expect(bounds[i].y).toBeGreaterThan(bounds[i - 1].y + bounds[i - 1].height);
expect(specialNodeRect(root)).toEqual({ x: 0, y: 0, width: 120, height: 60 });
});
test("tree table creates aligned columns and lets parents span their descendant rows", () => {
const root = tree();
arrangeSpecialMind(root, "treeTable");
const [first, second] = root.children;
const [firstChild, secondChild] = first.children;
expect(root.tableCell).toMatchObject({ depth: 0, x: 0, y: 0 });
expect(root.tableCell!.width).toBeGreaterThan(root.tableCell!.height);
expect(first.tableCell).toMatchObject({ depth: 1, branch: 0 });
expect(first.tableCell!.height).toBe(
firstChild.tableCell!.height + secondChild.tableCell!.height
);
expect(firstChild.tableCell!.x).toBe(secondChild.tableCell!.x);
expect(firstChild.tableCell!.width).toBe(secondChild.tableCell!.width);
expect(second.tableCell!.y).toBeGreaterThanOrEqual(
secondChild.tableCell!.y + secondChild.tableCell!.height
);
expect(specialNodeRect(first).x).toBeGreaterThanOrEqual(first.tableCell!.x);
expect(specialNodeRect(first).x + specialNodeRect(first).width).toBeLessThanOrEqual(
first.tableCell!.x + first.tableCell!.width
);
});
test("side-title tree table spans the root beside every leaf row", () => {
const root = tree();
arrangeSpecialMind(root, "treeTableSide");
const [first, second] = root.children;
const leaves = root.children.flatMap((branch) =>
branch.children.length ? branch.children : [branch]
);
expect(root.tableCell).toMatchObject({ depth: 0, x: 0, y: 0 });
expect(root.tableCell!.height).toBe(78 * leaves.length);
expect(first.tableCell!.x).toBe(root.tableCell!.x + root.tableCell!.width);
expect(first.tableCell!.y).toBe(root.tableCell!.y);
expect(second.tableCell!.y).toBeGreaterThan(first.tableCell!.y);
expect(first.tableCell!.height).toBe(156);
});
test("spreadsheet uses XMind columns and stacks entries that share a labelled cell", () => {
const mondayA = node(400, 0, 100, 40);
mondayA.origin.xmind = { labels: ["Monday"] };
const mondayB = node(400, 50, 100, 40);
mondayB.origin.xmind = { labels: ["Monday"] };
const friday = node(400, 100, 100, 40);
friday.origin.xmind = { labels: ["Friday"] };
const happy = node(200, 0, 120, 40, [mondayA, mondayB, friday]);
const calmTuesday = node(400, 0, 100, 40);
calmTuesday.origin.xmind = { labels: ["Tuesday"] };
const calm = node(200, 100, 120, 40, [calmTuesday]);
const root = node(0, 0, 150, 60, [happy, calm]);
root.origin.spreadsheetColumns = ["Monday", "Tuesday", "Friday"];
arrangeSpecialMind(root, "spreadsheet");
expect(root.tableGrid?.columns).toEqual(["Monday", "Tuesday", "Friday"]);
expect(root.tableCell!.width).toBe(root.tableGrid!.width);
expect(happy.tableCell!.x).toBe(root.tableGrid!.x);
expect(mondayA.tableCell!.x).toBe(mondayB.tableCell!.x);
expect(mondayB.tableCell!.y).toBeGreaterThan(mondayA.tableCell!.y);
expect(friday.tableCell!.x).toBeGreaterThan(mondayA.tableCell!.x);
expect(calmTuesday.tableCell!.x).toBeGreaterThan(mondayA.tableCell!.x);
expect(calm.tableCell!.y).toBeGreaterThan(
happy.tableCell!.y + happy.tableCell!.height - 1
);
});

View File

@ -0,0 +1,79 @@
import { expect, test } from "@playwright/test";
import { normalizeFlowBoardSnapshot } from "@/app/api/flow-board/_utils";
import {
getSafeTopicHref,
getTopicDetails,
patchTopicDetails,
} from "@/lib/flow-board/topic-details";
test("topic notes, labels, links and resources survive snapshot serialization", () => {
const topic = {
id: "topic",
type: "mindmap",
xmind: {
notes: { plain: { content: "第一行\n第二行" } },
labels: ["标签一", "标签二"],
href: "https://example.com",
topicLinkId: "target",
attachment: {
name: "spec.txt",
url: "data:application/octet-stream;base64,eA==",
mimeType: "application/octet-stream",
},
},
data: {
image: { url: "data:image/png;base64,eA==", width: 32, height: 32 },
},
};
const snapshot = normalizeFlowBoardSnapshot(
JSON.parse(JSON.stringify({ children: [topic] }))
);
expect(snapshot.children[0]).toEqual(topic);
expect(getTopicDetails(topic)).toMatchObject({
notes: "第一行\n第二行",
labels: ["标签一", "标签二"],
topicLinkId: "target",
attachment: { name: "spec.txt" },
});
});
test("editing a note preserves image metadata and non-edited labels and links", () => {
const topic = {
id: "topic",
xmind: {
notes: { plain: { content: "旧备注" } },
labels: ["保留"],
image: { src: "xap:resources/a.png" },
href: "https://example.com",
markers: [{ markerId: "priority-1" }],
},
};
const metadata = patchTopicDetails(topic, { notes: "新备注" });
expect(metadata).toEqual({
...topic.xmind,
notes: { plain: { content: "新备注" } },
});
expect(
getTopicDetails({
...topic,
xmind: patchTopicDetails(topic, { notes: "" }),
}).notes
).toBe("");
});
test("links cannot execute script and external URLs require an allowed protocol", () => {
for (const value of [
"javascript:alert(1)",
"data:text/html,<script>x</script>",
"file:///etc/passwd",
"//example.com",
"xmind:#target",
])
expect(getSafeTopicHref(value)).toBeNull();
expect(getSafeTopicHref("https://example.com/a?q=b")).toBe(
"https://example.com/a?q=b"
);
expect(getSafeTopicHref("mailto:hello@example.com")).toBe(
"mailto:hello@example.com"
);
});

View File

@ -0,0 +1,111 @@
import { expect, test } from "@playwright/test";
import JSZip from "jszip";
import { exportXmind } from "@/lib/flow-board/xmind-export";
const topic = (id: string, title: string, children: any[] = []): any => ({
id,
type: "mind_child",
data: { topic: { children: [{ text: title }] } },
children,
});
const root = (id: string, title: string, children: any[] = []): any => ({
...topic(id, title, children),
type: "mindmap",
layout: "right",
points: [[0, 0]],
});
const read = async (result: Awaited<ReturnType<typeof exportXmind>>) =>
JSZip.loadAsync(await result.blob.arrayBuffer());
test("XMind export uses current active content, fresh IDs and valid cross-topic references", async () => {
const a = topic("a", "研发");
const b = topic("b", "上线");
const r = root("r", "最新中心", [
a,
b,
{ ...topic("sum", "第一阶段"), start: 0, end: 1 },
]);
a.xmind = {
topicLinkId: "b",
notes: { plain: { content: "计划\n第二行" } },
labels: ["工作"],
};
a.data.topic.children[0] = { text: "研发", bold: true, "font-size": 24 };
const elements = [
r,
{
id: "boundary",
type: "geometry",
mindAnnotation: { kind: "boundary", topicIds: ["a", "b"], padding: 18 },
},
{
id: "rel",
type: "arrow-line",
texts: [{ text: { children: [{ text: "前置" }] } }],
mindAnnotation: { kind: "relationship", sourceId: "a", targetId: "b" },
},
];
const snapshot = {
activeSheetId: "one",
children: elements,
sheets: [
{ id: "one", title: "计划", children: [root("r", "旧数据")] },
{ id: "two", title: "归档", children: [root("r", "第二中心")] },
],
};
const before = JSON.stringify(snapshot);
const result = await exportXmind(snapshot, "计划");
const zip = await read(result);
const sheets = JSON.parse(await zip.file("content.json")!.async("string"));
expect(sheets).toHaveLength(2);
const out = sheets[0].rootTopic;
expect(out.title).toBe("最新中心");
expect(out.children.attached[0].href).toBe(
`xmind:#${out.children.attached[1].id}`
);
expect(out.children.attached[0].notes.plain.content).toBe("计划\n第二行");
expect(out.children.attached[0].style.properties["fo:font-size"]).toBe("24");
expect(out.boundaries[0].range).toBe("(0,1)");
expect(out.summaries[0].topicId).toBe(out.children.summary[0].id);
expect(sheets[0].relationships[0]).toMatchObject({
end1Id: out.children.attached[0].id,
end2Id: out.children.attached[1].id,
title: "前置",
});
expect(sheets[1].rootTopic.id).not.toBe(out.id);
expect(JSON.stringify(snapshot)).toBe(before);
expect(result.warnings).toEqual([]);
});
test("XMind attachment bytes and manifest entries are portable", async () => {
const r = root("root", "附件");
r.xmind = {
attachment: {
name: "需求.txt",
url: "data:application/octet-stream;base64,5YaF5a65",
mimeType: "application/octet-stream",
},
};
const zip = await read(await exportXmind({ children: [r] }, "附件"));
const sheets = JSON.parse(await zip.file("content.json")!.async("string"));
const path = sheets[0].rootTopic.href.replace(/^xap:/, "");
expect(await zip.file(path)!.async("string")).toBe("内容");
expect(
JSON.parse(await zip.file("manifest.json")!.async("string"))["file-entries"]
).toHaveProperty([path]);
expect(
JSON.parse(await zip.file("metadata.json")!.async("string"))
.dataStructureVersion
).toBe("2");
});
test("unsupported board elements warn and empty mind sheets never export misleading workbooks", async () => {
const result = await exportXmind(
{ children: [root("r", "中心"), { id: "text", type: "text" }] },
"测试"
);
expect(result.warnings[0]).toContain("自由画布");
await expect(exportXmind({ children: [] }, "空白")).rejects.toThrow(
"没有可导出"
);
});