PageSourceSearch

https://smartcone.ai/src/shell/types/mindmap/index.js

js smartcone.ai collected 2026-10-05 15:06:14 UTC 81,453 bytes, 1,622 lines download raw bytes

1// Mind Map entry point
2import { ensureMindMapStyles } from "./styles.js";
3import { buildToolbar } from "./toolbar.js";
4import { attachInteractions, hideNotesReader, hideLaneEditor, hideEdgeEditor, restoreNode, restoreEdge, restoreLane, refreshAllEdges, pruneEdgeMarkers, syncCounters, syncLaneCounters, updateLaneCounts, createGalleryNode, GALLERY_NODE_SIZE, applyNodeLabelText } from "./draw.js";
5import { createToast, showApiTemplateDialog } from "../../../helpers/dialogs.js";
6import { createMindMapDb } from "./mindmapDb.js";
7import { showAiImageDialog, insertAiImageMindmap } from "../../../helpers/aiImageGen.js";
8import { showGenerateDialog } from "../../common/aiAssist.js";
9import { repoFetchProxy } from "../../../repo/api.js";
10import * as clipboard from "../../shared/clipboard.js";
11import * as formatCopier from "../../shared/formatCopier.js";
12import { stopDragInside } from "../../shared/utils.js";
13/**
14 * NDJSON API template – demonstrates every element the Mind Map supports.
15 *
16 * Record types:
17 *   mindmap-header  – required first line; version must be 1.
18 *                     Optional: title (string) – name of the map
19 *                               mapVersion (string) – version number of the map
20 *   lane            – a labelled region; laneType: "free" | "vertical" | "horizontal" | "quadrant".
21 *                     quadrant lanes additionally require quadrant: "strengths" | "weaknesses" | "opportunities" | "threats".
22 *                     Optional description and notes fields are supported for all lane types.
23 *                     Optional formatting: bgColor (hex color), borderColor (hex color), 
24 *                     borderWidth (number), borderStyle ("solid" | "dashed" | "dotted").
25 *   node            – a shape on the canvas; shape: "rectangle" | "circle" | "ellipse" | "diamond" |
26 *                     "cylinder" | "parallelogram" | "hexagon" | "trapezoid" | "sticky" | "image".
27 *                     Optional laneId assigns the node to a lane.
28 *                     Optional description shows sub-text on the node; notes are private annotations.
29 *                     Optional fillColor and strokeColor (hex color strings) customize node appearance.
30 *                     Optional w and h (width/height in px) set custom node dimensions.
31 *                     Optional fontSize (number as string, e.g. "14"), fontColor (hex color),
32 *                     fontWeight ("normal" | "bold"), fontStyle ("normal" | "italic") customize label text.
33 *                     Optional fieldRef stores a FeBe field binding (e.g. "stream.temperature").
34 *   edge            – a connector between nodes, lanes, and/or edges; edgeType: "straight" | "elbow" | "curved".
35 *                     from/to can reference node id, lane id, or edge id values.
36 *                     Optional fromAnchor/toAnchor set edge attachment position on edge endpoints as 0..1 path ratios.
37 *                     Optional color (hex color, default: "#64748b").
38 *                     Optional thickness (number, default: "1.5").
39 *                     Optional lineStyle: "solid" | "dotted" | "dashed" | "double" (default: "solid").
40 *                     Optional label, description, notes, tasks (all strings).
41 *                     Optional startMarker/endMarker: "none" | "arrow" | "circle" | "square".
42 *                     Optional startMarkerColor/endMarkerColor (hex colors).
43 *
44 * Layout used below (all coordinates are absolute SVG canvas units):
45 *   Section 1 (y ≈  50) – all 10 node shapes inside a free lane
46 *   Section 2 (y ≈ 520) – all 3 edge types inside a free lane
47 *   Section 3 (y ≈ 855) – vertical lane type (two side-by-side columns)
48 *   Section 4 (y ≈ 855) – horizontal lane type (two stacked rows, right side)
49 *   Section 5 (y ≈1360) – quadrant (SWOT) lane type (2×2 grid)
50 */
51const MINDMAP_API_TEMPLATE = [
52  // ── Header ─────────────────────────────────────────────────────────────────
53  { type: "mindmap-header", version: 1, title: "API Template", mapVersion: "1.0" },
54
55  // ── Section 1: All node shapes (free lane) ─────────────────────────────────
56  { type: "lane", id: "l1", x: 50, y: 50, w: 1250, h: 400,
57    title: "Node Shapes – rectangle, circle, ellipse, diamond, cylinder, parallelogram, hexagon, trapezoid, sticky, image",
58    laneType: "free", quadrant: null, description: "All supported node shapes", notes: "" },
59
60  // Row 1
61  { type: "node", id: "n1",  x: 90,  y: 120, w: 160, h: 80,  shape: "rectangle",     label: "Rectangle",      description: "Standard box node",        notes: "",  laneId: "l1" },
62  { type: "node", id: "n2",  x: 310, y: 115, w: 100, h: 100, shape: "circle",         label: "Circle",         description: "Round node",                notes: "",  laneId: "l1" },
63  { type: "node", id: "n3",  x: 480, y: 125, w: 150, h: 70,  shape: "ellipse",        label: "Ellipse",        description: "Oval node",                 notes: "",  laneId: "l1" },
64  { type: "node", id: "n4",  x: 700, y: 110, w: 150, h: 100, shape: "diamond",        label: "Diamond",        description: "Decision / branch point",   notes: "",  laneId: "l1" },
65  { type: "node", id: "n5",  x: 930, y: 120, w: 140, h: 80,  shape: "cylinder",       label: "Cylinder",       description: "Database or storage",       notes: "",  laneId: "l1" },
66
67  // Row 2
68  { type: "node", id: "n6",  x: 90,  y: 295, w: 160, h: 70,  shape: "parallelogram",  label: "Parallelogram",  description: "Process or I/O",            notes: "",  laneId: "l1" },
69  { type: "node", id: "n7",  x: 320, y: 295, w: 150, h: 70,  shape: "hexagon",        label: "Hexagon",        description: "Task or function",          notes: "",  laneId: "l1" },
70  { type: "node", id: "n8",  x: 540, y: 295, w: 150, h: 70,  shape: "trapezoid",      label: "Trapezoid",      description: "Alternate container",       notes: "",  laneId: "l1" },
71  { type: "node", id: "n9",  x: 760, y: 285, w: 160, h: 80,  shape: "sticky",         label: "Sticky Note",    description: "Quick annotation",          notes: "Sticky notes use a yellow background and black text.", laneId: "l1" },
72  { type: "node", id: "n10", x: 990, y: 290, w: 160, h: 80,  shape: "image",          label: "Image",          description: "Image placeholder",         notes: "",  laneId: "l1" },
73
74  // ── Section 2: All edge types (free lane) ──────────────────────────────────
75  { type: "lane", id: "l2", x: 50, y: 520, w: 900, h: 260,
76    title: "Edge Types – straight, elbow, curved",
77    laneType: "free", quadrant: null, description: "All supported connector/edge types", notes: "" },
78
79  { type: "node", id: "n11", x: 90,  y: 580, w: 120, h: 60,  shape: "rectangle", label: "Source A",  description: "straight edge →", notes: "", laneId: "l2", fieldRef: "streamA.status" },
80  { type: "node", id: "n12", x: 320, y: 580, w: 120, h: 60,  shape: "rectangle", label: "Target A",  description: "",                notes: "", laneId: "l2" },
81  { type: "node", id: "n13", x: 90,  y: 690, w: 120, h: 60,  shape: "rectangle", label: "Source B",  description: "elbow edge →",    notes: "", laneId: "l2" },
82  { type: "node", id: "n14", x: 320, y: 690, w: 120, h: 60,  shape: "rectangle", label: "Target B",  description: "",                notes: "", laneId: "l2" },
83  { type: "node", id: "n15", x: 560, y: 575, w: 120, h: 60,  shape: "rectangle", label: "Source C",  description: "curved edge →",   notes: "", laneId: "l2" },
84  { type: "node", id: "n16", x: 800, y: 685, w: 120, h: 60,  shape: "rectangle", label: "Target C",  description: "",                notes: "", laneId: "l2" },
85
86  { type: "edge", id: "e1", from: "n11", to: "n12", edgeType: "straight", color: "#3b82f6", thickness: "2", lineStyle: "solid", label: "Primary", description: "Primary connector", notes: "", tasks: "", startMarker: "circle", startMarkerColor: "#60a5fa", endMarker: "arrow", endMarkerColor: "#3b82f6" },
87  { type: "edge", id: "e2", from: "n13", to: "n14", edgeType: "elbow", color: "#10b981", thickness: "2.5", lineStyle: "dashed", label: "Fallback", description: "", notes: "Dashed path", tasks: "", startMarker: "square", startMarkerColor: "#10b981", endMarker: "arrow", endMarkerColor: "#34d399" },
88  { type: "edge", id: "e3", from: "n15", to: "n16", edgeType: "curved", color: "#f59e0b", thickness: "3", lineStyle: "dotted", label: "Curved", description: "", notes: "", tasks: "Validate route", startMarker: "none", endMarker: "circle", endMarkerColor: "#f59e0b" },
89  { type: "edge", id: "e4", from: "e1", to: "n14", edgeType: "straight", color: "#eab308", thickness: "2", lineStyle: "solid", label: "Branch", description: "Edge → node branch", notes: "", tasks: "", startMarker: "none", endMarker: "arrow", endMarkerColor: "#eab308" },
90
91  // ── Section 3: Vertical lane type (two side-by-side columns) ───────────────
92  { type: "lane", id: "l3", x: 50,  y: 855, w: 280, h: 420, title: "Vertical Lane A", laneType: "vertical",   quadrant: null, description: "", notes: "" },
93  { type: "node", id: "n17", x: 115, y: 980,  w: 150, h: 60, shape: "rectangle", label: "Column Item 1", description: "", notes: "", laneId: "l3" },
94  { type: "node", id: "n18", x: 115, y: 1080, w: 150, h: 60, shape: "rectangle", label: "Column Item 2", description: "", notes: "", laneId: "l3" },
95
96  { type: "lane", id: "l4", x: 346, y: 855, w: 280, h: 420, title: "Vertical Lane B", laneType: "vertical",   quadrant: null, description: "", notes: "" },
97  { type: "node", id: "n19", x: 411, y: 980,  w: 150, h: 60, shape: "rectangle", label: "Column Item 3", description: "", notes: "", laneId: "l4" },
98  { type: "node", id: "n20", x: 411, y: 1080, w: 150, h: 60, shape: "rectangle", label: "Column Item 4", description: "", notes: "", laneId: "l4" },
99
100  // ── Section 4: Horizontal lane type (two stacked rows, right side) ──────────
101  { type: "lane", id: "l5", x: 660, y: 855,  w: 620, h: 200, title: "Horizontal Lane A", laneType: "horizontal", quadrant: null, description: "", notes: "" },
102  { type: "node", id: "n21", x: 730,  y: 925,  w: 130, h: 60, shape: "rectangle", label: "Row Item 1", description: "", notes: "", laneId: "l5" },
103  { type: "node", id: "n22", x: 1060, y: 925,  w: 130, h: 60, shape: "rectangle", label: "Row Item 2", description: "", notes: "", laneId: "l5" },
104
105  { type: "lane", id: "l6", x: 660, y: 1071, w: 620, h: 200, title: "Horizontal Lane B", laneType: "horizontal", quadrant: null, description: "", notes: "" },
106  { type: "node", id: "n23", x: 730,  y: 1141, w: 130, h: 60, shape: "rectangle", label: "Row Item 3", description: "", notes: "", laneId: "l6" },
107  { type: "node", id: "n24", x: 1060, y: 1141, w: 130, h: 60, shape: "rectangle", label: "Row Item 4", description: "", notes: "", laneId: "l6" },
108
109  // ── Section 5: Quadrant (SWOT) lane type (2×2 grid, 8 px gap) ──────────────
110  { type: "lane", id: "l7",  x: 50,  y: 1360, w: 500, h: 500, title: "Strengths",     laneType: "quadrant", quadrant: "strengths",     description: "What we do well", notes: "" },
111  { type: "node", id: "n25", x: 120, y: 1490, w: 160, h: 70, shape: "rectangle", label: "Strength 1",    description: "What we do well",        notes: "", laneId: "l7"  },
112
113  { type: "lane", id: "l8",  x: 558, y: 1360, w: 500, h: 500, title: "Weaknesses",    laneType: "quadrant", quadrant: "weaknesses",    description: "What needs improvement", notes: "" },
114  { type: "node", id: "n26", x: 628, y: 1490, w: 160, h: 70, shape: "rectangle", label: "Weakness 1",    description: "What needs improvement", notes: "", laneId: "l8"  },
115
116  { type: "lane", id: "l9",  x: 50,  y: 1868, w: 500, h: 500, title: "Opportunities", laneType: "quadrant", quadrant: "opportunities", description: "External chances", notes: "" },
117  { type: "node", id: "n27", x: 120, y: 1998, w: 160, h: 70, shape: "rectangle", label: "Opportunity 1", description: "External chances",       notes: "", laneId: "l9"  },
118
119  { type: "lane", id: "l10", x: 558, y: 1868, w: 500, h: 500, title: "Threats",       laneType: "quadrant", quadrant: "threats",       description: "External risks", notes: "" },
120  { type: "node", id: "n28", x: 628, y: 1998, w: 160, h: 70, shape: "rectangle", label: "Threat 1",      description: "External risks",         notes: "", laneId: "l10" },
121
122  // ── Section 6: Gallery nodes (shape "gallery" with embedded icons) ──────────
123  { type: "lane", id: "l_gallery", x: 1100, y: 1360, w: 350, h: 350,
124    title: "Gallery Nodes – galleryTool must match a tool ID in GALLERY_ITEMS",
125    laneType: "free", quadrant: null, description: "", notes: "" },
126
127  { type: "node", id: "n_g1", x: 1130, y: 1430, w: 110, h: 110, shape: "gallery", label: "Zimserver",  description: "Core service / backend",   notes: "", laneId: "l_gallery", galleryTool: "server"  },
128  { type: "node", id: "n_g2", x: 1260, y: 1430, w: 110, h: 110, shape: "gallery", label: "API",        description: "Endpoint surface",         notes: "", laneId: "l_gallery", galleryTool: "browser" },
129  { type: "node", id: "n_g3", x: 1130, y: 1560, w: 110, h: 110, shape: "gallery", label: "NDJSON",     description: "Stream format",            notes: "", laneId: "l_gallery", galleryTool: "report"  },
130  { type: "node", id: "n_g4", x: 1260, y: 1560, w: 110, h: 110, shape: "gallery", label: "Service",    description: "Cloud/service boundary",   notes: "", laneId: "l_gallery", galleryTool: "cloud"   },
131
132  // ── Section 7: Custom formatting examples ──────────────────────────────────
133  { type: "lane", id: "l_format", x: 50, y: 2400, w: 1350, h: 250,
134    title: "Custom Formatting – fillColor, strokeColor, custom dimensions, and font formatting",
135    laneType: "free", quadrant: null, description: "Examples of custom node formatting", notes: "" },
136
137  { type: "node", id: "n_f1", x: 90,  y: 2480, w: 200, h: 100, shape: "rectangle", label: "Custom Colors",  description: "Green fill, purple stroke", notes: "", laneId: "l_format", fillColor: "#10b981", strokeColor: "#a855f7" },
138  { type: "node", id: "n_f2", x: 340, y: 2480, w: 150, h: 150, shape: "circle",    label: "Large Circle",   description: "Custom size",               notes: "", laneId: "l_format", fillColor: "#f59e0b", strokeColor: "#dc2626" },
139  { type: "node", id: "n_f3", x: 540, y: 2490, w: 180, h: 70,  shape: "diamond",   label: "Blue Diamond",   description: "Light blue theme",          notes: "", laneId: "l_format", fillColor: "#0ea5e9", strokeColor: "#0284c7" },
140  { type: "node", id: "n_f4", x: 770, y: 2485, w: 160, h: 90,  shape: "hexagon",   label: "Purple Hexagon", description: "Dark purple",               notes: "", laneId: "l_format", fillColor: "#7c3aed", strokeColor: "#a78bfa" },
141  { type: "node", id: "n_f5", x: 990, y: 2480, w: 200, h: 100, shape: "rectangle", label: "Big Bold Text",  description: "Font: 20px bold italic",    notes: "", laneId: "l_format", fillColor: "#1e3a5f", strokeColor: "#3b82f6", fontSize: "20", fontColor: "#fbbf24", fontWeight: "bold", fontStyle: "italic" },
142  { type: "node", id: "n_f6", x: 1240, y: 2480, w: 160, h: 80,  shape: "ellipse",  label: "Small Normal",   description: "Font: 12px normal",         notes: "", laneId: "l_format", fillColor: "#0f
142172a", strokeColor: "#64748b", fontSize: "12", fontColor: "#94a3b8", fontWeight: "normal", fontStyle: "normal" },
143].map(r => JSON.stringify(r)).join("\n");
144
145const NS = "http://www.w3.org/2000/svg";
146
147/** Each mount gets a unique marker id so multiple instances don't clash. */
148let mountCounter = 0;
149
150function createStage(body, markerId) {
151  const svg = document.createElementNS(NS, "svg");
152  svg.classList.add("stage-mm");
153  svg.setAttribute("xmlns", NS);
154  // No fixed viewBox – pan/zoom is handled via the viewport transform group
155  svg.setAttribute("data-marker-mm", markerId);
156
157  // ── defs ──────────────────────────────────────────────────────────────────
158  const defs = document.createElementNS(NS, "defs");
159
160  // arrowhead
161  const marker = document.createElementNS(NS, "marker");
162  marker.setAttribute("id", markerId);
163  marker.setAttribute("markerWidth",  "10");
164  marker.setAttribute("markerHeight", "7");
165  marker.setAttribute("refX",         "10");
166  marker.setAttribute("refY",         "3.5");
167  marker.setAttribute("orient",       "auto");
168  const arrowHead = document.createElementNS(NS, "polygon");
169  arrowHead.setAttribute("points", "0 0, 10 3.5, 0 7");
170  arrowHead.setAttribute("fill",   "#64748b");
171  marker.appendChild(arrowHead);
172  defs.appendChild(marker);
173
174  // dot-grid pattern
175  const pat = document.createElementNS(NS, "pattern");
176  pat.setAttribute("id",            `mmGrid-${mountCounter}`);
177  pat.setAttribute("patternUnits",  "userSpaceOnUse");
178  pat.setAttribute("width",  "20");
179  pat.setAttribute("height", "20");
180  const dot = document.createElementNS(NS, "circle");
181  dot.setAttribute("cx", "1"); dot.setAttribute("cy", "1"); dot.setAttribute("r", "0.9");
182  dot.setAttribute("fill", "rgba(148,163,184,.28)");
183  pat.appendChild(dot);
184  defs.appendChild(pat);
185
186  svg.appendChild(defs);
187
188  // ── viewport group – all content lives here; transform = pan + zoom ────────
189  const viewport = document.createElementNS(NS, "g");
190  viewport.setAttribute("class", "viewport-mm");
191
192  // background – large enough to fill any pan/zoom level
193  const bg = document.createElementNS(NS, "rect");
194  bg.setAttribute("x",      "-5000");
195  bg.setAttribute("y",      "-5000");
196  bg.setAttribute("width",  "10000");
197  bg.setAttribute("height", "10000");
198  bg.setAttribute("fill",   `url(#mmGrid-${mountCounter})`);
199  viewport.appendChild(bg);
200
201  // lanes layer (drawn below edges and nodes)
202  const gLanes = document.createElementNS(NS, "g");
203  gLanes.setAttribute("class", "lanes-mm");
204  viewport.appendChild(gLanes);
205
206  // edges layer (drawn below nodes)
207  const gEdges = document.createElementNS(NS, "g");
208  gEdges.setAttribute("class", "edges-mm");
209  viewport.appendChild(gEdges);
210
211  // nodes layer
212  const gNodes = document.createElementNS(NS, "g");
213  gNodes.setAttribute("class", "nodes-mm");
214  viewport.appendChild(gNodes);
215
216  svg.appendChild(viewport);
217  body.appendChild(svg);
218  return { svg, gEdges, gNodes, gLanes };
219}
220
221/**
222 * Open-from-server picker: a modal listing saved mind maps for this tenant.
223 * Click selects, double-click (or Open) loads, Cancel/Esc/click-outside closes,
224 * Enter opens the current selection. Newest map is preselected.
225 * @param {Array<{docId,docName,updatedAt,lineCount}>} docs
226 * @param {(doc:{docId,docName}) => void} onOpen  Called with the chosen doc.
227 */
228function showMapPicker(docs, onOpen, onVersions) {
229  const items = [...docs].sort((a, b) =>
230    String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
231
232  const overlay = document.createElement("div");
233  overlay.style.cssText =
234    "position:fixed;inset:0;z-index:10000;background:rgba(15,23,42,.45);" +
235    "display:flex;align-items:center;justify-content:center;font:14px system-ui;";
236
237  const modal = document.createElement("div");
238  modal.style.cssText =
239    "width:min(520px,92vw);max-height:80vh;display:flex;flex-direction:column;" +
240    "background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.35);overflow:hidden;";
241  overlay.appendChild(modal);
242
243  const header = document.createElement("div");
244  header.style.cssText =
245    "padding:16px 20px;border-bottom:1px solid #e5e7eb;font:600 16px system-ui;color:#0f172a;";
246  header.textContent = `Open Mind Map from ZimXStudio (${items.length})`;
247  modal.appendChild(header);
248
249  const listWrap = document.createElement("div");
250  listWrap.style.cssText = "overflow:auto;padding:8px;flex:1;";
251  modal.appendChild(listWrap);
252
253  let selected = null;
254  let selectedRow = null;
255
256  // Normalize a stored timestamp for display. Docs may carry either an ISO
257  // string ("2026-06-27T13:20:00") OR an epoch value ("1782950000.000000"
258  // seconds, or ms). The old code only handled ISO (replace "T") so epochs
259  // printed raw. Detect an all-digits epoch and format it as a date.
260  const fmtWhen = (raw) => {
261    if (!raw) return "";
262    const s = String(raw).trim();
263    if (/^\d{9,}(\.\d+)?$/.test(s)) {          // epoch seconds or milliseconds
264      let n = parseFloat(s);
265      if (n < 1e11) n *= 1000;                 // seconds -> ms
266      const dt = new Date(Math.round(n));
267      return isNaN(dt.getTime()) ? "" : dt.toISOString().slice(0, 19).replace("T", " ");
268    }
269    return s.replace("T", " ").slice(0, 19);   // ISO-ish string
270  };
271
272  const fmtMeta = (d) => {
273    const v = d.version ? `v${d.version}` : "v1";
274    const n = Number(d.lineCount || 0);
275    const lines = n ? `${n} line${n === 1 ? "" : "s"}` : "empty";
276    const w = fmtWhen(d.updatedAt);
277    return [v, lines, w].filter(Boolean).join(" · ");
278  };
279
280  function close() {
281    document.removeEventListener("keydown", onKey, true);
282    overlay.remove();
283  }
284  function openSelected() {
285    if (!selected) return;
286    close();
287    onOpen(selected);
288  }
289  function select(doc, row) {
290    selected = doc;
291    if (selectedRow) selectedRow.style.background = "transparent";
292    selectedRow = row;
293    row.style.background = "#eef2ff";
294  }
295
296  items.forEach((d) => {
297    const row = document.createElement("div");
298    row.style.cssText =
299      "padding:10px 12px;border-radius:8px;cursor:pointer;display:flex;flex-direction:column;gap:2px;";
300    row.onmouseenter = () => { if (row !== selectedRow) row.style.background = "#f8fafc"; };
301    row.onmouseleave = () => { if (row !== selectedRow) row.style.background = "transparent"; };
302    const name = document.createElement("div");
303    name.style.cssText = "font:600 14px system-ui;color:#0f172a;";
304    name.textContent = d.docName || d.docId;
305    // "Versions" link: opens the per-doc version history. stopPropagation so the
306    // click does NOT also select/open the row.
307    const versionsLink = document.createElement("a");
308    versionsLink.textContent = "Versions";
309    versionsLink.href = "#";
310    versionsLink.style.cssText =
311      "font:12px system-ui;color:#4f46e5;cursor:pointer;margin-left:8px;text-decoration:none;";
312    versionsLink.onclick = (e) => {
313      e.preventDefault();
314      e.stopPropagation();
315      if (typeof onVersions === "function") onVersions(d);
316    };
317    const nameRow = document.createElement("div");
318    nameRow.style.cssText =
319      "display:flex;align-items:baseline;justify-content:space-between;gap:8px;";
320    nameRow.append(name, versionsLink);
321    const meta = document.createElement("div");
322    meta.style.cssText = "font:12px system-ui;color:#64748b;";
323    meta.textContent = fmtMeta(d);
324    row.append(nameRow, meta);
325    row.onclick = () => select(d, row);
326    row.ondblclick = () => { select(d, row); openSelected(); };
327    listWrap.appendChild(row);
328  });
329
330  const footer = document.createElement("div");
331  footer.style.cssText =
332    "padding:12px 16px;border-top:1px solid #e5e7eb;display:flex;justify-content:flex-end;gap:8px;";
333  const cancelBtn = document.createElement("button");
334  cancelBtn.textContent = "Cancel";
335  cancelBtn.style.cssText =
336    "padding:8px 16px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;cursor:pointer;font:600 13px system-ui;color:#334155;";
337  cancelBtn.onclick = close;
338  const openBtn = document.createElement("button");
339  openBtn.textContent = "Open";
340  openBtn.style.cssText =
341    "padding:8px 18px;border:none;border-radius:8px;background:#4f46e5;color:#fff;cursor:pointer;font:600 13px system-ui;";
342  openBtn.onclick = openSelected;
343  footer.append(cancelBtn, openBtn);
344  modal.appendChild(footer);
345
346  function onKey(e) {
347    if (e.key === "Escape") { e.preventDefault(); close(); }
348    else if (e.key === "Enter") { e.preventDefault(); openSelected(); }
349  }
350  document.addEventListener("keydown", onKey, true);
351  overlay.onclick = (e) => { if (e.target === overlay) close(); };
352
353  // Preselect the newest so Enter / Open works immediately.
354  if (listWrap.firstChild) select(items[0], listWrap.firstChild);
355  document.body.appendChild(overlay);
356}
357
358/**
359 * Second popup: list every version of one doc (clone of showMapPicker's modal
360 * CSS for consistency). Clicking a row VIEWS that version via onView(versionDocId).
361 * @param {string} docName  Base name shown in the header.
362 * @param {Array<{docId,version,updatedAt,lineCount,isLatest}>} versions
363 * @param {(versionDocId:string) => void} onView
364 */
365function showVersionPicker(docName, versions, onView) {
366  // Newest first: the server returns latest first then archives; sort by numeric
367  // version descending so the list always reads newest -> oldest.
368  const items = [...(versions || [])].sort(
369    (a, b) => Number(b.version || 0) - Number(a.version || 0)
370  );
371
372  const overlay = document.createElement("div");
373  overlay.style.cssText =
374    "position:fixed;inset:0;z-index:10001;background:rgba(15,23,42,.45);" +
375    "display:flex;align-items:center;justify-content:center;font:14px system-ui;";
376
377  const modal = document.createElement("div");
378  modal.style.cssText =
379    "width:min(520px,92vw);max-height:80vh;display:flex;flex-direction:column;" +
380    "background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.35);overflow:hidden;";
381  overlay.appendChild(modal);
382
383  const header = document.createElement("div");
384  header.style.cssText =
385    "padding:16px 20px;border-bottom:1px solid #e5e7eb;font:600 16px system-ui;color:#0f172a;";
386  header.textContent = `Versions of "${docName}" (${items.length})`;
387  modal.appendChild(header);
388
389  const listWrap = document.createElement("div");
390  listWrap.style.cssText = "overflow:auto;padding:8px;flex:1;";
391  modal.appendChild(listWrap);
392
393  const fmtWhen = (raw) => {
394    if (!raw) return "";
395    const s = String(raw).trim();
396    if (/^\d{9,}(\.\d+)?$/.test(s)) {
397      let n = parseFloat(s);
398      if (n < 1e11) n *= 1000;
399      const dt = new Date(Math.round(n));
400      return isNaN(dt.getTime()) ? "" : dt.toISOString().slice(0, 19).replace("T", " ");
401    }
402    return s.replace("T", " ").slice(0, 19);
403  };
404
405  function close() {
406    document.removeEventListener("keydown", onKey, true);
407    overlay.remove();
408  }
409
410  items.forEach((v) => {
411    const row = document.createElement("div");
412    row.style.cssText =
413      "padding:10px 12px;border-radius:8px;cursor:pointer;display:flex;flex-direction:column;gap:2px;";
414    row.onmouseenter = () => { row.style.background = "#f8fafc"; };
415    row.onmouseleave = () => { row.style.background = "transparent"; };
416    const title = document.createElement("div");
417    title.style.cssText = "font:600 14px system-ui;color:#0f172a;";
418    title.textContent = `v${v.version || "?"}${v.isLatest ? " (latest)" : ""}`;
419    const meta = document.createElement("div");
420    meta.style.cssText = "font:12px system-ui;color:#64748b;";
421    const n = Number(v.lineCount || 0);
422    const lines = n ? `${n} line${n === 1 ? "" : "s"}` : "empty";
423    const w = fmtWhen(v.updatedAt);
424    meta.textContent = [lines, w].filter(Boolean).join(" · ");
425    row.append(title, meta);
426    row.onclick = () => { close(); onView(v.docId); };
427    listWrap.appendChild(row);
428  });
429
430  const footer = document.createElement("div");
431  footer.style.cssText =
432    "padding:12px 16px;border-top:1px solid #e5e7eb;display:flex;justify-content:flex-end;gap:8px;";
433  const closeBtn = document.createElement("button");
434  closeBtn.textContent = "Close";
435  closeBtn.style.cssText =
436    "padding:8px 16px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;cursor:pointer;font:600 13px system-ui;color:#334155;";
437  closeBtn.onclick = close;
438  footer.append(closeBtn);
439  modal.appendChild(footer);
440
441  function onKey(e) {
442    if (e.key === "Escape") { e.preventDefault(); close(); }
443  }
444  document.addEventListener("keydown", onKey, true);
445  overlay.onclick = (e) => { if (e.target === overlay) close(); };
446
447  document.body.appendChild(overlay);
448}
449
450export function mount(el, body, opts = {}) {
451  ensureMindMapStyles();
452
453  mountCounter++;
454  const markerId = `arrow-mm-${mountCounter}`;
455
456  body.style.position = "relative";
457  body.style.overflow = "hidden";
458
459  const layout = document.createElement("div");
460  layout.className = "layout-mm";
461  const toolbarHost = document.createElement("div");
462  toolbarHost.className = "toolbar-host-mm";
463  stopDragInside(toolbarHost);
464  // body-area-mm wraps the palette sidebar + canvas area in a flex row
465  const bodyAreaMm = document.createElement("div");
466  bodyAreaMm.className = "body-area-mm";
467  const canvasArea = document.createElement("div");
468  canvasArea.className = "canvas-area-mm";
469  layout.append(bodyAreaMm);
470  bodyAreaMm.append(toolbarHost, canvasArea);
471  body.appendChild(layout);
472
473  // Make the container focusable so keydown events reach it
474  if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "0");
475
476  const state = {
477    tool:         "pan",      // "select" | "node" | "connect" | "pan" | "lane"
478    connector:    "straight", // "straight" | "elbow" | "curved"
479    shape:        "rectangle", // current shape type for new nodes
480    selected:     null,
481    connectFrom:  null,
482    connectFromAnchor: null,
483    laneReassign: "drop",     // "live" | "drop"
484    edgeColor:    "#64748b",  // current edge color
485    edgeThickness: "1.5",     // current edge thickness
486    edgeLineStyle: "solid",   // "solid" | "dotted" | "dashed" | "double"
487    mapTitle:     "Untitled Map", // title of the map
488    mapVersion:   "1.0",      // version number
489  };
490
491  // ── Wire title + version to the shell's TitleBlock controller ─────────────
492  // When the user edits the title/version inputs in the shell titlebar,
493  // keep the mindmap's internal state in sync so serializeNdjson() stays correct.
494  const shellTitleCtrl = opts.shell?.titleController;
495  if (shellTitleCtrl) {
496    shellTitleCtrl.setLabel("Map:");
497    // Intercept input events on the shared title/version inputs
498    shellTitleCtrl.titleInput.addEventListener("input", () => {
499      state.mapTitle = shellTitleCtrl.titleInput.value;
500    });
501    shellTitleCtrl.versionInput.addEventListener("input", () => {
502      state.mapVersion = shellTitleCtrl.versionInput.value;
503    });
504    // Seed inputs with initial state values
505    shellTitleCtrl.update({ title: state.mapTitle, version: state.mapVersion });
506  }
507
508  const { svg, gEdges, gNodes, gLanes } = createStage(canvasArea, markerId);
509
510  // ── Sticky overlay (HTML stickies positioned over the SVG stage) ──────────
511  const stickyOverlay = document.createElement("div");
512  stickyOverlay.className = "sticky-overlay-mm";
513  stickyOverlay.style.cssText =
514    "position:absolute;inset:0;pointer-events:none;z-index:100;overflow:hidden;";
515  canvasArea.appendChild(stickyOverlay);
516
517  // ── Sticky helpers ────────────────────────────────────────────────────────
518  /** Collect serializable data from all HTML stickies in the overlay */
519  function getStickiesData() {
520    return Array.from(stickyOverlay.querySelectorAll(".comp.sticky")).map(st => ({
521      x:            parseFloat(st.style.left)   || 0,
522      y:            parseFloat(st.style.top)    || 0,
523      width:        parseFloat(st.style.width)  || 220,
524      height:       parseFloat(st.style.height) || 210,
525      title:        st.querySelector(".title-st")?.textContent   ?? "",
526      text:         st.querySelector(".body-st")?.textContent    ?? "",
527      background:   st.style.background || "#fef08a",
528      stickyType:   st.dataset.stickyType  || undefined,
529      taskId:       st.dataset.taskId,
530      noteId:       st.dataset.noteId,
531      priority:     st.querySelector(".priority-st")?.value || undefined,
532      dueDate:      st.querySelector(".date-st")?.value     || undefined,
533      status:       st.classList.contains("done") ? "done" : undefined,
534      sourceApp:    st.dataset.sourceApp    || "mindmap",
535      sourceTitle:  st.dataset.sourceTitle,
536      projectId:    st.dataset.projectId,
537      projectName:  st.dataset.projectName,
538      sourceShellId: st.dataset.sourceShellId,
539    }));
540  }
541
542  /** Place a sticky at (x, y) in overlay-relative coords via addSticky() */
543  async function placeStickyAt(x, y, stickyOpts = {}) {
544    const { addSticky } = await import("../stickies/board.js");
545    const { ensureStickiesStyles } = await import("../stickies/styles.js");
546    const { getProjectContext } = await import("../stickies/projectUtils.js");
547
548    ensureStickiesStyles();
549
550    const context = getProjectContext(opts.shell);
551    const mergedOpts = {
552      sourceApp: "mindmap",
553      sourceShellId: el.id || `mm-${mountCounter}`,
554      sourceTitle: el.dataset?.title || "Mind Map",
555      projectId: context.projectId,
556      projectName: context.projectName,
557      ...stickyOpts,
558      x,
559      y,
560    };
561
562    addSticky(stickyOverlay, stickyOverlay, mergedOpts);
563  }
564
565  // ── Sticky click-to-place (active when _stickyToolActive is true) ─────────
566  let _stickyToolActive = false;
567  const _stickyPlacedCallbacks = [];
568
569  svg.addEventListener("pointerdown", async (e) => {
570    if (!_stickyToolActive) return;
571    e.stopPropagation();
572    e.preventDefault();
573    
574    console.log("Sticky tool: processing click");
575    _stickyToolActive = false;
576    svg.style.cursor = "";
577    
578    const callbacks = _stickyPlacedCallbacks.splice(0);
579    const rect = svg.getBoundingClientRect();
580    const x = e.clientX - rect.left;
581    const y = e.clientY - rect.top;
582    
583    try {
584      await placeStickyAt(x, y, {});
585    } catch (err) {
586      console.warn("MindMap sticky place error:", err);
587    } finally {
588      // Always execute callbacks to ensure toolbar button is cleared
589      callbacks.forEach(fn => {
590        try {
591          fn();
592        } catch (cbErr) {
593          console.warn("Sticky tool callback error:", cbErr);
594        }
595      });
596    }
597  }, { capture: true });
598
599  // ── NDJSON serialisation ──────────────────────────────────────────────────
600  function serializeNdjson() {
601    const lines = [];
602    lines.push(JSON.stringify({ 
603      type: "mindmap-header", 
604      version: 1,
605      title: state.mapTitle || "Untitled Map",
606      mapVersion: state.mapVersion || "1.0"
607    }));
608    gLanes.querySelectorAll(".lane-mm").forEach(lane => {
609      const obj = {
610        type:        "lane",
611        id:          lane.getAttribute("data-lane-id"),
612        x:           parseFloat(lane.getAttribute("data-x")) || 0,
613        y:           parseFloat(lane.getAttribute("data-y")) || 0,
614        w:           parseFloat(lane.getAttribute("data-w")) || 200,
615        h:           parseFloat(lane.getAttribute("data-h")) || 300,
616        title:       lane.getAttribute("data-title") || "Lane",
617        laneType:    lane.getAttribute("data-lane-type") || "free",
618        quadrant:    lane.getAttribute("data-quadrant") || null,
619        description: lane.getAttribute("data-description") || "",
620        notes:       lane.getAttribute("data-notes") || "",
621      };
622      // Add formatting properties if present
623      const bgColor = lane.getAttribute("data-bg-color");
624      const borderColor = lane.getAttribute("data-border-color");
625      const borderWidth = lane.getAttribute("data-border-width");
626      const borderStyle = lane.getAttribute("data-border-style");
627      if (bgColor) obj.bgColor = bgColor;
628      if (borderColor) obj.borderColor = borderColor;
629      if (borderWidth) obj.borderWidth = borderWidth;
630      if (borderStyle) obj.borderStyle = borderStyle;
631      lines.push(JSON.stringify(obj));
632    });
633    gNodes.querySelectorAll(".node-mm").forEach(node => {
634      const obj = {
635        type:        "node",
636        id:          node.getAttribute("data-node-id"),
637        x:           parseFloat(node.getAttribute("data-x")) || 0,
638        y:           parseFloat(node.getAttribute("data-y")) || 0,
639        w:           parseFloat(node.getAttribute("data-w")) || 160,
640        h:           parseFloat(node.getAttribute("data-h")) || 80,
641        shape:       node.getAttribute("data-shape") || "rectangle",
642        label:       node.getAttribute("data-label") || node.querySelector("text.label-text-mm")?.textContent || "",
643        description: node.getAttribute("data-description") || "",
644        notes:       node.getAttribute("data-notes") || "",
645        laneId:      node.getAttribute("data-lane-id") || null,
646        galleryTool: node.getAttribute("data-gallery-tool") || null,
647      };
648      const fieldRef = node.getAttribute("data-field-ref");
649      if (fieldRef) obj.fieldRef = fieldRef;
650      // Add formatting properties if present
651      const fillColor = node.getAttribute("data-fill-color");
652      const strokeColor = node.getAttribute("data-stroke-color");
653      if (fillColor) obj.fillColor = fillColor;
654      if (strokeColor) obj.strokeColor = strokeColor;
655      const fontSize = node.getAttribute("data-font-size");
656      const fontColor = node.getAttribute("data-font-color");
657      const fontWeight = node.getAttribute("data-font-weight");
658      const fontStyle = node.getAttribute("data-font-style");
659      if (fontSize) obj.fontSize = fontSize;
660      if (fontColor) obj.fontColor = fontColor;
661      if (fontWeight) obj.fontWeight = fontWeight;
662      if (fontStyle) obj.fontStyle = fontStyle;
663      lines.push(JSON.stringify(obj));
664    });
665    gEdges.querySelectorAll(".edge-mm").forEach(edge => {
666      const rec = {
667        type:      "edge",
668        id:        edge.getAttribute("data-edge-id"),
669        from:      edge.getAttribute("data-from"),
670        to:        edge.getAttribute("data-to"),
671        edgeType:  edge.getAttribute("data-type") || "straight",
672        color:     edge.getAttribute("data-color") || "#64748b",
673        thickness: edge.getAttribute("data-thickness") || "1.5",
674        lineStyle: edge.getAttribute("data-line-style") || "solid",
675        label: edge.getAttribute("data-label") || "",
676        description: edge.getAttribute("data-description") || "",
677        notes: edge.getAttribute("data-notes") || "",
678        tasks: edge.getAttribute("data-tasks") || "",
679        startMarker: edge.getAttribute("data-start-marker") || "none",
680        startMarkerColor: edge.getAttribute("data-start-marker-color") || edge.getAttribute("data-color") || "#64748b",
681        endMarker: edge.getAttribute("data-end-marker") || "arrow",
682        endMarkerColor: edge.getAttribute("data-end-marker-color") || edge.getAttribute("data-color") || "#64748b",
683      };
684      const fromAnchor = edge.getAttribute("data-from-anchor");
685      const toAnchor = edge.getAttribute("data-to-anchor");
686      if (fromAnchor !== null && fromAnchor !== "") rec.fromAnchor = parseFloat(fromAnchor);
687      if (toAnchor !== null && toAnchor !== "") rec.toAnchor = parseFloat(toAnchor);
688      lines.push(JSON.stringify(rec));
689    });
690    // Serialize HTML stickies
691    getStickiesData().forEach(d => {
692      lines.push(JSON.stringify({ type: "sticky-mm", ...d }));
693    });
694    return lines.join("\n");
695  }
696
697  function deserializeNdjson(text) {
698    const lines = text.split("\n").filter(l => l.trim());
699    while (gNodes.firstChild) gNodes.removeChild(gNodes.firstChild);
700    while (gEdges.firstChild) gEdges.removeChild(gEdges.firstChild);
701    while (gLanes.firstChild) gLanes.removeChild(gLanes.firstChild);
702    // Remove existing HTML stickies
703    stickyOverlay.querySelectorAll(".comp.sticky").forEach(s => s.remove());
704    // Markers are keyed to edge ids, so clear the generated ones before the
705    // document is rebuilt — otherwise every load leaves orphans in <defs>.
706    pruneEdgeMarkers(svg);
707
708    // ⛔ RESTORE BY TYPE, NEVER IN ARRIVAL ORDER. The doc store returns a
709    // document's lines in PHYSICAL (append) order, not the order they were
710    // written. MEASURED on "mindmap:ROADDEFENDER MODEL": ffSeq came back
711    // 44,45,9,10,11,42,43,... so 19 of its 26 edges arrived BEFORE the nodes
712    // they join. An edge restored ahead of its endpoints cannot resolve them,
713    // so it got neither `d` nor an arrowhead and vanished — silently, because
714    // the old per-line try/catch only warned on a JSON parse error. Bucketing
715    // by type and reconciling at the end makes line order irrelevant.
716    const stickyRecords = [];
717    const laneRecords = [];
718    const nodeRecords = [];
719    const edgeRecords = [];
720    lines.forEach(line => {
721      let obj;
722      try {
723        obj = JSON.parse(line);
724      } catch (err) {
725        console.warn("Mind Map NDJSON parse error:", err, line.slice(0, 120));
726        return;
727      }
728      if (!obj || typeof obj !== "object") return;
729      if (obj.type === "mindmap-header") {
730        // Extract title and version from header
731        if (obj.title) {
732          state.mapTitle = obj.title;
733          opts.shell?.titleController?.setTitle(obj.title);
734          opts.shell?.setFileName?.(obj.title);
735        }
736        if (obj.mapVersion) {
737          state.mapVersion = obj.mapVersion;
738          opts.shell?.titleController?.setVersion(obj.mapVersion);
739        }
740      } else if (obj.type === "lane")      laneRecords.push(obj);
741      else if (obj.type === "node")        nodeRecords.push(obj);
742      else if (obj.type === "edge")        edgeRecords.push(obj);
743      else if (obj.type === "sticky-mm")   stickyRecords.push(obj);
744    });
745
746    // Lanes, then nodes (a node may name a lane), then edges.
747    laneRecords.forEach(obj => {
748      try { restoreLane(gLanes, obj); }
749      catch (err) { console.warn("Mind Map lane restore failed:", obj.id, err); }
750    });
751    nodeRecords.forEach(obj => {
752      try {
753        const node = restoreNode(svg, gNodes, obj);
754        if (obj.laneId) node.setAttribute("data-lane-id", obj.laneId);
755      } catch (err) { console.warn("Mind Map node restore failed:", obj.id, err); }
756    });
757    edgeRecords.forEach(obj => {
758      try { restoreEdge(svg, gEdges, obj); }
759      catch (err) { console.warn("Mind Map edge restore failed:", obj.id, err); }
760    });
761
762    // ONE reconciliation pass with the whole document in the DOM: recomputes
763    // every edge's geometry AND re-applies its markers, so an edge whose
764    // endpoint is another EDGE resolves too, whatever order the lines came in.
765    const unresolved = refreshAllEdges(svg);
766    if (unresolved > 0) {
767      console.warn(`Mind Map: ${unresolved} of ${edgeRecords.length} edges reference an id that is not in this document.`);
768    }
769
770    syncCounters(svg);
771    syncLaneCounters(svg);
772    updateLaneCounts(gLanes, gNodes);
773    // Restore HTML stickies asynchronously
774    if (stickyRecords.length > 0) {
775      Promise.all([
776        import("../stickies/board.js"),
777        import("../stickies/styles.js"),
778        import("../stickies/projectUtils.js"),
779      ]).then(([{ addSticky }, { ensureStickiesStyles }, { getProjectContext }]) => {
780        ensureStickiesStyles();
781
782        const context = getProjectContext(opts.shell);
783
784        stickyRecords.forEach(d => {
785          addSticky(stickyOverlay, stickyOverlay, {
786            x: d.x, y: d.y,
787            width: d.width, height: d.height,
788            title: d.title, text: d.text,
789            background: d.background,
790            stickyType: d.stickyType,
791            taskId: d.taskId,
792            noteId: d.noteId,
793            priority: d.priority,
794            dueDate: d.dueDate,
795            status: d.status,
796            sourceApp: d.sourceApp || "mindmap",
797            sourceShellId: d.sourceShellId || context.sourceShellId,
798            sourceTitle: d.sourceTitle || context.sourceTitle,
799            projectId: d.projectId || context.projectId,
800            projectName: d.projectName || context.projectName,
801          });
802        });
803      }).catch(err => console.warn("MindMap sticky restore error:", err));
804    }
805  }
806
807  // ── Database ──────────────────────────────────────────────────────────────
808  const db = createMindMapDb(el.id || "mindmap-default");
809
810  // ── Undo / Redo history (NDJSON snapshots) ────────────────────────────────
811  const mmHistory = (() => {
812    const MAX_DEPTH = 100;
813    let undoStack = [], redoStack = [];
814    return {
815      snapshot: (ndjson)  => { undoStack.push(ndjson); if (undoStack.length > MAX_DEPTH) undoStack.shift(); redoStack = []; },
816      canUndo:  ()        => undoStack.length > 0,
817      canRedo:  ()        => redoStack.length > 0,
818      undo:     (current) => { if (!undoStack.length) return null; redoStack.push(current); return undoStack.pop(); },
819      redo:     (current) => { if (!redoStack.length) return null; undoStack.push(current); return redoStack.pop(); },
820      clear:    ()        => { undoStack = []; redoStack = []; },
821    };
822  })();
823
824  const controls = attachInteractions(svg, gEdges, gNodes, gLanes, state, el, {
825    sourceApp:     "mindmap",
826    sourceShellId: el.id || `mm-${mountCounter}`,
827    sourceTitle:   el.dataset?.title || "Mind Map",
828    onLaneSave: (_laneEl) => {
829      const ndjson = serializeNdjson();
830      db.saveLocal(ndjson);
831      createToast("Lane saved");
832    },
833    onBeforeMutate: () => mmHistory.snapshot(serializeNdjson()),
834  });
835
836  // ── Clipboard & Format-painter helpers ────────────────────────────────────
837
838  /**
839   * Serialize a node SVG <g> element to a plain object suitable for the clipboard.
840   * Mirrors the serialization in serializeNdjson() for consistency.
841   * @param {SVGGElement} nodeEl
842   * @returns {Object}
843   */
844  function serializeNodeEl(nodeEl) {
845    return {
846      type:           "node",
847      id:             nodeEl.getAttribute("data-node-id"),
848      x:              parseFloat(nodeEl.getAttribute("data-x"))   || 0,
849      y:              parseFloat(nodeEl.getAttribute("data-y"))   || 0,
850      w:              parseFloat(nodeEl.getAttribute("data-w"))   || 160,
851      h:              parseFloat(nodeEl.getAttribute("data-h"))   || 80,
852      shape:          nodeEl.getAttribute("data-shape")           || "rectangle",
853      label:          nodeEl.getAttribute("data-label") || nodeEl.querySelector("text.label-text-mm")?.textContent || "",
854      description:    nodeEl.getAttribute("data-description")     || "",
855      notes:          nodeEl.getAttribute("data-notes")           || "",
856      fieldRef:       nodeEl.getAttribute("data-field-ref")       || "",
857      laneId:         nodeEl.getAttribute("data-lane-id")         || null,
858      galleryTool:    nodeEl.getAttribute("data-gallery-tool")    || null,
859      fillColor:      nodeEl.getAttribute("data-fill-color")      || null,
860      strokeColor:    nodeEl.getAttribute("data-stroke-color")    || null,
861      fontSize:       nodeEl.getAttribute("data-font-size")       || null,
862      fontColor:      nodeEl.getAttribute("data-font-color")      || null,
863      fontWeight:     nodeEl.getAttribute("data-font-weight")     || null,
864      fontStyle:      nodeEl.getAttribute("data-font-style")      || null,
865    };
866  }
867
868  /**
869   * Return the currently selected node element, or null if none / non-node selected.
870   * @returns {SVGGElement|null}
871   */
872  function getSelectedNodeEl() {
873    return state.selected?.classList.contains("node-mm") ? state.selected : null;
874  }
875
876  /**
877   * Return an array of all selected node elements (multiselect-aware).
878   * @returns {SVGGElement[]}
879   */
880  function getSelectedNodeEls() {
881    return controls.getSelectedNodes?.() ?? [];
882  }
883
884  /**
885   * Remove a node element from the canvas along with any connected edges.
886   * Also hides the notes reader if it was showing this node.
887   * @param {SVGGElement} nodeEl
888   */
889  function removeNodeEl(nodeEl) {
890    if (!nodeEl) return;
891    const nodeId = nodeEl.getAttribute("data-node-id");
892    const removeEdgeCascade = (edgeEl, visited = new Set()) => {
893      if (!edgeEl) return;
894      const edgeId = edgeEl.getAttribute("data-edge-id");
895      if (edgeId) {
896        if (visited.has(edgeId)) return;
897        visited.add(edgeId);
898        gEdges.querySelectorAll(`.edge-mm[data-from="${edgeId}"], .edge-mm[data-to="${edgeId}"]`).forEach(dep => {
899          if (dep !== edgeEl) removeEdgeCascade(dep, visited);
900        });
901      }
902      edgeEl.remove();
903      if (!edgeId) return;
904      gEdges.querySelector(`.edge-label-mm[data-edge-id="${edgeId}"]`)?.remove();
905      gEdges.querySelectorAll(`.edge-connect-point-mm[data-edge-id="${edgeId}"]`).forEach(dot => dot.remove());
906      gEdges.querySelectorAll(`.edge-anchor-dot-mm[data-owner-edge-id="${edgeId}"]`).forEach(dot => dot.remove());
907    };
908    // Remove any edges connected to this node
909    gEdges.querySelectorAll(`[data-from="${nodeId}"], [data-to="${nodeId}"]`)
910          .forEach(edge => {
911            removeEdgeCascade(edge);
912          });
913    // Hide the notes reader panel if it was bound to this node
914    const reader = el._mmNotesReader;
915    if (reader && reader._node === nodeEl) {
916      reader.style.display = "none";
917      reader._node = null;
918    }
919    nodeEl.remove();
920    if (state.selected === nodeEl) state.selected = null;
921  }
922
923  /**
924   * Sync live SVG visual attributes (fill, stroke, font) from the data-* attributes
925   * stored on a node <g> element. Call this after programmatically setting data attrs
926   * (e.g. after pasteFormat) to make the visual change immediately visible.
927   * @param {SVGGElement} nodeEl
928   */
929  function refreshNodeVisuals(nodeEl) {
930    if (!nodeEl) return;
931    const shapeEl = nodeEl.querySelector("rect, ellipse, path");
932    const textEl  = nodeEl.querySelector("text.label-text-mm");
933    const fillColor   = nodeEl.getAttribute("data-fill-color");
934    const strokeColor = nodeEl.getAttribute("data-stroke-color");
935    const fontSize    = nodeEl.getAttribute("data-font-size");
936    const fontColor   = nodeEl.getAttribute("data-font-color");
937    const fontWeight  = nodeEl.getAttribute("data-font-weight");
938    const fontStyle   = nodeEl.getAttribute("data-font-style");
939    if (shapeEl) {
940      if (fillColor)   shapeEl.setAttribute("fill",   fillColor);
941      if (strokeColor) shapeEl.setAttribute("stroke", strokeColor);
942    }
943    if (textEl) {
944      if (fontSize)   textEl.setAttribute("font-size",   fontSize + "px");
945      if (fontColor)  textEl.setAttribute("fill",         fontColor);
946      if (fontWeight) textEl.setAttribute("font-weight",  fontWeight);
947      if (fontStyle)  textEl.setAttribute("font-style",   fontStyle);
948      // Re-apply the auto-wrapped label rendering
949      const isGallery  = (nodeEl.getAttribute("data-shape") || "") === "gallery";
950      const fullLabel  = nodeEl.getAttribute("data-label") || textEl.textContent;
951      const nodeX = parseFloat(nodeEl.getAttribute("data-x")) || 0;
952      const nodeY = parseFloat(nodeEl.getAttribute("data-y")) || 0;
953      const nodeW = parseFloat(nodeEl.getAttribute("data-w")) || 160;
954      const nodeH = parseFloat(nodeEl.getAttribute("data-h")) || 80;
955      applyNodeLabelText(nodeEl, textEl, fullLabel, nodeX, nodeY, nodeW, nodeH, isGallery);
956    }
957  }
958
959  const actions = {
960    setTool:      (t) => {
961      state.tool = t;
962      state.connectFrom = null;
963      state.connectFromAnchor = null;
964      // Hide notes/lane panels when switching away from select or pan tool
965      if (t !== "select" && t !== "pan") {
966        hideNotesReader(el);
967        hideLaneEditor(el);
968        hideEdgeEditor(el);
969      }
970      // Update SVG cursor class for pan mode
971      svg.classList.toggle("pan-mode-mm", t === "pan");
972      svg.classList.toggle("connect-mode-mm", t === "connect");
973      if (t === "connect") controls.refreshConnectVisuals?.();
974    },
975    setConnector: (c) => { state.connector = c; },
976    setShape:     (s) => { state.shape = s; },
977    setEdgeColor: (color) => { state.edgeColor = color; },
978    setEdgeThickness: (thickness) => { state.edgeThickness = thickness; },
979    setEdgeLineStyle: (lineStyle) => { state.edgeLineStyle = lineStyle; },
980    setLaneReassign: (mode) => { state.laneReassign = mode; },
981    addSticky: (onPlaced) => {
982      console.log("Sticky tool: activating");
983      _stickyToolActive = true;
984      svg.style.cursor = "crosshair";
985      if (typeof onPlaced === "function") _stickyPlacedCallbacks.push(onPlaced);
986    },
987    zoomIn:       () => controls.zoomIn(),
988    zoomOut:      () => controls.zoomOut(),
989    zoomReset:    () => controls.zoomReset(),
990    createVerticalLanes:   () => controls.createVerticalLanes?.(),
991    createHorizontalLanes: () => controls.createHorizontalLanes?.(),
992    createSwotLanes:       () => controls.createSwotLanes?.(),
993
994    // ── Clipboard actions ────────────────────────────────────────────────────
995    /** Copy selected node(s) to the in-app clipboard. Supports multiselect. */
996    cbCopy: () => {
997      const nodeEls = getSelectedNodeEls();
998      if (nodeEls.length === 0) { createToast("Select a node to copy", true); return; }
999      if (nodeEls.length === 1) {
1000        clipboard.copy(serializeNodeEl(nodeEls[0]), "node");
1001        createToast("Node copied ⧉");
1002      } else {
1003        clipboard.copy(nodeEls.map(serializeNodeEl), "nodes");
1004        createToast(`${nodeEls.length} nodes copied ⧉`);
1005      }
1006    },
1007    /** Cut selected node(s): copy then remove from canvas. Supports multiselect. */
1008    cbCut: () => {
1009      const nodeEls = getSelectedNodeEls();
1010      if (nodeEls.length === 0) { createToast("Select a node to cut", true); return; }
1011      mmHistory.snapshot(serializeNdjson());
1012      if (nodeEls.length === 1) {
1013        clipboard.cut(serializeNodeEl(nodeEls[0]), "node", () => removeNodeEl(nodeEls[0]));
1014        createToast("Node cut ✄");
1015      } else {
1016        clipboard.cut(nodeEls.map(serializeNodeEl), "nodes", () => {
1017          nodeEls.forEach(nodeEl => removeNodeEl(nodeEl));
1018        });
1019        createToast(`${nodeEls.length} nodes cut ✄`);
1020      }
1021    },
1022    /**
1023     * Paste the clipboard node(s) onto the canvas.
1024     * Each pasted node gets a new unique ID and is offset by +20 px on each axis
1025     * to avoid exact overlap with the original.  Lane assignment is cleared so the
1026     * user can place the pasted node independently.
1027     */
1028    cbPaste: () => {
1029      if (!clipboard.hasData()) { createToast("Nothing to paste", true); return; }
1030      const cbType = clipboard.getType();
1031      if (cbType === "node") {
1032        mmHistory.snapshot(serializeNdjson());
1033        clipboard.paste((data) => {
1034          const pastedData = {
1035            ...data,
1036            id:     `node-paste-mm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1037            x:      (data.x || 0) + 20,
1038            y:      (data.y || 0) + 20,
1039            laneId: null,
1040          };
1041          return restoreNode(svg, gNodes, pastedData);
1042        });
1043        createToast("Node pasted 📋");
1044      } else if (cbType === "nodes") {
1045        mmHistory.snapshot(serializeNdjson());
1046        clipboard.paste((dataArr) => {
1047          dataArr.forEach(data => {
1048            const pastedData = {
1049              ...data,
1050              id:     `node-paste-mm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1051              x:      (data.x || 0) + 20,
1052              y:      (data.y || 0) + 20,
1053              laneId: null,
1054            };
1055            restoreNode(svg, gNodes, pastedData);
1056          });
1057        });
1058        createToast("Nodes pasted 📋");
1059      } else {
1060        createToast("Clipboard does not contain a node", true);
1061      }
1062    },
1063
1064    // ── Format-painter actions ───────────────────────────────────────────────
1065    /** Copy the visual formatting (colors, fonts) from the selected node. */
1066    copyFormat: () => {
1067      const nodeEl = getSelectedNodeEl();
1068      if (!nodeEl) { createToast("Select a node to copy format", true); return; }
1069      formatCopier.copyFormat(nodeEl);
1070      createToast("Format copied 🖌");
1071    },
1072    /**
1073     * Paste the buffered visual formatting onto all selected nodes.
1074     * After setting the data-* attributes via formatCopier, refreshNodeVisuals()
1075     * immediately syncs the SVG visual elements so the change is visible at once.
1076     */
1077    pasteFormat: () => {
1078      const nodeEls = getSelectedNodeEls();
1079      if (nodeEls.length === 0) { createToast("Select a node to paste format", true); return; }
1080      if (!formatCopier.hasFormat()) { createToast("No format copied yet", true); return; }
1081      mmHistory.snapshot(serializeNdjson());
1082      nodeEls.forEach(nodeEl => {
1083        formatCopier.pasteFormat(nodeEl); // sets data-* attrs + emits audit event
1084        refreshNodeVisuals(nodeEl);       // syncs live SVG fill / stroke / font attrs
1085      });
1086      createToast("Format pasted 🖌");
1087    },
1088
1089    // ── Undo / Redo ──────────────────────────────────────────────────────────
1090    /** Undo the last canvas mutation. */
1091    undo: () => {
1092      if (!mmHistory.canUndo()) { createToast("Nothing to undo", true); return; }
1093      const prev = mmHistory.undo(serializeNdjson());
1094      if (prev) { deserializeNdjson(prev); createToast("Undo ↩"); }
1095    },
1096    /** Redo the last undone mutation. */
1097    redo: () => {
1098      if (!mmHistory.canRedo()) { createToast("Nothing to redo", true); return; }
1099      const next = mmHistory.redo(serializeNdjson());
1100      if (next) { deserializeNdjson(next); createToast("Redo ↪"); }
1101    },
1102    exportNdjson: () => {
1103      const ndjson = serializeNdjson();
1104      const blob = new Blob([ndjson], { type: "application/x-ndjson" });
1105      const a = document.createElement("a");
1106      a.href = URL.createObjectURL(blob);
1107      const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
1108      
1109      // Use map title and version in filename
1110      // Sanitize: replace spaces with hyphens, remove unsafe chars, collapse multiple hyphens
1111      const sanitizeFilename = (str) => str
1112        .replace(/\s+/g, '-')              // spaces to hyphens
1113        .replace(/[^a-z0-9_-]/gi, '')      // remove unsafe chars
1114        .replace(/-+/g, '-')               // collapse multiple hyphens
1115        .replace(/^-|-$/g, '');            // trim leading/trailing hyphens
1116      
1117      const titlePart = state.mapTitle ? sanitizeFilename(state.mapTitle) : "mindmap";
1118      const versionPart = state.mapVersion ? `-v${sanitizeFilename(state.mapVersion)}` : "";
1119      
1120      a.download = `${titlePart}${versionPart}-${ts}.ndjson`;
1121      document.body.appendChild(a);
1122      a.click();
1123      document.body.removeChild(a);
1124      URL.revokeObjectURL(a.href);
1125      createToast("Mind map exported ✓");
1126    },
1127    importNdjson: () => {
1128      const input = document.createElement("input");
1129      input.type = "file";
1130      input.accept = ".ndjson,.jsonl";
1131      input.onchange = (e) => {
1132        const file = e.target.files[0];
1133        if (!file) return;
1134        const reader = new FileReader();
1135        reader.onload = (ev) => {
1136          try {
1137            deserializeNdjson(ev.target.result);
1138            createToast("Mind map imported ✓");
1139          } catch (err) {
1140            console.error("Mind Map import failed:", err);
1141            createToast("Import failed", true);
1142          }
1143        };
1144        reader.readAsText(file);
1145      };
1146      input.click();
1147    },
1148    pasteNdjson: () => {
1149      // Show a textarea dialog so the user can paste raw NDJSON directly
1150      const existingDlg = document.getElementById("paste-dialog-mm");
1151      if (existingDlg) existingDlg.remove();
1152
1153      const dlg = document.createElement("div");
1154      dlg.id = "paste-dialog-mm";
1155      dlg.style.cssText = [
1156        "position:fixed;top:60px;left:50%;transform:translateX(-50%);z-index:20002;",
1157        "min-width:520px;max-width:82vw;max-height:82vh;",
1158        "background:#fff;border:1px solid #e5e7eb;border-radius:10px;",
1159        "box-shadow:0 4px 20px rgba(0,0,0,.14);display:flex;flex-direction:column;",
1160      ].join("");
1161
1162      const hdr = document.createElement("div");
1163      hdr.style.cssText = "padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;";
1164      const ttl = document.createElement("span");
1165      ttl.style.cssText = "font:700 14px system-ui;color:#1e293b;";
1166      ttl.textContent = "Paste Mind Map NDJSON";
1167      const closeX = document.createElement("button");
1168      closeX.textContent = "✕";
1169      closeX.style.cssText = "background:none;border:none;cursor:pointer;font-size:16px;color:#64748b;padding:0 2px;line-height:1;";
1170      closeX.setAttribute("aria-label", "Close");
1171      closeX.onclick = () => dlg.remove();
1172      hdr.append(ttl, closeX);
1173
1174      const body = document.createElement("div");
1175      body.style.cssText = "padding:12px 16px;flex:1;display:flex;flex-direction:column;gap:8px;";
1176      const ta = document.createElement("textarea");
1177      ta.placeholder = 'Paste NDJSON here…\n{"type":"mindmap-header","version":1}\n{"type":"lane",…}';
1178      ta.style.cssText = [
1179        "width:100%;height:260px;box-sizing:border-box;resize:vertical;",
1180        "font:12px/1.45 ui-monospace,Menlo,Consolas,'Courier New',monospace;",
1181        "padding:8px;border:1px solid #e5e7eb;border-radius:6px;outline:none;",
1182      ].join("");
1183      body.appendChild(ta);
1184
1185      const footer = document.createElement("div");
1186      footer.style.cssText = "padding:10px 16px;display:flex;justify-content:flex-end;
1186gap:8px;border-top:1px solid #e5e7eb;flex-shrink:0;";
1187      const cancelBtn = document.createElement("button");
1188      cancelBtn.textContent = "Cancel";
1189      cancelBtn.style.cssText = "padding:6px 14px;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font:600 13px system-ui;background:#fff;color:#374151;";
1190      cancelBtn.onclick = () => dlg.remove();
1191      const loadBtn = document.createElement("button");
1192      loadBtn.textContent = "Load";
1193      loadBtn.style.cssText = "padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font:600 13px system-ui;background:#4f46e5;color:#fff;";
1194      loadBtn.onclick = () => {
1195        const text = ta.value.trim();
1196        if (!text) { createToast("Nothing to paste", true); return; }
1197        try {
1198          deserializeNdjson(text);
1199          createToast("Mind map pasted ✓");
1200          dlg.remove();
1201        } catch (err) {
1202          console.error("Mind Map paste failed:", err);
1203          createToast("Paste failed", true);
1204        }
1205      };
1206      footer.append(cancelBtn, loadBtn);
1207      dlg.append(hdr, body, footer);
1208      document.body.appendChild(dlg);
1209      setTimeout(() => ta.focus(), 50);
1210      // Close when clicking outside (with small delay so opening click doesn't close it)
1211      const onOutside = (e) => {
1212        if (!dlg.contains(e.target)) { dlg.remove(); document.removeEventListener("pointerdown", onOutside, true); }
1213      };
1214      setTimeout(() => document.addEventListener("pointerdown", onOutside, true), 100);
1215    },
1216    saveToDb: async () => {
1217      const ndjson = serializeNdjson();
1218      // The map's title is its identity in ZimXStudio — re-saving the same title upserts.
1219      const name = (state.mapTitle && String(state.mapTitle).trim()) || "mindmap";
1220      db.saveLocal(ndjson); // offline cache, always
1221      try {
1222        await db.saveApi(ndjson, name);
1223        // Version goes up on every save: call the framework's version bump
1224        // (shell.js incrementMinorVersion → persists vMajor.vMinor → updateBadges
1225        // refreshes the header). The intended per-shell contract, never wired till now.
1226        opts.shell?.bumpMinorVersion?.();
1227        createToast(`Saved "${name}" to ZimXStudio ✓`);
1228      } catch (err) {
1229        console.error("Mind Map database save failed:", err);
1230        // Surface the real reason instead of a silent local-only fallback.
1231        const why = err?.message ? `: ${err.message}` : "";
1232        createToast(`DB (ZimXStudio) save FAILED${why} — kept a local cache only`, true);
1233      }
1234    },
1235    loadFromDb: async () => {
1236      // Open-from-server: list every saved map for this tenant, let the user pick one.
1237      let docs = [];
1238      try {
1239        docs = await db.listApi();
1240      } catch (err) {
1241        console.warn("Mind Map list-from-server failed:", err);
1242        createToast(`Couldn't list saved maps: ${err?.message || "error"}`, true);
1243      }
1244      if (!docs || docs.length === 0) {
1245        // Nothing server-side yet — fall back to the local cache so an offline map still opens.
1246        const local = db.loadLocal();
1247        if (local) {
1248          deserializeNdjson(local);
1249          createToast("Loaded from LOCAL cache (no maps saved in ZimXStudio yet)", true);
1250        } else {
1251          createToast("No saved maps found in ZimXStudio");
1252        }
1253        return;
1254      }
1255      showMapPicker(docs, async (doc) => {
1256        let data = null;
1257        try {
1258          data = await db.loadApiById(doc.docId);
1259        } catch (err) {
1260          createToast(`Load failed: ${err?.message || "error"}`, true);
1261          return;
1262        }
1263        if (!data) { createToast("That map has no content", true); return; }
1264        deserializeNdjson(data);
1265        if (doc.docName) state.mapTitle = doc.docName; // a later Save targets the same doc
1266        createToast(`Loaded "${doc.docName || doc.docId}" from ZimXStudio ✓`);
1267      },
1268      // Versions link: list every version, then VIEW the chosen one. Viewing an
1269      // old version keeps state.mapTitle = the BASE name, so a later Save creates
1270      // a NEW latest (it never writes back onto the archive id).
1271      async (doc) => {
1272        let vers = [];
1273        try {
1274          vers = await db.listVersions(doc.docName || doc.docId);
1275        } catch (err) {
1276          createToast(`Couldn't list versions: ${err?.message || "error"}`, true);
1277          return;
1278        }
1279        showVersionPicker(doc.docName || doc.docId, vers, async (versionDocId) => {
1280          let data = null;
1281          try {
1282            data = await db.loadVersion(versionDocId);
1283          } catch (err) {
1284            createToast(`Load failed: ${err?.message || "error"}`, true);
1285            return;
1286          }
1287          if (!data) { createToast("That version has no content", true); return; }
1288          deserializeNdjson(data);
1289          // Keep the canonical BASE title so a later Save makes a NEW latest.
1290          state.mapTitle = doc.docName || state.mapTitle;
1291          if (shellTitleCtrl && shellTitleCtrl.update) {
1292            shellTitleCtrl.update({ title: state.mapTitle, version: state.mapVersion });
1293          }
1294          createToast(`Viewing an older version of "${doc.docName || doc.docId}". Saving will create a new version.`);
1295        });
1296      });
1297    },
1298    loadFromUrls: () => {
1299      const STORAGE_KEY = `data-sources-mm-${el.id || "default"}`;
1300
1301      let sources = [];
1302      try {
1303        sources = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
1304        if (!Array.isArray(sources)) sources = [];
1305      } catch {}
1306
1307      function saveSources() {
1308        try { localStorage.setItem(STORAGE_KEY, JSON.stringify(sources)); } catch {}
1309      }
1310
1311      const existingDlg = document.getElementById("sources-dialog-mm");
1312      if (existingDlg) existingDlg.remove();
1313
1314      const dlg = document.createElement("div");
1315      dlg.id = "sources-dialog-mm";
1316      dlg.style.cssText = [
1317        "position:fixed;top:60px;left:50%;transform:translateX(-50%);z-index:20002;",
1318        "min-width:560px;max-width:86vw;max-height:82vh;",
1319        "background:#fff;border:1px solid #e5e7eb;border-radius:10px;",
1320        "box-shadow:0 4px 20px rgba(0,0,0,.14);display:flex;flex-direction:column;",
1321      ].join("");
1322
1323      let onOutside = null;
1324      function closeDialog() {
1325        dlg.remove();
1326        if (onOutside) { document.removeEventListener("pointerdown", onOutside, true); onOutside = null; }
1327      }
1328
1329      const hdr = document.createElement("div");
1330      hdr.style.cssText = "padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;";
1331      const ttl = document.createElement("span");
1332      ttl.style.cssText = "font:700 14px system-ui;color:#1e293b;";
1333      ttl.textContent = "📡 Data Sources (NDJSON)";
1334      const closeX = document.createElement("button");
1335      closeX.textContent = "✕";
1336      closeX.style.cssText = "background:none;border:none;cursor:pointer;font-size:16px;color:#64748b;padding:0 2px;line-height:1;";
1337      closeX.setAttribute("aria-label", "Close");
1338      closeX.onclick = () => closeDialog();
1339      hdr.append(ttl, closeX);
1340
1341      const bodyEl = document.createElement("div");
1342      bodyEl.style.cssText = "padding:12px 16px;flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:8px;min-height:80px;max-height:50vh;";
1343
1344      const emptyMsg = document.createElement("div");
1345      emptyMsg.style.cssText = "color:#94a3b8;font:13px system-ui;text-align:center;padding:16px 0;";
1346      emptyMsg.textContent = "No URL sources configured. Add a URL below, or click 'Load' to load from server/file.";
1347
1348      function renderSources() {
1349        bodyEl.innerHTML = "";
1350        if (sources.length === 0) { bodyEl.appendChild(emptyMsg); return; }
1351        sources.forEach((src) => {
1352          const row = document.createElement("div");
1353          row.style.cssText = "display:flex;gap:8px;align-items:center;padding:8px;background:#f8fafc;border:1px solid #e5e7eb;border-radius:6px;";
1354
1355          const urlText = document.createElement("div");
1356          urlText.style.cssText = "flex:1;font:12px/1.4 ui-monospace,monospace;color:#1e293b;word-break:break-all;";
1357          urlText.textContent = src.label ? `${src.label} — ${src.url}` : src.url;
1358          urlText.title = src.url;
1359
1360          const loadBtn = document.createElement("button");
1361          loadBtn.textContent = "Load";
1362          loadBtn.title = "Fetch this NDJSON and replace mindmap content";
1363          loadBtn.style.cssText = "padding:4px 10px;border:none;border-radius:5px;cursor:pointer;font:600 11px system-ui;background:#4f46e5;color:#fff;white-space:nowrap;";
1364          loadBtn.onclick = async () => {
1365            loadBtn.textContent = "…";
1366            loadBtn.disabled = true;
1367            try {
1368              const text = await repoFetchProxy(src.url);
1369              if (typeof text !== "string") throw new Error("Unexpected response from proxy");
1370              deserializeNdjson(text);
1371              createToast(`Loaded from ${src.label || "URL"} ✓`);
1372            } catch (err) {
1373              createToast(`Load failed: ${err.message}`, true);
1374              console.error("Mind Map URL load failed:", err);
1375            } finally {
1376              loadBtn.textContent = "Load";
1377              loadBtn.disabled = false;
1378            }
1379          };
1380
1381          const removeBtn = document.createElement("button");
1382          removeBtn.textContent = "✕";
1383          removeBtn.title = "Remove this source";
1384          removeBtn.style.cssText = "padding:4px 8px;border:1px solid #e5e7eb;border-radius:5px;cursor:pointer;font:600 12px system-ui;background:#fff;color:#64748b;";
1385          removeBtn.onclick = () => {
1386            const idx = sources.indexOf(src);
1387            if (idx !== -1) { sources.splice(idx, 1); saveSources(); renderSources(); }
1388          };
1389
1390          row.append(urlText, loadBtn, removeBtn);
1391          bodyEl.appendChild(row);
1392        });
1393      }
1394
1395      renderSources();
1396
1397      const addSection = document.createElement("div");
1398      addSection.style.cssText = "padding:12px 16px;border-top:1px solid #e5e7eb;display:flex;flex-direction:column;gap:8px;flex-shrink:0;";
1399
1400      const addTitle = document.createElement("div");
1401      addTitle.style.cssText = "font:600 12px system-ui;color:#374151;";
1402      addTitle.textContent = "Add NDJSON Data Source";
1403
1404      const inputRow = document.createElement("div");
1405      inputRow.style.cssText = "display:flex;gap:8px;";
1406
1407      const urlInp = document.createElement("input");
1408      urlInp.type = "url";
1409      urlInp.placeholder = "https://example.com/data.ndjson";
1410      urlInp.style.cssText = "flex:1;padding:7px 10px;border:1px solid #e5e7eb;border-radius:6px;font:13px system-ui;outline:none;";
1411
1412      const labelInp = document.createElement("input");
1413      labelInp.type = "text";
1414      labelInp.placeholder = "Label (optional)";
1415      labelInp.style.cssText = "width:140px;padding:7px 10px;border:1px solid #e5e7eb;border-radius:6px;font:13px system-ui;outline:none;";
1416
1417      const addBtn = document.createElement("button");
1418      addBtn.textContent = "Add";
1419      addBtn.style.cssText = "padding:7px 16px;border:none;border-radius:6px;cursor:pointer;font:600 13px system-ui;background:#0f172a;color:#fff;white-space:nowrap;";
1420      addBtn.onclick = () => {
1421        const url = urlInp.value.trim();
1422        if (!url) { createToast("Enter a URL first", true); return; }
1423        try { new URL(url); } catch { createToast("Invalid URL format", true); return; }
1424        const label = labelInp.value.trim();
1425        sources.push({ url, label });
1426        saveSources();
1427        urlInp.value = "";
1428        labelInp.value = "";
1429        renderSources();
1430      };
1431      urlInp.addEventListener("keydown", (e) => { if (e.key === "Enter") addBtn.click(); });
1432
1433      inputRow.append(urlInp, labelInp, addBtn);
1434      addSection.append(addTitle, inputRow);
1435
1436      const footer = document.createElement("div");
1437      footer.style.cssText = "padding:10px 16px;display:flex;justify-content:flex-end;
1437gap:8px;border-top:1px solid #e5e7eb;flex-shrink:0;";
1438
1439      const loadAllBtn = document.createElement("button");
1440      loadAllBtn.textContent = "Load All";
1441      loadAllBtn.title = "Fetch all sources and combine into one mindmap";
1442      loadAllBtn.style.cssText = "padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font:600 13px system-ui;background:#4f46e5;color:#fff;";
1443      loadAllBtn.onclick = async () => {
1444        if (sources.length === 0) { createToast("No sources configured", true); return; }
1445        loadAllBtn.textContent = "Loading…";
1446        loadAllBtn.disabled = true;
1447        try {
1448          const results = await Promise.allSettled(
1449            sources.map(src => repoFetchProxy(src.url).then(text => {
1450              if (typeof text !== "string") throw new Error("Unexpected response from proxy");
1451              return text;
1452            }))
1453          );
1454          const lines = [];
1455          let hasHeader = false;
1456          for (const res of results) {
1457            if (res.status === "fulfilled") {
1458              for (const line of res.value.split("\n")) {
1459                const trimmed = line.trim();
1460                if (!trimmed) continue;
1461                try {
1462                  const obj = JSON.parse(trimmed);
1463                  if (obj.type === "mindmap-header") {
1464                    if (!hasHeader) { lines.push(trimmed); hasHeader = true; }
1465                  } else {
1466                    lines.push(trimmed);
1467                  }
1468                } catch (err) {
1469                  console.warn("Mind Map: skipping malformed NDJSON line:", err);
1470                }
1471              }
1472            }
1473          }
1474          if (!hasHeader) lines.unshift(JSON.stringify({ type: "mindmap-header", version: 1 }));
1475          if (lines.length <= 1) {
1476            createToast("No records loaded", true);
1477          } else {
1478            deserializeNdjson(lines.join("\n"));
1479            const ok = results.filter(r => r.status === "fulfilled").length;
1480            createToast(`Loaded ${ok} of ${sources.length} source(s) ✓`);
1481          }
1482        } catch (err) {
1483          createToast("Load failed: " + err.message, true);
1484        } finally {
1485          loadAllBtn.textContent = "Load All";
1486          loadAllBtn.disabled = false;
1487        }
1488      };
1489
1490      const cancelBtn = document.createElement("button");
1491      cancelBtn.textContent = "Close";
1492      cancelBtn.style.cssText = "padding:6px 14px;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font:600 13px system-ui;background:#fff;color:#374151;";
1493      cancelBtn.onclick = () => closeDialog();
1494
1495      // Unified Load button - tries repo first, then offers file import
1496      const loadBtn = document.createElement("button");
1497      loadBtn.textContent = "📥 Load";
1498      loadBtn.title = "Load mindmap from server or local file";
1499      loadBtn.style.cssText = "padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font:600 13px system-ui;background:#10b981;color:#fff;";
1500      loadBtn.onclick = async () => {
1501        loadBtn.textContent = "Loading…";
1502        loadBtn.disabled = true;
1503        try {
1504          // First try to load from repo API (saved files)
1505          const data = await db.loadApi();
1506          if (data && typeof data === "string" && data.trim()) {
1507            deserializeNdjson(data);
1508            createToast("Loaded from server ✓");
1509            closeDialog();
1510            return;
1511          }
1512        } catch (err) {
1513          console.warn("Repo load failed, falling back to file picker:", err.message);
1514        }
1515        // If repo load fails or returns empty, offer file import
1516        loadBtn.textContent = "📥 Load";
1517        loadBtn.disabled = false;
1518        
1519        const input = document.createElement("input");
1520        input.type = "file";
1521        input.accept = ".ndjson,.jsonl,.json,.txt";
1522        input.onchange = (e) => {
1523          const file = e.target.files[0];
1524          if (!file) return;
1525          const reader = new FileReader();
1526          reader.onload = (ev) => {
1527            try {
1528              deserializeNdjson(ev.target.result);
1529              createToast(`Loaded from ${file.name} ✓`);
1530              closeDialog();
1531            } catch (err) {
1532              console.error("Mind Map file import failed:", err);
1533              createToast("Import failed: " + err.message, true);
1534            }
1535          };
1536          reader.readAsText(file);
1537        };
1538        input.click();
1539      };
1540
1541      footer.append(loadBtn, cancelBtn, loadAllBtn);
1542      dlg.append(hdr, bodyEl, addSection, footer);
1543      document.body.appendChild(dlg);
1544      setTimeout(() => urlInp.focus(), 50);
1545
1546      onOutside = (e) => {
1547        if (!dlg.contains(e.target)) closeDialog();
1548      };
1549      setTimeout(() => document.addEventListener("pointerdown", onOutside, true), 100);
1550    },
1551    exportApiTemplate: () => showApiTemplateDialog("mindmap", MINDMAP_API_TEMPLATE),
1552    aiGenerateImage: () => {
1553      showAiImageDialog(({ url, width, height }) => {
1554        insertAiImageMindmap(gNodes, url, width, height);
1555      });
1556    },
1557    aiGenerate: () => {
1558      showGenerateDialog(
1559        "mindmap",
1560        (ndjson) => {
1561          try {
1562            deserializeNdjson(ndjson);
1563            createToast("Mind map generated ✓");
1564          } catch (err) {
1565            console.error("AI mindmap apply failed:", err);
1566            createToast("Couldn't apply generated map: " + err.message, true);
1567          }
1568        },
1569        {
1570          // edit/continue mode: hand the AI the current map IF it has content
1571          // (more than just the header), so it revises instead of replacing.
1572          getCurrentDoc: () => {
1573            const d = serializeNdjson();
1574            const hasNodes = d
1575              .split("\n")
1576              .some((l) => l.trim() && !l.includes('"mindmap-header"'));
1577            return hasNodes ? d : "";
1578          },
1579        }
1580      );
1581    },
1582    addGalleryNode: ({ tool, label }) => {
1583      // Place node at the center of the current viewport
1584      const rect = svg.getBoundingClientRect();
1585      const cx = rect.left + rect.width / 2;
1586      const cy = rect.top + rect.height / 2;
1587      const viewport = svg.querySelector(".viewport-mm");
1588      const ctm = (viewport || svg).getScreenCTM();
1589      let wx = 200, wy = 200;
1590      if (ctm) {
1591        const pt = svg.createSVGPoint();
1592        pt.x = cx; pt.y = cy;
1593        const p = pt.matrixTransform(ctm.inverse());
1594        wx = Math.round(p.x - GALLERY_NODE_SIZE / 2);
1595        wy = Math.round(p.y - GALLERY_NODE_SIZE / 2);
1596      }
1597      createGalleryNode(svg, gNodes, wx, wy, tool, label);
1598    },
1599  };
1600
1601  buildToolbar(toolbarHost, state, actions);
1602
1603  // ── Undo/Redo keyboard shortcuts (Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z) ────────
1604  el.addEventListener("keydown", (kbdEvt) => {
1605    if (kbdEvt.target.closest("input, textarea, [contenteditable]")) return;
1606    const ctrl = kbdEvt.ctrlKey || kbdEvt.metaKey;
1607    if (ctrl && !kbdEvt.shiftKey && kbdEvt.key === "z") {
1608      kbdEvt.preventDefault();
1609      actions.undo();
1610      return;
1611    }
1612    if (ctrl && (kbdEvt.key === "y" || (kbdEvt.shiftKey && kbdEvt.key === "z"))) {
1613      kbdEvt.preventDefault();
1614      actions.redo();
1615    }
1616  });
1617
1618  return { api: {}, chips: {}, serializeNdjson, deserializeNdjson, exportNdjson: actions.exportNdjson, importNdjson: actions.importNdjson };
1619}
1620
1621export function extendToolbar() { /* no-op */ }
1622export function update()        { /* no-op */ }

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.