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.