feat: recreate XMind editing and file workflows
This commit is contained in:
parent
4ada7eaf38
commit
a492d082ed
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
17
app/dev/flow-board-preview/page.tsx
Normal file
17
app/dev/flow-board-preview/page.tsx
Normal 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
@ -7,6 +7,7 @@ type FlowBoardClientProps = {
|
||||
siteName: string;
|
||||
siteLogo: string | null;
|
||||
siteLogoDark: string | null;
|
||||
previewMode?: boolean;
|
||||
};
|
||||
|
||||
const DrawnixWorkspace = dynamic(
|
||||
|
||||
206
components/flow-board/mind-annotation-board.ts
Normal file
206
components/flow-board/mind-annotation-board.ts
Normal 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);
|
||||
}
|
||||
503
components/flow-board/mind-special-layout-board.ts
Normal file
503
components/flow-board/mind-special-layout-board.ts
Normal 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;
|
||||
}
|
||||
129
components/flow-board/xmind-annotation-panel.tsx
Normal file
129
components/flow-board/xmind-annotation-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1084
components/flow-board/xmind-editor-chrome.tsx
Normal file
1084
components/flow-board/xmind-editor-chrome.tsx
Normal file
File diff suppressed because it is too large
Load Diff
310
components/flow-board/xmind-outline.tsx
Normal file
310
components/flow-board/xmind-outline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
329
components/flow-board/xmind-topic-panel.tsx
Normal file
329
components/flow-board/xmind-topic-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
docs/XMind-26.05-安装包核验.md
Normal file
41
docs/XMind-26.05-安装包核验.md
Normal 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 项。新增内容涉及浮动工具栏显示策略、主题添加按钮、编辑器偏好、默认中日韩字体、默认缩放、一周起始日、快捷输入启动设置、专区和打印选项。
|
||||
|
||||
这些是安装包中的功能线索,仍需原生交互核对后才能认定具体行为或版本差异。原生应用界面连接当前超时,未完成界面实测;不以文案存在代替功能验收。
|
||||
99
docs/XMind-复刻功能对照.md
Normal file
99
docs/XMind-复刻功能对照.md
Normal 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 的列扩展和标签映射;列增删/改名的专用交互、复杂嵌套子树、原生应用打开导出文件和逐像素多主题对照仍待实现。
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
123
lib/flow-board/mind-annotations.ts
Normal file
123
lib/flow-board/mind-annotations.ts
Normal 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],
|
||||
];
|
||||
}
|
||||
@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
183
lib/flow-board/mind-outline.ts
Normal file
183
lib/flow-board/mind-outline.ts
Normal 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() || "未命名主题" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
394
lib/flow-board/mind-special-layout.ts
Normal file
394
lib/flow-board/mind-special-layout.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
128
lib/flow-board/topic-details.ts
Normal file
128
lib/flow-board/topic-details.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
355
lib/flow-board/xmind-export.ts
Normal file
355
lib/flow-board/xmind-export.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
657
lib/flow-board/xmind-import.ts
Normal file
657
lib/flow-board/xmind-import.ts
Normal 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 文件"
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
|
||||
52
patches/@drawnix__drawnix@0.4.0-2.patch
Normal file
52
patches/@drawnix__drawnix@0.4.0-2.patch
Normal file
File diff suppressed because one or more lines are too long
46
patches/@plait-board__react-text@0.4.0-2.patch
Normal file
46
patches/@plait-board__react-text@0.4.0-2.patch
Normal 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
35
patches/README.md
Normal 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.
|
||||
23
playwright.flow-board-ui.config.ts
Normal file
23
playwright.flow-board-ui.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
12
playwright.flow-board.config.ts
Normal file
12
playwright.flow-board.config.ts
Normal 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
86
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
21
proxy.ts
21
proxy.ts
@ -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();
|
||||
}
|
||||
|
||||
|
||||
41
scripts/flow-board/inspect-xmind-package.py
Normal file
41
scripts/flow-board/inspect-xmind-package.py
Normal 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))
|
||||
106
tests/flow-board/annotations.spec.ts
Normal file
106
tests/flow-board/annotations.spec.ts
Normal 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);
|
||||
});
|
||||
224
tests/flow-board/matrix-tables.spec.ts
Normal file
224
tests/flow-board/matrix-tables.spec.ts
Normal 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([]);
|
||||
});
|
||||
158
tests/flow-board/outline.spec.ts
Normal file
158
tests/flow-board/outline.spec.ts
Normal 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([]);
|
||||
});
|
||||
123
tests/flow-board/special-layouts.spec.ts
Normal file
123
tests/flow-board/special-layouts.spec.ts
Normal 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([]);
|
||||
});
|
||||
194
tests/flow-board/topic-details.spec.ts
Normal file
194
tests/flow-board/topic-details.spec.ts
Normal 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();
|
||||
});
|
||||
108
tests/flow-board/tree-table.spec.ts
Normal file
108
tests/flow-board/tree-table.spec.ts
Normal 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([]);
|
||||
});
|
||||
108
tests/flow-board/vertical-timeline.spec.ts
Normal file
108
tests/flow-board/vertical-timeline.spec.ts
Normal 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([]);
|
||||
});
|
||||
132
tests/flow-board/xmind-export.spec.ts
Normal file
132
tests/flow-board/xmind-export.spec.ts
Normal 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([]);
|
||||
});
|
||||
72
tests/flow-board/xmind-import.spec.ts
Normal file
72
tests/flow-board/xmind-import.spec.ts
Normal 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();
|
||||
});
|
||||
101
tests/flow-board/xmind-native-fixtures.spec.ts
Normal file
101
tests/flow-board/xmind-native-fixtures.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
139
tests/routes/flow-board-annotations.test.ts
Normal file
139
tests/routes/flow-board-annotations.test.ts
Normal 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);
|
||||
});
|
||||
109
tests/routes/flow-board-outline.test.ts
Normal file
109
tests/routes/flow-board-outline.test.ts
Normal 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 }] },
|
||||
});
|
||||
});
|
||||
160
tests/routes/flow-board-snapshot.test.ts
Normal file
160
tests/routes/flow-board-snapshot.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
167
tests/routes/flow-board-special-layout.test.ts
Normal file
167
tests/routes/flow-board-special-layout.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
79
tests/routes/flow-board-topic-details.test.ts
Normal file
79
tests/routes/flow-board-topic-details.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
111
tests/routes/flow-board-xmind-export.test.ts
Normal file
111
tests/routes/flow-board-xmind-export.test.ts
Normal 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(
|
||||
"没有可导出"
|
||||
);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user