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

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.
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)"], ["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 (horizontal)"], ["vline", "Timeline (vertical)"], ["brace", "Brace (right)"], ["brace-left", "Brace (left)"], ["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 (horizontal)"], ["vline", "Timeline (vertical)"], ["brace", "Brace (right)"], ["brace-left", "Brace (left)"], ["matrix", "Matrix (columns)"], ["matrix-row", "Matrix (rows)"]] as const).map(([v, l]) => ({
label: l, checked: (s.root.layout ?? "right") === v, on: () => setDocProp({ layout: v }),
})),
{ heading: "Theme" },
Expand Down
6 changes: 6 additions & 0 deletions src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export function branchD(b: Box, p: Box): string {
return `M${x0} ${y0} C${x0} ${my} ${x1} ${my} ${x1} ${b.y}`;
}
if (b.flow === "brace") return ""; // drawn per parent by `braces`
if (b.flow === "rmatrix") {
const a = b.anchor;
if (a && b.depth === 1) return `M${a.x} ${a.y}V${b.y + b.h / 2}H${b.x}`; // down the line from the previous row header, then across
if (a) return `M${a.x} ${a.y}H${b.x}`; // along the row from the previous cell
return `M${p.x + 12} ${p.y + p.h}V${b.y + b.h / 2}H${b.x}`; // a stack: down from its cell, then across
}
if (b.flow === "vline" && b.anchor) return `M${b.anchor.x} ${b.anchor.y}V${b.y + b.h / 2}H${b.side === 1 ? b.x : b.x + b.w}`; // down the line from the previous topic, then across to this one
if (b.flow === "line" || b.flow === "matrix") {
if (b.anchor) return `M${b.anchor.x} ${b.anchor.y}H${b.x}`; // along the line from the previous topic
Expand Down
4 changes: 2 additions & 2 deletions src/importreport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ test("a clean file reports nothing", async () => {
});

test("XML import lists what it drops", async () => {
const xml = `<xmap-content><sheet><topic id="r" structure-class="org.xmind.ui.spreadsheet.row"><title>r</title>
const xml = `<xmap-content><sheet><topic id="r" structure-class="org.xmind.ui.treetable"><title>r</title>
<marker-refs><marker-ref marker-id="smiley-smile"/></marker-refs>
<children><topics type="attached"><topic id="a"><title>a</title></topic></topics><topics type="detached"><topic id="f"><title>f</title></topic></topics></children>
</topic><relationships><relationship id="x" end1="r" end2="a"/></relationships></sheet></xmap-content>`;
const { lost } = await importXmindReport(zip({ "content.xml": xml }));
expect(lost).toEqual(expect.arrayContaining([
"structure spreadsheet.row opened as matrix", "markers with no counterpart dropped", "floating topics dropped", "relationships dropped",
"structure treetable opened as tree", "markers with no counterpart dropped", "floating topics dropped", "relationships dropped",
]));
});

Expand Down
72 changes: 68 additions & 4 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" | "vline" | "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
layout?: "right" | "left" | "map" | "down" | "tree" | "fish" | "line" | "brace" | "matrix" | "matrix-row" | "vline" | "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 @@ -37,7 +37,7 @@ export type Box = {
parent?: string; depth: number;
side: 1 | -1; // which side of the root the node grows towards
branch: number; // index of the top-level ancestor (-1 for the root)
flow: "right" | "down" | "tree" | "fish" | "line" | "vline" | "brace" | "matrix"; // how branches run: sideways, top-down (org chart), an indented list (tree chart), along a fishbone or a timeline
flow: "right" | "down" | "tree" | "fish" | "line" | "vline" | "rmatrix" | "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
};
Expand Down Expand Up @@ -285,6 +285,50 @@ function vtimeline(root: Node): (Raw & { side: 1 | -1 })[] {

const TL = { GAP: 36, ROW: 14, NEST: 24 };

const RM = { GAP: 28 };

/**
* Row matrix: the root heads a vertical line and each top-level topic is a row header on it. Its children are the cells of the row,
* side by side, and each cell's descendants stack beneath it. Anchors: a header's marks where its stretch of the line starts,
* a cell's where the previous cell (or the header) ends.
*/
function rowmatrix(root: Node): Raw[] {
const rowH = H + GAP_Y;
const out: Raw[] = [{ id: root.id, x: 0, y: 0, w: widthOf(root), h: H, depth: 0 }];
let top = H + RM.GAP, prev = H; // top of the next row; where the previous stretch of the line ended
(root.collapsed ? [] : root.children).forEach((label) => {
const lw = widthOf(label);
const head: Raw = { id: label.id, x: RM.GAP, y: top, w: lw, h: H, parent: root.id, depth: 1, anchor: { x: RM.GAP / 2, y: prev } };
out.push(head);
let bottom = top + H, cursor = RM.GAP + lw + RM.GAP, prevRight = RM.GAP + lw;
(label.collapsed ? [] : label.children).forEach((cell) => {
const cw = widthOf(cell);
const box: Raw = { id: cell.id, x: cursor, y: top, w: cw, h: H, parent: label.id, depth: 2, anchor: { x: prevRight, y: top + H / 2 } };
out.push(box);
const at = new Map<string, Raw>([[cell.id, box]]);
let right = cursor + cw, j = 0;
const walk = (n: Node, d: number) => {
if (n.collapsed) return;
for (const c of n.children) {
const w = widthOf(c), x = at.get(n.id)!.x + TL.NEST;
const b: Raw = { id: c.id, x, y: top + H + TL.ROW + j++ * rowH, w, h: H, parent: n.id, depth: d + 1 };
out.push(b);
at.set(c.id, b);
right = Math.max(right, x + w);
bottom = Math.max(bottom, b.y + H);
walk(c, d + 1);
}
};
walk(cell, 2);
prevRight = cursor + cw;
cursor = right + RM.GAP;
});
prev = top + H / 2;
top = bottom + RM.GAP;
});
return out;
}

/**
* Timeline: the root starts a horizontal line and the top-level topics sit on it, in order. Each topic's
* descendants stack in a column under it (first topic), above it (second), and so on, indented by depth.
Expand Down Expand Up @@ -328,14 +372,14 @@ function timeline(root: Node, alternate = true): Raw[] {
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", vline: "vline", matrix: "matrix", brace: "brace" };
const flows: Record<string, Box["flow"]> = { down: "down", tree: "tree", fish: "fish", line: "line", vline: "vline", matrix: "matrix", "matrix-row": "rmatrix", brace: "brace" };
const flow = flows[base] ?? "right";
const out: Box[] = [];

if (flow === "vline") {
out.push(...vtimeline({ ...root, children: kids }).map((b): Box => ({ ...b, branch: -1, flow })));
} else 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 });
const raw = { down: vertical, tree: indented, fish: fishbone, line: timeline, matrix: (r: Node) => timeline(r, false), rmatrix: rowmatrix }[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.
Expand Down Expand Up @@ -397,6 +441,26 @@ export function neighbour(root: Node, boxes: Box[], id: string, dir: "left" | "r
const stops = boxes.filter((b) => b.flow === "vline" && b.depth <= 1).sort((a, b) => a.y - b.y); // the head, then the topics down the line
return (stops[stops.findIndex((b) => b.id === id) + (dir === "down" ? 1 : -1)] ?? me).id;
}
if (me.flow === "rmatrix") {
const rows = boxes.filter((b) => b.flow === "rmatrix" && b.depth === 1).sort((a, b) => a.y - b.y);
if (me.depth === 0) return dir === "down" || dir === "right" ? (rows[0]?.id ?? id) : id;
if (me.depth === 1) {
if (dir === "left") return me.parent ?? id;
if (dir === "right") return firstKid();
const next = dir === "down" ? rows[rows.findIndex((b) => b.id === id) + 1] : rows[rows.findIndex((b) => b.id === id) - 1] ?? boxes[0];
return (next ?? me).id;
}
if (me.depth === 2) {
const cells = findNode(root, me.parent!)!.children, i = cells.findIndex((c) => c.id === id);
if (dir === "left") return (cells[i - 1] ?? findNode(root, me.parent!)!).id;
if (dir === "right") return (cells[i + 1] ?? cells[i]).id;
return dir === "down" ? firstKid() : id;
}
const order = boxes.filter((b) => b.flow === "rmatrix"); // document order: a cell's stack follows the cell
if (dir === "left" || dir === "up") return me.parent ?? id;
const next = order[order.findIndex((b) => b.id === id) + 1];
return dir === "down" && next && next.depth >= 3 ? next.id : id;
}
if (me.flow === "down") {
if (dir === "up") return me.parent ?? id;
if (dir === "down") return firstKid();
Expand Down
45 changes: 44 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", "up", "tree-left", "brace-left", "fish-left", "map-ccw", "vline"] as const) {
for (const kind of ["right", "left", "map", "down", "tree", "fish", "line", "brace", "matrix", "up", "tree-left", "brace-left", "fish-left", "map-ccw", "vline", "matrix-row"] as const) {
const back = await importXmindSheets(exportXmind({ ...mk("r"), layout: kind }));
expect(back[0].layout ?? "right").toBe(kind);
}
Expand Down Expand Up @@ -338,3 +338,46 @@ test("vertical timeline: arrow keys run down the line and into the stacks", () =
expect([go(t[0].id, "right"), go(r.id, "right"), go(r.id, "left")]).toEqual([kids[0].id, t[0].id, t[1].id]); // right-hand topic into its stack; root to the first topic on each side
expect(go(kids[0].id, "left")).toBe(t[0].id);
});

function rows() {
let r: Node = { ...mk("head"), layout: "matrix-row" };
const [x, y] = [mk("row X"), mk("row Y")];
r = addChild(addChild(r, r.id, x), r.id, y);
const cells = ["c1", "c2", "c3"].map(mk);
for (const c of cells) r = addChild(r, x.id, c);
const sub = ["s1", "s2"].map(mk);
for (const s of sub) r = addChild(r, cells[0].id, s); // the first cell is tall: it widens its column, not the row above
r = addChild(r, y.id, mk("lonely"));
return { r, x, y, cells, sub };
}

test("row matrix: headers down the line, cells side by side, stacks under cells", () => {
const { r, x, y, cells, sub } = rows();
const boxes = layout(r), by = Object.fromEntries(boxes.map((b) => [b.id, b]));
expect(boxes.every((b) => b.flow === "rmatrix")).toBe(true);
expect(by[y.id].y).toBeGreaterThan(by[x.id].y + by[x.id].h); // rows below one another
expect(by[x.id].x).toBe(by[y.id].x); // headers share a column
const cy = cells.map((c) => by[c.id]);
expect(cy.every((b) => b.y === by[x.id].y)).toBe(true); // cells sit on the header's line
expect(cy[0].x).toBeGreaterThan(by[x.id].x + by[x.id].w);
expect(cy[1].x).toBeGreaterThan(cy[0].x + cy[0].w);
expect(cy[2].x).toBeGreaterThan(cy[1].x + cy[1].w);
expect(sub.map((s) => by[s.id].y)).toEqual([...sub.map((s) => by[s.id].y)].sort((a, b) => a - b)); // stacked downwards
expect(by[sub[0].id].y).toBeGreaterThan(cy[0].y + cy[0].h);
expect(by[y.id].y).toBeGreaterThan(by[sub[1].id].y + H_BOX); // the next row starts below the tallest stack
for (const a of boxes) for (const b of boxes) {
if (a.id < b.id) expect(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y, `${a.id} vs ${b.id}`).toBe(true);
}
});
const H_BOX = 34;

test("row matrix: connectors and arrow keys", () => {
const { r, x, y, cells, sub } = rows();
const boxes = layout(r), by = Object.fromEntries(boxes.map((b) => [b.id, b])), go = (id: string, d: "left" | "right" | "up" | "down") => neighbour(r, boxes, id, d);
expect(branchD(by[x.id], boxes[0])).toMatch(/^M[\d.]+ [\d.]+V[\d.]+H[\d.]+$/);
expect(branchD(by[cells[1].id], by[x.id])).toMatch(/^M[\d.]+ [\d.]+H[\d.]+$/);
expect(branchD(by[sub[0].id], by[cells[0].id])).toMatch(/V/);
expect([go(r.id, "down"), go(x.id, "down"), go(y.id, "up"), go(x.id, "up")]).toEqual([x.id, y.id, x.id, r.id]);
expect([go(x.id, "right"), go(cells[0].id, "right"), go(cells[2].id, "right"), go(cells[1].id, "left"), go(cells[0].id, "left")]).toEqual([cells[0].id, cells[1].id, cells[2].id, cells[0].id, x.id]);
expect([go(cells[0].id, "down"), go(sub[0].id, "down"), go(sub[1].id, "down"), go(sub[1].id, "up")]).toEqual([sub[0].id, sub[1].id, sub[1].id, cells[0].id]); // up from a stacked topic goes to its parent
});
1 change: 1 addition & 0 deletions src/xmind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ const STRUCTURE: Record<NonNullable<Node["layout"]>, string> = {
fish: "org.xmind.ui.fishbone.rightHeaded",
line: "org.xmind.ui.timeline.horizontal",
vline: "org.xmind.ui.timeline.vertical",
"matrix-row": "org.xmind.ui.spreadsheet.row",
left: "org.xmind.ui.logic.left",
brace: "org.xmind.ui.brace.right",
matrix: "org.xmind.ui.spreadsheet.column",
Expand Down
3 changes: 2 additions & 1 deletion src/xmindxml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ test("layoutOf covers the remaining XMind structure classes", () => {
"org.xmind.ui.brace.right": "brace",
"org.xmind.ui.brace.left": "brace-left",
"org.xmind.ui.spreadsheet.column": "matrix",
"org.xmind.ui.structure.spreadsheet.row": "matrix",
"org.xmind.ui.structure.spreadsheet.row": "matrix-row",
"org.xmind.ui.spreadsheet.row": "matrix-row",
"org.xmind.ui.treetable.toptitle": "tree", // nearest match
"org.xmind.ui.map.unbalanced": "map", // XMind's default map
"org.xmind.ui.logic-chart.right": undefined,
Expand Down
4 changes: 2 additions & 2 deletions src/xmindxml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const allText = (n: XNode): string => n.text.trim() || n.children.map(allText).f
export function layoutOf(structureClass: string): Node["layout"] | undefined {
if (structureClass.includes("logic.left")) return "left";
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("spreadsheet")) return /row/i.test(structureClass) ? "matrix-row" : "matrix"; // XMind's matrix, by rows or by columns
if (structureClass.includes("fishbone")) return structureClass.includes("leftHeaded") ? "fish-left" : "fish";
if (structureClass.includes("timeline")) return structureClass.includes("vertical") ? "vline" : "line";
if (structureClass.includes("org-chart")) return structureClass.includes("org-chart.up") ? "up" : "down";
Expand All @@ -98,7 +98,7 @@ export function layoutOf(structureClass: string): Node["layout"] | undefined {
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.timeline.vertical": "vline", "org.xmind.ui.brace.right": "brace", "org.xmind.ui.spreadsheet.column": "matrix",
"org.xmind.ui.timeline.horizontal": "line", "org.xmind.ui.timeline.vertical": "vline", "org.xmind.ui.brace.right": "brace", "org.xmind.ui.spreadsheet.column": "matrix", "org.xmind.ui.spreadsheet.row": "matrix-row",
"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",
};
Expand Down
Loading