diff --git a/README.md b/README.md index 4222466..7b16297 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/App.tsx b/src/App.tsx index aad8ec7..be9d632 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -630,7 +630,7 @@ export default function App({ active, primary, tabId, restored, openPath, onMeta ]} /> ({ + ...([["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" }, diff --git a/src/export.ts b/src/export.ts index d334d07..857fe21 100644 --- a/src/export.ts +++ b/src/export.ts @@ -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"; @@ -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}`; @@ -35,7 +36,8 @@ 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(); for (const b of boxes) if (b.flow === "brace" && b.parent) groups.set(b.parent, [...(groups.get(b.parent) ?? []), b]); @@ -43,11 +45,11 @@ export function braces(boxes: Box[]) { 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), }; }); } diff --git a/src/importreport.test.ts b/src/importreport.test.ts index ccf2a9f..08eb075 100644 --- a/src/importreport.test.ts +++ b/src/importreport.test.ts @@ -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: "n" } }, style: { properties: { "fo:color": "#ff0000", "line-color": "#00ff00" } }, @@ -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", diff --git a/src/model.ts b/src/model.ts index e8bef75..4f37ec1 100644 --- a/src/model.ts +++ b/src/model.ts @@ -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 @@ -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, [NonNullable, 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 => @@ -264,9 +300,10 @@ 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 = { 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") { @@ -274,7 +311,7 @@ export function layout(root: Node): Box[] { 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 }))); @@ -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; @@ -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; diff --git a/src/structure.test.ts b/src/structure.test.ts index 387e7c4..4e98f5e 100644 --- a/src/structure.test.ts +++ b/src/structure.test.ts @@ -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); } @@ -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, 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 +}); diff --git a/src/xmind.ts b/src/xmind.ts index f443ae0..1b02160 100644 --- a/src/xmind.ts +++ b/src/xmind.ts @@ -254,6 +254,11 @@ const STRUCTURE: Record, 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 => { diff --git a/src/xmindxml.test.ts b/src/xmindxml.test.ts index f454af3..3c13b1c 100644 --- a/src/xmindxml.test.ts +++ b/src/xmindxml.test.ts @@ -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 () => { @@ -70,7 +70,7 @@ test("layoutOf covers the remaining XMind structure classes", () => { const cases: Record> = { "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 diff --git a/src/xmindxml.ts b/src/xmindxml.ts index 13071e6..cc71e63 100644 --- a/src/xmindxml.ts +++ b/src/xmindxml.ts @@ -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; } @@ -97,6 +99,8 @@ const EXACT: Record = { "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. */