@markdstage/markdstage 2.5.1 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,35 @@ const LABEL_LAYERS = [
15
15
  { value: "front", label: "In front of boxes" },
16
16
  { value: "behind", label: "Behind boxes" },
17
17
  ];
18
- const SHAPES = ["rect", "rounded-rect", "ellipse"];
18
+ const SHAPES = [
19
+ "rect",
20
+ "rounded-rect",
21
+ "ellipse",
22
+ "diamond",
23
+ "triangle",
24
+ "hexagon",
25
+ "parallelogram",
26
+ ];
27
+ const SHAPE_LABELS = new Map([
28
+ ["rect", "Rectangle"],
29
+ ["rounded-rect", "Rounded rectangle"],
30
+ ["ellipse", "Ellipse"],
31
+ ["diamond", "Diamond"],
32
+ ["triangle", "Triangle"],
33
+ ["hexagon", "Hexagon"],
34
+ ["parallelogram", "Parallelogram"],
35
+ ]);
36
+ const LINE_STYLES = [
37
+ { value: "solid", label: "Solid" },
38
+ { value: "dotted", label: "Dotted" },
39
+ { value: "dashed", label: "Dashed" },
40
+ { value: "custom", label: "Custom" },
41
+ ];
42
+ const LINE_STYLE_PATTERNS = {
43
+ solid: "",
44
+ dotted: "1 5",
45
+ dashed: "10 6",
46
+ };
19
47
  const IMAGE_FITS = ["contain", "cover", "stretch"];
20
48
  const ASSET_MAX_BYTES = 10 * 1024 * 1024;
21
49
  const SNAP_SIZE = 10;
@@ -28,6 +56,14 @@ const tree = document.getElementById("elementTree");
28
56
  const inspector = document.getElementById("inspector");
29
57
  const viewport = document.getElementById("viewport");
30
58
  const surface = document.getElementById("canvasSurface");
59
+ const elementPanel = document.getElementById("elementPanel");
60
+ const inspectorPanel = document.getElementById("inspectorPanel");
61
+ const elementsPanelButton = document.getElementById("elementsPanelButton");
62
+ const inspectorPanelButton = document.getElementById("inspectorPanelButton");
63
+ const shapePaletteButton = document.getElementById("shapePaletteButton");
64
+ const shapePalette = document.getElementById("shapePalette");
65
+ const toolbarMoreButton = document.getElementById("toolbarMoreButton");
66
+ const toolbarMoreMenu = document.getElementById("toolbarMoreMenu");
31
67
  const status = document.getElementById("status");
32
68
  const zoomStatus = document.getElementById("zoomStatus");
33
69
  const snapToggle = document.getElementById("snapToggle");
@@ -45,6 +81,8 @@ const assetPreviewPath = document.getElementById("assetPreviewPath");
45
81
  const assetDialogStatus = document.getElementById("assetDialogStatus");
46
82
  const assetCancelButton = document.getElementById("assetCancelButton");
47
83
  const assetChooseButton = document.getElementById("assetChooseButton");
84
+ const responsivePanels = window.matchMedia("(max-width: 1100px)");
85
+ const compactPanels = window.matchMedia("(max-width: 620px)");
48
86
 
49
87
  let architecture = null;
50
88
  let selectedRef = null;
@@ -52,6 +90,7 @@ let sourcePath = "";
52
90
  let blockIndex = 0;
53
91
  let dirty = false;
54
92
  let zoom = 1;
93
+ let fitToViewport = true;
55
94
  let draftRevision = 0;
56
95
  let targetGeneration = null;
57
96
  let draftQueue = Promise.resolve();
@@ -66,12 +105,153 @@ let availableAssets = [];
66
105
  let selectedAssetPath = "";
67
106
  let assetUploadPending = false;
68
107
  let assetLibraryRequest = 0;
108
+ let dragFrame = 0;
109
+ let responsivePanel = null;
110
+ let desktopElementsOpen = true;
111
+ let desktopInspectorOpen = false;
112
+ let suppressCanvasClick = false;
69
113
 
70
114
  function announce(message, kind = "info") {
71
115
  status.textContent = message;
72
116
  status.dataset.kind = kind;
73
117
  }
74
118
 
