Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The ‹ chip on a node collapses its branch. The map autosaves to local storage;

## Not yet

Direction variants of XMind structures (up-pointing org chart, left-hand tree), which open as the nearest structure; XMind's smileys, arrows, font sizes and line styles. An import lists what it could not keep.
Vertical timelines, row-wise matrices, tree tables and the unbalanced map, which open as the nearest structure; XMind's smileys, arrows, font sizes and line styles. An import lists what it could not keep.

## Added

Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ export default function App({ active, primary, tabId, restored, openPath, onMeta
]} />
<Menu label="View" items={[
{ heading: "Structure" },
...([["right", "Logic (right)"], ["left", "Logic (left)"], ["map", "Map (both sides)"], ["down", "Org chart (down)"], ["tree", "Tree (indented)"], ["fish", "Fishbone"], ["line", "Timeline"], ["brace", "Brace"], ["matrix", "Matrix"]] as const).map(([v, l]) => ({
...([["right", "Logic (right)"], ["left", "Logic (left)"], ["map", "Map (both sides)"], ["map-ccw", "Map (anticlockwise)"], ["down", "Org chart (down)"], ["up", "Org chart (up)"], ["tree", "Tree (right)"], ["tree-left", "Tree (left)"], ["fish", "Fishbone (head right)"], ["fish-left", "Fishbone (head left)"], ["line", "Timeline"], ["brace", "Brace (right)"], ["brace-left", "Brace (left)"], ["matrix", "Matrix"]] as const).map(([v, l]) => ({
label: l, checked: (s.root.layout ?? "right") === v, on: () => setDocProp({ layout: v }),
})),
{ heading: "Theme" },
Expand Down
12 changes: 7 additions & 5 deletions src/export.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, Node, findNode, thumbW, validImage, visibleIds } from "./model";
import { Box, Node, findNode, mirrorPath, thumbW, unflip, validImage, visibleIds } from "./model";
import { summaryD, summaryGeo } from "./summary";
import { progressText } from "./task";
import { runsSvg, validRuns } from "./rich";
Expand All @@ -12,7 +12,8 @@ export function edgePoint(b: Box, tx: number, ty: number) {
}

/** Curve from parent `p` to child `b`, leaving on the side the child grows towards. */
export function branchD(b: Box, p: Box) {
export function branchD(b: Box, p: Box): string {
if (b.flip) return mirrorPath(branchD(unflip(b), unflip(p)), b.flip); // drawn in the base structure, then mirrored
if (b.flow === "down") {
const x0 = p.x + p.w / 2, y0 = p.y + p.h, x1 = b.x + b.w / 2, my = (y0 + b.y) / 2;
return `M${x0} ${y0} C${x0} ${my} ${x1} ${my} ${x1} ${b.y}`;
Expand All @@ -35,19 +36,20 @@ export function branchD(b: Box, p: Box) {
}

/** Curly braces for the brace structure: one per parent, spanning its children, with a stem back to the parent. */
export function braces(boxes: Box[]) {
export function braces(all: Box[]) {
const flip = all.find((b) => b.flip)?.flip, boxes = all.map(unflip); // drawn in the base structure, then mirrored
const byId = Object.fromEntries(boxes.map((b) => [b.id, b]));
const groups = new Map<string, Box[]>();
for (const b of boxes) if (b.flow === "brace" && b.parent) groups.set(b.parent, [...(groups.get(b.parent) ?? []), b]);
return [...groups].map(([pid, kids]) => {
const p = byId[pid];
const xc = Math.min(...kids.map((k) => k.x)), y0 = Math.min(...kids.map((k) => k.y)), y1 = Math.max(...kids.map((k) => k.y + k.h));
const ym = (y0 + y1) / 2, x0 = p.x + p.w;
if (y1 - y0 < 70) return { id: kids[0].id, d: `M${x0} ${ym}H${xc}` }; // a row or two: a plain line will do
if (y1 - y0 < 70) return { id: kids[0].id, d: mirrorPath(`M${x0} ${ym}H${xc}`, flip) }; // a row or two: a plain line will do
const xr = xc - 10, xl = xr - 12, xt = xl - 12; // right end, spine and tip of the brace
return {
id: kids[0].id,
d: `M${x0} ${ym}H${xt}M${xr} ${y0}Q${xl} ${y0} ${xl} ${y0 + 12}V${ym - 12}Q${xl} ${ym} ${xt} ${ym}Q${xl} ${ym} ${xl} ${ym + 12}V${y1 - 12}Q${xl} ${y1} ${xr} ${y1}`,
d: mirrorPath(`M${x0} ${ym}H${xt}M${xr} ${y0}Q${xl} ${y0} ${xl} ${y0 + 12}V${ym - 12}Q${xl} ${ym} ${xt} ${ym}Q${xl} ${ym} ${xl} ${ym + 12}V${y1 - 12}Q${xl} ${y1} ${xr} ${y1}`, flip),
};
});
}
Expand Down
4 changes: 2 additions & 2 deletions src/importreport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const json = (rootTopic: object, extra: object = {}) => zip({ "content.json": JS
test("JSON import lists what it drops", async () => {
const bytes = json(
{
id: "r", title: "root", structureClass: "org.xmind.ui.map.anticlockwise",
id: "r", title: "root", structureClass: "org.xmind.ui.map.unbalanced",
markers: [{ markerId: "priority-1" }, { markerId: "flag-red" }, { markerId: "symbol-wrong" }],
notes: { plain: { content: "n" }, html: { content: "<b>n</b>" } },
style: { properties: { "fo:color": "#ff0000", "line-color": "#00ff00" } },
Expand All @@ -26,7 +26,7 @@ test("JSON import lists what it drops", async () => {
const { roots, lost } = await importXmindReport(bytes);
expect(roots[0].children.map((c) => c.text)).toEqual(["kid", "float"]); // the floating topic is kept
expect(lost).toEqual(expect.arrayContaining([
"structure map.anticlockwise opened as map",
"structure map.unbalanced opened as map",
"markers with no counterpart dropped",
"rich-text notes flattened to plain text",
"topic styles other than bold, italic, strike, text colour and fill dropped",
Expand Down
62 changes: 59 additions & 3 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export type Node = {
shape?: Shape;
float?: { x: number; y: number }; // top-level topics only: free-standing at this canvas position, no branch to the root
// Document-level settings, only meaningful on the root node.
layout?: "right" | "left" | "map" | "down" | "tree" | "fish" | "line" | "brace" | "matrix"; // logic chart (right or left), two-sided map, org chart, tree chart, fishbone, timeline, brace map, matrix
layout?: "right" | "left" | "map" | "down" | "tree" | "fish" | "line" | "brace" | "matrix" | "up" | "tree-left" | "brace-left" | "fish-left" | "map-ccw"; // logic chart (right or left), two-sided map, org chart, tree chart, fishbone, timeline, brace map, matrix
theme?: string;
themeColors?: string[]; // the "custom" theme: central topic and accent, then six branch colours (#rrggbb)
title?: string; // sheet name (tab label); falls back to the central topic text
Expand All @@ -39,8 +39,44 @@ export type Box = {
branch: number; // index of the top-level ancestor (-1 for the root)
flow: "right" | "down" | "tree" | "fish" | "line" | "brace" | "matrix"; // how branches run: sideways, top-down (org chart), an indented list (tree chart), along a fishbone or a timeline
anchor?: { x: number; y: number }; // fishbone and timeline: where this topic's connector starts or meets the line
flip?: Flip; // the structure is a mirror image of its base (org chart up, tree left…): the box is already mirrored, `unflip` gives the base geometry
};

export type Flip = { h?: boolean; v?: boolean }; // mirrored left-right and/or top-bottom, about the central topic's origin

/** Structures drawn as the mirror image of another: the base layout and the mirror axes. */
const VARIANTS: Partial<Record<NonNullable<Node["layout"]>, [NonNullable<Node["layout"]>, Flip]>> = {
up: ["down", { v: true }],
"tree-left": ["tree", { h: true }],
"brace-left": ["brace", { h: true }],
"fish-left": ["fish", { h: true }],
"map-ccw": ["map", { h: true }],
};

/** The box in its base structure's own coordinates, before mirroring. */
export const unflip = (b: Box): Box => {
const f = b.flip;
if (!f) return b;
const { flip: _flip, ...rest } = b;
return {
...rest,
x: f.h ? -(b.x + b.w) : b.x,
y: f.v ? -(b.y + b.h) : b.y,
side: f.h ? (-b.side as 1 | -1) : b.side,
...(b.anchor ? { anchor: { x: f.h ? -b.anchor.x : b.anchor.x, y: f.v ? -b.anchor.y : b.anchor.y } } : {}),
};
};

/** Mirror an absolute SVG path (M L C Q H V, as the connector code writes them) the same way. */
export function mirrorPath(d: string, f?: Flip) {
if (!f) return d;
return d.replace(/([MLCQHV])([^MLCQHV]*)/g, (_, c: string, args: string) => {
const nums = (args.match(/-?\d*\.?\d+(?:e-?\d+)?/gi) ?? []).map(Number);
const out = nums.map((n, i) => (c === "H" || (c !== "V" && i % 2 === 0) ? (f.h ? -n : n) : f.v ? -n : n));
return c + out.join(" ");
});
}

export const mk = (text = ""): Node => ({ id: crypto.randomUUID(), text, children: [] });

const map = (n: Node, id: string, f: (n: Node) => Node): Node =>
Expand Down Expand Up @@ -264,17 +300,18 @@ function timeline(root: Node, alternate = true): Raw[] {
* "down": org chart. "tree": indented tree chart. "fish": fishbone. "line": timeline.
*/
export function layout(root: Node): Box[] {
const [base, flip] = VARIANTS[root.layout ?? "right"] ?? [root.layout ?? "right", undefined];
const kids = root.children.filter((c) => !c.float); // floating topics are placed on their own below
const flows: Record<string, Box["flow"]> = { down: "down", tree: "tree", fish: "fish", line: "line", matrix: "matrix", brace: "brace" };
const flow = flows[root.layout ?? ""] ?? "right";
const flow = flows[base] ?? "right";
const out: Box[] = [];

if (flow !== "right" && flow !== "brace") {
const raw = { down: vertical, tree: indented, fish: fishbone, line: timeline, matrix: (r: Node) => timeline(r, false) }[flow]({ ...root, children: kids });
out.push(...raw.map((b): Box => ({ ...b, side: 1, branch: -1, flow })));
} else {
// Sideways structures share one geometry: "right" and "brace" grow right, "left" grows left, "map" splits both ways.
const cut = root.collapsed ? kids.length : root.layout === "map" ? Math.ceil(kids.length / 2) : root.layout === "left" ? 0 : kids.length;
const cut = root.collapsed ? kids.length : base === "map" ? Math.ceil(kids.length / 2) : base === "left" ? 0 : kids.length;
const right = half({ ...root, children: kids.slice(0, cut) });
out.push(...right.map((b): Box => ({ ...b, side: 1, branch: -1, flow })));

Expand All @@ -286,6 +323,20 @@ export function layout(root: Node): Box[] {
}
}

if (flip) {
// The mirror image of the base structure; floating topics below keep their own absolute positions.
for (const [i, b] of out.entries()) {
out[i] = {
...b,
x: flip.h ? -(b.x + b.w) : b.x,
y: flip.v ? -(b.y + b.h) : b.y,
side: flip.h ? (-b.side as 1 | -1) : b.side,
...(b.anchor ? { anchor: { x: flip.h ? -b.anchor.x : b.anchor.x, y: flip.v ? -b.anchor.y : b.anchor.y } } : {}),
flip,
};
}
}

for (const f of root.children.filter((c) => c.float)) {
const raw = half(f);
const dx = f.float!.x - raw[0].x, dy = f.float!.y - raw[0].y;
Expand All @@ -308,6 +359,11 @@ export const toMarkdown = (n: Node, depth = 0): string =>
/** Arrow-key navigation over the visible layout. */
export function neighbour(root: Node, boxes: Box[], id: string, dir: "left" | "right" | "up" | "down"): string {
const me = boxes.find((b) => b.id === id)!;
// Down, tree and fishbone arrows follow the structure itself (parent, first child), so a mirrored one swaps them.
if (me.flip && (me.flow === "down" || me.flow === "tree" || me.flow === "fish")) {
const swap = { left: "right", right: "left", up: "down", down: "up" } as const;
if ((me.flip.h && (dir === "left" || dir === "right")) || (me.flip.v && (dir === "up" || dir === "down"))) dir = swap[dir];
}
const firstKid = () => (findNode(root, id)!.collapsed ? undefined : findNode(root, id)!.children.find((c) => !c.float))?.id ?? id;
if (me.flow === "down") {
if (dir === "up") return me.parent ?? id;
Expand Down
62 changes: 61 additions & 1 deletion src/structure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ test("summaries are only drawn in sideways layouts", () => {
});

test("xmind export/import keeps the structure", async () => {
for (const kind of ["right", "left", "map", "down", "tree", "fish", "line", "brace", "matrix"] as const) {
for (const kind of ["right", "left", "map", "down", "tree", "fish", "line", "brace", "matrix", "up", "tree-left", "brace-left", "fish-left", "map-ccw"] as const) {
const back = await importXmindSheets(exportXmind({ ...mk("r"), layout: kind }));
expect(back[0].layout ?? "right").toBe(kind);
}
Expand Down Expand Up @@ -230,3 +230,63 @@ test("matrix: topics along the top, every stack below, nothing overlaps", () =>
expect(p.x + p.w <= q.x || q.x + q.w <= p.x || p.y + p.h <= q.y || q.y + q.h <= p.y, `${p.id} overlaps ${q.id}`).toBe(true);
}
});

// Mirrored structures: the base layout flipped about the central topic, with connectors, braces and arrow keys following.
import { branchD } from "./export";
import { Flip, unflip } from "./model";

const sample = (kind: Node["layout"]) => {
let r: Node = { ...mk("root"), layout: kind };
for (const t of ["a", "b", "c"]) r = addChild(r, r.id, mk(t));
r = addChild(r, r.children[0].id, mk("a1"));
return r;
};

test.each([
["up", "down", { v: true }],
["tree-left", "tree", { h: true }],
["brace-left", "brace", { h: true }],
["fish-left", "fish", { h: true }],
["map-ccw", "map", { h: true }],
] as [Node["layout"], Node["layout"], Flip][])("%s is the mirror image of %s", (kind, base, flip) => {
const mirrored = layout(sample(kind)), plain = layout(sample(base));
expect(mirrored).toHaveLength(plain.length);
for (const [i, m] of mirrored.entries()) {
const p = plain[i];
expect(m.flip).toEqual(flip);
expect(m.w).toBe(p.w);
expect(m.h).toBe(p.h);
expect(Math.abs(unflip(m).x - p.x)).toBeLessThan(1e-9);
expect(Math.abs(unflip(m).y - p.y)).toBeLessThan(1e-9);
}
const lean = (bs: ReturnType<typeof layout>, axis: "x" | "y") => {
const size = axis === "x" ? "w" : "h";
return Math.sign(bs.slice(1).reduce((t, b) => t + b[axis] + b[size] / 2 - (bs[0][axis] + bs[0][size] / 2), 0));
};
// The branches lean to the opposite side of the central topic.
if (flip.h && kind !== "map-ccw") expect(lean(mirrored, "x")).toBe(-lean(plain, "x"));
if (flip.v) expect(lean(mirrored, "y")).toBe(-lean(plain, "y"));
});

test("connectors of a mirrored structure are the base connectors mirrored", () => {
const down = layout(sample("down")), up = layout(sample("up"));
const d0 = branchD(down[1], down[0]), d1 = branchD(up[1], up[0]);
const nums = (d: string) => d.match(/-?\d*\.?\d+/g)!.map(Number);
const [a, b] = [nums(d0), nums(d1)];
expect(b.length).toBe(a.length);
b.forEach((n, i) => expect(Math.abs(n - (i % 2 === 1 ? -a[i] : a[i]))).toBeLessThan(1e-6)); // y mirrored, x kept
const leftBrace = braces(layout({ ...sample("brace-left"), children: sample("brace-left").children.concat([mk("d"), mk("e"), mk("f")]) }));
expect(leftBrace.length).toBeGreaterThan(0);
expect(leftBrace[0].d).toMatch(/-/); // drawn on the negative-x side
});

test("arrow keys follow a mirrored structure", () => {
const r = sample("tree-left"), boxes = layout(r), [a] = r.children;
expect(neighbour(r, boxes, a.id, "right")).toBe(r.id); // towards the central topic, which now sits to the right
expect(neighbour(r, boxes, a.id, "left")).toBe(a.children[0].id); // away from it, into the children
const up = sample("up"), ub = layout(up);
expect(neighbour(up, ub, up.children[0].id, "down")).toBe(up.id); // the parent is below
expect(neighbour(up, ub, up.id, "up")).toBe(up.children[0].id);
const ccw = sample("map-ccw"), cb = layout(ccw);
expect(cb.find((b) => b.id === ccw.children[0].id)!.side).toBe(-1); // the first half of a map now grows leftwards
});
5 changes: 5 additions & 0 deletions src/xmind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,11 @@ const STRUCTURE: Record<NonNullable<Node["layout"]>, string> = {
left: "org.xmind.ui.logic.left",
brace: "org.xmind.ui.brace.right",
matrix: "org.xmind.ui.spreadsheet.column",
up: "org.xmind.ui.org-chart.up",
"tree-left": "org.xmind.ui.tree.left",
"brace-left": "org.xmind.ui.brace.left",
"fish-left": "org.xmind.ui.fishbone.leftHeaded",
"map-ccw": "org.xmind.ui.map.anticlockwise",
};

const toSheet = (root: Node, i: number, out: Out): XSheet => {
Expand Down
4 changes: 2 additions & 2 deletions src/xmindxml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ test("sheetsFromXml maps an XMind 8 document", () => {

test("layoutOf", () => {
expect(["org.xmind.ui.fishbone.leftHeaded", "org.xmind.ui.org-chart.down", "org.xmind.ui.tree.right", "org.xmind.ui.map.clockwise", "org.xmind.ui.logic.right", "org.xmind.ui.matrix", "org.xmind.ui.timeline.vertical"].map(layoutOf))
.toEqual(["fish", "down", "tree", "map", undefined, undefined, "line"]);
.toEqual(["fish-left", "down", "tree", "map", undefined, undefined, "line"]);
});

test("a zip with only content.xml opens; content.json wins when both exist", async () => {
Expand All @@ -70,7 +70,7 @@ test("layoutOf covers the remaining XMind structure classes", () => {
const cases: Record<string, ReturnType<typeof layoutOf>> = {
"org.xmind.ui.logic.left": "left",
"org.xmind.ui.brace.right": "brace",
"org.xmind.ui.brace.left": "brace",
"org.xmind.ui.brace.left": "brace-left",
"org.xmind.ui.spreadsheet.column": "matrix",
"org.xmind.ui.structure.spreadsheet.row": "matrix",
"org.xmind.ui.treetable.toptitle": "tree", // nearest match
Expand Down
14 changes: 9 additions & 5 deletions src/xmindxml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,17 @@ export function parseXml(src: string): XNode {
const kid = (n: XNode, name: string) => n.children.find((c) => c.name === name);
const allText = (n: XNode): string => n.text.trim() || n.children.map(allText).filter(Boolean).join("\n");

/** Logic, map, org chart, tree chart and fishbone; anything else (matrix, timeline…) stays the default logic chart. */
/** The structure for an XMind structure class, or undefined for the default logic chart. Variants the editor lacks open as the nearest one. */
export function layoutOf(structureClass: string): Node["layout"] | undefined {
if (structureClass.includes("logic.left")) return "left";
if (structureClass.includes("brace")) return "brace"; // brace.left opens as the right-hand brace
if (structureClass.includes("brace")) return structureClass.includes("brace.left") ? "brace-left" : "brace";
if (structureClass.includes("spreadsheet")) return "matrix"; // XMind's matrix, row or column
if (structureClass.includes("fishbone")) return "fish";
if (structureClass.includes("fishbone")) return structureClass.includes("leftHeaded") ? "fish-left" : "fish";
if (structureClass.includes("timeline")) return "line"; // every timeline variant opens as the horizontal one
if (structureClass.includes("org-chart")) return "down";
if (structureClass.includes("tree")) return "tree";
if (structureClass.includes("org-chart")) return structureClass.includes("org-chart.up") ? "up" : "down";
if (structureClass.includes("treetable")) return "tree"; // a table with tree rows: nearest is the indented tree
if (structureClass.includes("tree")) return structureClass.includes("tree.left") ? "tree-left" : "tree";
if (structureClass.includes("map.anticlockwise")) return "map-ccw";
if (structureClass.includes("map")) return "map";
return undefined;
}
Expand All @@ -97,6 +99,8 @@ const EXACT: Record<string, string> = {
"org.xmind.ui.logic.right": "right", "org.xmind.ui.logic.left": "left", "org.xmind.ui.map.clockwise": "map",
"org.xmind.ui.org-chart.down": "down", "org.xmind.ui.tree.right": "tree", "org.xmind.ui.fishbone.rightHeaded": "fish",
"org.xmind.ui.timeline.horizontal": "line", "org.xmind.ui.brace.right": "brace", "org.xmind.ui.spreadsheet.column": "matrix",
"org.xmind.ui.org-chart.up": "up", "org.xmind.ui.tree.left": "tree-left", "org.xmind.ui.brace.left": "brace-left",
"org.xmind.ui.fishbone.leftHeaded": "fish-left", "org.xmind.ui.map.anticlockwise": "map-ccw",
};

/** Records a structure XMind uses that opens as the nearest one here. An absent class is XMind's default and loses nothing. */
Expand Down
Loading