diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9482dff --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +package.json diff --git a/CLAUDE.md b/CLAUDE.md index 432009f..674035d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,3 +12,6 @@ For example, PR #16 with `tools/d2-editor.html` is at: `https://tools.bobwhitelock.co.uk/pr-16/tools/d2-editor.html` Use this to inspect deployed code and debug issues when working on any tool. + +After pushing, wait for CI to pass, then wait a further ~1 minute for the deploy +to complete before loading the preview URL. diff --git a/tools/d2-editor.html b/tools/d2-editor.html index e826f1a..fbd4f9b 100644 --- a/tools/d2-editor.html +++ b/tools/d2-editor.html @@ -559,15 +559,16 @@ background: #1f2330; border: 1px solid #30363d; border-radius: 8px; padding: 4px; box-shadow: 0 4px 14px rgba(0,0,0,.45); } - .d2-action-menu.show { display: flex; } + .d2-action-menu.show { display: flex; flex-direction: column; align-items: stretch; } .d2-action-menu .d2-act-label { color: #8b949e; font: 12px/1 'SF Mono','Fira Code',Consolas,monospace; - padding: 0 6px; max-width: 120px; overflow: hidden; + padding: 4px 6px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .d2-action-menu button { background: #2d333b; color: #c9d1d9; border: 1px solid #444c56; border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; + text-align: left; } .d2-action-menu button:hover { background: #373e47; } .d2-action-menu button.d2-act-x { padding: 6px 9px; color: #8b949e; } @@ -575,11 +576,17 @@ /* Floating label popup (appears near target node after connect) */ .d2-label-popup { position: fixed; z-index: 30; display: none; - align-items: center; gap: 4px; + flex-direction: column; gap: 4px; background: #1f2330; border: 1px solid #30363d; border-radius: 8px; - padding: 4px; box-shadow: 0 4px 14px rgba(0,0,0,.45); + padding: 6px; box-shadow: 0 4px 14px rgba(0,0,0,.45); } .d2-label-popup.show { display: flex; } + .d2-popup-hint { + color: #8b949e; font-size: 11px; padding: 0 2px; + } + .d2-label-popup .d2-add-row { + display: flex; align-items: center; gap: 4px; + } .d2-label-input { height: 28px; width: 140px; border: 1px solid #444c56; border-radius: 6px; @@ -596,6 +603,82 @@ .d2-label-confirm:hover { background: #373e47; } .d2-label-confirm svg { width: 14px; height: 14px; } + .d2-image-icon-popup { + position: fixed; z-index: 30; display: none; + flex-direction: column; gap: 6px; + background: #1f2330; border: 1px solid #30363d; border-radius: 8px; + padding: 8px; box-shadow: 0 4px 14px rgba(0,0,0,.45); width: 320px; + } + .d2-image-icon-popup.show { display: flex; } + .d2-image-icon-hint { font-size: 11px; color: #8b949e; } + .d2-image-icon-hint a { color: var(--accent); text-decoration: none; } + .d2-image-icon-hint a:hover { text-decoration: underline; } + .d2-image-icon-input { width: 100% !important; } + + /* Danger button (Delete) in action menu */ + .d2-action-menu button.d2-act-danger { color: #f85149; } + .d2-action-menu button.d2-act-danger:hover { background: #2a1a1a; border-color: #6e2222; } + + /* Shape picker popup */ + .d2-shape-picker { + position: fixed; z-index: 30; display: none; + background: #1f2330; border: 1px solid #30363d; border-radius: 10px; + padding: 10px; box-shadow: 0 6px 20px rgba(0,0,0,.5); + width: min(calc(100vw - 20px), 520px); + max-height: min(calc(100vh - 20px), 700px); overflow-y: auto; + } + .d2-shape-picker.show { display: block; } + .d2-picker-header { + display: flex; align-items: center; justify-content: space-between; + padding: 0 2px 8px; margin-bottom: 8px; border-bottom: 1px solid #30363d; + font-size: 11px; color: #8b949e; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; + } + .d2-picker-close { + background: none; border: none; color: #8b949e; cursor: pointer; + font-size: 14px; padding: 0 2px; line-height: 1; + } + .d2-picker-close:hover { color: #c9d1d9; } + .d2-shape-grid { + display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; + } + .d2-shape-btn { + display: flex; align-items: center; justify-content: center; + padding: 4px; background: #fff; + border: none; border-radius: 8px; cursor: pointer; overflow: hidden; + outline: 2px solid transparent; outline-offset: -2px; transition: background 0.1s; + } + .d2-shape-btn:hover { background: #e8eaed; } + .d2-shape-btn.d2-shape-active { box-shadow: 0 0 0 3px var(--accent); background: #d4eddf; } + .d2-shape-btn svg { width: 82px; height: 62px; display: block; } + .d2-shape-placeholder { + width: 82px; height: 62px; display: flex; align-items: center; justify-content: center; + font-size: 10px; color: #8b949e; border: 1px dashed #444c56; border-radius: 4px; + background: #2d333b; + } + + /* Add node popup */ + .d2-add-popup { + position: fixed; z-index: 30; display: none; + flex-direction: column; gap: 8px; + background: #1f2330; border: 1px solid #30363d; border-radius: 10px; + padding: 10px; box-shadow: 0 6px 20px rgba(0,0,0,.5); + width: min(calc(100vw - 20px), 520px); + } + .d2-add-popup.show { display: flex; } + .d2-add-row { display: flex; gap: 4px; align-items: center; } + .d2-add-row .d2-label-input { flex: 1; min-width: 0; } + + @media (max-width: 600px) { + .d2-shape-picker, .d2-add-popup { + position: fixed; inset: 0; width: 100% !important; max-width: 100% !important; + height: 100% !important; max-height: 100% !important; + border-radius: 0; overflow-y: auto; + } + .d2-shape-grid { grid-template-columns: repeat(4, 1fr); } + .d2-shape-btn svg { width: 72px; height: 54px; } + .d2-shape-placeholder { width: 72px; height: 54px; } + } + /* Status bar shown while picking a target */ .d2-action-bar { position: absolute; top: 8px; left: 50%; transform: translateX(-50%); @@ -1159,6 +1242,10 @@ +
+
@@ -1168,8 +1255,12 @@
- - + + + + + +
@@ -1349,13 +1440,88 @@
- - + Label edge (optional) +
+ + +
+
+ + +
+ Label node +
+ + +
+
+ + +
+ Label edge (blank to remove) +
+ + +
+
+ + +
+
+ Icon URL — + browse icons.terrastruct.com +
+
+ + +
+
+ + +
+
+ Add node + +
+
+ + +
+
+
+ + +
+
+ Shape + +
+
@@ -1365,12 +1531,21 @@ // @terrastruct/d2-wasm is the underlying compiled WASM binary; @terrastruct/d2 // is the JS wrapper that provides the clean API used here. import { D2 } from 'https://esm.sh/@terrastruct/d2'; + import { SHAPE_PREVIEW_SVGS } from './d2-shape-previews.js'; // ─── Constants ──────────────────────────────────────────────────────────── const LS_KEY = 'd2-editor-source'; const DEBOUNCE_MS = 300; + const D2_SHAPES = [ + 'callout','circle','class','cloud','code', + 'cylinder','diamond','document','hexagon','image', + 'oval','package','page','parallelogram','person', + 'queue','rectangle','sql_table','square','step', + 'stored_data','text', + ]; + const DEFAULT_SOURCE = `vars: { d2-config: { @@ -1838,8 +2013,10 @@ /** Push the current editor value onto the undo stack (before a programmatic change). */ function pushUndo() { - const prev = undoStack[undoStack.length - 1] ?? snapValue; - if (normalizeD2Source(editor.value) === normalizeD2Source(prev)) return; + if (undoStack.length > 0) { + const prev = undoStack[undoStack.length - 1]; + if (normalizeD2Source(editor.value) === normalizeD2Source(prev)) return; + } undoStack.push(editor.value); if (undoStack.length > MAX_UNDO) undoStack.shift(); redoStack.length = 0; @@ -2367,7 +2544,20 @@ if (decoded == null) continue; const key = unescapeXml(decoded); if (shapeIds.has(key)) { g.dataset.d2key = key; g.dataset.d2kind = 'shape'; tagged++; } - else if (connIds.has(key)) { g.dataset.d2key = key; g.dataset.d2kind = 'conn'; tagged++; } + else if (connIds.has(key)) { + g.dataset.d2key = key; g.dataset.d2kind = 'conn'; tagged++; + // Add invisible wide-stroke overlay paths for easier tap/click targeting + for (const path of g.querySelectorAll('path')) { + if (path.dataset.hitOverlay) continue; + const overlay = path.cloneNode(false); + overlay.setAttribute('stroke', 'rgba(0,0,0,0)'); + overlay.setAttribute('stroke-width', '30'); + overlay.setAttribute('fill', 'none'); + overlay.style.pointerEvents = 'all'; + overlay.dataset.hitOverlay = '1'; + path.parentNode.insertBefore(overlay, path.nextSibling); + } + } } return tagged; } @@ -2384,6 +2574,80 @@ // ── Source mutations ──────────────────────────────────────────────────────── + // D2 range strings look like "index,LINE:COL:BYTE-LINE:COL:BYTE" + function parseD2Range(r) { + const m = String(r).match(/,\d+:\d+:(\d+)-\d+:\d+:(\d+)/); + return m ? { start: +m[1], end: +m[2] } : null; + } + + // Extract the text value from a D2 path segment object + function segVal(seg) { + return Object.values(seg)[0]?.value?.[0]?.string ?? null; + } + + // Walk ast.nodes recursively, finding map_key whose range starts at startByte + function findMapKeyAt(nodes, startByte) { + for (const node of (nodes || [])) { + const mk = node.map_key; + if (!mk) continue; + const r = parseD2Range(mk.range); + if (r?.start === startByte) return { mk, r }; + const found = findMapKeyAt(mk.value?.map?.nodes, startByte); + if (found) return found; + } + return null; + } + + // Build lookup: edge-segment byte start → {mk, r} for every edge in the AST + function buildEdgeSegLookup(astNodes) { + const lookup = new Map(); + function traverse(nodes) { + for (const node of (nodes || [])) { + const mk = node.map_key; + if (!mk) continue; + if (mk.edges?.length) { + const r = parseD2Range(mk.range); + for (const edge of mk.edges) { + for (const seg of [...(edge.src?.path || []), ...(edge.dst?.path || [])]) { + const sr = parseD2Range(Object.values(seg)[0]?.range); + if (sr) lookup.set(sr.start, { mk, r }); + } + } + } + if (mk.value?.map?.nodes) traverse(mk.value.map.nodes); + } + } + traverse(astNodes); + return lookup; + } + + // Expand a byte range to full-line boundaries in src + function lineRangeInSrc(r, src) { + let start = r.start; + while (start > 0 && src[start - 1] !== '\n') start--; + const nl = src.indexOf('\n', r.end - 1); + return { start, end: nl === -1 ? src.length : nl + 1 }; + } + + // Find the graph object for a given fully-qualified nodeKey. + // Falls back to short-id lookup for block-scope nodes. + function findNodeTargetObj(nodeKey) { + if (!lastCompiled?.graph?.objects) return null; + const leaf = nodeKey.includes('.') ? nodeKey.slice(nodeKey.lastIndexOf('.') + 1) : nodeKey; + for (const obj of lastCompiled.graph.objects) { + for (const ref of (obj.references || [])) { + const segs = ref.key?.path; + if (!segs?.length) continue; + const kpi = Math.min(ref.key_path_index ?? 0, segs.length - 1); + if (kpi !== segs.length - 1) continue; + const joined = segs.slice(0, kpi + 1).map(segVal).filter(Boolean).join('.'); + if (joined === nodeKey) return obj; + } + } + const matches = lastCompiled.graph.objects.filter(o => o.id === leaf); + return matches.length === 1 ? matches[0] : null; + } + function applyEditedSource(newSrc) { editor.value = newSrc; commitSnap(); saveSource(newSrc); syncControls(newSrc); scheduleRender(); @@ -2398,18 +2662,6 @@ function moveNode(nodeKey, newParentKey) { if (!lastCompiled?.graph?.objects) return; - // D2 range strings look like "index,LINE:COL:BYTE-LINE:COL:BYTE" - function parseD2Range(r) { - const m = String(r).match(/,\d+:\d+:(\d+)-\d+:\d+:(\d+)/); - return m ? { start: +m[1], end: +m[2] } : null; - } - - // Extract the text value from a D2 path segment object - // (e.g. { unquoted_string: { range: "...", value: [{ string: "api" }] } }) - function segVal(seg) { - return Object.values(seg)[0]?.value?.[0]?.string ?? null; - } - // nodeKey is the SVG/diagram fully-qualified id (e.g. "network.api"). // graph.objects use short/relative ids, so we match by scanning references // for any ref whose path[0..kpi] spells out nodeKey. @@ -2462,42 +2714,6 @@ const declKeyR = parseD2Range(Object.values(declRef.key.path[0])[0]?.range); if (!declKeyR) { showToast('Could not parse declaration range'); return; } - // Walk ast.nodes recursively, finding map_key whose range starts at startByte - function findMapKeyAt(nodes, startByte) { - for (const node of (nodes || [])) { - const mk = node.map_key; - if (!mk) continue; - const r = parseD2Range(mk.range); - if (r?.start === startByte) return { mk, r }; - const found = findMapKeyAt(mk.value?.map?.nodes, startByte); - if (found) return found; - } - return null; - } - - // Build lookup: edge-segment byte start → {mk, r} for every edge in the AST - function buildEdgeSegLookup(astNodes) { - const lookup = new Map(); - function traverse(nodes) { - for (const node of (nodes || [])) { - const mk = node.map_key; - if (!mk) continue; - if (mk.edges?.length) { - const r = parseD2Range(mk.range); - for (const edge of mk.edges) { - for (const seg of [...(edge.src?.path || []), ...(edge.dst?.path || [])]) { - const sr = parseD2Range(Object.values(seg)[0]?.range); - if (sr) lookup.set(sr.start, { mk, r }); - } - } - } - if (mk.value?.map?.nodes) traverse(mk.value.map.nodes); - } - } - traverse(astNodes); - return lookup; - } - // Fully-qualify one edge endpoint path relative to the containing scope. // Segments starting with the moved node's leaf are rewritten to newKey; // everything else is prefixed with the scope (e.g. "network.db"). @@ -2534,15 +2750,7 @@ ).join('\n'); } - // Helper: full-line removal range (strip leading whitespace + trailing \n) - function lineRange(r) { - let start = r.start; - while (start > 0 && src[start - 1] !== '\n') start--; - const nl = src.indexOf('\n', r.end - 1); - return { start, end: nl === -1 ? src.length : nl + 1 }; - } - - const declLineR = lineRange(fullDeclR); + const declLineR = lineRangeInSrc(fullDeclR, src); const edgeSegLookup = buildEdgeSegLookup(ast.nodes); const edits = []; // source removals / cross-scope substitutions @@ -2578,7 +2786,7 @@ const lastSegR = parseD2Range(Object.values(segs[segs.length - 1])[0]?.range); if (!lastSegR) continue; const attrValueSuffix = src.slice(lastSegR.end, attrMkR.end); - const attrLineR = lineRange(attrMkR); + const attrLineR = lineRangeInSrc(attrMkR, src); edits.push({ start: attrLineR.start, end: attrLineR.end, replacement: '' }); appendLines.push(newAttrKey + attrValueSuffix); } else { @@ -2612,7 +2820,7 @@ edgeText = edgeText.slice(0, s) + fq + edgeText.slice(e); } - const edgeLineR = lineRange(edgeMkR); + const edgeLineR = lineRangeInSrc(edgeMkR, src); edits.push({ start: edgeLineR.start, end: edgeLineR.end, replacement: '' }); appendLines.push(edgeText); } @@ -2656,6 +2864,306 @@ applyEditedSource(src); } + function deleteNode(nodeKey) { + const targetObj = findNodeTargetObj(nodeKey); + if (!targetObj) { showToast('Node not found'); return; } + const ast = lastCompiled?.graph?.ast; + if (!ast) { showToast('AST unavailable'); return; } + + const src = editor.value; + const edits = []; + const processedLines = new Set(); + const edgeSegLookup = buildEdgeSegLookup(ast.nodes); + + for (const ref of (targetObj.references || [])) { + const segs = ref.key?.path; + if (!segs?.length) continue; + if (ref.map_key_edge_index >= 0) { + const segByte = parseD2Range(Object.values(segs[0])[0]?.range)?.start; + if (segByte == null) continue; + const edgeInfo = edgeSegLookup.get(segByte); + if (!edgeInfo) continue; + const { r } = edgeInfo; + if (processedLines.has(r.start)) continue; + processedLines.add(r.start); + const lr = lineRangeInSrc(r, src); + edits.push({ start: lr.start, end: lr.end, replacement: '' }); + } else { + const kpi = Math.min(ref.key_path_index ?? 0, segs.length - 1); + const segByte = parseD2Range(Object.values(segs[kpi])[0]?.range)?.start; + if (segByte == null) continue; + const info = findMapKeyAt(ast.nodes, segByte); + if (!info) continue; + const { r } = info; + if (processedLines.has(r.start)) continue; + processedLines.add(r.start); + const lr = lineRangeInSrc(r, src); + edits.push({ start: lr.start, end: lr.end, replacement: '' }); + } + } + + if (!edits.length) { showToast('Nothing to delete'); return; } + edits.sort((a, b) => b.start - a.start); + pushUndo(); + let newSrc = src; + for (const { start, end, replacement } of edits) { + newSrc = newSrc.slice(0, start) + replacement + newSrc.slice(end); + } + applyEditedSource(newSrc); + resetInteraction(); + showToast('Deleted ' + nodeKey); + } + + function deleteEdge(edgeKey) { + const ast = lastCompiled?.graph?.ast; + if (!ast) { showToast('AST unavailable'); return; } + const src = editor.value; + + // Walk AST with scope tracking to match fully-qualified edge keys + function walkForEdge(nodes, scope) { + const counts = new Map(); + for (const node of (nodes || [])) { + const mk = node.map_key; + if (!mk) continue; + const mkR = parseD2Range(mk.range); + if (mk.edges?.length) { + for (const edge of mk.edges) { + const srcParts = edge.src?.path?.map(s => segVal(s)).filter(Boolean) ?? []; + const dstParts = edge.dst?.path?.map(s => segVal(s)).filter(Boolean) ?? []; + const fqSrc = [scope, ...srcParts].filter(Boolean).join('.'); + const fqDst = [scope, ...dstParts].filter(Boolean).join('.'); + const pair = `${fqSrc}->${fqDst}`; + const n = counts.get(pair) ?? 0; + counts.set(pair, n + 1); + if (`(${fqSrc} -> ${fqDst})[${n}]` === edgeKey) return mkR; + } + } + if (mk.value?.map?.nodes) { + const keyParts = mk.key?.path?.map(s => segVal(s)).filter(Boolean) ?? []; + const childScope = [scope, ...keyParts].filter(Boolean).join('.'); + const found = walkForEdge(mk.value.map.nodes, childScope); + if (found) return found; + } + } + return null; + } + + const edgeMkR = walkForEdge(ast.nodes, ''); + if (!edgeMkR) { showToast('Edge not found in source'); return; } + const lr = lineRangeInSrc(edgeMkR, src); + pushUndo(); + applyEditedSource(src.slice(0, lr.start) + src.slice(lr.end)); + resetInteraction(); + showToast('Deleted edge'); + } + + // Returns { labelStart, labelEnd } (byte offsets in src) if the decl has a label, + // else null. Works for both simple `key: Label` and block `key: Label { ... }`. + function getDeclLabelRange(targetObj, ast, src) { + const declRef = targetObj.references?.find(r => r.map_key_edge_index === -1); + if (!declRef?.key?.path?.length || !ast) return null; + const seg0 = declRef.key.path[0]; + const segByte = parseD2Range(Object.values(seg0)[0]?.range)?.start; + if (segByte == null) return null; + const declInfo = findMapKeyAt(ast.nodes, segByte); + if (!declInfo) return null; + const { r } = declInfo; + const lastSeg = declRef.key.path[declRef.key.path.length - 1]; + const lastSegR = parseD2Range(Object.values(lastSeg)[0]?.range); + if (!lastSegR) return null; + const afterKey = src.slice(lastSegR.end, r.end); + const colonIdx = afterKey.indexOf(':'); + if (colonIdx < 0) return null; + const braceIdx = afterKey.indexOf('{'); + const newlineIdx = afterKey.indexOf('\n'); + if ((braceIdx >= 0 && colonIdx > braceIdx) || (newlineIdx >= 0 && colonIdx > newlineIdx)) return null; + // Label text is everything after ':' up to the first '{' or newline + const afterColon = afterKey.slice(colonIdx + 1); + const braceOrNl = Math.min( + afterColon.indexOf('{') < 0 ? Infinity : afterColon.indexOf('{'), + afterColon.indexOf('\n') < 0 ? Infinity : afterColon.indexOf('\n'), + ); + const rawLabel = braceOrNl === Infinity ? afterColon : afterColon.slice(0, braceOrNl); + const trimmed = rawLabel.trim(); + if (!trimmed) return null; + const labelTextStart = lastSegR.end + colonIdx + 1 + afterColon.indexOf(trimmed); + return { labelStart: labelTextStart, labelEnd: labelTextStart + trimmed.length }; + } + + function renameNode(nodeKey, newLeafName) { + newLeafName = newLeafName.trim(); + if (!newLeafName) return; + const targetObj = findNodeTargetObj(nodeKey); + if (!targetObj) { showToast('Node not found'); return; } + + const src = editor.value; + const ast = lastCompiled?.graph?.ast; + + // If the declaration has a label (e.g. `api: API Server`), update the label only + const labelRange = getDeclLabelRange(targetObj, ast, src); + if (labelRange) { + pushUndo(); + const newSrc = src.slice(0, labelRange.labelStart) + newLeafName + src.slice(labelRange.labelEnd); + applyEditedSource(newSrc); + resetInteraction(); + showToast('Renamed to ' + newLeafName); + return; + } + + // No label — rename the key identifier in all references + const leaf = nodeKey.includes('.') ? nodeKey.slice(nodeKey.lastIndexOf('.') + 1) : nodeKey; + const edits = []; + const processedBytes = new Set(); + + for (const ref of (targetObj.references || [])) { + const segs = ref.key?.path; + if (!segs?.length) continue; + const kpi = Math.min(ref.key_path_index ?? 0, segs.length - 1); + const seg = segs[kpi]; + if (!seg || segVal(seg) !== leaf) continue; + const segR = parseD2Range(Object.values(seg)[0]?.range); + if (!segR || processedBytes.has(segR.start)) continue; + processedBytes.add(segR.start); + edits.push({ start: segR.start, end: segR.end, replacement: newLeafName }); + } + + if (!edits.length) { showToast('No references found to rename'); return; } + edits.sort((a, b) => b.start - a.start); + pushUndo(); + let newSrc = src; + for (const { start, end, replacement } of edits) { + newSrc = newSrc.slice(0, start) + replacement + newSrc.slice(end); + } + applyEditedSource(newSrc); + resetInteraction(); + showToast('Renamed to ' + newLeafName); + } + + function editEdgeLabel(edgeKey, newLabel) { + const ast = lastCompiled?.graph?.ast; + if (!ast) { showToast('AST unavailable'); return; } + newLabel = newLabel.trim(); + + // Walk AST to find the edge (same as deleteEdge but also returns edge info) + function walkForEdgeFull(nodes, scope) { + const counts = new Map(); + for (const node of (nodes || [])) { + const mk = node.map_key; + if (!mk) continue; + const mkR = parseD2Range(mk.range); + if (mk.edges?.length) { + for (let ei = 0; ei < mk.edges.length; ei++) { + const edge = mk.edges[ei]; + const srcParts = edge.src?.path?.map(s => segVal(s)).filter(Boolean) ?? []; + const dstParts = edge.dst?.path?.map(s => segVal(s)).filter(Boolean) ?? []; + const fqSrc = [scope, ...srcParts].filter(Boolean).join('.'); + const fqDst = [scope, ...dstParts].filter(Boolean).join('.'); + const pair = `${fqSrc}->${fqDst}`; + const n = counts.get(pair) ?? 0; + counts.set(pair, n + 1); + if (`(${fqSrc} -> ${fqDst})[${n}]` === edgeKey) return { mkR, edge }; + } + } + if (mk.value?.map?.nodes) { + const keyParts = mk.key?.path?.map(s => segVal(s)).filter(Boolean) ?? []; + const childScope = [scope, ...keyParts].filter(Boolean).join('.'); + const found = walkForEdgeFull(mk.value.map.nodes, childScope); + if (found) return found; + } + } + return null; + } + + const found = walkForEdgeFull(ast.nodes, ''); + if (!found) { showToast('Edge not found in source'); return; } + const { mkR, edge } = found; + + const src = editor.value; + const dstPath = edge.dst?.path ?? []; + const lastDstSeg = dstPath[dstPath.length - 1]; + const dstLastR = lastDstSeg ? parseD2Range(Object.values(lastDstSeg)[0]?.range) : null; + if (!dstLastR) { showToast('Could not locate edge endpoints'); return; } + + // Find ':' between dstLastR.end and mkR.end (the label separator, if present) + let colonPos = -1; + for (let i = dstLastR.end; i < mkR.end; i++) { + const ch = src[i]; + if (ch === ':') { colonPos = i; break; } + if (ch !== ' ' && ch !== '\t') break; + } + + let newEdgeText; + if (colonPos >= 0) { + if (newLabel) { + newEdgeText = src.slice(mkR.start, colonPos) + ': ' + newLabel; + } else { + newEdgeText = src.slice(mkR.start, colonPos).trimEnd(); + } + } else { + newEdgeText = newLabel + ? src.slice(mkR.start, mkR.end) + ': ' + newLabel + : src.slice(mkR.start, mkR.end); + } + + pushUndo(); + applyEditedSource(src.slice(0, mkR.start) + newEdgeText + src.slice(mkR.end)); + resetInteraction(); + showToast(newLabel ? 'Label set: ' + newLabel : 'Label removed'); + } + + function addNode(name, shape) { + name = name.trim(); + if (!name) return; + pushUndo(); + const decl = (shape && shape !== 'rectangle') + ? `${name} {\n shape: ${shape}\n}` + : name; + applyEditedSource(editor.value.replace(/\n*$/, '') + '\n' + decl + '\n'); + showToast('Added node: ' + name); + } + + function changeNodeShape(nodeKey, shape) { + if (shape === 'image') { + showImageIconPopup(nodeKey, selectedEl); + return; + } + const targetObj = findNodeTargetObj(nodeKey); + if (!targetObj) { showToast('Node not found'); return; } + const ast = lastCompiled?.graph?.ast; + if (!ast) { showToast('AST unavailable'); return; } + + const declRef = targetObj.references?.find(r => r.map_key_edge_index === -1); + if (!declRef?.key?.path?.length) { showToast('Declaration not found'); return; } + + const segByte = parseD2Range(Object.values(declRef.key.path[0])[0]?.range)?.start; + if (segByte == null) { showToast('Declaration range not found'); return; } + const declInfo = findMapKeyAt(ast.nodes, segByte); + if (!declInfo) { showToast('Could not locate declaration in AST'); return; } + + const src = editor.value; + const { r } = declInfo; + const declText = src.slice(r.start, r.end); + + let newDeclText; + if (declText.includes('{')) { + if (/\bshape:\s*[^\s}]+/.test(declText)) { + newDeclText = declText.replace(/\bshape:\s*[^\s}]+/, `shape: ${shape}`); + } else { + const braceIdx = declText.indexOf('{'); + newDeclText = declText.slice(0, braceIdx + 1) + '\n shape: ' + shape + declText.slice(braceIdx + 1); + } + } else { + if (shape === 'rectangle') { resetInteraction(); return; } + newDeclText = declText.trimEnd() + ' {\n shape: ' + shape + '\n}'; + } + + if (newDeclText === declText) { resetInteraction(); return; } + pushUndo(); + applyEditedSource(src.slice(0, r.start) + newDeclText + src.slice(r.end)); + resetInteraction(); + showToast('Shape: ' + shape); + } + // ── Tap-to-act interaction ────────────────────────────────────────────────── // // Tap a shape → a small menu offers actions (Connect / Move). Pick one, then @@ -2676,13 +3184,24 @@ .forEach(g => g.classList.remove('d2-selected', 'd2-action-src')); clearHover(); hideLabelPopup(); + if (typeof hideRenamePopup === 'function') hideRenamePopup(); + if (typeof hideEdgeLabelPopup === 'function') hideEdgeLabelPopup(); + if (typeof hideShapePickerPopup === 'function') hideShapePickerPopup(); + if (typeof hideImageIconPopup === 'function') hideImageIconPopup(); } - function openMenu(g) { + function openMenu(g, kind) { resetInteraction(); selectedEl = g; g.classList.add('d2-selected'); actionLabel.textContent = g.dataset.d2key; + // Show buttons appropriate for the clicked element kind + actionMenu.querySelectorAll('.d2-act-shape').forEach(b => { b.style.display = kind === 'conn' ? 'none' : ''; }); + actionMenu.querySelectorAll('.d2-act-conn').forEach(b => { b.style.display = kind === 'shape' ? 'none' : ''; }); + if (kind === 'conn') { + const editLabelBtn = actionMenu.querySelector('[data-act="edit-label"]'); + if (editLabelBtn) editLabelBtn.textContent = getEdgeCurrentLabel(g.dataset.d2key) ? 'Rename' : 'Name'; + } // Position the fixed menu just below the node, clamped to the viewport. const r = g.getBoundingClientRect(); actionMenu.classList.add('show'); // show first so we can measure it @@ -2714,8 +3233,9 @@ if (type === 'connect') { if (!targetG || targetG === srcEl) return; // need a different node; keep waiting + const srcElRef = srcEl; // capture before resetInteraction clears selectedEl resetInteraction(); - showLabelPopup(targetG, srcKey, targetKey); + showLabelPopup(srcElRef, targetG, srcKey, targetKey); return; } @@ -2734,8 +3254,16 @@ const btn = e.target.closest('button'); if (!btn) return; const act = btn.dataset.act; - if (act === 'cancel') resetInteraction(); - else beginAction(act); + const key = selectedEl?.dataset.d2key; + if (act === 'cancel') { resetInteraction(); } + else if (act === 'connect' || act === 'move') { beginAction(act); } + else if (act === 'rename') { showRenamePopup(selectedEl); } + else if (act === 'shape') { showShapePickerPopup(selectedEl); } + else if (act === 'delete') { + if (selectedEl?.dataset.d2kind === 'conn') deleteEdge(key); + else deleteNode(key); + } + else if (act === 'edit-label') { showEdgeLabelPopup(selectedEl); } }); document.getElementById('d2-act-cancel').addEventListener('click', resetInteraction); @@ -2745,27 +3273,17 @@ if (pendingAction) { completeAction(g); } else if (g && g.dataset.d2kind === 'shape') { - openMenu(g); - } else if (g) { - showElementKey(g); // edges: just report the key + openMenu(g, 'shape'); + } else if (g && g.dataset.d2kind === 'conn') { + openMenu(g, 'conn'); } else { - resetInteraction(); // tapped empty space with menu open → dismiss + resetInteraction(); // tapped empty space or no menu open → dismiss } }); // Reposition / dismiss the menu if the diagram re-renders or layout shifts. previewArea.addEventListener('scroll', () => { if (!pendingAction) resetInteraction(); }); - // showElementKey reports a key without opening the menu (used for edges). - function showElementKey(g) { - clearHover(); - g.classList.add('d2-hover'); - const isConn = g.dataset.d2kind === 'conn'; - keyReadout.textContent = (isConn ? 'edge: ' : 'node: ') + g.dataset.d2key; - keyReadout.classList.add('show'); - showToast((isConn ? 'Edge: ' : 'Node: ') + g.dataset.d2key); - } - // ─── Export helpers ─────────────────────────────────────────────────────── function download(content, filename, type) { @@ -2883,16 +3401,19 @@ const labelInput = document.getElementById('d2-label-input'); let pendingConnKeys = null; - function showLabelPopup(targetG, srcKey, dstKey) { + function showLabelPopup(srcG, targetG, srcKey, dstKey) { pendingConnKeys = { srcKey, dstKey }; labelInput.value = ''; labelPopup.classList.add('show'); const pw = labelPopup.offsetWidth, ph = labelPopup.offsetHeight; - const r = targetG.getBoundingClientRect(); - let left = r.left; - let top = r.bottom + 6; + const rSrc = srcG.getBoundingClientRect(); + const rDst = targetG.getBoundingClientRect(); + const midX = (rSrc.left + rSrc.right + rDst.left + rDst.right) / 4; + const midY = (rSrc.top + rSrc.bottom + rDst.top + rDst.bottom) / 4; + let left = midX - pw / 2; + let top = midY - ph / 2; left = Math.max(6, Math.min(left, window.innerWidth - pw - 6)); - if (top + ph > window.innerHeight - 6) top = Math.max(6, r.top - ph - 6); + top = Math.max(6, Math.min(top, window.innerHeight - ph - 6)); labelPopup.style.left = left + 'px'; labelPopup.style.top = top + 'px'; labelInput.focus(); @@ -2918,6 +3439,363 @@ if (e.key === 'Escape') hideLabelPopup(); }); + // ─── Rename popup ───────────────────────────────────────────────────────── + const renamePopup = document.getElementById('d2-rename-popup'); + const renameInput = document.getElementById('d2-rename-input'); + let pendingRenameKey = null; + + function showRenamePopup(g) { + if (!g) return; + pendingRenameKey = g.dataset.d2key; + const leaf = pendingRenameKey.includes('.') + ? pendingRenameKey.slice(pendingRenameKey.lastIndexOf('.') + 1) + : pendingRenameKey; + // Pre-fill with label text if one exists, otherwise with the key identifier + const targetObj = findNodeTargetObj(pendingRenameKey); + const ast = lastCompiled?.graph?.ast; + const src = editor.value; + const labelRange = targetObj ? getDeclLabelRange(targetObj, ast, src) : null; + renameInput.value = labelRange ? src.slice(labelRange.labelStart, labelRange.labelEnd) : leaf; + renamePopup.classList.add('show'); + const pw = renamePopup.offsetWidth, ph = renamePopup.offsetHeight; + const r = g.getBoundingClientRect(); + let left = r.left; + let top = r.bottom + 6; + left = Math.max(6, Math.min(left, window.innerWidth - pw - 6)); + if (top + ph > window.innerHeight - 6) top = Math.max(6, r.top - ph - 6); + renamePopup.style.left = left + 'px'; + renamePopup.style.top = top + 'px'; + renameInput.focus(); + renameInput.select(); + } + + function hideRenamePopup() { + renamePopup.classList.remove('show'); + pendingRenameKey = null; + } + + function confirmRename() { + if (pendingRenameKey) renameNode(pendingRenameKey, renameInput.value); + hideRenamePopup(); + } + + document.getElementById('d2-rename-confirm').addEventListener('click', confirmRename); + renameInput.addEventListener('keydown', e => { + if (e.key === 'Enter') confirmRename(); + if (e.key === 'Escape') { hideRenamePopup(); resetInteraction(); } + }); + + // ─── Edge label popup ───────────────────────────────────────────────────── + const edgeLabelPopup = document.getElementById('d2-edge-label-popup'); + const edgeLabelInput = document.getElementById('d2-edge-label-input'); + let pendingEdgeLabelKey = null; + + function getEdgeCurrentLabel(edgeKey) { + const ast = lastCompiled?.graph?.ast; + if (!ast) return ''; + function walkForEdgeFull(nodes, scope) { + const counts = new Map(); + for (const node of (nodes || [])) { + const mk = node.map_key; + if (!mk) continue; + const mkR = parseD2Range(mk.range); + if (mk.edges?.length) { + for (let ei = 0; ei < mk.edges.length; ei++) { + const edge = mk.edges[ei]; + const srcParts = edge.src?.path?.map(s => segVal(s)).filter(Boolean) ?? []; + const dstParts = edge.dst?.path?.map(s => segVal(s)).filter(Boolean) ?? []; + const fqSrc = [scope, ...srcParts].filter(Boolean).join('.'); + const fqDst = [scope, ...dstParts].filter(Boolean).join('.'); + const pair = `${fqSrc}->${fqDst}`; + const n = counts.get(pair) ?? 0; + counts.set(pair, n + 1); + if (`(${fqSrc} -> ${fqDst})[${n}]` === edgeKey) return { mkR, edge }; + } + } + if (mk.value?.map?.nodes) { + const keyParts = mk.key?.path?.map(s => segVal(s)).filter(Boolean) ?? []; + const childScope = [scope, ...keyParts].filter(Boolean).join('.'); + const found = walkForEdgeFull(mk.value.map.nodes, childScope); + if (found) return found; + } + } + return null; + } + const found = walkForEdgeFull(ast.nodes, ''); + if (!found) return ''; + const { mkR, edge } = found; + const src = editor.value; + const dstPath = edge.dst?.path ?? []; + const lastDstSeg = dstPath[dstPath.length - 1]; + const dstLastR = lastDstSeg ? parseD2Range(Object.values(lastDstSeg)[0]?.range) : null; + if (!dstLastR) return ''; + let colonPos = -1; + for (let i = dstLastR.end; i < mkR.end; i++) { + const ch = src[i]; + if (ch === ':') { colonPos = i; break; } + if (ch !== ' ' && ch !== '\t') break; + } + if (colonPos < 0) return ''; + return src.slice(colonPos + 1, mkR.end).trim(); + } + + function showEdgeLabelPopup(g) { + if (!g) return; + pendingEdgeLabelKey = g.dataset.d2key; + edgeLabelInput.value = getEdgeCurrentLabel(g.dataset.d2key); + edgeLabelPopup.classList.add('show'); + const pw = edgeLabelPopup.offsetWidth, ph = edgeLabelPopup.offsetHeight; + const r = g.getBoundingClientRect(); + let left = r.left; + let top = r.bottom + 6; + left = Math.max(6, Math.min(left, window.innerWidth - pw - 6)); + if (top + ph > window.innerHeight - 6) top = Math.max(6, r.top - ph - 6); + edgeLabelPopup.style.left = left + 'px'; + edgeLabelPopup.style.top = top + 'px'; + edgeLabelInput.focus(); + } + + function hideEdgeLabelPopup() { + edgeLabelPopup.classList.remove('show'); + pendingEdgeLabelKey = null; + } + + function confirmEdgeLabel() { + if (pendingEdgeLabelKey) editEdgeLabel(pendingEdgeLabelKey, edgeLabelInput.value); + hideEdgeLabelPopup(); + } + + document.getElementById('d2-edge-label-confirm').addEventListener('click', confirmEdgeLabel); + edgeLabelInput.addEventListener('keydown', e => { + if (e.key === 'Enter') confirmEdgeLabel(); + if (e.key === 'Escape') { hideEdgeLabelPopup(); resetInteraction(); } + }); + + // ─── Image icon URL popup ───────────────────────────────────────────────── + const imageIconPopup = document.getElementById('d2-image-icon-popup'); + const imageIconInput = document.getElementById('d2-image-icon-input'); + let pendingImageIconKey = null; + let pendingImageIconSrcEl = null; + + function showImageIconPopup(nodeKey, srcEl) { + pendingImageIconKey = nodeKey; + pendingImageIconSrcEl = srcEl; + imageIconInput.value = ''; + imageIconPopup.classList.add('show'); + const anchor = srcEl || actionMenu; + const pw = imageIconPopup.offsetWidth, ph = imageIconPopup.offsetHeight; + const r = anchor.getBoundingClientRect(); + let left = r.left, top = r.bottom + 6; + left = Math.max(6, Math.min(left, window.innerWidth - pw - 6)); + if (top + ph > window.innerHeight - 6) top = Math.max(6, r.top - ph - 6); + imageIconPopup.style.left = left + 'px'; + imageIconPopup.style.top = top + 'px'; + imageIconInput.focus(); + } + + function hideImageIconPopup() { + imageIconPopup.classList.remove('show'); + pendingImageIconKey = null; + } + + function confirmImageIcon() { + const url = imageIconInput.value.trim(); + if (url && pendingImageIconKey) applyImageShape(pendingImageIconKey, url); + hideImageIconPopup(); + resetInteraction(); + } + + function applyImageShape(nodeKey, iconUrl) { + const targetObj = findNodeTargetObj(nodeKey); + if (!targetObj) { showToast('Node not found'); return; } + const ast = lastCompiled?.graph?.ast; + if (!ast) { showToast('AST unavailable'); return; } + const declRef = targetObj.references?.find(r => r.map_key_edge_index === -1); + if (!declRef?.key?.path?.length) { showToast('Declaration not found'); return; } + const segByte = parseD2Range(Object.values(declRef.key.path[0])[0]?.range)?.start; + if (segByte == null) return; + const declInfo = findMapKeyAt(ast.nodes, segByte); + if (!declInfo) return; + const src = editor.value; + const { r } = declInfo; + const declText = src.slice(r.start, r.end); + let newDeclText; + if (declText.includes('{')) { + // Block exists: replace or add shape + icon + let t = declText.replace(/\bshape:\s*[^\s}]+/, `shape: image`); + if (t === declText) t = t.replace('{', '{\n shape: image'); + if (/\bicon:\s*\S+/.test(t)) { + t = t.replace(/\bicon:\s*\S+/, `icon: ${iconUrl}`); + } else { + t = t.replace(/\bshape:\s*image/, `shape: image\n icon: ${iconUrl}`); + } + newDeclText = t; + } else { + newDeclText = declText.trimEnd() + ` {\n shape: image\n icon: ${iconUrl}\n}`; + } + pushUndo(); + applyEditedSource(src.slice(0, r.start) + newDeclText + src.slice(r.end)); + showToast('Shape: image'); + } + + document.getElementById('d2-image-icon-confirm').addEventListener('click', confirmImageIcon); + imageIconInput.addEventListener('keydown', e => { + if (e.key === 'Enter') confirmImageIcon(); + if (e.key === 'Escape') { hideImageIconPopup(); resetInteraction(); } + }); + + // ─── Shape picker & Add node popup ──────────────────────────────────────── + const shapePickerEl = document.getElementById('d2-shape-picker'); + const shapePickerGrid = document.getElementById('d2-shape-picker-grid'); + const addPopupEl = document.getElementById('d2-add-popup'); + const addInputEl = document.getElementById('d2-add-input'); + const addShapeGrid = document.getElementById('d2-add-shape-grid'); + let addSelectedShape = 'rectangle'; + let shapePickerKey = null; + + // Placeholder for the 'image' shape (requires an icon URL, can't be previewed standalone) + const IMAGE_PLACEHOLDER_SVG = `image`; + + function buildShapeGrid(container, selectedShape, onSelect) { + container.innerHTML = ''; + for (const shape of D2_SHAPES) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'd2-shape-btn' + (shape === selectedShape ? ' d2-shape-active' : ''); + btn.dataset.shape = shape; + btn.title = shape.replace(/_/g, ' '); + const svg = SHAPE_PREVIEW_SVGS?.[shape] ?? (shape === 'image' ? IMAGE_PLACEHOLDER_SVG : null); + btn.innerHTML = svg ?? `
${shape.replace(/_/g, ' ')}
`; + // D2 SVGs are nested: outer contains an inner + // where -101 is D2's built-in padding. + // The inner SVG fills 100%×100% of its parent, so only its viewBox + // controls what's visible. Crop it to the content area with padding. + const outerSvg = btn.querySelector('svg'); + if (outerSvg) outerSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + const innerSvg = outerSvg?.querySelector('svg'); + if (innerSvg) { + const vb = innerSvg.getAttribute('viewBox'); + const parts = vb ? vb.trim().split(/\s+/).map(Number) : []; + if (parts.length === 4) { + const [x0, y0, W, H] = parts; + if (x0 < 0 && y0 < 0) { + const pad = 20; + const nw = W + 2 * x0 + 2 * pad; + const nh = H + 2 * y0 + 2 * pad; + if (nw > 0 && nh > 0) { + innerSvg.setAttribute('viewBox', `${-pad} ${-pad} ${nw} ${nh}`); + innerSvg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + } + } + } + } + btn.addEventListener('click', () => { + container.querySelectorAll('.d2-shape-btn').forEach(b => b.classList.remove('d2-shape-active')); + btn.classList.add('d2-shape-active'); + onSelect(shape); + }); + container.appendChild(btn); + } + } + + function positionPopupNearEl(popup, refEl) { + if (window.innerWidth <= 600) return; // full-screen on mobile via CSS + popup.style.left = '0'; popup.style.top = '0'; // reset before measuring + const pw = popup.offsetWidth, ph = popup.offsetHeight; + const r = refEl.getBoundingClientRect(); + let left = r.left; + let top = r.bottom + 6; + left = Math.max(6, Math.min(left, window.innerWidth - pw - 6)); + if (top + ph > window.innerHeight - 6) top = Math.max(6, r.top - ph - 6); + popup.style.left = left + 'px'; + popup.style.top = top + 'px'; + } + + function getCurrentShape(nodeKey) { + const targetObj = findNodeTargetObj(nodeKey); + if (!targetObj) return 'rectangle'; + const ast = lastCompiled?.graph?.ast; + if (!ast) return 'rectangle'; + const declRef = targetObj.references?.find(r => r.map_key_edge_index === -1); + if (!declRef?.key?.path?.length) return 'rectangle'; + const segByte = parseD2Range(Object.values(declRef.key.path[0])[0]?.range)?.start; + if (segByte == null) return 'rectangle'; + const declInfo = findMapKeyAt(ast.nodes, segByte); + if (!declInfo) return 'rectangle'; + const declText = editor.value.slice(declInfo.r.start, declInfo.r.end); + const m = declText.match(/\bshape:\s*([^\s}]+)/); + return m ? m[1] : 'rectangle'; + } + + function showShapePickerPopup(g) { + if (!g) return; + shapePickerKey = g.dataset.d2key; + shapePickerEl.classList.add('show'); + buildShapeGrid(shapePickerGrid, getCurrentShape(shapePickerKey), shape => { + changeNodeShape(shapePickerKey, shape); + shapePickerEl.classList.remove('show'); + shapePickerKey = null; + }); + positionPopupNearEl(shapePickerEl, g); + } + + function hideShapePickerPopup() { + shapePickerEl.classList.remove('show'); + shapePickerKey = null; + } + + // Close shape picker on outside click or close button + document.addEventListener('click', e => { + if (!shapePickerEl.classList.contains('show')) return; + if (!shapePickerEl.contains(e.target) && !actionMenu.contains(e.target)) { + hideShapePickerPopup(); + } + }, true); + document.getElementById('d2-shape-picker-close').addEventListener('click', hideShapePickerPopup); + + // ─── Add node popup ─────────────────────────────────────────────────────── + function showAddPopup() { + addInputEl.value = ''; + addSelectedShape = 'rectangle'; + addPopupEl.classList.add('show'); + + const btnAddNode = document.getElementById('btn-add-node'); + buildShapeGrid(addShapeGrid, addSelectedShape, shape => { addSelectedShape = shape; }); + positionPopupNearEl(addPopupEl, btnAddNode); + + addInputEl.focus(); + } + + function hideAddPopup() { addPopupEl.classList.remove('show'); } + + function confirmAddNode() { + const name = addInputEl.value.trim(); + if (name) addNode(name, addSelectedShape); + hideAddPopup(); + } + + document.getElementById('btn-add-node').addEventListener('click', e => { + e.stopPropagation(); + if (addPopupEl.classList.contains('show')) { hideAddPopup(); return; } + showAddPopup(); + }); + + document.getElementById('d2-add-confirm').addEventListener('click', confirmAddNode); + document.getElementById('d2-add-cancel').addEventListener('click', hideAddPopup); + addInputEl.addEventListener('keydown', e => { + if (e.key === 'Enter') confirmAddNode(); + if (e.key === 'Escape') hideAddPopup(); + }); + + // Close add popup on outside click + document.addEventListener('click', e => { + if (!addPopupEl.classList.contains('show')) return; + const btnAddNode = document.getElementById('btn-add-node'); + if (!addPopupEl.contains(e.target) && e.target !== btnAddNode) { + hideAddPopup(); + } + }, true); + function applyReset(source, toastMsg) { closeResetModal(); pushUndo(); diff --git a/tools/d2-shape-previews.js b/tools/d2-shape-previews.js new file mode 100644 index 0000000..7c0c999 --- /dev/null +++ b/tools/d2-shape-previews.js @@ -0,0 +1,26 @@ +// Pre-rendered SVG previews for D2 shapes (generated via generate-shape-previews.mjs) +// Do not edit by hand — regenerate with: node tools/generate-shape-previews.mjs > tools/d2-shape-previews.js +export const SHAPE_PREVIEW_SVGS = { + callout: "callout\n\n\n", + circle: "circle\n\n\n", + class: "ClassName+fieldtype+method()void\n\n\n", + cloud: "cloud\n\n\n", + code: "codecode\n\n\n", + cylinder: "cylinder\n\n\n", + diamond: "diamond\n\n\n", + document: "document\n\n\n", + hexagon: "hexagon\n\n\n", + image: null, + oval: "oval\n\n\n", + package: "package\n\n\n", + page: "page\n\n\n", + parallelogram: "parallelogram\n\n\n", + person: "person\n\n\n", + queue: "queue\n\n\n", + rectangle: "rectangle\n\n\n", + sql_table: "sql_tableidintnamestring\n\n\n", + square: "square\n\n\n", + step: "step\n\n\n", + stored_data: "stored_data\n\n\n", + text: "text\n\n\n", +}; diff --git a/tools/generate-shape-previews.mjs b/tools/generate-shape-previews.mjs new file mode 100644 index 0000000..436119a --- /dev/null +++ b/tools/generate-shape-previews.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Regenerate tools/d2-shape-previews.js: +// node tools/generate-shape-previews.mjs > tools/d2-shape-previews.js +import { D2 } from '../node_modules/@terrastruct/d2/dist/node-esm/index.js'; + +const D2_SHAPES = [ + 'callout','circle','class','cloud','code', + 'cylinder','diamond','document','hexagon','image', + 'oval','package','page','parallelogram','person', + 'queue','rectangle','sql_table','square','step', + 'stored_data','text', +]; + +const SHAPE_SOURCES = { + class: 'ClassName {\n shape: class\n +field: type\n +method()\n}', + sql_table: 'sql_table {\n shape: sql_table\n id: int\n name: string\n}', +}; + +const d2 = new D2(); +await d2.ready; + +const svgs = {}; +for (const shape of D2_SHAPES) { + try { + const src = SHAPE_SOURCES[shape] ?? `${shape} { shape: ${shape} }`; + const compiled = await d2.compile(src); + let svg = await d2.render(compiled.diagram, compiled.renderOptions); + svg = svg.replace(/\s+width="[^"]*"/, '').replace(/\s+height="[^"]*"/, ''); + svgs[shape] = svg; + process.stderr.write(`✓ ${shape}\n`); + } catch (e) { + process.stderr.write(`✗ ${shape}: ${e.message}\n`); + svgs[shape] = null; + } +} + +const lines = [ + '// Pre-rendered SVG previews for D2 shapes (generated via generate-shape-previews.mjs)', + '// Do not edit by hand — regenerate with: node tools/generate-shape-previews.mjs > tools/d2-shape-previews.js', + 'export const SHAPE_PREVIEW_SVGS = {', +]; +for (const [shape, svg] of Object.entries(svgs)) { + lines.push(` ${shape}: ${svg ? JSON.stringify(svg) : 'null'},`); +} +lines.push('};'); +process.stdout.write(lines.join('\n') + '\n'); + +d2.worker?.terminate();