From d07eeb7af13cc33b6857201ceaa5a67e3ee7c76e Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sat, 17 Oct 2026 13:54:34 +0200 Subject: [PATCH 1/3] Open and write FreeMind/Freeplane .mm maps FreeMind's .mm is the de facto open interchange format for mind maps, so Cartocortex reads and writes it. Reading is the strict XML parser from the old .xmind files (no DOCTYPE, predefined entities only), on plain and gzipped files: texts (richcontent keeps its line order), notes (richcontent and the old map/Note hook), links, links between topics (arrowlink), folding, fill and text colour, bold / italic, priority (full-N) and progress (0%-100%) icons. Left-side top-level topics open as a logic-left or two-sided map. Writing goes the other way (Save as .mm and a new "Export FreeMind" button): one root node per sheet, so extra sheets may not show in FreeMind itself, and structures beyond the sideways layouts, pictures, relationships, summaries, per-word style and floating positions are not carried. The xmindxml entity decoder is exported for the reader. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- README.md | 48 +++++----- src/App.tsx | 72 +++++++-------- src/files.test.ts | 6 ++ src/files.ts | 12 ++- src/freemind.test.ts | 95 ++++++++++++++++++++ src/freemind.ts | 205 +++++++++++++++++++++++++++++++++++++++++++ src/xmindxml.ts | 2 +- 7 files changed, 377 insertions(+), 63 deletions(-) create mode 100644 src/freemind.test.ts create mode 100644 src/freemind.ts diff --git a/README.md b/README.md index 7c13ecf..e36d3d4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A local, native mind-mapping app (XMind-style). Tauri 2 + React + TypeScript. Files stay on your machine. -Tabs and menus: the toolbar is three menus (File with templates, recent files and export; Insert; View with structure, theme and panels) plus Undo, Redo, Delete, Find and Save. Each map opens in its own tab (a blank tab is reused; Open… puts a map in a new tab when the current one holds a file or unsaved work); the second row of tabs is the sheets inside one map. Only the first tab keeps the autosave. +Tabs and menus: the toolbar is three menus (File with templates, recent files and export; Insert; View with structure, theme and panels) plus Undo, Redo, Delete, Find and Save. Each map opens in its own tab (a blank tab is reused; Open… puts a map in a new tab when the current one holds a file or unsaved work); the second row of tabs is the sheets inside one map. Only the first tab keeps the autosave. ## Download @@ -29,18 +29,18 @@ npm test # unit tests for the tree model and layout | Space / F2 / double-click | Edit text (Enter to commit, Esc to cancel) | | Arrows | Move selection | | Delete / Backspace | Delete node | -| ⌘/Ctrl+Z, +Shift | Undo, redo | -| ⌘/Ctrl+S | Save as JSON | +| ⌘/Ctrl+Z, +Shift | Undo, redo | +| ⌘/Ctrl+S | Save as JSON | | Drag a node onto another | Re-parent | -| Wheel / drag background | Pan; ⌘/Ctrl+wheel or pinch zooms | +| Wheel / drag background | Pan; ⌘/Ctrl+wheel or pinch zooms | -The ‹ chip on a node collapses its branch. The map autosaves to local storage; Save/Open use `.json`, and Export MD writes a Markdown outline. +The ‹ chip on a node collapses its branch. The map autosaves to local storage; Save/Open use `.json`, and Export MD writes a Markdown outline. ## Layout -- `src/model.ts` – immutable tree ops, tidy-tree layout, Markdown export -- `src/App.tsx` – canvas, keyboard handling, undo/redo, save/open -- `src-tauri/` – Rust shell with the dialog and fs plugins +- `src/model.ts` – immutable tree ops, tidy-tree layout, Markdown export +- `src/App.tsx` – canvas, keyboard handling, undo/redo, save/open +- `src-tauri/` – Rust shell with the dialog and fs plugins ## Not yet @@ -48,19 +48,19 @@ Direction variants of XMind structures (up-pointing org chart, left-hand tree), ## Added -Fill colour, shape, emoji markers, notes and links (inspector panel); relationship lines and boundaries; Logic and Map (two-sided) structures; five themes with per-branch colours; copy/cut/paste/duplicate (⌘C/X/V/D); find (⌘F, Enter cycles); fit-to-view; `.xmind` open and export (all sheets; text, notes, links, folding and structure only). +Fill colour, shape, emoji markers, notes and links (inspector panel); relationship lines and boundaries; Logic and Map (two-sided) structures; five themes with per-branch colours; copy/cut/paste/duplicate (⌘C/X/V/D); find (⌘F, Enter cycles); fit-to-view; `.xmind` open and export (all sheets; text, notes, links, folding and structure only). Outline view (toolbar "Outline": Enter = sibling, Tab / Shift+Tab = indent / outdent, shares undo with the map) and PNG / PDF export (the PDF is one page holding an image of the map, so its text isn't selectable). Summaries: select a topic, click "Summary", then click the last topic of the range (it must share a parent). A bracket and an editable label (double-click) appear beside the span; click the label and press Delete to remove it. They are included in PNG/PDF export but not in `.xmind` export. -Presentation: "Present" goes full-screen and reveals one top-level branch per step (→ / Space / Enter next, ← back, Esc exit); earlier branches dim. +Presentation: "Present" goes full-screen and reveals one top-level branch per step (→ / Space / Enter next, ← back, Esc exit); earlier branches dim. -Multi-select: Shift / ⌘ / Ctrl-click toggles topics; Shift-drag on empty canvas draws a selection box (⌘/Ctrl-drag adds to the current selection); ⌘A selects all. Delete, drag-to-reparent, and the Fill / Shape / Marker controls act on the whole selection, and Summary wraps selected siblings in one click. Copy / paste / duplicate still work on the primary topic only. +Multi-select: Shift / ⌘ / Ctrl-click toggles topics; Shift-drag on empty canvas draws a selection box (⌘/Ctrl-drag adds to the current selection); ⌘A selects all. Delete, drag-to-reparent, and the Fill / Shape / Marker controls act on the whole selection, and Summary wraps selected siblings in one click. Copy / paste / duplicate still work on the primary topic only. Floating topics: "Float" (or double-click empty canvas) adds a free-standing topic with no branch to the root. Drag it anywhere; release over another topic to attach it there. In `.xmind` export they become ordinary top-level topics (positions are not kept). Deleting topics also removes relationships, boundaries and summaries that pointed at them. -Sheets: tabs under the toolbar, one root topic and one undo history per sheet. "+" adds a sheet, double-click a tab renames it, × then "Delete?" removes it. Save writes every sheet to one `.json` (older single-map files still open); `.xmind` import and export carry all sheets. PNG, PDF, Markdown and presentation use the open sheet. +Sheets: tabs under the toolbar, one root topic and one undo history per sheet. "+" adds a sheet, double-click a tab renames it, × then "Delete?" removes it. Save writes every sheet to one `.json` (older single-map files still open); `.xmind` import and export carry all sheets. PNG, PDF, Markdown and presentation use the open sheet. Security: the native window runs under a Content-Security-Policy (`src-tauri/tauri.conf.json`): scripts only from the app itself, no network connections except Tauri's own bridge, images only from the app, `data:` and `blob:`. Inline styles are allowed because topic colours are applied as style attributes. Files you open are parsed as data and never executed. @@ -68,27 +68,27 @@ Security: the native window runs under a Content-Security-Policy (`src-tauri/tau `app-icon.svg` is generated by `scripts/make-icon.mjs` (a side-view brain with a neuron network on a pale teal tile, in the Glossolalie palette: teal #0D9488, navy #1E3A5F and pale tints; the palette and the neuron layout seed are constants at the top). To rebuild: `node scripts/make-icon.mjs`, render the SVG to `app-icon.png` at 1024px (any SVG renderer, e.g. `@resvg/resvg-js`), then `npx tauri icon app-icon.png` to refresh `src-tauri/icons/`. -Custom theme: pick "Custom…" in the theme menu to edit the central/accent colour and six branch colours in the panel; changes preview live and commit as one undo step. Colours are stored in the document (`themeColors`) and only `#rrggbb` values are accepted. +Custom theme: pick "Custom…" in the theme menu to edit the central/accent colour and six branch colours in the panel; changes preview live and commit as one undo step. Colours are stored in the document (`themeColors`) and only `#rrggbb` values are accepted. Look: the UI follows the Glossolalie palette (teal `#0D9488` accent, navy `#1E3A5F` ink, pale teal tints for surfaces, thin 40%-tint rules) with no dark fills and no dark mode; the central topic is a tinted pill with a thicker teal border. The default theme is named "Glossolalie"; the other themes only change branch colours. PNG / PDF export uses the same ink and tints. -Task markers and links: the panel sets a priority (1-5, a numbered disc: darker is more urgent) and a progress (0-100%, a pie that becomes a check at 100%) on the selected topics; both map to XMind's `priority-N` and `task-*` markers on `.xmind` import and export (other XMind markers are dropped). A topic's link shows a 🔗 badge: click it, or use "Open link", to open it in the system browser. Only http, https and mailto links open; a bare host such as `example.com` gets https://, and `javascript:` or `file:` links are refused. +Task markers and links: the panel sets a priority (1-5, a numbered disc: darker is more urgent) and a progress (0-100%, a pie that becomes a check at 100%) on the selected topics; both map to XMind's `priority-N` and `task-*` markers on `.xmind` import and export (other XMind markers are dropped). A topic's link shows a 🔗 badge: click it, or use "Open link", to open it in the system browser. Only http, https and mailto links open; a bare host such as `example.com` gets https://, and `javascript:` or `file:` links are refused. -Structures: the toolbar picker offers Logic (right), Logic (left), Map (both sides), Org chart (top-down), Tree (indented list), Fishbone, Timeline, Brace and Matrix. Arrow keys follow the structure (in an org chart, ↓ goes to the first child and ← / → move between topics in a row; in a tree, ↑ / ↓ walk the rows). Summaries are drawn in the Logic and Map structures only. The structure is saved per sheet and maps to XMind's org-chart, tree-chart, logic and map structures; XMind structures not listed open as their nearest match, or as Logic. +Structures: the toolbar picker offers Logic (right), Logic (left), Map (both sides), Org chart (top-down), Tree (indented list), Fishbone, Timeline, Brace and Matrix. Arrow keys follow the structure (in an org chart, ↓ goes to the first child and ← / → move between topics in a row; in a tree, ↑ / ↓ walk the rows). Summaries are drawn in the Logic and Map structures only. The structure is saved per sheet and maps to XMind's org-chart, tree-chart, logic and map structures; XMind structures not listed open as their nearest match, or as Logic. -Text style: the panel's Text section (and ⌘B / ⌘I) sets bold, italic, strikethrough and a text colour on the selected topics. With the cursor in a topic (or in the notes) the same controls style just the selected words; otherwise they style the whole topic. Notes are now a small rich editor too (plain text underneath, so search still works). Per-word style is saved with the map and shows in PNG / PDF export, but `.xmind` export writes plain text for titles and notes (topic-level style is still exported). The style is kept in PNG / PDF export and in `.xmind` files (as XMind's `fo:font-weight`, `fo:font-style`, `fo:text-decoration` and `fo:color` properties; I have not checked it against a file made by XMind itself). Colours read from files must be plain `#rrggbb`. +Text style: the panel's Text section (and ⌘B / ⌘I) sets bold, italic, strikethrough and a text colour on the selected topics. With the cursor in a topic (or in the notes) the same controls style just the selected words; otherwise they style the whole topic. Notes are now a small rich editor too (plain text underneath, so search still works). Per-word style is saved with the map and shows in PNG / PDF export, but `.xmind` export writes plain text for titles and notes (topic-level style is still exported). The style is kept in PNG / PDF export and in `.xmind` files (as XMind's `fo:font-weight`, `fo:font-style`, `fo:text-decoration` and `fo:color` properties; I have not checked it against a file made by XMind itself). Colours read from files must be plain `#rrggbb`. -Fishbone: the central topic is the head on the right; top-level topics alternate above and below the spine on slanted bones, with their sub-topics as ribs along each bone and deeper levels hanging to the left of their parent. Arrow keys walk the bones in reading order (← towards the tail, → towards the head). The head is always on the right (XMind's right-headed fishbone). +Fishbone: the central topic is the head on the right; top-level topics alternate above and below the spine on slanted bones, with their sub-topics as ribs along each bone and deeper levels hanging to the left of their parent. Arrow keys walk the bones in reading order (← towards the tail, → towards the head). The head is always on the right (XMind's right-headed fishbone). Old XMind files: `.xmind` files saved by XMind 8 and earlier (XML inside, `content.xml`) now open too, with every sheet's title, structure, topics, notes, links, folding and priority / progress markers. Floating topics, relationships, styles and other markers in those files are dropped. Files from newer XMind (JSON inside) are read as before, and take precedence when a file has both. The reader is strict by design: XML with a DOCTYPE is refused. -Timeline: the central topic starts a horizontal line and the top-level topics sit on it, in order. Each topic's sub-topics stack in a column under the first topic, above the second, and so on (nearest first, indented by depth). ← / → move along the line; ↑ / ↓ move into and along a stack. It maps to XMind's horizontal timeline, and any XMind timeline variant opens as this one. +Timeline: the central topic starts a horizontal line and the top-level topics sit on it, in order. Each topic's sub-topics stack in a column under the first topic, above the second, and so on (nearest first, indented by depth). ← / → move along the line; ↑ / ↓ move into and along a stack. It maps to XMind's horizontal timeline, and any XMind timeline variant opens as this one. XMind compatibility notes: structure classes, `fo:*` style keys, marker ids and the style / marker JSON layout were checked against identifiers inside the installed XMind app, but not against a document saved by XMind (none was available on this machine). More structures: **Logic (left)** mirrors the logic chart around the root. **Brace** keeps the logic layout but draws a curly brace around each parent's children (a plain line when there is only a row or two). **Matrix** (XMind's spreadsheet structure) puts the top-level topics side by side as columns, each with its sub-topics listed beneath. They map to XMind's `logic.left`, `brace.right` and `spreadsheet.column`; XMind's `brace.left` opens as the right-hand brace, and its row-wise matrix opens as columns. -Saving (desktop app): ⌘S saves to the current file, and asks where the first time (or after New); ⇧⌘S saves under a new name. The format follows the extension: `.json` (Cartocortex, keeps everything) or `.xmind`. The window title shows the file name, with a • while there are unsaved changes, and New, Open and closing the window ask before discarding them. "Recent…" lists the last 8 files (kept across restarts). In a browser, Save downloads a `.json` instead. The map is also autosaved in the app's own storage, which is what you see when you start without opening a file. +Saving (desktop app): ⌘S saves to the current file, and asks where the first time (or after New); ⇧⌘S saves under a new name. The format follows the extension: `.json` (Cartocortex, keeps everything) or `.xmind`. The window title shows the file name, with a • while there are unsaved changes, and New, Open and closing the window ask before discarding them. "Recent…" lists the last 8 files (kept across restarts). In a browser, Save downloads a `.json` instead. The map is also autosaved in the app's own storage, which is what you see when you start without opening a file. ## Development and releases @@ -99,12 +99,14 @@ Saving (desktop app): ⌘S saves to the current file, and asks where the first t MIT, see [LICENSE](LICENSE). -Outline formats: Open reads Markdown (`.md`, headings and nested lists; `- [x]` task boxes become 100% progress, the first link becomes the topic's link) and OPML (`.opml`, with notes, links and checked items). "Export OPML" writes one top-level outline per sheet, and Save as can write `.opml`, `.md` or `.xmind` as well as `.json`. +Outline formats: Open reads Markdown (`.md`, headings and nested lists; `- [x]` task boxes become 100% progress, the first link becomes the topic's link) and OPML (`.opml`, with notes, links and checked items). "Export OPML" writes one top-level outline per sheet, and Save as can write `.opml`, `.md`, `.xmind` or FreeMind `.mm` as well as `.json`. -Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml` or Markdown file is an *import*: the map starts as unsaved, and ⌘S asks for a new file rather than overwriting your original with a lossy copy. +FreeMind / Freeplane: Open reads FreeMind's `.mm` maps (plain or gzipped) — topics, notes, links, links between topics, folding, fill and text colours, bold / italic, priority and progress icons — and left-side topics open as a one-sided or two-sided map. "Export FreeMind" (and Save as `.mm`) writes one root node per sheet, so extra sheets may not show in FreeMind itself; multi-line texts keep their lines, but pictures, relationships, summaries, per-word style, floating positions and structures beyond the sideways layouts are not carried. -Links between topics: in the panel, "Link to a topic" → "Pick a topic…", then click the target. The source gets a ↪ badge (and "Go ↪" in the panel); clicking it opens any collapsed branches above the target and selects it, also when the target is on another sheet. In `.xmind` files a topic link is `xmind:#`: it survives a round trip, and XMind allows one link per topic, so an outside web link wins if a topic has both. +Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind or Markdown file is an *import*: the map starts as unsaved, and ⌘S asks for a new file rather than overwriting your original with a lossy copy. + +Links between topics: in the panel, "Link to a topic" → "Pick a topic…", then click the target. The source gets a ↪ badge (and "Go ↪" in the panel); clicking it opens any collapsed branches above the target and selects it, also when the target is on another sheet. In `.xmind` files a topic link is `xmind:#`: it survives a round trip, and XMind allows one link per topic, so an outside web link wins if a topic has both. Pictures: the panel's "Picture" section puts an image (PNG, JPEG, GIF or WebP) on the selected topic, shown as a small thumbnail in the topic; click the thumbnail for the full image (Esc or a click closes it). The picture is downscaled to at most 800 px and stored inside the map file, so keep an eye on size (the autosave in the app's own storage has a few-megabyte limit). Pictures appear in PNG / PDF export but not in `.xmind`, OPML or Markdown. Only embedded raster images are ever drawn: SVG and web addresses in a map file are ignored. -Templates: the "Template…" menu next to New starts a map from one of eight starters (brainstorm, project plan as a timeline, SWOT as a matrix, cause and effect as a fishbone, meeting notes as a tree, org chart, weekly plan, pros and cons as a brace map). Like New, it asks before discarding unsaved changes. +Templates: the "Template…" menu next to New starts a map from one of eight starters (brainstorm, project plan as a timeline, SWOT as a matrix, cause and effect as a fishbone, meeting notes as a tree, org chart, weekly plan, pros and cons as a brace map). Like New, it asks before discarding unsaved changes. diff --git a/src/App.tsx b/src/App.tsx index 84f8c05..9c64895 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { } from "./model"; import { exportXmind } from "./xmind"; import { toOpml } from "./outline-io"; +import { toFreemind } from "./freemind"; import { downscale } from "./image"; import { TEMPLATES } from "./templates"; import { customColors, customTheme } from "./theme"; @@ -33,7 +34,7 @@ const THEMES: Record { const name = docPath ? baseName(docPath) : docName ?? "Untitled"; - const title = `${dirty ? "• " : ""}${name} — Cartocortex`; + const title = `${dirty ? "• " : ""}${name} — Cartocortex`; onMeta(name, dirty); if (!active) return; document.title = title; @@ -542,7 +543,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP window.addEventListener("pointerup", up); }; - // Shift-drag on empty canvas draws a selection box; ⌘/Ctrl-drag adds to the current selection. + // Shift-drag on empty canvas draws a selection box; ⌘/Ctrl-drag adds to the current selection. const marquee = (e: React.PointerEvent) => { const r = stage.current!.getBoundingClientRect(); const sx = e.clientX - r.left, sy = e.clientY - r.top; @@ -586,10 +587,10 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP
open() }, - { label: "Save", kbd: "⌘S", on: () => saveDoc() }, - ...(isTauri ? [{ label: "Save as…", kbd: "⇧⌘S", on: () => saveDoc(true) }] : []), + { label: "New map", kbd: "⌘N", on: newDoc }, + { label: "Open…", kbd: "⌘O", on: () => open() }, + { label: "Save", kbd: "⌘S", on: () => saveDoc() }, + ...(isTauri ? [{ label: "Save as…", kbd: "⇧⌘S", on: () => saveDoc(true) }] : []), { heading: "New from template" }, ...TEMPLATES.map((x) => ({ label: x.name, on: () => startFrom(x.build()) })), ...(isTauri && recent.length ? [{ heading: "Recent" }, ...recent.map((p) => ({ label: baseName(p), title: p, on: () => open(p) }))] : []), @@ -597,18 +598,19 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { label: "XMind (.xmind)", on: () => saveFile(fileName("xmind"), "xmind", exportXmind(park().roots)) }, { label: "Markdown (.md)", on: () => saveFile(fileName("md"), "md", toMarkdown(s.root)) }, { label: "OPML (.opml)", on: () => saveFile(fileName("opml"), "opml", toOpml(park().roots)) }, + { label: "FreeMind (.mm)", on: () => saveFile(fileName("mm"), "mm", toFreemind(park().roots)) }, { label: "Image (.png)", on: () => exportImage("png") }, { label: "Document (.pdf)", on: () => exportImage("pdf") }, ]} /> setLinking(!linking) }, + { label: linking ? "Relationship: pick the target…" : "Relationship", title: "Choose this, then click the target topic", on: () => setLinking(!linking) }, { label: "Boundary", checked: !!s.root.bounds?.includes(s.sel), on: toggleBound }, { - label: summarizing ? "Summary: pick the range end…" : "Summary", + label: summarizing ? "Summary: pick the range end…" : "Summary", title: "Select several sibling topics first, or choose this and then click the last topic of the range", on: () => { const span = siblingSpan(s.root, ids); @@ -623,7 +625,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP label: l, checked: (s.root.layout ?? "right") === v, on: () => setDocProp({ layout: v }), })), { heading: "Theme" }, - ...[...Object.entries(THEMES).map(([id, t]) => [id, t.name]), ["custom", "Custom…"]].map(([id, name]) => ({ + ...[...Object.entries(THEMES).map(([id, t]) => [id, t.name]), ["custom", "Custom…"]].map(([id, name]) => ({ label: name, checked: (s.root.theme ?? "classic") === id, on: () => { setDraft(null); @@ -638,14 +640,14 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { label: "Present", on: startPresenting }, ]} /> - - - + + + (setQ(e.target.value), setHit(-1))} onKeyDown={(e) => { @@ -653,7 +655,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP if (e.key === "Escape") (setQ(""), e.currentTarget.blur()); }} /> - +
{sheets.roots.map((r0, i) => { @@ -691,7 +693,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP title={delAsk === i ? "Click again to delete this sheet" : "Delete sheet"} onClick={(e) => (e.stopPropagation(), delAsk === i ? deleteSheet(i) : setDelAsk(i))} > - {delAsk === i ? "Delete?" : "×"} + {delAsk === i ? "Delete?" : "×"} )}
@@ -796,7 +798,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP ) : null} {n.progress != null && ( - {n.progress >= 100 ? "✓" : ""} + {n.progress >= 100 ? "✓" : ""} )} {s.edit === b.id ? ( @@ -810,23 +812,23 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP /> ) : ( - {validRuns(n.text, n.runs)?.map((r, k) => {r.t}) ?? (n.text || "…")} + {validRuns(n.text, n.runs)?.map((r, k) => {r.t}) ?? (n.text || "…")} )} - {n.note && 📝} + {n.note && 📝} {n.link && ( e.stopPropagation()} onClick={(e) => (e.stopPropagation(), openLink(n.link!))}> - 🔗 + 🔗 )} {n.topicLink && where(n.topicLink) && ( e.stopPropagation()} onClick={(e) => (e.stopPropagation(), goTo(n.topicLink!))} > - ↪ + ↪ )} {n.children.length > 0 && ( @@ -836,7 +838,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP onPointerDown={(e) => e.stopPropagation()} onClick={() => commit((r) => toggle(r, b.id))} > - {n.collapsed ? n.children.length : "‹"} + {n.collapsed ? n.children.length : "‹"} )}
@@ -863,7 +865,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP }} /> ) : ( - {x.text || "…"} + {x.text || "…"} )} ))} @@ -917,8 +919,8 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP
e.preventDefault() /* keep the editor's selection */}> - - + +
e.preventDefault()}> @@ -944,22 +946,22 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP {!multi && ( <> - e.target.value !== (selNode.link ?? "") && setNode({ link: e.target.value || undefined })} /> - {selNode.link && } + e.target.value !== (selNode.link ?? "") && setNode({ link: e.target.value || undefined })} /> + {selNode.link && }
- {selNode.topicLink && where(selNode.topicLink) && } + {selNode.topicLink && where(selNode.topicLink) && } {selNode.topicLink && }
{selNode.topicLink && ( -
{where(selNode.topicLink) ? `→ ${where(selNode.topicLink)!.node.text || "…"}` : "The linked topic no longer exists."}
+
{where(selNode.topicLink) ? `→ ${where(selNode.topicLink)!.node.text || "…"}` : "The linked topic no longer exists."}
)}
- + {selNode.image && }
@@ -983,9 +985,9 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP )} {present !== null && (
- + {present + 1} / {steps} - +
)} diff --git a/src/files.test.ts b/src/files.test.ts index 14ab949..8fecea7 100644 --- a/src/files.test.ts +++ b/src/files.test.ts @@ -32,6 +32,12 @@ test("documents are written and read in the format their extension names", async expect(back.roots.map((r) => r.text)).toEqual(["Plan", "Second"]); expect(back.roots[0].children[0].text).toBe("child"); expect(back.roots[0].title).toBe("Sheet A"); + + const mm = docBytes("/tmp/plan.mm", roots, 1); + expect(new TextDecoder().decode(mm).startsWith(" r.text)).toEqual(["Plan", "Second"]); // one root node per sheet + expect(backMm.roots[0].children[0].text).toBe("child"); }); const store = new Map(); diff --git a/src/files.ts b/src/files.ts index f242e82..1f2f594 100644 --- a/src/files.ts +++ b/src/files.ts @@ -1,4 +1,5 @@ import { Book, bookJson, parseBook } from "./book"; +import { fromFreemindBytes, toFreemind } from "./freemind"; import { Node, toMarkdown } from "./model"; import { fromMarkdown, fromOpml, toOpml } from "./outline-io"; import { exportXmind, importXmindSheets } from "./xmind"; @@ -16,21 +17,23 @@ export const pushRecent = (list: string[], path: string, max = 8) => [path, ...l /** Only Cartocortex's own format keeps its place on disk when opened; every other format is an import (Save asks for a new file). */ export const keepsPath = (name: string) => extOf(name) === "json"; -/** What to write for a document saved at `path`, by extension: XMind, OPML, Markdown, otherwise Cartocortex JSON. */ +/** What to write for a document saved at `path`, by extension: XMind, OPML, Markdown, FreeMind, otherwise Cartocortex JSON. */ export function docBytes(path: string, roots: Node[], cur: number): Uint8Array { const ext = extOf(path), text = (s: string) => new TextEncoder().encode(s); if (ext === "xmind") return exportXmind(roots); if (ext === "opml") return text(toOpml(roots)); if (ext === "md" || ext === "markdown") return text(roots.map((r) => toMarkdown(r)).join("\n")); + if (ext === "mm") return text(toFreemind(roots)); return text(bookJson(roots, cur, true)); } -/** The document in a file, by its extension. A Markdown or OPML file gives one sheet, named after the file if it has no single top topic. */ +/** The document in a file, by its extension. A Markdown or OPML file gives one sheet, named after the file if it has no single top topic; a FreeMind map gives one sheet per root topic. */ export async function parseDoc(name: string, bytes: Uint8Array): Promise { const ext = extOf(name), text = () => new TextDecoder().decode(bytes); if (ext === "xmind") return { roots: await importXmindSheets(bytes), cur: 0 }; if (ext === "opml") return { roots: [fromOpml(text())], cur: 0 }; if (ext === "md" || ext === "markdown" || ext === "txt") return { roots: [fromMarkdown(text(), baseName(name).replace(/\.[^.]*$/, ""))], cur: 0 }; + if (ext === "mm") return { roots: await fromFreemindBytes(bytes), cur: 0 }; return parseBook(text()); } @@ -67,11 +70,11 @@ export async function writePath(path: string, bytes: Uint8Array) { export async function pickOpen(): Promise { if (tauri()) { const { open } = await import("@tauri-apps/plugin-dialog"); - const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown"] }] }); + const path = await open({ filters: [{ name: "Mind map or outline", extensions: ["json", "xmind", "opml", "md", "markdown", "mm"] }] }); return typeof path === "string" ? readPath(path) : null; } return new Promise((res) => { - const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,.xmind,.opml,.md,.markdown" }); + const i = Object.assign(document.createElement("input"), { type: "file", accept: ".json,.xmind,.opml,.md,.markdown,.mm" }); i.onchange = async () => { const f = i.files?.[0]; res(f ? { name: f.name, path: null, bytes: new Uint8Array(await f.arrayBuffer()) } : null); @@ -105,6 +108,7 @@ export async function pickSavePath(defaultName: string): Promise { name: "Cartocortex map (.json)", extensions: ["json"] }, { name: "XMind (.xmind)", extensions: ["xmind"] }, { name: "OPML outline (.opml)", extensions: ["opml"] }, + { name: "FreeMind / Freeplane (.mm)", extensions: ["mm"] }, { name: "Markdown outline (.md)", extensions: ["md"] }, ], }); diff --git a/src/freemind.test.ts b/src/freemind.test.ts new file mode 100644 index 0000000..e020bab --- /dev/null +++ b/src/freemind.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "vitest"; +import { fromFreemind, fromFreemindBytes, toFreemind } from "./freemind"; +import { mk } from "./model"; + +const sample = ` + + +

Ligne un

Ligne <deux>

+ + + + Voir la revue de 2025 + +
+ + + + + + +
+
`; + +test("a FreeMind sample opens: rich text keeps its line order, links and icons come across", () => { + const roots = fromFreemind(sample); + expect(roots).toHaveLength(1); + const root = roots[0]; + expect(root.text).toBe("Projet"); + expect(root.collapsed).toBe(true); // FOLDED, with children under it + expect(root.layout).toBe("map"); // one right-side topic, one left-side + expect(root.children.map((c) => c.text)).toEqual(["Ligne un\nLigne ", "Création"]); // right first + const study = root.children[0]; + expect(study.textColor).toBe("#ff0000"); + expect(study.priority).toBe(3); + expect(study.topicLink).toBe(root.children[1].id); // arrowlink remapped to the new ids + expect(study.children[0].note).toBe("Voir la revue de 2025"); // FreeMind 0.9's map/Note hook + const creation = root.children[1]; + expect(creation.link).toBe("https://example.com/"); + expect(creation.progress).toBe(75); + expect(creation.children[0].color).toBe("#00ccff"); // a hex without the # + expect(creation.children[0].bold).toBe(true); + expect(creation.children[0].italic).toBe(true); +}); + +test("left-side topics everywhere open as a one-sided chart", () => { + const roots = fromFreemind(``); + expect(roots[0].layout).toBe("left"); + expect(roots[0].children.map((c) => c.text)).toEqual(["A", "B"]); +}); + +test("a gzipped .mm opens like a plain one", async () => { + const plain = new TextEncoder().encode(sample); + const gz = new Uint8Array(await new Response(new Blob([plain as BlobPart]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer()); + expect(await fromFreemindBytes(gz)).toEqual(await fromFreemindBytes(plain)); +}); + +test("strict reading: a DOCTYPE, non-XML and a map without a topic are rejected", () => { + expect(() => fromFreemind("")).toThrow(); + expect(() => fromFreemind("ceci n'est pas du XML")).toThrow(); + expect(() => fromFreemind("")).toThrow(); +}); + +test("colours, multi-line text and progress survive the round trip", () => { + const root = { + ...mk("Sauvegarde"), + layout: "left" as const, + children: [ + { ...mk("Première\nDeuxième"), color: "#00ccff", textColor: "#ff0000", bold: true, italic: true, priority: 2, progress: 30, children: [mk("Enfant & \"suite\"")] }, + mk("Simple"), + ], + }; + const back = fromFreemind(toFreemind([root]))[0]; + expect(back.text).toBe("Sauvegarde"); + expect(back.layout).toBe("left"); + expect(back.children.map((c) => c.text)).toEqual(["Première\nDeuxième", "Simple"]); + const first = back.children[0]; + expect(first.color).toBe("#00ccff"); + expect(first.textColor).toBe("#ff0000"); + expect(first.bold).toBe(true); + expect(first.italic).toBe(true); + expect(first.priority).toBe(2); + expect(first.progress).toBe(25); // written as the nearest FreeMind progress icon + expect(first.children[0].text).toBe('Enfant & "suite"'); // XML escaping holds +}); + +test("notes open from both FreeMind 0.9's hook and newer richcontent", () => { + const roots = fromFreemind( + `` + + `ancienne note` + + `

note riche

` + + `
`, + ); + expect(roots[0].children[0].note).toBe("ancienne note"); + expect(roots[0].children[1].note).toBe("note riche"); +}); diff --git a/src/freemind.ts b/src/freemind.ts new file mode 100644 index 0000000..1950bbc --- /dev/null +++ b/src/freemind.ts @@ -0,0 +1,205 @@ +import { Node } from "./model"; +import { decode, parseXml, resolveTopicLinks, XNode } from "./xmindxml"; + +// Reader and writer for the FreeMind / Freeplane .mm format: plain XML, or the gzip FreeMind also writes +// (magic bytes 1f 8b). Reading reuses the strict parser from the old .xmind reader (no DOCTYPE, predefined +// entities only). Writing is deliberately lossy: one root node per sheet, only the properties FreeMind +// itself knows, and nothing but plain lines of text where FreeMind allows rich HTML. + +const kid = (n: XNode, name: string) => n.children.find((c) => c.name === name); + +/** Attribute lookup, case-insensitive: files in the wild mix TEXT with text, COLOR with color. */ +function attr(n: XNode, name: string): string | undefined { + const lower = name.toLowerCase(); + for (const [k, v] of Object.entries(n.attrs)) if (k.toLowerCase() === lower) return v; + return undefined; +} + +/** "#rrggbb" from FreeMind's colour attributes, which sometimes drop the #; anything else is dropped. */ +const hexColor = (v: string | undefined): string | undefined => { + const s = v?.trim(); + return s ? (/^#[0-9a-f]{6}$/i.test(s) ? s : /^[0-9a-f]{6}$/i.test(s) ? "#" + s : undefined) : undefined; +}; + +/** FreeMind's progress icons and the percentage each one stands for. */ +const PROGRESS: Record = { "0%": 0, "25%": 25, "50%": 50, "75%": 75, "100%": 100 }; + +/** The FreeMind progress icon closest to `pct`. */ +const progressIcon = (pct: number) => Object.keys(PROGRESS).reduce((best, k) => (Math.abs(PROGRESS[k] - pct) < Math.abs(PROGRESS[best] - pct) ? k : best), "0%"); + +/** Raw `` blocks in source order: the parsed tree keeps the text but not where the `
`s were. */ +const richSlices = (src: string): string[] => { + const out: string[] = []; + let i = 0; + for (;;) { + const s = src.indexOf("", s); + if (e < 0) return out; + out.push(src.slice(s, e)); + i = e + 14; + } +}; + +/** Every node called `name` in document order. */ +const nodesNamed = (n: XNode, name: string, out: XNode[] = []): XNode[] => { + if (n.name === name) out.push(n); + n.children.forEach((c) => nodesNamed(c, name, out)); + return out; +}; + +/** richcontent node -> raw source slice, zipped in document order; empty map when the counts do not match up. */ +const rawSlices = (src: string, root: XNode): Map => { + const m = new Map(); + const slices = richSlices(src); + const nodes = nodesNamed(root, "richcontent"); + if (slices.length === nodes.length) nodes.forEach((n, i) => m.set(n, slices[i])); + return m; +}; + +/** Lines of text in a raw richcontent block: a new line per `

` and per `
`, tags dropped, entities decoded. */ +const htmlOf = (raw: string): string => { + let out = ""; + let i = 0; + for (;;) { + const lt = raw.indexOf("<", i); + if (lt < 0) { + out += decode(raw.slice(i)); + break; + } + if (lt > i) out += decode(raw.slice(i, lt)); + const gt = raw.indexOf(">", lt); + if (gt < 0) break; + const name = (/^([a-z0-9]+)/i.exec(raw.slice(lt + 1, gt))?.[1] ?? "").toLowerCase(); + if (name === "br" || name === "p" || name === "li") out += "\n"; + i = gt + 1; + } + return out.split("\n").map((l) => l.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n"); +}; + +/** richcontent text from the parsed node alone (no raw slice): the text survives, the line breaks may not. */ +const plainOf = (n: XNode): string => { + const own = n.text.trim(); + const kids = n.children.map(plainOf).filter(Boolean); + return kids.length ? (own ? [own, ...kids].join("\n") : kids.join("\n")) : own; +}; + +/** The text of a richcontent block: from the raw slice when we have one (keeping text and `
` order), else from the tree. */ +const htmlText = (n: XNode, raw: Map): string => { + const slice = raw.get(n); + return slice === undefined ? plainOf(n) : htmlOf(slice); +}; + +/** One FreeMind node -> one Cartocortex topic. */ +function nodeFromXml(x: XNode, ids: Map, raw: Map): Node { + const children = x.children.filter((c) => c.name === "node").map((c) => nodeFromXml(c, ids, raw)); + const nodeRich = x.children.find((c) => c.name === "richcontent" && attr(c, "TYPE")?.toUpperCase() === "NODE"); + const noteRich = x.children.find((c) => c.name === "richcontent" && attr(c, "TYPE")?.toUpperCase() === "NOTE"); + const noteHook = x.children.find((c) => c.name === "hook" && attr(c, "NAME") === "map/Note"); // FreeMind 0.9 + const icons = x.children.filter((c) => c.name === "icon").map((c) => attr(c, "BUILTIN") ?? ""); + const prio = icons.map((b) => /^full-(\d+)$/.exec(b)?.[1]).find(Boolean); + const prog = icons.map((b) => PROGRESS[b]).find((p) => p !== undefined); + const text = nodeRich ? htmlText(nodeRich, raw) : attr(x, "TEXT") ?? ""; + const note = noteRich ? htmlText(noteRich, raw) : noteHook ? (kid(noteHook, "text")?.text ?? "").trim() : ""; + const fill = hexColor(attr(x, "BACKGROUND_COLOR")); + const ink = hexColor(attr(x, "COLOR")); + const font = kid(x, "font"); + const dest = x.children.find((c) => c.name === "arrowlink"); + const to = dest ? attr(dest, "DESTINATION") : undefined; + const link = attr(x, "LINK"); + const node: Node = { + id: crypto.randomUUID(), + text, + children, + ...(note ? { note } : {}), + ...(link ? { link } : {}), + ...(to ? { topicLink: to } : {}), + ...(fill ? { color: fill } : {}), + ...(ink ? { textColor: ink } : {}), + ...(font && attr(font, "BOLD")?.toLowerCase() === "true" ? { bold: true } : {}), + ...(font && attr(font, "ITALIC")?.toLowerCase() === "true" ? { italic: true } : {}), + ...(children.length && attr(x, "FOLDED")?.toLowerCase() === "true" ? { collapsed: true } : {}), + ...(prio ? { priority: Math.min(9, Math.max(1, Number(prio))) } : {}), + ...(prog !== undefined ? { progress: prog } : {}), + }; + const id = attr(x, "ID"); + if (id) ids.set(id, node.id); // for arrowlink: FreeMind's id -> ours + return node; +} + +/** The root topics of a .mm map. Left-side first-level topics open as a one-sided (logic left) or two-sided (map) chart. */ +export function fromFreemind(src: string): Node[] { + const root = parseXml(src); // throws on DOCTYPE, on anything but plain XML + const raw = rawSlices(src, root); + const ids = new Map(); + const roots = root.children.filter((c) => c.name === "node").map((c) => { + const sides = c.children.filter((k) => k.name === "node").map((k) => (attr(k, "POSITION") ?? "").toLowerCase() === "left"); + const node = nodeFromXml(c, ids, raw); + const lefts = sides.filter(Boolean).length; + if (!lefts) return node; + node.layout = lefts === sides.length ? "left" : "map"; + // The two-sided map grows its first half to the right, so right-side topics come first. + node.children = node.children.filter((_, i) => !sides[i]).concat(node.children.filter((_, i) => sides[i])); + return node; + }); + if (!roots.length) throw new Error("no topic found in this mind map"); + resolveTopicLinks(roots, ids); // arrowlink destinations -> our topic ids; links to missing topics are dropped + return roots; +} + +/** A .mm file: plain XML, or gzip (magic bytes 1f 8b). */ +export async function fromFreemindBytes(bytes: Uint8Array): Promise { + if (bytes[0] !== 0x1f || bytes[1] !== 0x8b) return fromFreemind(new TextDecoder().decode(bytes)); + const text = await new Response(new Blob([bytes as BlobPart]).stream().pipeThrough(new DecompressionStream("gzip"))).text(); + return fromFreemind(text); +} + +const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!); + +const hex = (c: string | undefined) => (c && /^#[0-9a-f]{6}$/i.test(c) ? c : undefined); + +/** FreeMind keeps line breaks in a richcontent block, one `

` per line. */ +const richBlock = (type: string, lines: string[]): string => + `${lines.map((l) => `

${esc(l)}

`).join("")}
`; + +/** One Cartocortex topic -> one FreeMind node. `side` is the POSITION of a first-level topic; `childSides` of its children. */ +function nodeToXml(n: Node, fm: Map, side?: "left" | "right", childSides?: Array<"left" | "right" | undefined>): string { + const lines = n.text.split("\n"); + const attrs = + ` ID="${fm.get(n.id)}"` + + ` TEXT="${esc(lines[0])}"` + + (side ? ` POSITION="${side}"` : "") + + (hex(n.textColor) ? ` COLOR="${n.textColor}"` : "") + + (hex(n.color) ? ` BACKGROUND_COLOR="${n.color}"` : "") + + (n.link ? ` LINK="${esc(n.link)}"` : "") + + (n.collapsed && n.children.length ? ' FOLDED="true"' : ""); + const inner: string[] = []; + if (n.bold || n.italic) inner.push(``); + if (n.priority) inner.push(``); + if (n.progress != null) inner.push(``); + if (lines.length > 1) inner.push(richBlock("NODE", lines)); // TEXT carries the first line for plain-text readers + if (n.note) inner.push(richBlock("NOTE", n.note.split("\n"))); + const to = n.topicLink ? fm.get(n.topicLink) : undefined; + if (to) inner.push(``); + const kids = n.children.map((c, i) => nodeToXml(c, fm, childSides?.[i])).join(""); + return `${inner.join("")}${kids}`; +} + +/** Where a first-level topic sits, from the root's layout: everything left, or the first half right and the rest left. */ +const childSides = (root: Node): Array<"left" | "right" | undefined> => { + if (root.layout === "left") return root.children.map(() => "left" as const); + if (root.layout === "map") { + const cut = Math.ceil(root.children.length / 2); + return root.children.map((_, i) => (i < cut ? ("right" as const) : ("left" as const))); + } + return root.children.map(() => undefined); +}; + +/** A .mm map from Cartocortex sheets: one root node per sheet, so extra sheets may not show in FreeMind itself. */ +export function toFreemind(roots: Node[]): string { + const fm = new Map(); + const claim = (n: Node) => (fm.set(n.id, "ID_" + crypto.randomUUID()), n.children.forEach(claim)); + roots.forEach(claim); // every id first: an arrowlink may point at a topic that comes later + const nodes = roots.map((r) => nodeToXml(r, fm, undefined, childSides(r))).join("\n"); + return `\n\n${nodes}\n\n`; +} diff --git a/src/xmindxml.ts b/src/xmindxml.ts index 50794dd..2d51a25 100644 --- a/src/xmindxml.ts +++ b/src/xmindxml.ts @@ -8,7 +8,7 @@ export type XNode = { name: string; attrs: Record; children: XNo const local = (name: string) => name.replace(/^.*:/, ""); // drop namespace prefixes: xlink:href -> href -const decode = (s: string) => +export const decode = (s: string) => s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (m, e: string) => { if (e[0] === "#") { const code = e[1].toLowerCase() === "x" ? parseInt(e.slice(2), 16) : Number(e.slice(1)); From 75c75ab96ecdfb5dcffaf696be6b0bebbfcb75d3 Mon Sep 17 00:00:00 2001 From: Martin-PB Date: Sat, 17 Oct 2026 14:16:38 +0200 Subject: [PATCH 2/3] Restore the UTF-8 characters the first commit mangled The first commit of this branch wrote App.tsx and README.md with the parts it did not mean to change double-encoded: every non-ASCII character (the command-key shortcut signs, arrows, ellipses) had become mojibake. Both files are rebuilt from main with the same .mm edits, so the branch now only changes what the FreeMind support needs. Agent: Vibe (Mistral, glm-5-latest-short) Relu-par-humain: Martin-PB --- README.md | 44 ++++++++++++++++----------------- src/App.tsx | 70 ++++++++++++++++++++++++++--------------------------- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index e36d3d4..3d6676f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A local, native mind-mapping app (XMind-style). Tauri 2 + React + TypeScript. Files stay on your machine. -Tabs and menus: the toolbar is three menus (File with templates, recent files and export; Insert; View with structure, theme and panels) plus Undo, Redo, Delete, Find and Save. Each map opens in its own tab (a blank tab is reused; Open… puts a map in a new tab when the current one holds a file or unsaved work); the second row of tabs is the sheets inside one map. Only the first tab keeps the autosave. +Tabs and menus: the toolbar is three menus (File with templates, recent files and export; Insert; View with structure, theme and panels) plus Undo, Redo, Delete, Find and Save. Each map opens in its own tab (a blank tab is reused; Open… puts a map in a new tab when the current one holds a file or unsaved work); the second row of tabs is the sheets inside one map. Only the first tab keeps the autosave. ## Download @@ -29,18 +29,18 @@ npm test # unit tests for the tree model and layout | Space / F2 / double-click | Edit text (Enter to commit, Esc to cancel) | | Arrows | Move selection | | Delete / Backspace | Delete node | -| ⌘/Ctrl+Z, +Shift | Undo, redo | -| ⌘/Ctrl+S | Save as JSON | +| ⌘/Ctrl+Z, +Shift | Undo, redo | +| ⌘/Ctrl+S | Save as JSON | | Drag a node onto another | Re-parent | -| Wheel / drag background | Pan; ⌘/Ctrl+wheel or pinch zooms | +| Wheel / drag background | Pan; ⌘/Ctrl+wheel or pinch zooms | -The ‹ chip on a node collapses its branch. The map autosaves to local storage; Save/Open use `.json`, and Export MD writes a Markdown outline. +The ‹ chip on a node collapses its branch. The map autosaves to local storage; Save/Open use `.json`, and Export MD writes a Markdown outline. ## Layout -- `src/model.ts` – immutable tree ops, tidy-tree layout, Markdown export -- `src/App.tsx` – canvas, keyboard handling, undo/redo, save/open -- `src-tauri/` – Rust shell with the dialog and fs plugins +- `src/model.ts` – immutable tree ops, tidy-tree layout, Markdown export +- `src/App.tsx` – canvas, keyboard handling, undo/redo, save/open +- `src-tauri/` – Rust shell with the dialog and fs plugins ## Not yet @@ -48,19 +48,19 @@ Direction variants of XMind structures (up-pointing org chart, left-hand tree), ## Added -Fill colour, shape, emoji markers, notes and links (inspector panel); relationship lines and boundaries; Logic and Map (two-sided) structures; five themes with per-branch colours; copy/cut/paste/duplicate (⌘C/X/V/D); find (⌘F, Enter cycles); fit-to-view; `.xmind` open and export (all sheets; text, notes, links, folding and structure only). +Fill colour, shape, emoji markers, notes and links (inspector panel); relationship lines and boundaries; Logic and Map (two-sided) structures; five themes with per-branch colours; copy/cut/paste/duplicate (⌘C/X/V/D); find (⌘F, Enter cycles); fit-to-view; `.xmind` open and export (all sheets; text, notes, links, folding and structure only). Outline view (toolbar "Outline": Enter = sibling, Tab / Shift+Tab = indent / outdent, shares undo with the map) and PNG / PDF export (the PDF is one page holding an image of the map, so its text isn't selectable). Summaries: select a topic, click "Summary", then click the last topic of the range (it must share a parent). A bracket and an editable label (double-click) appear beside the span; click the label and press Delete to remove it. They are included in PNG/PDF export but not in `.xmind` export. -Presentation: "Present" goes full-screen and reveals one top-level branch per step (→ / Space / Enter next, ← back, Esc exit); earlier branches dim. +Presentation: "Present" goes full-screen and reveals one top-level branch per step (→ / Space / Enter next, ← back, Esc exit); earlier branches dim. -Multi-select: Shift / ⌘ / Ctrl-click toggles topics; Shift-drag on empty canvas draws a selection box (⌘/Ctrl-drag adds to the current selection); ⌘A selects all. Delete, drag-to-reparent, and the Fill / Shape / Marker controls act on the whole selection, and Summary wraps selected siblings in one click. Copy / paste / duplicate still work on the primary topic only. +Multi-select: Shift / ⌘ / Ctrl-click toggles topics; Shift-drag on empty canvas draws a selection box (⌘/Ctrl-drag adds to the current selection); ⌘A selects all. Delete, drag-to-reparent, and the Fill / Shape / Marker controls act on the whole selection, and Summary wraps selected siblings in one click. Copy / paste / duplicate still work on the primary topic only. Floating topics: "Float" (or double-click empty canvas) adds a free-standing topic with no branch to the root. Drag it anywhere; release over another topic to attach it there. In `.xmind` export they become ordinary top-level topics (positions are not kept). Deleting topics also removes relationships, boundaries and summaries that pointed at them. -Sheets: tabs under the toolbar, one root topic and one undo history per sheet. "+" adds a sheet, double-click a tab renames it, × then "Delete?" removes it. Save writes every sheet to one `.json` (older single-map files still open); `.xmind` import and export carry all sheets. PNG, PDF, Markdown and presentation use the open sheet. +Sheets: tabs under the toolbar, one root topic and one undo history per sheet. "+" adds a sheet, double-click a tab renames it, × then "Delete?" removes it. Save writes every sheet to one `.json` (older single-map files still open); `.xmind` import and export carry all sheets. PNG, PDF, Markdown and presentation use the open sheet. Security: the native window runs under a Content-Security-Policy (`src-tauri/tauri.conf.json`): scripts only from the app itself, no network connections except Tauri's own bridge, images only from the app, `data:` and `blob:`. Inline styles are allowed because topic colours are applied as style attributes. Files you open are parsed as data and never executed. @@ -68,27 +68,27 @@ Security: the native window runs under a Content-Security-Policy (`src-tauri/tau `app-icon.svg` is generated by `scripts/make-icon.mjs` (a side-view brain with a neuron network on a pale teal tile, in the Glossolalie palette: teal #0D9488, navy #1E3A5F and pale tints; the palette and the neuron layout seed are constants at the top). To rebuild: `node scripts/make-icon.mjs`, render the SVG to `app-icon.png` at 1024px (any SVG renderer, e.g. `@resvg/resvg-js`), then `npx tauri icon app-icon.png` to refresh `src-tauri/icons/`. -Custom theme: pick "Custom…" in the theme menu to edit the central/accent colour and six branch colours in the panel; changes preview live and commit as one undo step. Colours are stored in the document (`themeColors`) and only `#rrggbb` values are accepted. +Custom theme: pick "Custom…" in the theme menu to edit the central/accent colour and six branch colours in the panel; changes preview live and commit as one undo step. Colours are stored in the document (`themeColors`) and only `#rrggbb` values are accepted. Look: the UI follows the Glossolalie palette (teal `#0D9488` accent, navy `#1E3A5F` ink, pale teal tints for surfaces, thin 40%-tint rules) with no dark fills and no dark mode; the central topic is a tinted pill with a thicker teal border. The default theme is named "Glossolalie"; the other themes only change branch colours. PNG / PDF export uses the same ink and tints. -Task markers and links: the panel sets a priority (1-5, a numbered disc: darker is more urgent) and a progress (0-100%, a pie that becomes a check at 100%) on the selected topics; both map to XMind's `priority-N` and `task-*` markers on `.xmind` import and export (other XMind markers are dropped). A topic's link shows a 🔗 badge: click it, or use "Open link", to open it in the system browser. Only http, https and mailto links open; a bare host such as `example.com` gets https://, and `javascript:` or `file:` links are refused. +Task markers and links: the panel sets a priority (1-5, a numbered disc: darker is more urgent) and a progress (0-100%, a pie that becomes a check at 100%) on the selected topics; both map to XMind's `priority-N` and `task-*` markers on `.xmind` import and export (other XMind markers are dropped). A topic's link shows a 🔗 badge: click it, or use "Open link", to open it in the system browser. Only http, https and mailto links open; a bare host such as `example.com` gets https://, and `javascript:` or `file:` links are refused. -Structures: the toolbar picker offers Logic (right), Logic (left), Map (both sides), Org chart (top-down), Tree (indented list), Fishbone, Timeline, Brace and Matrix. Arrow keys follow the structure (in an org chart, ↓ goes to the first child and ← / → move between topics in a row; in a tree, ↑ / ↓ walk the rows). Summaries are drawn in the Logic and Map structures only. The structure is saved per sheet and maps to XMind's org-chart, tree-chart, logic and map structures; XMind structures not listed open as their nearest match, or as Logic. +Structures: the toolbar picker offers Logic (right), Logic (left), Map (both sides), Org chart (top-down), Tree (indented list), Fishbone, Timeline, Brace and Matrix. Arrow keys follow the structure (in an org chart, ↓ goes to the first child and ← / → move between topics in a row; in a tree, ↑ / ↓ walk the rows). Summaries are drawn in the Logic and Map structures only. The structure is saved per sheet and maps to XMind's org-chart, tree-chart, logic and map structures; XMind structures not listed open as their nearest match, or as Logic. -Text style: the panel's Text section (and ⌘B / ⌘I) sets bold, italic, strikethrough and a text colour on the selected topics. With the cursor in a topic (or in the notes) the same controls style just the selected words; otherwise they style the whole topic. Notes are now a small rich editor too (plain text underneath, so search still works). Per-word style is saved with the map and shows in PNG / PDF export, but `.xmind` export writes plain text for titles and notes (topic-level style is still exported). The style is kept in PNG / PDF export and in `.xmind` files (as XMind's `fo:font-weight`, `fo:font-style`, `fo:text-decoration` and `fo:color` properties; I have not checked it against a file made by XMind itself). Colours read from files must be plain `#rrggbb`. +Text style: the panel's Text section (and ⌘B / ⌘I) sets bold, italic, strikethrough and a text colour on the selected topics. With the cursor in a topic (or in the notes) the same controls style just the selected words; otherwise they style the whole topic. Notes are now a small rich editor too (plain text underneath, so search still works). Per-word style is saved with the map and shows in PNG / PDF export, but `.xmind` export writes plain text for titles and notes (topic-level style is still exported). The style is kept in PNG / PDF export and in `.xmind` files (as XMind's `fo:font-weight`, `fo:font-style`, `fo:text-decoration` and `fo:color` properties; I have not checked it against a file made by XMind itself). Colours read from files must be plain `#rrggbb`. -Fishbone: the central topic is the head on the right; top-level topics alternate above and below the spine on slanted bones, with their sub-topics as ribs along each bone and deeper levels hanging to the left of their parent. Arrow keys walk the bones in reading order (← towards the tail, → towards the head). The head is always on the right (XMind's right-headed fishbone). +Fishbone: the central topic is the head on the right; top-level topics alternate above and below the spine on slanted bones, with their sub-topics as ribs along each bone and deeper levels hanging to the left of their parent. Arrow keys walk the bones in reading order (← towards the tail, → towards the head). The head is always on the right (XMind's right-headed fishbone). Old XMind files: `.xmind` files saved by XMind 8 and earlier (XML inside, `content.xml`) now open too, with every sheet's title, structure, topics, notes, links, folding and priority / progress markers. Floating topics, relationships, styles and other markers in those files are dropped. Files from newer XMind (JSON inside) are read as before, and take precedence when a file has both. The reader is strict by design: XML with a DOCTYPE is refused. -Timeline: the central topic starts a horizontal line and the top-level topics sit on it, in order. Each topic's sub-topics stack in a column under the first topic, above the second, and so on (nearest first, indented by depth). ← / → move along the line; ↑ / ↓ move into and along a stack. It maps to XMind's horizontal timeline, and any XMind timeline variant opens as this one. +Timeline: the central topic starts a horizontal line and the top-level topics sit on it, in order. Each topic's sub-topics stack in a column under the first topic, above the second, and so on (nearest first, indented by depth). ← / → move along the line; ↑ / ↓ move into and along a stack. It maps to XMind's horizontal timeline, and any XMind timeline variant opens as this one. XMind compatibility notes: structure classes, `fo:*` style keys, marker ids and the style / marker JSON layout were checked against identifiers inside the installed XMind app, but not against a document saved by XMind (none was available on this machine). More structures: **Logic (left)** mirrors the logic chart around the root. **Brace** keeps the logic layout but draws a curly brace around each parent's children (a plain line when there is only a row or two). **Matrix** (XMind's spreadsheet structure) puts the top-level topics side by side as columns, each with its sub-topics listed beneath. They map to XMind's `logic.left`, `brace.right` and `spreadsheet.column`; XMind's `brace.left` opens as the right-hand brace, and its row-wise matrix opens as columns. -Saving (desktop app): ⌘S saves to the current file, and asks where the first time (or after New); ⇧⌘S saves under a new name. The format follows the extension: `.json` (Cartocortex, keeps everything) or `.xmind`. The window title shows the file name, with a • while there are unsaved changes, and New, Open and closing the window ask before discarding them. "Recent…" lists the last 8 files (kept across restarts). In a browser, Save downloads a `.json` instead. The map is also autosaved in the app's own storage, which is what you see when you start without opening a file. +Saving (desktop app): ⌘S saves to the current file, and asks where the first time (or after New); ⇧⌘S saves under a new name. The format follows the extension: `.json` (Cartocortex, keeps everything) or `.xmind`. The window title shows the file name, with a • while there are unsaved changes, and New, Open and closing the window ask before discarding them. "Recent…" lists the last 8 files (kept across restarts). In a browser, Save downloads a `.json` instead. The map is also autosaved in the app's own storage, which is what you see when you start without opening a file. ## Development and releases @@ -103,10 +103,10 @@ Outline formats: Open reads Markdown (`.md`, headings and nested lists; `- [x]` FreeMind / Freeplane: Open reads FreeMind's `.mm` maps (plain or gzipped) — topics, notes, links, links between topics, folding, fill and text colours, bold / italic, priority and progress icons — and left-side topics open as a one-sided or two-sided map. "Export FreeMind" (and Save as `.mm`) writes one root node per sheet, so extra sheets may not show in FreeMind itself; multi-line texts keep their lines, but pictures, relationships, summaries, per-word style, floating positions and structures beyond the sideways layouts are not carried. -Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind or Markdown file is an *import*: the map starts as unsaved, and ⌘S asks for a new file rather than overwriting your original with a lossy copy. +Safety rule for opened files: only Cartocortex's own `.json` keeps its place on disk. Opening a `.xmind`, `.opml`, FreeMind or Markdown file is an *import*: the map starts as unsaved, and ⌘S asks for a new file rather than overwriting your original with a lossy copy. -Links between topics: in the panel, "Link to a topic" → "Pick a topic…", then click the target. The source gets a ↪ badge (and "Go ↪" in the panel); clicking it opens any collapsed branches above the target and selects it, also when the target is on another sheet. In `.xmind` files a topic link is `xmind:#`: it survives a round trip, and XMind allows one link per topic, so an outside web link wins if a topic has both. +Links between topics: in the panel, "Link to a topic" → "Pick a topic…", then click the target. The source gets a ↪ badge (and "Go ↪" in the panel); clicking it opens any collapsed branches above the target and selects it, also when the target is on another sheet. In `.xmind` files a topic link is `xmind:#`: it survives a round trip, and XMind allows one link per topic, so an outside web link wins if a topic has both. Pictures: the panel's "Picture" section puts an image (PNG, JPEG, GIF or WebP) on the selected topic, shown as a small thumbnail in the topic; click the thumbnail for the full image (Esc or a click closes it). The picture is downscaled to at most 800 px and stored inside the map file, so keep an eye on size (the autosave in the app's own storage has a few-megabyte limit). Pictures appear in PNG / PDF export but not in `.xmind`, OPML or Markdown. Only embedded raster images are ever drawn: SVG and web addresses in a map file are ignored. -Templates: the "Template…" menu next to New starts a map from one of eight starters (brainstorm, project plan as a timeline, SWOT as a matrix, cause and effect as a fishbone, meeting notes as a tree, org chart, weekly plan, pros and cons as a brace map). Like New, it asks before discarding unsaved changes. +Templates: the "Template…" menu next to New starts a map from one of eight starters (brainstorm, project plan as a timeline, SWOT as a matrix, cause and effect as a fishbone, meeting notes as a tree, org chart, weekly plan, pros and cons as a brace map). Like New, it asks before discarding unsaved changes. diff --git a/src/App.tsx b/src/App.tsx index 9c64895..235c4de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,7 @@ const THEMES: Record { const name = docPath ? baseName(docPath) : docName ?? "Untitled"; - const title = `${dirty ? "• " : ""}${name} — Cartocortex`; + const title = `${dirty ? "• " : ""}${name} — Cartocortex`; onMeta(name, dirty); if (!active) return; document.title = title; @@ -543,7 +543,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP window.addEventListener("pointerup", up); }; - // Shift-drag on empty canvas draws a selection box; ⌘/Ctrl-drag adds to the current selection. + // Shift-drag on empty canvas draws a selection box; ⌘/Ctrl-drag adds to the current selection. const marquee = (e: React.PointerEvent) => { const r = stage.current!.getBoundingClientRect(); const sx = e.clientX - r.left, sy = e.clientY - r.top; @@ -587,10 +587,10 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP
open() }, - { label: "Save", kbd: "⌘S", on: () => saveDoc() }, - ...(isTauri ? [{ label: "Save as…", kbd: "⇧⌘S", on: () => saveDoc(true) }] : []), + { label: "New map", kbd: "⌘N", on: newDoc }, + { label: "Open…", kbd: "⌘O", on: () => open() }, + { label: "Save", kbd: "⌘S", on: () => saveDoc() }, + ...(isTauri ? [{ label: "Save as…", kbd: "⇧⌘S", on: () => saveDoc(true) }] : []), { heading: "New from template" }, ...TEMPLATES.map((x) => ({ label: x.name, on: () => startFrom(x.build()) })), ...(isTauri && recent.length ? [{ heading: "Recent" }, ...recent.map((p) => ({ label: baseName(p), title: p, on: () => open(p) }))] : []), @@ -604,13 +604,13 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP ]} /> setLinking(!linking) }, + { label: linking ? "Relationship: pick the target…" : "Relationship", title: "Choose this, then click the target topic", on: () => setLinking(!linking) }, { label: "Boundary", checked: !!s.root.bounds?.includes(s.sel), on: toggleBound }, { - label: summarizing ? "Summary: pick the range end…" : "Summary", + label: summarizing ? "Summary: pick the range end…" : "Summary", title: "Select several sibling topics first, or choose this and then click the last topic of the range", on: () => { const span = siblingSpan(s.root, ids); @@ -625,7 +625,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP label: l, checked: (s.root.layout ?? "right") === v, on: () => setDocProp({ layout: v }), })), { heading: "Theme" }, - ...[...Object.entries(THEMES).map(([id, t]) => [id, t.name]), ["custom", "Custom…"]].map(([id, name]) => ({ + ...[...Object.entries(THEMES).map(([id, t]) => [id, t.name]), ["custom", "Custom…"]].map(([id, name]) => ({ label: name, checked: (s.root.theme ?? "classic") === id, on: () => { setDraft(null); @@ -640,14 +640,14 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP { label: "Present", on: startPresenting }, ]} /> - - - + + + (setQ(e.target.value), setHit(-1))} onKeyDown={(e) => { @@ -655,7 +655,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP if (e.key === "Escape") (setQ(""), e.currentTarget.blur()); }} /> - +
{sheets.roots.map((r0, i) => { @@ -693,7 +693,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP title={delAsk === i ? "Click again to delete this sheet" : "Delete sheet"} onClick={(e) => (e.stopPropagation(), delAsk === i ? deleteSheet(i) : setDelAsk(i))} > - {delAsk === i ? "Delete?" : "×"} + {delAsk === i ? "Delete?" : "×"} )}
@@ -798,7 +798,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP ) : null} {n.progress != null && ( - {n.progress >= 100 ? "✓" : ""} + {n.progress >= 100 ? "✓" : ""} )} {s.edit === b.id ? ( @@ -812,23 +812,23 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP /> ) : ( - {validRuns(n.text, n.runs)?.map((r, k) => {r.t}) ?? (n.text || "…")} + {validRuns(n.text, n.runs)?.map((r, k) => {r.t}) ?? (n.text || "…")} )} - {n.note && 📝} + {n.note && 📝} {n.link && ( e.stopPropagation()} onClick={(e) => (e.stopPropagation(), openLink(n.link!))}> - 🔗 + 🔗 )} {n.topicLink && where(n.topicLink) && ( e.stopPropagation()} onClick={(e) => (e.stopPropagation(), goTo(n.topicLink!))} > - ↪ + ↪ )} {n.children.length > 0 && ( @@ -838,7 +838,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP onPointerDown={(e) => e.stopPropagation()} onClick={() => commit((r) => toggle(r, b.id))} > - {n.collapsed ? n.children.length : "‹"} + {n.collapsed ? n.children.length : "‹"} )}
@@ -865,7 +865,7 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP }} /> ) : ( - {x.text || "…"} + {x.text || "…"} )}
))} @@ -919,8 +919,8 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP
e.preventDefault() /* keep the editor's selection */}> - - + +
e.preventDefault()}> @@ -946,22 +946,22 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP {!multi && ( <> - e.target.value !== (selNode.link ?? "") && setNode({ link: e.target.value || undefined })} /> - {selNode.link && } + e.target.value !== (selNode.link ?? "") && setNode({ link: e.target.value || undefined })} /> + {selNode.link && }
- {selNode.topicLink && where(selNode.topicLink) && } + {selNode.topicLink && where(selNode.topicLink) && } {selNode.topicLink && }
{selNode.topicLink && ( -
{where(selNode.topicLink) ? `→ ${where(selNode.topicLink)!.node.text || "…"}` : "The linked topic no longer exists."}
+
{where(selNode.topicLink) ? `→ ${where(selNode.topicLink)!.node.text || "…"}` : "The linked topic no longer exists."}
)}
- + {selNode.image && }
@@ -985,9 +985,9 @@ export default function App({ active, primary, openPath, onMeta, openTab }: AppP )} {present !== null && (
- + {present + 1} / {steps} - +
)} From 1ac219c1d87435fcfcc6373fd1d902d902757a89 Mon Sep 17 00:00:00 2001 From: Thierry Sayegh-Sauvage Date: Sat, 17 Oct 2026 14:22:15 +0200 Subject: [PATCH 3/3] Compare gzipped and plain .mm reads without their random ids Co-Authored-By: Claude Sonnet 5.5 --- node_modules | 1 + src/freemind.test.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 120000 node_modules diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..883f1a5 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/Users/thierry/Claude Code/mindmap/node_modules \ No newline at end of file diff --git a/src/freemind.test.ts b/src/freemind.test.ts index e020bab..f3b569a 100644 --- a/src/freemind.test.ts +++ b/src/freemind.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "vitest"; import { fromFreemind, fromFreemindBytes, toFreemind } from "./freemind"; -import { mk } from "./model"; +import { Node, mk } from "./model"; const sample = ` @@ -48,10 +48,13 @@ test("left-side topics everywhere open as a one-sided chart", () => { expect(roots[0].children.map((c) => c.text)).toEqual(["A", "B"]); }); +// Reading mints fresh ids (and remaps topic links onto them), so compare the maps without them. +const noIds = (r: Node[]) => JSON.stringify(r, (k, v) => (k === "id" || k === "topicLink" ? undefined : v)); + test("a gzipped .mm opens like a plain one", async () => { const plain = new TextEncoder().encode(sample); const gz = new Uint8Array(await new Response(new Blob([plain as BlobPart]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer()); - expect(await fromFreemindBytes(gz)).toEqual(await fromFreemindBytes(plain)); + expect(noIds(await fromFreemindBytes(gz))).toBe(noIds(await fromFreemindBytes(plain))); }); test("strict reading: a DOCTYPE, non-XML and a map without a topic are rejected", () => {