119
+ function syncResponsivePanels() {
120
+ const adaptive = responsivePanels.matches;
121
+ const elementsOpen = adaptive ? responsivePanel === "elements" : desktopElementsOpen;
122
+ const inspectorOpen = adaptive ? responsivePanel === "inspector" : desktopInspectorOpen;
123
+ document.body.dataset.elementsOpen = String(elementsOpen);
124
+ document.body.dataset.inspectorOpen = String(inspectorOpen);
125
+ elementsPanelButton.setAttribute("aria-expanded", String(elementsOpen));
126
+ inspectorPanelButton.setAttribute("aria-expanded", String(inspectorOpen));
127
+ elementPanel.inert = !elementsOpen;
128
+ inspectorPanel.inert = !inspectorOpen;
129
+ if (architecture && fitToViewport) requestAnimationFrame(fitZoom);
130
+ }
131
+
132
+ function toggleResponsivePanel(panel) {
133
+ let opened = false;
134
+ if (responsivePanels.matches) {
135
+ opened = responsivePanel !== panel;
136
+ responsivePanel = opened ? panel : null;
137
+ } else if (panel === "elements") {
138
+ desktopElementsOpen = !desktopElementsOpen;
139
+ opened = desktopElementsOpen;
140
+ } else {
141
+ desktopInspectorOpen = !desktopInspectorOpen;
142
+ opened = desktopInspectorOpen;
143
+ }
144
+ syncResponsivePanels();
145
+ if (opened) requestAnimationFrame(() => focusPanel(panel));
146
+ }
147
+
148
+ function focusPanel(panel) {
149
+ if (panel === "elements") {
150
+ (tree.querySelector('[aria-selected="true"]') || tree.querySelector(".tree-item"))?.focus();
151
+ return;
152
+ }
153
+ inspector.querySelector("input, select, textarea, button")?.focus();
154
+ }
155
+
156
+ function closeResponsivePanel(panel = responsivePanel, { restoreFocus = true } = {}) {
157
+ if (!panel) return;
158
+ if (responsivePanels.matches) {
159
+ if (responsivePanel !== panel) return;
160
+ responsivePanel = null;
161
+ } else if (panel === "elements") {
162
+ desktopElementsOpen = false;
163
+ } else {
164
+ desktopInspectorOpen = false;
165
+ }
166
+ syncResponsivePanels();
167
+ if (restoreFocus) {
168
+ (panel === "elements" ? elementsPanelButton : inspectorPanelButton).focus();
169
+ }
170
+ }
171
+
172
+ function positionShapePalette() {
173
+ if (shapePalette.hidden) return;
174
+ const trigger = shapePaletteButton.getBoundingClientRect();
175
+ const palette = shapePalette.getBoundingClientRect();
176
+ const left = Math.min(
177
+ Math.max(8, trigger.left),
178
+ Math.max(8, window.innerWidth - palette.width - 8),
179
+ );
180
+ const below = trigger.bottom + 6;
181
+ const top = below + palette.height <= window.innerHeight - 8
182
+ ? below
183
+ : Math.max(8, trigger.top - palette.height - 6);
184
+ shapePalette.style.left = `${left}px`;
185
+ shapePalette.style.top = `${top}px`;
186
+ }
187
+
188
+ function closeShapePalette({ restoreFocus = false } = {}) {
189
+ if (shapePalette.hidden) return;
190
+ shapePalette.hidden = true;
191
+ shapePaletteButton.setAttribute("aria-expanded", "false");
192
+ if (restoreFocus) shapePaletteButton.focus();
193
+ }
194
+
195
+ function openShapePalette() {
196
+ closeToolbarMore();
197
+ shapePalette.hidden = false;
198
+ shapePaletteButton.setAttribute("aria-expanded", "true");
199
+ positionShapePalette();
200
+ shapePalette.querySelector("button")?.focus();
201
+ }
202
+
203
+ function renderShapePalette() {
204
+ shapePalette.replaceChildren(
205
+ ...SHAPES.map((shape) => {
206
+ const button = document.createElement("button");
207
+ button.type = "button";
208
+ button.role = "menuitem";
209
+ button.dataset.shape = shape;
210
+ button.innerHTML = `<span class="shape-swatch" data-shape="${shape}" aria-hidden="true"></span><span>${SHAPE_LABELS.get(shape)}</span>`;
211
+ return button;
212
+ }),
213
+ );
214
+ }
215
+
216
+ function positionToolbarMoreMenu() {
217
+ if (toolbarMoreMenu.hidden) return;
218
+ const trigger = toolbarMoreButton.getBoundingClientRect();
219
+ const menu = toolbarMoreMenu.getBoundingClientRect();
220
+ const left = Math.min(
221
+ Math.max(8, trigger.right - menu.width),
222
+ Math.max(8, window.innerWidth - menu.width - 8),
223
+ );
224
+ const below = trigger.bottom + 6;
225
+ const top = below + menu.height <= window.innerHeight - 8
226
+ ? below
227
+ : Math.max(8, trigger.top - menu.height - 6);
228
+ toolbarMoreMenu.style.left = `${left}px`;
229
+ toolbarMoreMenu.style.top = `${top}px`;
230
+ }
231
+
232
+ function toolbarMoreItems() {
233
+ return [
234
+ ...toolbarMoreMenu.querySelectorAll(
235
+ 'button:not([disabled]), input:not([disabled])',
236
+ ),
237
+ ];
238
+ }
239
+
240
+ function closeToolbarMore({ restoreFocus = false } = {}) {
241
+ if (toolbarMoreMenu.hidden) return;
242
+ toolbarMoreMenu.hidden = true;
243
+ toolbarMoreButton.setAttribute("aria-expanded", "false");
244
+ if (restoreFocus) toolbarMoreButton.focus();
245
+ }
246
+
247
+ function openToolbarMore() {
248
+ closeShapePalette();
249
+ toolbarMoreMenu.hidden = false;
250
+ toolbarMoreButton.setAttribute("aria-expanded", "true");
251
+ positionToolbarMoreMenu();
252
+ toolbarMoreItems()[0]?.focus();
253
+ }
254
+
75
255
  function setDirty(value) {
76
256
  dirty = Boolean(value);
77
257
  dirtyBadge.hidden = !dirty;
@@ -815,6 +995,23 @@ function decorateDiagram(svg) {
815
995
  }
816
996
 
817
997
  function renderSurface() {
998
+ if (!architecture.model.elements.length) {
999
+ const empty = document.createElement("section");
1000
+ empty.className = "editor-empty-state";
1001
+ empty.setAttribute("aria-labelledby", "emptyStateTitle");
1002
+ empty.innerHTML = `
1003
+ <span class="empty-state-shape" aria-hidden="true"></span>
1004
+ <h2 id="emptyStateTitle">Build your first diagram</h2>
1005
+ <p>Add a shape, then connect elements to describe the architecture.</p>
1006
+ <button type="button" class="save-button">Add first shape</button>
1007
+ <small>Right-click the canvas to add at a specific location.</small>
1008
+ `;
1009
+ empty.querySelector("button").addEventListener("click", () => {
1010
+ invokeAction("add-node", { shape: "rounded-rect" });
1011
+ });
1012
+ surface.replaceChildren(empty);
1013
+ return;
1014
+ }
818
1015
  const wrapper = renderArchitectureBlock(architecture.source, document);
819
1016
  wrapper.style.width = `${960 * zoom}px`;
820
1017
  surface.replaceChildren(wrapper);
@@ -910,7 +1107,15 @@ function addInspectorAction(container, label, onClick) {
910
1107
  return button;
911
1108
  }
912
1109
 
913
- function addStyleFields(container) {
1110
+ function lineStyleForDash(value) {
1111
+ const dash = String(value || "").trim();
1112
+ if (!dash) return "solid";
1113
+ if (dash === LINE_STYLE_PATTERNS.dotted) return "dotted";
1114
+ if (dash === LINE_STYLE_PATTERNS.dashed) return "dashed";
1115
+ return "custom";
1116
+ }
1117
+
1118
+ function addStyleFields(container, { connector = false } = {}) {
914
1119
  for (const [label, path] of [
915
1120
  ["Fill", "style.fill"],
916
1121
  ["Stroke", "style.stroke"],
@@ -939,11 +1144,38 @@ function addStyleFields(container) {
939
1144
  step,
940
1145
  });
941
1146
  }
942
- addField(container, {
943
- label: "Dash pattern",
944
- path: "style.dash",
945
- value: readValue("style.dash"),
946
- });
1147
+ if (connector) {
1148
+ const currentDash = readValue("style.dash");
1149
+ const currentLineStyle = lineStyleForDash(currentDash);
1150
+ addField(container, {
1151
+ label: "Line style",
1152
+ path: "style.dash-preset",
1153
+ value: currentLineStyle,
1154
+ options: LINE_STYLES,
1155
+ onChange: (value) => {
1156
+ if (value === "custom") {
1157
+ applyResult(architecture.setElement(selectedRef, "style.dash", "6 3"));
1158
+ return;
1159
+ }
1160
+ applyResult(
1161
+ architecture.setElement(selectedRef, "style.dash", LINE_STYLE_PATTERNS[value]),
1162
+ );
1163
+ },
1164
+ });
1165
+ if (currentLineStyle === "custom") {
1166
+ addField(container, {
1167
+ label: "Dash pattern",
1168
+ path: "style.dash",
1169
+ value: currentDash,
1170
+ });
1171
+ }
1172
+ } else {
1173
+ addField(container, {
1174
+ label: "Dash pattern",
1175
+ path: "style.dash",
1176
+ value: readValue("style.dash"),
1177
+ });
1178
+ }
947
1179
  }
948
1180
 
949
1181
  function renderRootInspector() {
@@ -1242,7 +1474,7 @@ function renderInspector() {
1242
1474
  });
1243
1475
 
1244
1476
  const style = section("Style");
1245
- addStyleFields(style);
1477
+ addStyleFields(style, { connector: entry.element.type === "connector" });
1246
1478
  }
1247
1479
 
1248
1480
  function refreshToolbar() {
@@ -1304,10 +1536,14 @@ function beginMove(event) {
1304
1536
  event.preventDefault();
1305
1537
  event.stopPropagation();
1306
1538
  event.currentTarget.setPointerCapture?.(event.pointerId);
1539
+ event.currentTarget.classList.add("editor-drag-target");
1540
+ viewport.classList.add("is-dragging");
1307
1541
  drag = {
1308
1542
  kind: "move",
1309
1543
  ref,
1310
1544
  target: event.currentTarget,
1545
+ captureTarget: event.currentTarget,
1546
+ baseTransform: event.currentTarget.getAttribute("transform"),
1311
1547
  pointerId: event.pointerId,
1312
1548
  startX: event.clientX,
1313
1549
  startY: event.clientY,
@@ -1322,6 +1558,13 @@ function beginResize(event) {
1322
1558
  const ref = event.currentTarget.dataset.ref;
1323
1559
  const element = modelFor(ref);
1324
1560
  if (!element) return;
1561
+ event.currentTarget.setPointerCapture?.(event.pointerId);
1562
+ const targets = [
1563
+ surface.querySelector(`[data-editor-ref="${CSS.escape(ref)}"]`),
1564
+ ...surface.querySelectorAll(`.editor-resize-handle[data-ref="${CSS.escape(ref)}"]`),
1565
+ ].filter(Boolean);
1566
+ targets[0]?.classList.add("editor-drag-target");
1567
+ viewport.classList.add("is-dragging");
1325
1568
  drag = {
1326
1569
  kind: "resize",
1327
1570
  ref,
@@ -1330,51 +1573,117 @@ function beginResize(event) {
1330
1573
  startX: event.clientX,
1331
1574
  startY: event.clientY,
1332
1575
  box: { x: element.x, y: element.y, width: element.width, height: element.height },
1576
+ targets: targets.map((target) => ({
1577
+ target,
1578
+ baseTransform: target.getAttribute("transform"),
1579
+ })),
1580
+ captureTarget: event.currentTarget,
1333
1581
  dx: 0,
1334
1582
  dy: 0,
1335
1583
  };
1336
1584
  }
1337
1585
 
1586
+ function resizedBox(pending, shouldSnap) {
1587
+ let { x, y, width, height } = pending.box;
1588
+ const right = x + width;
1589
+ const bottom = y + height;
1590
+ const normalize = shouldSnap ? snap : (value) => value;
1591
+ const nextX = normalize(x + pending.dx);
1592
+ const nextY = normalize(y + pending.dy);
1593
+ const nextRight = normalize(right + pending.dx);
1594
+ const nextBottom = normalize(bottom + pending.dy);
1595
+ if (pending.corner.includes("w")) {
1596
+ x = Math.min(nextX, right - 20);
1597
+ width = right - x;
1598
+ }
1599
+ if (pending.corner.includes("e")) width = Math.max(20, nextRight - x);
1600
+ if (pending.corner.includes("n")) {
1601
+ y = Math.min(nextY, bottom - 20);
1602
+ height = bottom - y;
1603
+ }
1604
+ if (pending.corner.includes("s")) height = Math.max(20, nextBottom - y);
1605
+ return { x, y, width, height };
1606
+ }
1607
+
1608
+ function restoreTransform(target, transform) {
1609
+ if (transform == null) target.removeAttribute("transform");
1610
+ else target.setAttribute("transform", transform);
1611
+ }
1612
+
1613
+ function renderDragPreview() {
1614
+ dragFrame = 0;
1615
+ if (!drag) return;
1616
+ if (drag.kind === "move") {
1617
+ const translation = `translate(${drag.dx} ${drag.dy})`;
1618
+ drag.target.setAttribute(
1619
+ "transform",
1620
+ drag.baseTransform ? `${drag.baseTransform} ${translation}` : translation,
1621
+ );
1622
+ return;
1623
+ }
1624
+ const next = resizedBox(drag, false);
1625
+ const sx = next.width / drag.box.width;
1626
+ const sy = next.height / drag.box.height;
1627
+ const tx = next.x - drag.box.x * sx;
1628
+ const ty = next.y - drag.box.y * sy;
1629
+ const transform = `matrix(${sx} 0 0 ${sy} ${tx} ${ty})`;
1630
+ for (const item of drag.targets) {
1631
+ item.target.setAttribute(
1632
+ "transform",
1633
+ item.baseTransform ? `${item.baseTransform} ${transform}` : transform,
1634
+ );
1635
+ }
1636
+ }
1637
+
1338
1638
  function updateDrag(event) {
1339
1639
  if (!drag || event.pointerId !== drag.pointerId) return;
1340
1640
  const svg = surface.querySelector("svg");
1341
1641
  const scale = viewBoxScale(svg);
1342
1642
  drag.dx = (event.clientX - drag.startX) / scale.x;
1343
1643
  drag.dy = (event.clientY - drag.startY) / scale.y;
1344
- if (drag.kind === "move") {
1345
- drag.target.setAttribute("transform", `translate(${drag.dx} ${drag.dy})`);
1346
- }
1644
+ if (!dragFrame) dragFrame = requestAnimationFrame(renderDragPreview);
1645
+ }
1646
+
1647
+ function settleElement(ref) {
1648
+ const target = surface.querySelector(`[data-editor-ref="${CSS.escape(ref)}"]`);
1649
+ if (!target || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
1650
+ target.classList.add("editor-settle");
1651
+ target.addEventListener("animationend", () => target.classList.remove("editor-settle"), {
1652
+ once: true,
1653
+ });
1347
1654
  }
1348
1655
 
1349
1656
  function finishDrag(event) {
1350
1657
  if (!drag || event.pointerId !== drag.pointerId) return;
1351
1658
  const pending = drag;
1352
1659
  drag = null;
1660
+ if (dragFrame) {
1661
+ cancelAnimationFrame(dragFrame);
1662
+ dragFrame = 0;
1663
+ }
1664
+ viewport.classList.remove("is-dragging");
1665
+ if (pending.captureTarget?.hasPointerCapture?.(pending.pointerId)) {
1666
+ pending.captureTarget.releasePointerCapture(pending.pointerId);
1667
+ }
1353
1668
  if (pending.kind === "move") {
1354
- pending.target.removeAttribute("transform");
1669
+ pending.target.classList.remove("editor-drag-target");
1670
+ restoreTransform(pending.target, pending.baseTransform);
1355
1671
  const dx = snap(pending.dx);
1356
1672
  const dy = snap(pending.dy);
1357
- if (dx || dy) applyResult(architecture.move(pending.ref, dx, dy));
1673
+ if (dx || dy) {
1674
+ if (applyResult(architecture.move(pending.ref, dx, dy))) settleElement(pending.ref);
1675
+ }
1358
1676
  return;
1359
1677
  }
1360
- let { x, y, width, height } = pending.box;
1361
- const right = x + width;
1362
- const bottom = y + height;
1363
- const nextX = snap(x + pending.dx);
1364
- const nextY = snap(y + pending.dy);
1365
- const nextRight = snap(right + pending.dx);
1366
- const nextBottom = snap(bottom + pending.dy);
1367
- if (pending.corner.includes("w")) {
1368
- x = Math.min(nextX, right - 20);
1369
- width = right - x;
1370
- }
1371
- if (pending.corner.includes("e")) width = Math.max(20, nextRight - x);
1372
- if (pending.corner.includes("n")) {
1373
- y = Math.min(nextY, bottom - 20);
1374
- height = bottom - y;
1678
+ for (const item of pending.targets) {
1679
+ item.target.classList.remove("editor-drag-target");
1680
+ restoreTransform(item.target, item.baseTransform);
1375
1681
  }
1376
- if (pending.corner.includes("s")) height = Math.max(20, nextBottom - y);
1377
- applyResult(architecture.resize(pending.ref, { x, y, width, height }));
1682
+ const next = resizedBox(pending, true);
1683
+ const changed = Object.entries(next).some(
1684
+ ([key, value]) => value !== pending.box[key],
1685
+ );
1686
+ if (changed && applyResult(architecture.resize(pending.ref, next))) settleElement(pending.ref);
1378
1687
  }
1379
1688
 
1380
1689
  function onElementKeyDown(event) {
@@ -1464,25 +1773,79 @@ async function reloadFromMarkdown() {
1464
1773
  announce("Reloaded from the source Markdown.");
1465
1774
  }
1466
1775
 
1467
- function setZoom(value) {
1776
+ function setZoom(value, { fit = false } = {}) {
1468
1777
  zoom = Math.min(2.5, Math.max(0.3, value));
1778
+ fitToViewport = fit;
1469
1779
  renderSurface();
1470
1780
  zoomStatus.textContent = `${Math.round(zoom * 100)}%`;
1471
1781
  }
1472
1782
 
1473
1783
  function fitZoom() {
1474
1784
  const available = Math.max(320, viewport.clientWidth - 96);
1475
- setZoom(Math.min(1, available / 996));
1785
+ setZoom(Math.min(1, available / 996), { fit: true });
1476
1786
  viewport.scrollTo({ left: 0, top: 0 });
1477
1787
  }
1478
1788
 
1789
+ function visibleCanvasCenter() {
1790
+ const bounds = viewport.getBoundingClientRect();
1791
+ return (
1792
+ architecturePoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2) || {
1793
+ x: architecture.model.canvas.width / 2,
1794
+ y: architecture.model.canvas.height / 2,
1795
+ }
1796
+ );
1797
+ }
1798
+
1799
+ function siblingBoxes(parentId) {
1800
+ return rawEntries(architecture.raw)
1801
+ .filter((entry) => entry.parentId === parentId)
1802
+ .map((entry) => entry.element)
1803
+ .filter(
1804
+ (element) =>
1805
+ element.type !== "connector" &&
1806
+ [element.x, element.y, element.width, element.height].every(Number.isFinite),
1807
+ );
1808
+ }
1809
+
1810
+ function boxesOverlap(left, right, gap = 20) {
1811
+ return !(
1812
+ left.x + left.width + gap <= right.x ||
1813
+ right.x + right.width + gap <= left.x ||
1814
+ left.y + left.height + gap <= right.y ||
1815
+ right.y + right.height + gap <= left.y
1816
+ );
1817
+ }
1818
+
1819
+ function avoidSiblingOverlap(box, parentId) {
1820
+ const occupied = siblingBoxes(parentId);
1821
+ const step = 40;
1822
+ for (let ring = 0; ring <= 12; ring += 1) {
1823
+ for (let y = -ring; y <= ring; y += 1) {
1824
+ for (let x = -ring; x <= ring; x += 1) {
1825
+ if (Math.max(Math.abs(x), Math.abs(y)) !== ring) continue;
1826
+ const candidate = {
1827
+ ...box,
1828
+ x: snap(box.x + x * step),
1829
+ y: snap(box.y + y * step),
1830
+ };
1831
+ if (!occupied.some((item) => boxesOverlap(candidate, item))) return candidate;
1832
+ }
1833
+ }
1834
+ }
1835
+ return box;
1836
+ }
1837
+
1479
1838
  function addPosition(point, parentId, width, height) {
1480
- if (!point) return {};
1839
+ const explicitPoint = Boolean(point);
1840
+ const target = point || visibleCanvasCenter();
1481
1841
  const parent = parentId ? modelFor(parentId) : null;
1482
- return {
1483
- x: snap(point.x - (parent?.x || 0) - width / 2),
1484
- y: snap(point.y - (parent?.y || 0) - height / 2),
1842
+ const box = {
1843
+ x: snap(target.x - (parent?.x || 0) - width / 2),
1844
+ y: snap(target.y - (parent?.y || 0) - height / 2),
1845
+ width,
1846
+ height,
1485
1847
  };
1848
+ return explicitPoint ? box : avoidSiblingOverlap(box, parentId);
1486
1849
  }
1487
1850
 
1488
1851
  function invokeAction(action, context = {}) {
@@ -1491,10 +1854,14 @@ function invokeAction(action, context = {}) {
1491
1854
  applyResult(architecture.undo(), { quiet: true });
1492
1855
  } else if (action === "redo") {
1493
1856
  applyResult(architecture.redo(), { quiet: true });
1857
+ } else if (action === "toggle-shape-palette") {
1858
+ if (shapePalette.hidden) openShapePalette();
1859
+ else closeShapePalette({ restoreFocus: true });
1494
1860
  } else if (action === "add-node") {
1495
1861
  const parentId = entryFor(ref)?.element.type === "group" ? ref : null;
1496
1862
  applyResult(architecture.addNode({
1497
1863
  parentId,
1864
+ shape: SHAPES.includes(context.shape) ? context.shape : "rounded-rect",
1498
1865
  ...addPosition(context.point, parentId, 260, 140),
1499
1866
  }));
1500
1867
  } else if (action === "add-group") {
@@ -1551,8 +1918,104 @@ function invokeAction(action, context = {}) {
1551
1918
  }
1552
1919
 
1553
1920
  function wireControls() {
1921
+ renderShapePalette();
1922
+ syncResponsivePanels();
1554
1923
  document.querySelectorAll("[data-action]").forEach((button) => {
1555
- button.addEventListener("click", () => invokeAction(button.dataset.action));
1924
+ button.addEventListener("click", () => {
1925
+ const insideMore = toolbarMoreMenu.contains(button);
1926
+ if (insideMore) closeToolbarMore({ restoreFocus: true });
1927
+ invokeAction(
1928
+ button.dataset.action,
1929
+ insideMore ? { returnFocus: toolbarMoreButton } : {},
1930
+ );
1931
+ });
1932
+ });
1933
+ elementsPanelButton.addEventListener("click", () => toggleResponsivePanel("elements"));
1934
+ inspectorPanelButton.addEventListener("click", () => toggleResponsivePanel("inspector"));
1935
+ document.querySelectorAll("[data-panel-close]").forEach((button) => {
1936
+ button.addEventListener("click", () => {
1937
+ closeResponsivePanel(button.dataset.panelClose);
1938
+ });
1939
+ });
1940
+ responsivePanels.addEventListener("change", () => {
1941
+ if (responsivePanels.matches) {
1942
+ const focusedPanel = elementPanel.contains(document.activeElement)
1943
+ ? "elements"
1944
+ : inspectorPanel.contains(document.activeElement)
1945
+ ? "inspector"
1946
+ : null;
1947
+ responsivePanel =
1948
+ focusedPanel ||
1949
+ (desktopInspectorOpen ? "inspector" : desktopElementsOpen ? "elements" : null);
1950
+ } else if (responsivePanel === "elements") {
1951
+ desktopElementsOpen = true;
1952
+ } else if (responsivePanel === "inspector") {
1953
+ desktopInspectorOpen = true;
1954
+ }
1955
+ if (!responsivePanels.matches) responsivePanel = null;
1956
+ syncResponsivePanels();
1957
+ });
1958
+ toolbarMoreButton.addEventListener("click", () => {
1959
+ if (toolbarMoreMenu.hidden) openToolbarMore();
1960
+ else closeToolbarMore({ restoreFocus: true });
1961
+ });
1962
+ toolbarMoreMenu.addEventListener("keydown", (event) => {
1963
+ const items = toolbarMoreItems();
1964
+ const current = items.indexOf(document.activeElement);
1965
+ let next = null;
1966
+ if (event.key === "ArrowDown" || event.key === "ArrowRight") {
1967
+ next = items[(current + 1 + items.length) % items.length];
1968
+ } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
1969
+ next = items[(current - 1 + items.length) % items.length];
1970
+ } else if (event.key === "Home") {
1971
+ next = items[0];
1972
+ } else if (event.key === "End") {
1973
+ next = items.at(-1);
1974
+ } else if (event.key === "Escape") {
1975
+ event.preventDefault();
1976
+ closeToolbarMore({ restoreFocus: true });
1977
+ return;
1978
+ } else if (event.key === "Tab") {
1979
+ closeToolbarMore();
1980
+ return;
1981
+ }
1982
+ if (next) {
1983
+ event.preventDefault();
1984
+ next.focus();
1985
+ }
1986
+ });
1987
+ window.addEventListener("resize", () => {
1988
+ positionShapePalette();
1989
+ positionToolbarMoreMenu();
1990
+ if (fitToViewport) requestAnimationFrame(fitZoom);
1991
+ });
1992
+ shapePalette.addEventListener("click", (event) => {
1993
+ const button = event.target.closest("button[data-shape]");
1994
+ if (!button) return;
1995
+ closeShapePalette({ restoreFocus: true });
1996
+ invokeAction("add-node", { shape: button.dataset.shape });
1997
+ });
1998
+ shapePalette.addEventListener("keydown", (event) => {
1999
+ const items = [...shapePalette.querySelectorAll("button[data-shape]")];
2000
+ const current = items.indexOf(document.activeElement);
2001
+ let next = null;
2002
+ if (event.key === "ArrowDown" || event.key === "ArrowRight") {
2003
+ next = items[(current + 1 + items.length) % items.length];
2004
+ } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
2005
+ next = items[(current - 1 + items.length) % items.length];
2006
+ } else if (event.key === "Home") {
2007
+ next = items[0];
2008
+ } else if (event.key === "End") {
2009
+ next = items.at(-1);
2010
+ } else if (event.key === "Escape") {
2011
+ event.preventDefault();
2012
+ closeShapePalette({ restoreFocus: true });
2013
+ return;
2014
+ }
2015
+ if (next) {
2016
+ event.preventDefault();
2017
+ next.focus();
2018
+ }
1556
2019
  });
1557
2020
  document.addEventListener("pointermove", updateDrag);
1558
2021
  document.addEventListener("pointerup", finishDrag);
@@ -1580,6 +2043,28 @@ function wireControls() {
1580
2043
  });
1581
2044
  document.addEventListener("pointerdown", (event) => {
1582
2045
  if (!contextMenu.hidden && !contextMenu.contains(event.target)) closeContextMenu();
2046
+ if (
2047
+ !shapePalette.hidden &&
2048
+ !shapePalette.contains(event.target) &&
2049
+ !shapePaletteButton.contains(event.target)
2050
+ ) {
2051
+ closeShapePalette();
2052
+ }
2053
+ if (
2054
+ !toolbarMoreMenu.hidden &&
2055
+ !toolbarMoreMenu.contains(event.target) &&
2056
+ !toolbarMoreButton.contains(event.target)
2057
+ ) {
2058
+ closeToolbarMore();
2059
+ }
2060
+ if (
2061
+ compactPanels.matches &&
2062
+ responsivePanel &&
2063
+ !(responsivePanel === "elements" ? elementPanel : inspectorPanel).contains(event.target) &&
2064
+ !event.target.closest(".editor-panel-actions")
2065
+ ) {
2066
+ closeResponsivePanel(responsivePanel, { restoreFocus: false });
2067
+ }
1583
2068
  }, true);
1584
2069
  contextMenu.addEventListener("keydown", (event) => {
1585
2070
  const activeMenu = document.activeElement.closest('[role="menu"]');
@@ -1642,24 +2127,54 @@ function wireControls() {
1642
2127
  });
1643
2128
  });
1644
2129
  viewport.addEventListener("pointerdown", (event) => {
1645
- if (event.button !== 1 && !(event.button === 0 && spacePressed)) return;
2130
+ const interactiveTarget = event.target.closest(
2131
+ "[data-editor-ref], .editor-resize-handle, button, input, select, textarea, a",
2132
+ );
2133
+ const primaryBlankDrag = event.button === 0 && !interactiveTarget;
2134
+ if (event.button !== 1 && !(event.button === 0 && spacePressed) && !primaryBlankDrag) return;
1646
2135
  event.preventDefault();
2136
+ viewport.setPointerCapture?.(event.pointerId);
1647
2137
  pan = {
1648
2138
  pointerId: event.pointerId,
1649
2139
  x: event.clientX,
1650
2140
  y: event.clientY,
1651
2141
  left: viewport.scrollLeft,
1652
2142
  top: viewport.scrollTop,
2143
+ moved: false,
1653
2144
  };
2145
+ viewport.classList.add("is-panning");
1654
2146
  });
1655
2147
  viewport.addEventListener("pointermove", (event) => {
1656
2148
  if (!pan || pan.pointerId !== event.pointerId) return;
1657
- viewport.scrollLeft = pan.left - (event.clientX - pan.x);
1658
- viewport.scrollTop = pan.top - (event.clientY - pan.y);
2149
+ const dx = event.clientX - pan.x;
2150
+ const dy = event.clientY - pan.y;
2151
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) pan.moved = true;
2152
+ viewport.scrollLeft = pan.left - dx;
2153
+ viewport.scrollTop = pan.top - dy;
1659
2154
  });
1660
- viewport.addEventListener("pointerup", () => {
2155
+ const finishPan = (event) => {
2156
+ if (!pan || pan.pointerId !== event.pointerId) return;
2157
+ suppressCanvasClick = pan.moved;
2158
+ pan = null;
2159
+ viewport.classList.remove("is-panning");
2160
+ if (viewport.hasPointerCapture?.(event.pointerId)) {
2161
+ viewport.releasePointerCapture(event.pointerId);
2162
+ }
2163
+ };
2164
+ viewport.addEventListener("pointerup", finishPan);
2165
+ viewport.addEventListener("pointercancel", finishPan);
2166
+ viewport.addEventListener("lostpointercapture", (event) => {
2167
+ if (pan?.pointerId !== event.pointerId) return;
2168
+ suppressCanvasClick = pan.moved;
1661
2169
  pan = null;
2170
+ viewport.classList.remove("is-panning");
1662
2171
  });
2172
+ viewport.addEventListener("click", (event) => {
2173
+ if (!suppressCanvasClick) return;
2174
+ suppressCanvasClick = false;
2175
+ event.preventDefault();
2176
+ event.stopPropagation();
2177
+ }, true);
1663
2178
  viewport.addEventListener("scroll", () => closeContextMenu(), { passive: true });
1664
2179
  tree.closest(".editor-sidebar")?.addEventListener("scroll", () => closeContextMenu(), {
1665
2180
  passive: true,
@@ -1679,6 +2194,21 @@ function wireControls() {
1679
2194
  if (event.code === "Space" && !editable) spacePressed = true;
1680
2195
  const modifier = event.ctrlKey || event.metaKey;
1681
2196
  const key = event.key.toLowerCase();
2197
+ if (event.key === "Escape" && !toolbarMoreMenu.hidden) {
2198
+ event.preventDefault();
2199
+ closeToolbarMore({ restoreFocus: true });
2200
+ return;
2201
+ }
2202
+ if (event.key === "Escape" && !shapePalette.hidden) {
2203
+ event.preventDefault();
2204
+ closeShapePalette({ restoreFocus: true });
2205
+ return;
2206
+ }
2207
+ if (event.key === "Escape" && responsivePanels.matches && responsivePanel) {
2208
+ event.preventDefault();
2209
+ closeResponsivePanel();
2210
+ return;
2211
+ }
1682
2212
  if (modifier && key === "s") {
1683
2213
  event.preventDefault();
1684
2214
  if (editable) event.target.blur();
@@ -1728,6 +2258,7 @@ async function refreshState() {
1728
2258
  const stateGeneration = state.generation ?? 0;
1729
2259
  document.documentElement.dataset.theme = state.theme || "dark";
1730
2260
  sourceLabel.textContent = `${sourcePath} — diagram ${blockIndex + 1}`;
2261
+ sourceLabel.title = sourceLabel.textContent;
1731
2262
  if (targetGeneration !== stateGeneration) {
1732
2263
  targetGeneration = stateGeneration;
1733
2264
  draftRevision = stateRevision;
@@ -1758,7 +2289,11 @@ async function init() {
1758
2289
  wireControls();
1759
2290
  await refreshState();
1760
2291
  fitZoom();
1761
- announce("Select the diagram to edit it. The Markdown remains unchanged until you save.");
2292
+ announce(
2293
+ architecture.model.elements.length
2294
+ ? "Select an element to edit it. The Markdown remains unchanged until you save."
2295
+ : "Add the first shape to start the diagram. The Markdown remains unchanged until you save.",
2296
+ );
1762
2297
  const events = new EventSource("./events");
1763
2298
  events.onmessage = () => {
1764
2299
  void refreshState().catch((error) => announce(error.message, "error"));