PageSourceSearch

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

js smartcone.ai collected 2026-10-05 15:07:21 UTC 29,496 bytes, 586 lines download raw bytes

1// Mind Map toolbar
2import { stopDragInside } from "../../shared/utils.js";
3import { buildFieldLibrary } from "../erdiagram/fieldlibrary.js";
4import { GALLERY_ITEMS, GALLERY_CATEGORIES } from "../../shared/gallery.js";
5import {
6  mkIcon,
7  mkBtn as _mkBtn,
8  mkSection,
9  buildToolbar as buildToolbarEl,
10  ensureToolbarBaseStyles,
11} from "../../common/toolbar.js";
12
13const NS = "http://www.w3.org/2000/svg";
14
15/** Fallback category id for gallery items that omit the category field. */
16const GALLERY_DEFAULT_CATEGORY = "documents";
17
18const ICONS = {
19  select: `<path d="M5 3l14 9-7 1-4 7L5 3z"/>`,
20  node: `<rect x="4" y="6" width="16" height="12" rx="2"/><line x1="12" y1="3" x2="12" y2="9"/><line x1="9" y1="6" x2="15" y2="6"/>`,
21  pan: `<path d="M12 11V5a1.6 1.6 0 0 1 3.2 0v5"/><path d="M8.8 11V7a1.6 1.6 0 0 1 3.2 0v4"/><path d="M15.2 11V8.2a1.6 1.6 0 1 1 3.2 0v4.8a6.4 6.4 0 0 1-6.4 6.4h-.8A6.4 6.4 0 0 1 4.8 13v-2.4a1.6 1.6 0 1 1 3.2 0V11"/>`,
22  sticky: `<rect x="4" y="4" width="14" height="14" rx="1" fill="currentColor" opacity="0.15"/><rect x="4" y="4" width="14" height="14" rx="1"/><line x1="15" y1="4" x2="18" y2="7"/><path d="M15 4 L15 7 L18 7"/>`,
23  shapes: `<rect x="3" y="6" width="8" height="8" rx="1"/><circle cx="17" cy="10" r="4"/><path d="M6 20h12"/>`,
24  connect: `<circle cx="5" cy="12" r="2.2"/><circle cx="19" cy="6" r="2.2"/><circle cx="19" cy="18" r="2.2"/><path d="M7.2 11.2l9.6-4.4M7.2 12.8l9.6 4.4"/>`,
25  straight: `<line x1="4" y1="20" x2="20" y2="4"/><polyline points="13 4 20 4 20 11"/>`,
26  elbow: `<path d="M4 19h10V7h6"/><polyline points="14 7 20 7 20 13"/>`,
27  curved: `<path d="M4 18c6-10 10-10 16-2"/><polyline points="13 16 20 16 20 9"/>`,
28  edgeFormat: `<line x1="4" y1="7" x2="20" y2="7"/><line x1="4" y1="12" x2="20" y2="12" stroke-dasharray="2 2"/><line x1="4" y1="17" x2="20" y2="17" stroke-width="3"/>`,
29  lane: `<rect x="3" y="5" width="18" height="14" rx="2"/><line x1="9" y1="5" x2="9" y2="19"/><line x1="15" y1="5" x2="15" y2="19"/>`,
30  vertical: `<rect x="3" y="5" width="18" height="14" rx="2"/><line x1="12" y1="5" x2="12" y2="19"/><polyline points="8 8 6 10 8 12"/><polyline points="16 12 18 10 16 8"/>`,
31  horizontal: `<rect x="3" y="5" width="18" height="14" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="3" y1="14" x2="21" y2="14"/>`,
32  swot: `<rect x="4" y="4" width="16" height="16" rx="2"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="4" y1="12" x2="20" y2="12"/>`,
33  reassignDrop: `<path d="M8 4h8"/><path d="M12 4v12"/><polyline points="8 12 12 16 16 12"/>`,
34  reassignLive: `<path d="M4 12a8 8 0 0 0 14 4"/><polyline points="15 16 18 16 18 19"/><path d="M20 12A8 8 0 0 0 6 8"/><polyline points="9 8 6 8 6 5"/>`,
35  zoomin: `<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/>`,
36  zoomout: `<circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/><line x1="8" y1="11" x2="14" y2="11"/>`,
37  zoomreset: `<polyline points="4 8 4 4 8 4"/><polyline points="20 16 20 20 16 20"/><path d="M4 4l6 6M20 20l-6-6"/>`,
38  copy: `<rect x="9" y="9" width="10" height="10" rx="2"/><rect x="5" y="5" width="10" height="10" rx="2"/>`,
39  cut: `<circle cx="6.5" cy="7" r="2"/><circle cx="6.5" cy="17" r="2"/><line x1="8.2" y1="8.4" x2="20" y2="3"/><line x1="8.2" y1="15.6" x2="20" y2="21"/>`,
40  paste: `<path d="M16 4h2a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/>`,
41  copyFmt: `<path d="M4 20l4-1 10-10-3-3L5 16l-1 4z"/><path d="M14 5l3 3"/><line x1="4" y1="4" x2="9" y2="4"/>`,
42  pasteFmt: `<path d="M4 20l4-1 10-10-3-3L5 16l-1 4z"/><path d="M14 5l3 3"/><path d="M19 16v5"/><path d="M16.5 18.5H21.5"/>`,
43  pasteNdjson: `<rect x="5" y="3" width="14" height="18" rx="2"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="9" y1="12" x2="15" y2="12"/><polyline points="9 16 12 19 15 16"/>`,
44  save: `<ellipse cx="12" cy="14" rx="9" ry="3"/><path d="M3 14v5c0 1.66 4 3 9 3s9-1.34 9-3v-5"/><line x1="12" y1="11" x2="12" y2="2"/><polyline points="9 4.5 12 2 15 4.5"/>`,
45  load: `<ellipse cx="12" cy="14" rx="9" ry="3"/><path d="M3 14v5c0 1.66 4 3 9 3s9-1.34 9-3v-5"/><line x1="12" y1="2" x2="12" y2="11"/><polyline points="9 8.5 12 11 15 8.5"/>`,
46  sources: `<path d="M4 12a8 8 0 0 1 16 0"/><path d="M7 12a5 5 0 0 1 10 0"/><circle cx="12" cy="16.5" r="1.4"/>
46`,
47  template: `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="12" y2="17"/>`,
48  ai: `<path d="M8 12a4 4 0 1 1 8 0v1h1a2 2 0 1 1 0 4H7a2 2 0 1 1 0-4h1z"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="4" y1="6" x2="6" y2="8"/><line x1="20" y1="6" x2="18" y2="8"/>`,
49  library: `<rect x="4" y="5" width="6" height="14" rx="1"/><rect x="10" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="6" height="14" rx="1"/>`,
50  gallery: `<rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/>`,
51};
52
53function getIconPaths(iconKey) {
54  const paths = ICONS[iconKey];
55  if (paths) return paths;
56  if (import.meta?.env?.DEV) {
57    throw new Error(
58      `Unknown mind map toolbar icon key: ${iconKey}. Add it to ICONS in mindmap/toolbar.js or use one of: ${Object.keys(ICONS).join(", ")}`
59    );
60  }
61  return ICONS.select;
62}
63
64function appendSafeSvgThumb(svgEl, markup) {
65  const parser = new DOMParser();
66  const doc = parser.parseFromString(`<svg xmlns="${NS}">${markup || ""}</svg>`, "image/svg+xml");
67  const parsedSvg = doc.documentElement;
68  const allowedTags = new Set([
69    "svg", "g", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "text", "defs", "clipPath",
70  ]);
71  const allowedAttrs = /^(d|fill|stroke|stroke-width|stroke-linecap|stroke-linejoin|stroke-dasharray|opacity|x|y|cx|cy|r|rx|ry|x1|y1|x2|y2|points|viewBox|transform|font-size|font-weight|font-family|text-anchor|class)$/i;
72  const walker = doc.createTreeWalker(parsedSvg, NodeFilter.SHOW_ELEMENT);
73  const toRemove = [];
74  while (walker.nextNode()) {
75    const node = walker.currentNode;
76    if (!allowedTags.has(node.tagName)) {
77      toRemove.push(node);
78      continue;
79    }
80    [...node.attributes].forEach((attr) => {
81      if (!allowedAttrs.test(attr.name) || /^on/i.test(attr.name) || /javascript:/i.test(attr.value)) {
82        node.removeAttribute(attr.name);
83      }
84    });
85  }
86  toRemove.forEach((node) => node.remove());
87  [...parsedSvg.childNodes].forEach((node) => {
88    svgEl.appendChild(document.importNode(node, true));
89  });
90}
91
92function mkBtn(iconKey, title, ariaLabel = title, label = iconKey) {
93  return _mkBtn(getIconPaths(iconKey), title, ariaLabel, label);
94}
95
96function setBtnIcon(btn, iconKey) {
97  btn.dataset.icon = iconKey;
98  const icon = mkIcon(getIconPaths(iconKey));
99  icon.classList.add("btn-icon-ab");
100  const existingIcon = btn.querySelector(".btn-icon-ab");
101  if (existingIcon) {
102    existingIcon.replaceWith(icon);
103    return;
104  }
105  btn.prepend(icon);
106}
107
108function mkPanel(title) {
109  const panel = document.createElement("div");
110  panel.className = "toolbar-panel-mm";
111  panel.style.display = "none";
112  stopDragInside(panel);
113
114  const header = document.createElement("div");
115  header.className = "toolbar-panel-header-mm";
116  const titleEl = document.createElement("div");
117  titleEl.className = "toolbar-panel-title-mm";
118  titleEl.textContent = title;
119  const closeBtn = document.createElement("button");
120  closeBtn.type = "button";
121  closeBtn.className = "btn-ab toolbar-panel-close-mm";
122  closeBtn.title = `Close ${title}`;
123  closeBtn.setAttribute("aria-label", `Close ${title}`);
124  closeBtn.textContent = "✕";
125  header.append(titleEl, closeBtn);
126
127  const body = document.createElement("div");
128  body.className = "toolbar-panel-body-mm";
129  panel.append(header, body);
130  return { panel, body, closeBtn };
131}
132
133export function buildToolbar(el, state, actions) {
134  ensureToolbarBaseStyles();
135  const btnSelect  = mkBtn("select", "Select and move nodes (S)", "Select and move nodes (S)", "Select");
136  const btnNode    = mkBtn("node", "Click canvas to add a node (N)", "Click canvas to add a node (N)", "Node");
137  const btnPan     = mkBtn("pan", "Drag canvas to pan (P)", "Drag canvas to pan (P)", "Pan");
138  const btnSticky  = mkBtn("sticky", "Click canvas to place a sticky note (T)", "Click canvas to place a sticky note (T)", "Sticky");
139  const btnShapes  = mkBtn("shapes", "Open node shape tools", "Open node shape tools", "Shapes");
140
141  // Shape tools panel
142  const shapeOptions = [
143    { label: "Rectangle", value: "rectangle" },
144    { label: "Circle", value: "circle" },
145    { label: "Ellipse", value: "ellipse" },
146    { label: "Diamond", value: "diamond" },
147    { label: "Cylinder", value: "cylinder" },
148    { label: "Parallelogram", value: "parallelogram" },
149    { label: "Hexagon", value: "hexagon" },
150    { label: "Trapezoid", value: "trapezoid" },
151    { label: "Sticky Note", value: "sticky" },
152    { label: "Image", value: "image" },
153  ];
154  const shapePanelParts = mkPanel("Shape Tools");
155  shapePanelParts.panel.classList.add("shape-panel-mm");
156  const shapeGrid = document.createElement("div");
157  shapeGrid.className = "shape-grid-mm";
158  const shapeBtns = new Map();
159  shapeOptions.forEach(({ label, value }) => {
160    const shapeBtn = document.createElement("button");
161    shapeBtn.type = "button";
162    shapeBtn.className = "btn-ab shape-option-mm";
163    shapeBtn.textContent = label;
164    shapeBtn.title = `Set node shape to ${label}`;
165    shapeBtn.setAttribute("aria-label", `Set node shape to ${label}`);
166    shapeBtn.addEventListener("click", () => {
167      shapeBtns.forEach((btn) => btn.classList.remove("btn-active-ab"));
168      shapeBtn.classList.add("btn-active-ab");
169      actions.setShape(value);
170    });
171    shapeBtns.set(value, shapeBtn);
172    shapeGrid.appendChild(shapeBtn);
173  });
174  shapePanelParts.body.appendChild(shapeGrid);
175  el.appendChild(shapePanelParts.panel);
176
177  const btnConnect = mkBtn("connect", "Click source then target (node, lane, or edge). Edge clicks attach at the exact point clicked (C)", "Click source then target (node, lane, or edge). Edge clicks attach at the exact point clicked (C)", "Connect");
178  const btnStraight = mkBtn("straight", "Straight arrow connector", "Straight arrow connector", "Straight");
179  const btnElbow    = mkBtn("elbow", "Right-angle elbow connector", "Right-angle elbow connector", "Elbow");
180  const btnCurved   = mkBtn("curved", "Curved bezier connector", "Curved bezier connector", "Curved");
181  const btnEdgeFormat = mkBtn("edgeFormat", "Open edge formatting", "Open edge formatting", "Edge");
182
183  // Edge formatting panel
184  const edgePanelParts = mkPanel("Edge Format");
185  edgePanelParts.panel.classList.add("edge-format-panel-mm");
186  const edgeForm = document.createElement("div");
187  edgeForm.className = "edge-panel-form-mm";
188  const lineStyleOptions = [
189    { label: "Solid", value: "solid" },
190    { label: "Dotted", value: "dotted" },
191    { label: "Dashed", value: "dashed" },
192    { label: "Double", value: "double" },
193  ];
194  const lineStyleLabel = document.createElement("label");
195  lineStyleLabel.className = "panel-field-mm";
196  const lineStyleCaption = document.createElement("span");
197  lineStyleCaption.textContent = "Line style";
198  lineStyleLabel.appendChild(lineStyleCaption);
199  const lineStyleSelect = document.createElement("select");
200  lineStyleSelect.className = "panel-select-mm";
201  lineStyleOptions.forEach(({ label, value }) => {
202    const opt = document.createElement("option");
203    opt.value = value;
204    opt.textContent = label;
205    lineStyleSelect.appendChild(opt);
206  });
207  lineStyleSelect.value = "solid";
208  lineStyleSelect.addEventListener("change", () => {
209    actions.setEdgeLineStyle?.(lineStyleSelect.value);
210  });
211  lineStyleLabel.appendChild(lineStyleSelect);
212
213  const colorLabel = document.createElement("label");
214  colorLabel.className = "panel-field-mm";
215  const colorCaption = document.createElement("span");
216  colorCaption.textContent = "Color";
217  colorLabel.appendChild(colorCaption);
218  const colorInput = document.createElement("input");
219  colorInput.className = "panel-color-mm";
220  colorInput.type = "color";
221  colorInput.value = "#64748b";
222  colorInput.title = "Edge color";
223  colorInput.addEventListener("input", (e) => {
224    actions.setEdgeColor?.(e.target.value);
225  });
226  colorLabel.appendChild(colorInput);
227
228  const thicknessLabel = document.createElement("label");
229  thicknessLabel.className = "panel-field-mm";
230  const thicknessCaption = document.createElement("span");
231  thicknessCaption.textContent = "Thickness";
232  thicknessLabel.appendChild(thicknessCaption);
233  const thicknessSelect = document.createElement("select");
234  thicknessSelect.className = "panel-select-mm";
235  const thicknessOptions = [
236    { label: "Thin (1)", value: "1" },
237    { label: "Normal (1.5)", value: "1.5" },
238    { label: "Thick (2)", value: "2" },
239    { label: "Extra (2.5)", value: "2.5" },
240    { label: "Heavy (3)", value: "3" },
241  ];
242  thicknessOptions.forEach(({ label, value }) => {
243    const opt = document.createElement("option");
244    opt.value = value;
245    opt.textContent = label;
246    thicknessSelect.appendChild(opt);
247  });
248  thicknessSelect.value = "1.5";
249  thicknessSelect.addEventListener("change", () => {
250    actions.setEdgeThickness?.(thicknessSelect.value);
251  });
252  thicknessLabel.appendChild(thicknessSelect);
253  edgeForm.append(lineStyleLabel, colorLabel, thicknessLabel);
254  edgePanelParts.body.appendChild(edgeForm);
255  el.appendChild(edgePanelParts.panel);
256
257  // Lane controls
258  const btnLane       = mkBtn("lane", "Click canvas to add a free lane (L)", "Click canvas to add a free lane (L)", "Lane");
259  const btnVertical   = mkBtn("vertical", "Create 3 vertical lanes", "Create 3 vertical lanes", "Vertical");
260  const btnHorizontal = mkBtn("horizontal", "Create 3 horizontal lanes", "Create 3 horizontal lanes", "Horizontal");
261  const btnSwot       = mkBtn("swot", "Create a 2×2 SWOT quadrant layout", "Create a 2×2 SWOT quadrant layout", "SWOT");
262
263  const btnReassign = mkBtn(
264    "reassignDrop",
265    "On drop: reassign nodes to lanes when you release the drag (click to switch to live)",
266    "Drop lane reassignment enabled",
267    "Reassign"
268  );
269  btnReassign.classList.add("lane-toggle-mm");
270  btnReassign.dataset.mode = "drop";
271
272  // Zoom controls
273  const btnZoomIn    = mkBtn("zoomin", "Zoom in", "Zoom in", "Zoom+");
274  const btnZoomOut   = mkBtn("zoomout", "Zoom out", "Zoom out", "Zoom-");
275  const btnZoomReset = mkBtn("zoomreset", "Reset zoom and pan", "Reset zoom and pan", "Reset");
276
277  // Clipboard / format-painter buttons
278  const btnCbCopy   = mkBtn("copy", "Copy selected node (Ctrl+C)", "Copy selected node (Ctrl+C)", "Copy");
279  const btnCbCut    = mkBtn("cut", "Cut selected node (Ctrl+X)", "Cut selected node (Ctrl+X)", "Cut");
280  const btnCbPaste  = mkBtn("paste", "Paste copied node (Ctrl+V)", "Paste copied node (Ctrl+V)", "Paste");
281  const btnCopyFmt  = mkBtn("copyFmt", "Copy formatting of selected node (Ctrl+Shift+C)", "Copy formatting of selected node (Ctrl+Shift+C)", "Copy Fmt");
282  const btnPasteFmt = mkBtn("pasteFmt", "Paste formatting onto selected node(s) (Ctrl+Shift+V)", "Paste formatting onto selected node(s) (Ctrl+Shift+V)", "Paste Fmt");
283
284  // File / DB actions
285  const btnPasteNdjson = mkBtn("pasteNdjson", "Paste mind map NDJSON directly", "Paste mind map NDJSON directly", "NDJSON");
286  const btnSave     = mkBtn("save", "Save mind map to the database (ZimXStudio, via ws-studio)", "Save mind map to the database (ZimXStudio)", "Save");
287  const btnLoad     = mkBtn("load", "Open a saved mind map from the database (ZimXStudio) — pick from a list", "Open a saved mind map from the database (ZimXStudio)", "Load");
288  const btnSources  = mkBtn("sources", "Import NDJSON from URLs or local files (NOT the database)", "Import NDJSON from URLs or local files (NOT the database)", "Sources");
289  const btnTemplate = mkBtn("template", "View / copy / download API template (NDJSON schema)", "View / copy / download API template (NDJSON schema)", "Template");
290  const btnAiImage  = mkBtn("ai", "Generate an image with AI and insert it into the mind map", "Generate an image with AI and insert it into the mind map", "AI Img");
291  const btnAiGen    = mkBtn("ai", "Generate a mind map from a description with AI", "Generate a mind map from a description with AI", "AI Map");
292  const btnLibrary  = mkBtn("library", "Toggle the field library panel", "Toggle the field library panel", "Library");
293  const btnGallery  = mkBtn("gallery", "Open shape gallery panel (G)", "Open shape gallery panel (G)", "Gallery");
294
295  const bar = buildToolbarEl("Mind Map", [
296    mkSection("Tools",     btnSelect, btnNode, btnPan, btnSticky, btnShapes),
297    mkSection("Connect",   btnConnect, btnStraight, btnElbow, btnCurved, btnEdgeFormat),
298    mkSection("Lanes",     btnLane, btnVertical, btnHorizontal, btnSwot, btnReassign),
299    mkSection("Zoom",      btnZoomIn, btnZoomOut, btnZoomReset),
300    mkSection("Clipboard", btnCbCopy, btnCbCut, btnCbPaste, btnCopyFmt, btnPasteFmt),
301    mkSection("Data",      btnPasteNdjson, btnSave, btnLoad, btnSources, btnTemplate, btnAiImage, btnAiGen, btnLibrary, btnGallery),
302  ]);
303  stopDragInside(bar);
304  el.appendChild(bar);
305
306  // Gallery panel
307  const galleryBox = document.createElement("div");
308  galleryBox.className = "gallery-mm toolbar-panel-mm";
309  galleryBox.style.display = "none";
310  stopDragInside(galleryBox);
311
312  const gallHeader = document.createElement("div");
313  gallHeader.className = "toolbar-panel-header-mm";
314  const gallTitle = document.createElement("div");
315  gallTitle.className = "toolbar-panel-title-mm gallery-title-mm";
316  gallTitle.textContent = "Shape Gallery";
317  const gallClose = document.createElement("button");
318  gallClose.type = "button";
319  gallClose.className = "btn-ab toolbar-panel-close-mm";
320  gallClose.title = "Close shape gallery";
321  gallClose.setAttribute("aria-label", "Close shape gallery");
322  gallClose.textContent = "✕";
323  gallHeader.append(gallTitle, gallClose);
324  galleryBox.appendChild(gallHeader);
325
326  const gallBody = document.createElement("div");
327  gallBody.className = "toolbar-panel-body-mm";
328  galleryBox.appendChild(gallBody);
329
330  const itemsByCategory = new Map();
331  GALLERY_ITEMS.forEach(item => {
332    const cat = item.category || GALLERY_DEFAULT_CATEGORY;
333    if (!itemsByCategory.has(cat)) itemsByCategory.set(cat, []);
334    itemsByCategory.get(cat).push(item);
335  });
336
337  GALLERY_CATEGORIES.forEach(({ id, label: catLabel }) => {
338    const items = itemsByCategory.get(id);
339    if (!items || items.length === 0) return;
340
341    const catSection = document.createElement("div");
342    catSection.className = "gallery-category-mm";
343
344    const catHeader = document.createElement("button");
345    catHeader.type = "button";
346    catHeader.className = "gallery-category-header-mm";
347    const catName = document.createElement("span");
348    catName.textContent = catLabel;
349    const catToggle = document.createElement("span");
350    catToggle.className = "gallery-category-toggle-mm";
351    catToggle.textContent = "▼";
352    catHeader.append(catName, catToggle);
353    catSection.appendChild(catHeader);
354
355    const catBody = document.createElement("div");
356    catBody.className = "gallery-category-body-mm";
357
358    const gallGrid = document.createElement("div");
359    gallGrid.className = "gallery-grid-mm";
360    items.forEach(({ tool, label: itemLabel, thumb }) => {
361      const item = document.createElement("button");
362      item.type = "button";
363      item.className = "gallery-item-mm";
364      item.setAttribute("data-tool", tool);
365      item.setAttribute("data-label", itemLabel);
366      item.title = `Add "${itemLabel}" node`;
367      item.setAttribute("aria-label", `Add ${itemLabel} node`);
368      const thumbSvg = document.createElementNS(NS, "svg");
369      thumbSvg.setAttribute("class", "gallery-thumb-mm");
370      thumbSvg.setAttribute("viewBox", "0 0 44 44");
371      appendSafeSvgThumb(thumbSvg, thumb);
372      const labelSpan = document.createElement("span");
373      labelSpan.className = "gallery-label-mm";
374      labelSpan.textContent = itemLabel;
375      item.append(thumbSvg, labelSpan);
376      gallGrid.appendChild(item);
377    });
378    catBody.appendChild(gallGrid);
379    catSection.appendChild(catBody);
380
381    catHeader.addEventListener("click", () => {
382      const open = catBody.style.display !== "none";
383      catBody.style.display = open ? "none" : "block";
384      catToggle.textContent = open ? "▶" : "▼";
385    });
386
387    gallBody.appendChild(catSection);
388  });
389
390  el.appendChild(galleryBox);
391
392  const panelPairs = [
393    [shapePanelParts.panel, btnShapes],
394    [edgePanelParts.panel, btnEdgeFormat],
395    [galleryBox, btnGallery],
396  ];
397  let fieldLibraryPanel = null;
398  function closePanel(panel) {
399    panel.style.display = "none";
400    panelPairs.forEach(([p, b]) => {
401      if (p === panel) b.classList.remove("btn-active-ab");
402    });
403  }
404  function closeFieldLibrary() {
405    const existing = fieldLibraryPanel && fieldLibraryPanel.isConnected
406      ? fieldLibraryPanel
407      : el.querySelector(".field-library-erd");
408    if (existing) existing.remove();
409    fieldLibraryPanel = null;
410    btnLibrary.classList.remove("btn-active-ab");
411  }
412  function closeAllPanels() {
413    panelPairs.forEach(([panel, btn]) => {
414      panel.style.display = "none";
415      btn.classList.remove("btn-active-ab");
416    });
417    closeFieldLibrary();
418  }
419  function togglePanel(panel, btn) {
420    const willOpen = panel.style.display !== "flex";
421    closeAllPanels();
422    if (willOpen) {
423      panel.style.display = "flex";
424      btn.classList.add("btn-active-ab");
425    }
426  }
427  shapePanelParts.closeBtn.addEventListener("click", () => closePanel(shapePanelParts.panel));
428  edgePanelParts.closeBtn.addEventListener("click", () => closePanel(edgePanelParts.panel));
429  gallClose.addEventListener("click", () => closePanel(galleryBox));
430  const hasOpenPanels = () =>
431    panelPairs.some(([panel]) => panel.style.display === "flex") ||
432    !!(fieldLibraryPanel && fieldLibraryPanel.isConnected);
433  const isEditingTarget = (target) => target.closest("input, textarea, select, [contenteditable]");
434
435  const toolBtns      = [btnSelect, btnNode, btnPan, btnConnect, btnLane];
436  const connectorBtns = [btnStraight, btnElbow, btnCurved];
437
438  function activateTool(tool) {
439    toolBtns.forEach(b => b.classList.remove("btn-active-ab"));
440    ({ select: btnSelect, node: btnNode, pan: btnPan, connect: btnConnect, lane: btnLane }[tool])?.classList.add("btn-active-ab");
441    actions.setTool(tool);
442  }
443
444  function activateConnector(c) {
445    connectorBtns.forEach(b => b.classList.remove("btn-active-ab"));
446    ({ straight: btnStraight, elbow: btnElbow, curved: btnCurved }[c])?.classList.add("btn-active-ab");
447    actions.setConnector(c);
448  }
449
450  function syncReassignButton(mode) {
451    if (mode === "live") {
452      setBtnIcon(btnReassign, "reassignLive");
453      btnReassign.dataset.mode = "live";
454      btnReassign.title = "Live: reassign nodes to lanes while dragging (click to switch to drop)";
455      btnReassign.setAttribute("aria-label", "Live lane reassignment enabled");
456      btnReassign.classList.add("btn-active-ab");
457    } else {
458      setBtnIcon(btnReassign, "reassignDrop");
459      btnReassign.dataset.mode = "drop";
460      btnReassign.title = "On drop: reassign nodes to lanes when you release the drag (click to switch to live)";
461      btnReassign.setAttribute("aria-label", "Drop lane reassignment enabled");
462      btnReassign.classList.remove("btn-active-ab");
463    }
464  }
465
466  btnSelect.addEventListener("click",   () => activateTool("select"));
467  btnNode.addEventListener("click",     () => activateTool("node"));
468  btnPan.addEventListener("click",      () => activateTool("pan"));
469  btnConnect.addEventListener("click",  () => activateTool("connect"));
470  btnLane.addEventListener("click",     () => activateTool("lane"));
471  btnShapes.addEventListener("click",   () => togglePanel(shapePanelParts.panel, btnShapes));
472  btnEdgeFormat.addEventListener("click", () => togglePanel(edgePanelParts.panel, btnEdgeFormat));
473  btnSticky.addEventListener("click",   () => {
474    actions.addSticky?.(() => { btnSticky.classList.remove("btn-active-ab"); });
475    btnSticky.classList.add("btn-active-ab");
476  });
477  btnStraight.addEventListener("click", () => activateConnector("straight"));
478  btnElbow.addEventListener("click",    () => activateConnector("elbow"));
479  btnCurved.addEventListener("click",   () => activateConnector("curved"));
480
481  // Lane action buttons
482  btnVertical.addEventListener("click",   () => actions.createVerticalLanes?.());
483  btnHorizontal.addEventListener("click", () => actions.createHorizontalLanes?.());
484  btnSwot.addEventListener("click",       () => actions.createSwotLanes?.());
485
486  // Live / Drop reassignment toggle
487  btnReassign.addEventListener("click", () => {
488    const next = state.laneReassign === "drop" ? "live" : "drop";
489    actions.setLaneReassign?.(next);
490    syncReassignButton(next);
491  });
492
493  btnZoomIn.addEventListener("click",    () => actions.zoomIn?.());
494  btnZoomOut.addEventListener("click",   () => actions.zoomOut?.());
495  btnZoomReset.addEventListener("click", () => actions.zoomReset?.());
496
497  btnPasteNdjson.addEventListener("click", () => actions.pasteNdjson?.());
498  btnSave.addEventListener("click",     () => actions.saveToDb?.());
499
500  // Clipboard / format-painter actions
501  btnCbCopy.addEventListener("click",   () => actions.cbCopy?.());
502  btnCbCut.addEventListener("click",    () => actions.cbCut?.());
503  btnCbPaste.addEventListener("click",  () => actions.cbPaste?.());
504  btnCopyFmt.addEventListener("click",  () => actions.copyFormat?.());
505  btnPasteFmt.addEventListener("click", () => actions.pasteFormat?.());
506  btnLoad.addEventListener("click",     () => actions.loadFromDb?.());
507  btnSources.addEventListener("click",  () => actions.loadFromUrls?.());
508  btnTemplate.addEventListener("click", () => actions.exportApiTemplate?.());
509  btnAiImage.addEventListener("click",  () => actions.aiGenerateImage?.());
510  btnAiGen.addEventListener("click",    () => actions.aiGenerate?.());
511
512  // Gallery toggle and item click
513  btnGallery.addEventListener("click", () => togglePanel(galleryBox, btnGallery));
514  galleryBox.addEventListener("click", (e) => {
515    const item = e.target.closest(".gallery-item-mm");
516    if (!item) return;
517    const tool = item.getAttribute("data-tool");
518    const label = item.getAttribute("data-label");
519    if (tool) actions.addGalleryNode?.({ tool, label });
520  });
521
522  // Field library toggle
523  btnLibrary.addEventListener("click", () => {
524    const existing = el.querySelector(".field-library-erd");
525    if (existing) {
526      closeFieldLibrary();
527    } else {
528      closeAllPanels();
529      const panel = buildFieldLibrary(el, { hint: "Drag a field to use as a node label, or assign fields in the node editor." });
530      panel.classList.add("toolbar-field-library-mm");
531      fieldLibraryPanel = panel;
532      btnLibrary.classList.add("btn-active-ab");
533    }
534  });
535
536  // Default state
537  activateTool("pan");
538  activateConnector("straight");
539  shapeBtns.get(state.shape || "rectangle")?.classList.add("btn-active-ab");
540  syncReassignButton(state.laneReassign || "drop");
541
542  // Close popovers on outside click
543  document.addEventListener("pointerdown", (e) => {
544    const clickedToolbar = bar.contains(e.target);
545    const clickedPanel = panelPairs.some(([panel]) => panel.contains(e.target));
546    const fieldLibrary = fieldLibraryPanel && fieldLibraryPanel.isConnected ? fieldLibraryPanel : null;
547    const clickedFieldLibrary = !!fieldLibrary && fieldLibrary.contains(e.target);
548    if (!clickedToolbar && !clickedPanel && !clickedFieldLibrary) closeAllPanels();
549  });
550
551  // Keyboard shortcuts within the mind map shell
552  el.addEventListener("keydown", (e) => {
553    if (e.key === "Escape" && hasOpenPanels()) {
554      e.preventDefault();
555      closeAllPanels();
556    }
557    if (isEditingTarget(e.target)) return;
558    // Skip single-key shortcuts when a modifier key is held (handled separately below)
559    if (e.ctrlKey || e.metaKey) return;
560    if (e.key === "s" || e.key === "S") activateTool("select");
561    if (e.key === "n" || e.key === "N") activateTool("node");
562    if (e.key === "p" || e.key === "P") activateTool("pan");
563    if (e.key === "t" || e.key === "T") {
564      actions.addSticky?.(() => { btnSticky.classList.remove("btn-active-ab"); });
565      btnSticky.classList.add("btn-active-ab");
566    }
567    if (e.key === "c" || e.key === "C") activateTool("connect");
568    if (e.key === "l" || e.key === "L") activateTool("lane");
569    if (e.key === "+" || e.key === "=") actions.zoomIn?.();
570    if (e.key === "-" || e.key === "_") actions.zoomOut?.();
571    if (e.key === "0")                  actions.zoomReset?.();
572    if (e.key === "g" || e.key === "G") btnGallery.click();
573  });
574
575  // Clipboard & format-painter keyboard shortcuts (Ctrl/Cmd + key)
576  el.addEventListener("keydown", (e) => {
577    if (isEditingTarget(e.target)) return;
578    if (!e.ctrlKey && !e.metaKey) return;
579    const key = e.key.toLowerCase();
580    if (e.shiftKey && key === "c") { e.preventDefault(); btnCopyFmt.click(); }
581    else if (e.shiftKey && key === "v") { e.preventDefault(); btnPasteFmt.click(); }
582    else if (key === "c") { e.preventDefault(); btnCbCopy.click(); }
583    else if (key === "x") { e.preventDefault(); btnCbCut.click(); }
584    else if (key === "v") { e.preventDefault(); btnCbPaste.click(); }
585  });
586}

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.