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.