@@ -1349,13 +1440,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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 = `
`;
+
+ 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