@markdstage/markdstage 3.3.0 → 3.8.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.
@@ -86,6 +86,8 @@ const compactPanels = window.matchMedia("(max-width: 620px)");
86
86
 
87
87
  let architecture = null;
88
88
  let selectedRef = null;
89
+ let selectedRefs = new Set();
90
+ let selectionAnchor = null;
89
91
  let sourcePath = "";
90
92
  let blockIndex = 0;
91
93
  let dirty = false;
@@ -96,6 +98,7 @@ let targetGeneration = null;
96
98
  let draftQueue = Promise.resolve();
97
99
  let drag = null;
98
100
  let pan = null;
101
+ let marquee = null;
99
102
  let spacePressed = false;
100
103
  let connectorTool = null;
101
104
  let serverVersion = -1;
@@ -302,6 +305,52 @@ function modelFor(ref) {
302
305
  ) || null;
303
306
  }
304
307
 
308
+ function setSelection(refs, primary = refs.at(-1) ?? null) {
309
+ selectedRefs = new Set(refs.filter((ref) => modelFor(ref)));
310
+ selectedRef = selectedRefs.has(primary) ? primary : [...selectedRefs].at(-1) ?? null;
311
+ }
312
+
313
+ function selectOnly(ref) {
314
+ setSelection(ref ? [ref] : []);
315
+ selectionAnchor = ref;
316
+ }
317
+
318
+ function selectFromEvent(ref, event, { range = false } = {}) {
319
+ const additive = event.ctrlKey || event.metaKey;
320
+ if (range && event.shiftKey && selectionAnchor) {
321
+ const refs = rawEntries(architecture.raw).map((entry) => entry.ref);
322
+ const start = refs.indexOf(selectionAnchor);
323
+ const end = refs.indexOf(ref);
324
+ if (start !== -1 && end !== -1) {
325
+ const slice = refs.slice(Math.min(start, end), Math.max(start, end) + 1);
326
+ setSelection(additive ? [...selectedRefs, ...slice] : slice, ref);
327
+ return;
328
+ }
329
+ }
330
+ if (additive) {
331
+ setSelection([...selectedRefs, ref], ref);
332
+ selectionAnchor = ref;
333
+ } else {
334
+ selectOnly(ref);
335
+ }
336
+ }
337
+
338
+ function selectionRoots() {
339
+ const elements = [...selectedRefs].map(modelFor).filter(
340
+ (element) => element && element.type !== "connector",
341
+ );
342
+ return elements.filter((element) => !elements.some(
343
+ (parent) => parent.type === "group" &&
344
+ element.sourcePath.startsWith(`${parent.sourcePath}.children[`),
345
+ ));
346
+ }
347
+
348
+ function setSelectedProperty(path, value) {
349
+ return selectedRefs.size > 1
350
+ ? architecture.setElements([...selectedRefs], path, value)
351
+ : architecture.setElement(selectedRef, path, value);
352
+ }
353
+
305
354
  function endpointOptions() {
306
355
  return architecture.model.elements
307
356
  .filter((element) => element.type !== "connector")
@@ -337,6 +386,7 @@ function queueDraft() {
337
386
 
338
387
  function applyResult(result, { select = undefined, quiet = false } = {}) {
339
388
  if (!result?.ok) {
389
+ if (result?.reason === "unchanged") return false;
340
390
  const message =
341
391
  result?.reason === "layout-managed"
342
392
  ? `The layout of ${result.layoutOwner} controls the placement of ${result.id}.`
@@ -344,8 +394,10 @@ function applyResult(result, { select = undefined, quiet = false } = {}) {
344
394
  announce(message, "error");
345
395
  return false;
346
396
  }
347
- if (select !== undefined) selectedRef = select;
348
- else if (result.ref) selectedRef = result.ref;
397
+ if (select !== undefined) selectOnly(select);
398
+ else if (result.refs) setSelection(result.refs, selectedRef);
399
+ else if (result.ref) selectOnly(result.ref);
400
+ else setSelection([...selectedRefs]);
349
401
  renderAll();
350
402
  queueDraft();
351
403
  if (!quiet) announce("Changed. The update will not affect the Markdown until you save.");
@@ -444,6 +496,16 @@ function menuItemsFor({ ref, point }) {
444
496
  ];
445
497
  }
446
498
 
499
+ if (selectedRefs.size > 1) {
500
+ return [
501
+ { label: "Duplicate", action: "duplicate", shortcut: "Ctrl+D" },
502
+ { label: "Bring forward", action: "order-front", disabled: true },
503
+ { label: "Send backward", action: "order-back", disabled: true },
504
+ { separator: true },
505
+ { label: "Delete", action: "delete", shortcut: "Delete", danger: true },
506
+ ];
507
+ }
508
+
447
509
  const items = [];
448
510
  if (entry.element.type === "group") {
449
511
  const suffix = point ? " here" : "";
@@ -662,7 +724,7 @@ function openElementContextMenu(ref, options) {
662
724
  options.origin === "diagram"
663
725
  ? architecturePoint(options.clientX, options.clientY)
664
726
  : null;
665
- selectedRef = ref;
727
+ if (!selectedRefs.has(ref)) selectOnly(ref);
666
728
  connectorTool = null;
667
729
  renderAll();
668
730
  openContextMenu({ ...options, ref, point });
@@ -673,7 +735,7 @@ function openBlankContextMenu(options) {
673
735
  options.origin === "canvas"
674
736
  ? architecturePoint(options.clientX, options.clientY)
675
737
  : null;
676
- selectedRef = null;
738
+ selectOnly(null);
677
739
  connectorTool = null;
678
740
  renderAll();
679
741
  openContextMenu({ ...options, point });
@@ -873,7 +935,7 @@ function renderTree() {
873
935
  button.dataset.ref = entry.ref;
874
936
  button.setAttribute("role", "treeitem");
875
937
  button.setAttribute("aria-level", String(entry.depth + 1));
876
- button.setAttribute("aria-selected", entry.ref === selectedRef ? "true" : "false");
938
+ button.setAttribute("aria-selected", selectedRefs.has(entry.ref) ? "true" : "false");
877
939
  button.setAttribute("aria-haspopup", "menu");
878
940
  const icon = document.createElement("span");
879
941
  icon.className = "tree-icon";
@@ -882,10 +944,11 @@ function renderTree() {
882
944
  label.className = "tree-label";
883
945
  label.textContent = labelFor(entry);
884
946
  button.append(icon, label);
885
- button.addEventListener("click", () => {
886
- selectedRef = entry.ref;
947
+ button.addEventListener("click", (event) => {
948
+ selectFromEvent(entry.ref, event, { range: true });
887
949
  connectorTool = null;
888
950
  renderAll();
951
+ tree.querySelector(`[data-ref="${CSS.escape(entry.ref)}"]`)?.focus({ preventScroll: true });
889
952
  });
890
953
  button.addEventListener("contextmenu", (event) => {
891
954
  event.preventDefault();
@@ -944,6 +1007,7 @@ function addResizeHandles(svg, element) {
944
1007
  }
945
1008
 
946
1009
  function decorateDiagram(svg) {
1010
+ addGrid(svg);
947
1011
  const byOrder = new Map(
948
1012
  architecture.model.elements.map((element) => [
949
1013
  String(element.order),
@@ -963,10 +1027,10 @@ function decorateDiagram(svg) {
963
1027
  element?.type !== "connector" && architecture.describe(ref).movable ? "true" : "false";
964
1028
  node.setAttribute("tabindex", "0");
965
1029
  node.setAttribute("aria-haspopup", "menu");
966
- if (ref === selectedRef) node.dataset.editorSelected = "true";
1030
+ if (selectedRefs.has(ref)) node.dataset.editorSelected = "true";
967
1031
  node.addEventListener("click", (event) => {
968
1032
  event.stopPropagation();
969
- chooseElement(ref);
1033
+ chooseElement(ref, event);
970
1034
  });
971
1035
  node.addEventListener("pointerdown", beginMove);
972
1036
  node.addEventListener("keydown", onElementKeyDown);
@@ -985,15 +1049,38 @@ function decorateDiagram(svg) {
985
1049
  .querySelector(`[data-editor-ref="${CSS.escape(connectorTool.from)}"]`)
986
1050
  ?.classList.add("connector-source");
987
1051
  }
988
- addResizeHandles(svg, modelFor(selectedRef));
1052
+ if (selectedRefs.size === 1) addResizeHandles(svg, modelFor(selectedRef));
989
1053
  svg.addEventListener("click", (event) => {
990
1054
  if (event.target === svg) {
991
- selectedRef = null;
1055
+ selectOnly(null);
992
1056
  renderAll();
993
1057
  }
994
1058
  });
995
1059
  }
996
1060
 
1061
+ function addGrid(svg) {
1062
+ const defs = document.createElementNS(SVG_NS, "defs");
1063
+ const pattern = document.createElementNS(SVG_NS, "pattern");
1064
+ pattern.id = "editor-grid-pattern";
1065
+ pattern.setAttribute("patternUnits", "userSpaceOnUse");
1066
+ pattern.setAttribute("width", String(SNAP_SIZE));
1067
+ pattern.setAttribute("height", String(SNAP_SIZE));
1068
+ const path = document.createElementNS(SVG_NS, "path");
1069
+ path.setAttribute("d", `M ${SNAP_SIZE} 0 H 0 V ${SNAP_SIZE}`);
1070
+ path.setAttribute("fill", "none");
1071
+ path.setAttribute("stroke", "var(--editor-grid)");
1072
+ path.setAttribute("stroke-width", "0.5");
1073
+ pattern.appendChild(path);
1074
+ defs.appendChild(pattern);
1075
+ const grid = document.createElementNS(SVG_NS, "rect");
1076
+ grid.classList.add("editor-grid");
1077
+ grid.setAttribute("aria-hidden", "true");
1078
+ const box = svg.viewBox.baseVal;
1079
+ for (const key of ["x", "y", "width", "height"]) grid.setAttribute(key, String(box[key]));
1080
+ grid.setAttribute("fill", "url(#editor-grid-pattern)");
1081
+ svg.prepend(defs, grid);
1082
+ }
1083
+
997
1084
  function renderSurface() {
998
1085
  if (!architecture.model.elements.length) {
999
1086
  const empty = document.createElement("section");
@@ -1035,6 +1122,44 @@ function readValue(path) {
1035
1122
  return value;
1036
1123
  }
1037
1124
 
1125
+ function fieldApplies(entry, path) {
1126
+ const type = entry.element.type;
1127
+ if (["id", "parent", "layout"].includes(path) || path.startsWith("layout.")) return false;
1128
+ if (path.startsWith("style.")) {
1129
+ return path !== "style.dash-preset" || type === "connector";
1130
+ }
1131
+ if (["ariaLabel", "z"].includes(path)) return true;
1132
+ if (["x", "y", "width", "height"].includes(path)) {
1133
+ return type !== "connector" && architecture.describe(entry.ref).movable;
1134
+ }
1135
+ const byType = {
1136
+ node: ["text", "shape", "icon"],
1137
+ image: ["src", "fit"],
1138
+ group: ["title"],
1139
+ connector: ["from", "to", "fromPort", "toPort", "label", "labelLayer", "routing", "arrow", "lane", "points"],
1140
+ };
1141
+ return byType[type]?.includes(path) &&
1142
+ (path !== "points" || entry.element.routing === "polyline");
1143
+ }
1144
+
1145
+ function effectiveValue(ref, path) {
1146
+ const entry = entryFor(ref);
1147
+ const model = modelFor(ref);
1148
+ if (path === "style.dash-preset") {
1149
+ return lineStyleForDash(effectiveValue(ref, "style.dash"));
1150
+ }
1151
+ const get = (value) => path.split(".").reduce((owner, key) => owner?.[key], value);
1152
+ let value = get(entry.element) ?? get(model);
1153
+ if (["style.fill", "style.stroke", "style.textColor"].includes(path)) {
1154
+ const color = THEME_TOKENS[value] ?? value;
1155
+ value = Object.entries(THEME_TOKENS).find(([, resolved]) => resolved === color)?.[0] ?? color;
1156
+ }
1157
+ if (["x", "y"].includes(path) && entry.element[path] === undefined) {
1158
+ value = model[path] - (modelFor(entry.parentId)?.[path] ?? 0);
1159
+ }
1160
+ return value;
1161
+ }
1162
+
1038
1163
  function addField(container, {
1039
1164
  label,
1040
1165
  path,
@@ -1048,6 +1173,15 @@ function addField(container, {
1048
1173
  suggestions = null,
1049
1174
  onChange,
1050
1175
  }) {
1176
+ const multiple = selectedRefs.size > 1;
1177
+ if (multiple && ![...selectedRefs].every((ref) => fieldApplies(entryFor(ref), path))) return;
1178
+ let mixed = false;
1179
+ if (multiple) {
1180
+ const values = [...selectedRefs].map((ref) => effectiveValue(ref, path));
1181
+ mixed = values.some((item) => JSON.stringify(item) !== JSON.stringify(values[0]));
1182
+ value = mixed ? undefined : values[0];
1183
+ if (path === "points" && value !== undefined) value = JSON.stringify(value, null, 2);
1184
+ }
1051
1185
  const id = `field-${path.replace(/[^A-Za-z0-9_-]/g, "-")}-${container.children.length}`;
1052
1186
  const caption = document.createElement("label");
1053
1187
  caption.htmlFor = id;
@@ -1083,19 +1217,41 @@ function addField(container, {
1083
1217
  if (step !== undefined) input.step = String(step);
1084
1218
  if (type === "checkbox") input.checked = Boolean(value);
1085
1219
  else input.value = value ?? "";
1220
+ if (mixed) {
1221
+ input.dataset.mixed = "true";
1222
+ if (type === "checkbox") input.indeterminate = true;
1223
+ else if (options) {
1224
+ const option = document.createElement("option");
1225
+ option.value = "__mixed__";
1226
+ option.textContent = "Multiple values";
1227
+ option.disabled = true;
1228
+ input.prepend(option);
1229
+ input.value = option.value;
1230
+ } else input.placeholder = "Multiple values";
1231
+ const hint = document.createElement("span");
1232
+ hint.id = `${id}-mixed`;
1233
+ hint.className = "visually-hidden";
1234
+ hint.textContent = "Multiple values";
1235
+ input.setAttribute("aria-describedby", hint.id);
1236
+ container.appendChild(hint);
1237
+ }
1086
1238
  input.addEventListener("change", () => {
1239
+ input.setCustomValidity("");
1240
+ if (!input.reportValidity()) return;
1241
+ if (options && input.value === "__mixed__") return;
1087
1242
  let next;
1088
1243
  if (type === "checkbox") next = input.checked;
1089
1244
  else if (type === "number") next = input.value === "" ? undefined : Number(input.value);
1090
1245
  else next = input.value === "" ? undefined : input.value;
1091
1246
  if (onChange) onChange(next, input);
1092
- else applyResult(architecture.setElement(selectedRef, path, next));
1247
+ else applyResult(setSelectedProperty(path, next));
1093
1248
  });
1094
1249
  container.append(caption, input);
1095
1250
  return input;
1096
1251
  }
1097
1252
 
1098
1253
  function addInspectorAction(container, label, onClick) {
1254
+ if (selectedRefs.size > 1) return;
1099
1255
  const row = document.createElement("div");
1100
1256
  row.className = "inspector-action-row";
1101
1257
  const button = document.createElement("button");
@@ -1154,11 +1310,11 @@ function addStyleFields(container, { connector = false } = {}) {
1154
1310
  options: LINE_STYLES,
1155
1311
  onChange: (value) => {
1156
1312
  if (value === "custom") {
1157
- applyResult(architecture.setElement(selectedRef, "style.dash", "6 3"));
1313
+ applyResult(setSelectedProperty("style.dash", "6 3"));
1158
1314
  return;
1159
1315
  }
1160
1316
  applyResult(
1161
- architecture.setElement(selectedRef, "style.dash", LINE_STYLE_PATTERNS[value]),
1317
+ setSelectedProperty("style.dash", LINE_STYLE_PATTERNS[value]),
1162
1318
  );
1163
1319
  },
1164
1320
  });
@@ -1227,7 +1383,7 @@ function renderInspector() {
1227
1383
  return;
1228
1384
  }
1229
1385
 
1230
- const general = section(entry.element.type);
1386
+ const general = section(selectedRefs.size > 1 ? `${selectedRefs.size} selected` : entry.element.type);
1231
1387
  if (entry.element.type !== "connector") {
1232
1388
  addField(general, { label: "ID", path: "id", value: entry.element.id });
1233
1389
  addField(general, {
@@ -1257,7 +1413,7 @@ function renderInspector() {
1257
1413
  addField(general, {
1258
1414
  label: "Shape",
1259
1415
  path: "shape",
1260
- value: entry.element.shape || "rect",
1416
+ value: entry.element.shape || "rounded-rect",
1261
1417
  options: SHAPES,
1262
1418
  });
1263
1419
  addField(general, {
@@ -1427,7 +1583,7 @@ function renderInspector() {
1427
1583
  onChange: (value, input) => {
1428
1584
  try {
1429
1585
  input.setCustomValidity("");
1430
- applyResult(architecture.setElement(selectedRef, "points", JSON.parse(value || "[]")));
1586
+ applyResult(setSelectedProperty("points", JSON.parse(value || "[]")));
1431
1587
  } catch (_) {
1432
1588
  input.setCustomValidity("Enter a JSON array of points containing x/y values.");
1433
1589
  input.reportValidity();
@@ -1474,7 +1630,12 @@ function renderInspector() {
1474
1630
  });
1475
1631
 
1476
1632
  const style = section("Style");
1477
- addStyleFields(style, { connector: entry.element.type === "connector" });
1633
+ addStyleFields(style, {
1634
+ connector: [...selectedRefs].every((ref) => modelFor(ref).type === "connector"),
1635
+ });
1636
+ for (const empty of inspector.querySelectorAll(".inspector-section")) {
1637
+ if (empty !== general && empty.children.length === 1) empty.remove();
1638
+ }
1478
1639
  }
1479
1640
 
1480
1641
  function refreshToolbar() {
@@ -1482,23 +1643,32 @@ function refreshToolbar() {
1482
1643
  document.querySelector('[data-action="undo"]').disabled = !architecture.canUndo;
1483
1644
  document.querySelector('[data-action="redo"]').disabled = !architecture.canRedo;
1484
1645
  for (const action of ["duplicate", "delete", "order-back", "order-front"]) {
1485
- document.querySelector(`[data-action="${action}"]`).disabled = !entry;
1646
+ document.querySelector(`[data-action="${action}"]`).disabled =
1647
+ !entry || (selectedRefs.size > 1 && action.startsWith("order-"));
1486
1648
  }
1487
1649
  document.querySelector('[data-action="release-layout"]').disabled =
1488
- !selectedRef || !releaseLayoutAvailable(selectedRef);
1650
+ selectedRefs.size !== 1 || !releaseLayoutAvailable(selectedRef);
1489
1651
  setDirty(dirty);
1490
1652
  }
1491
1653
 
1492
1654
  function renderAll() {
1655
+ const focused = document.activeElement;
1656
+ const ref = focused?.dataset?.ref || focused?.dataset?.editorRef;
1657
+ const inTree = tree.contains(focused);
1658
+ setSelection([...selectedRefs], selectedRef);
1493
1659
  closeContextMenu();
1494
1660
  renderTree();
1495
1661
  renderSurface();
1496
1662
  renderInspector();
1497
1663
  refreshToolbar();
1498
1664
  zoomStatus.textContent = `${Math.round(zoom * 100)}%`;
1665
+ if (ref) {
1666
+ const selector = inTree ? `.tree-item[data-ref="${CSS.escape(ref)}"]` : `[data-editor-ref="${CSS.escape(ref)}"]`;
1667
+ (inTree ? tree : surface).querySelector(selector)?.focus({ preventScroll: true });
1668
+ }
1499
1669
  }
1500
1670
 
1501
- function chooseElement(ref) {
1671
+ function chooseElement(ref, event) {
1502
1672
  const element = modelFor(ref);
1503
1673
  if (connectorTool && element?.type !== "connector") {
1504
1674
  if (!connectorTool.from) {
@@ -1517,42 +1687,57 @@ function chooseElement(ref) {
1517
1687
  return;
1518
1688
  }
1519
1689
  connectorTool = null;
1520
- selectedRef = ref;
1690
+ selectFromEvent(ref, event);
1521
1691
  renderAll();
1522
1692
  }
1523
1693
 
1524
1694
  function beginMove(event) {
1525
- if (event.button !== 0 || event.currentTarget.dataset.architectureType === "connector") return;
1695
+ if (event.button !== 0 || spacePressed || event.ctrlKey || event.metaKey ||
1696
+ event.currentTarget.dataset.architectureType === "connector") return;
1526
1697
  if (connectorTool) return;
1527
1698
  const ref = event.currentTarget.dataset.editorRef;
1528
1699
  if (!ref) return;
1529
- selectedRef = ref;
1530
- const placement = architecture.describe(ref);
1531
- if (!placement.movable) {
1532
- announce(`${ref} cannot move because it is managed by a layout.`, "error");
1533
- renderAll();
1534
- return;
1535
- }
1536
1700
  event.preventDefault();
1537
1701
  event.stopPropagation();
1538
- event.currentTarget.setPointerCapture?.(event.pointerId);
1539
- event.currentTarget.classList.add("editor-drag-target");
1702
+ event.currentTarget.focus({ preventScroll: true });
1703
+ if (!selectedRefs.has(ref)) selectOnly(ref);
1704
+ viewport.setPointerCapture?.(event.pointerId);
1705
+ const roots = selectionRoots();
1706
+ const targets = architecture.model.elements.filter((element) =>
1707
+ element.type !== "connector" && roots.some((root) =>
1708
+ root === element || element.sourcePath.startsWith(`${root.sourcePath}.children[`),
1709
+ ),
1710
+ ).map((element) => surface.querySelector(
1711
+ `[data-editor-ref="${CSS.escape(element.id)}"]`,
1712
+ )).filter(Boolean);
1713
+ for (const target of targets) target.classList.add("editor-drag-target");
1714
+ for (const node of surface.querySelectorAll("[data-editor-ref]")) {
1715
+ node.dataset.editorSelected = String(selectedRefs.has(node.dataset.editorRef));
1716
+ }
1717
+ surface.querySelectorAll(".editor-resize-handle").forEach((handle) => handle.remove());
1718
+ renderTree();
1719
+ renderInspector();
1720
+ refreshToolbar();
1540
1721
  viewport.classList.add("is-dragging");
1541
1722
  drag = {
1542
1723
  kind: "move",
1543
1724
  ref,
1544
- target: event.currentTarget,
1545
- captureTarget: event.currentTarget,
1546
- baseTransform: event.currentTarget.getAttribute("transform"),
1725
+ refs: [...selectedRefs],
1726
+ roots,
1727
+ targets: targets.map((target) => ({ target, baseTransform: target.getAttribute("transform") })),
1728
+ captureTarget: viewport,
1547
1729
  pointerId: event.pointerId,
1548
1730
  startX: event.clientX,
1549
1731
  startY: event.clientY,
1732
+ startPoint: architecturePoint(event.clientX, event.clientY),
1733
+ moved: false,
1550
1734
  dx: 0,
1551
1735
  dy: 0,
1552
1736
  };
1553
1737
  }
1554
1738
 
1555
1739
  function beginResize(event) {
1740
+ if (event.button !== 0 || spacePressed || selectedRefs.size !== 1) return;
1556
1741
  event.preventDefault();
1557
1742
  event.stopPropagation();
1558
1743
  const ref = event.currentTarget.dataset.ref;
@@ -1572,6 +1757,7 @@ function beginResize(event) {
1572
1757
  pointerId: event.pointerId,
1573
1758
  startX: event.clientX,
1574
1759
  startY: event.clientY,
1760
+ startPoint: architecturePoint(event.clientX, event.clientY),
1575
1761
  box: { x: element.x, y: element.y, width: element.width, height: element.height },
1576
1762
  targets: targets.map((target) => ({
1577
1763
  target,
@@ -1580,9 +1766,32 @@ function beginResize(event) {
1580
1766
  captureTarget: event.currentTarget,
1581
1767
  dx: 0,
1582
1768
  dy: 0,
1769
+ moved: false,
1583
1770
  };
1584
1771
  }
1585
1772
 
1773
+ function moveDelta(roots, dx, dy, snapping = true) {
1774
+ const blocked = roots.find((root) => !architecture.describe(root.id).movable);
1775
+ if (blocked || !roots.length) return { dx: 0, dy: 0 };
1776
+ const x = Math.min(...roots.map((root) => root.x));
1777
+ const y = Math.min(...roots.map((root) => root.y));
1778
+ if (snapping) {
1779
+ dx = snap(x + dx) - x;
1780
+ dy = snap(y + dy) - y;
1781
+ }
1782
+ for (const axis of ["x", "y"]) {
1783
+ const positions = roots.map((root) =>
1784
+ root[axis] - architecture.describe(root.id).origin[axis],
1785
+ );
1786
+ const value = axis === "x" ? dx : dy;
1787
+ const clamped = Math.max(-4000 - Math.min(...positions),
1788
+ Math.min(4000 - Math.max(...positions), value));
1789
+ if (axis === "x") dx = clamped;
1790
+ else dy = clamped;
1791
+ }
1792
+ return { dx, dy };
1793
+ }
1794
+
1586
1795
  function resizedBox(pending, shouldSnap) {
1587
1796
  let { x, y, width, height } = pending.box;
1588
1797
  const right = x + width;
@@ -1612,16 +1821,18 @@ function restoreTransform(target, transform) {
1612
1821
 
1613
1822
  function renderDragPreview() {
1614
1823
  dragFrame = 0;
1615
- if (!drag) return;
1824
+ if (!drag || !drag.moved) return;
1616
1825
  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
- );
1826
+ const { dx, dy } = moveDelta(drag.roots, drag.dx, drag.dy);
1827
+ const translation = `translate(${dx} ${dy})`;
1828
+ for (const item of drag.targets) {
1829
+ item.target.setAttribute(
1830
+ "transform", item.baseTransform ? `${item.baseTransform} ${translation}` : translation,
1831
+ );
1832
+ }
1622
1833
  return;
1623
1834
  }
1624
- const next = resizedBox(drag, false);
1835
+ const next = resizedBox(drag, true);
1625
1836
  const sx = next.width / drag.box.width;
1626
1837
  const sy = next.height / drag.box.height;
1627
1838
  const tx = next.x - drag.box.x * sx;
@@ -1639,8 +1850,10 @@ function updateDrag(event) {
1639
1850
  if (!drag || event.pointerId !== drag.pointerId) return;
1640
1851
  const svg = surface.querySelector("svg");
1641
1852
  const scale = viewBoxScale(svg);
1642
- drag.dx = (event.clientX - drag.startX) / scale.x;
1643
- drag.dy = (event.clientY - drag.startY) / scale.y;
1853
+ const point = architecturePoint(event.clientX, event.clientY);
1854
+ drag.dx = point && drag.startPoint ? point.x - drag.startPoint.x : (event.clientX - drag.startX) / scale.x;
1855
+ drag.dy = point && drag.startPoint ? point.y - drag.startPoint.y : (event.clientY - drag.startY) / scale.y;
1856
+ if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 3) drag.moved = true;
1644
1857
  if (!dragFrame) dragFrame = requestAnimationFrame(renderDragPreview);
1645
1858
  }
1646
1859
 
@@ -1653,8 +1866,9 @@ function settleElement(ref) {
1653
1866
  });
1654
1867
  }
1655
1868
 
1656
- function finishDrag(event) {
1869
+ function finishDrag(event, cancelled = false) {
1657
1870
  if (!drag || event.pointerId !== drag.pointerId) return;
1871
+ if (!cancelled) updateDrag(event);
1658
1872
  const pending = drag;
1659
1873
  drag = null;
1660
1874
  if (dragFrame) {
@@ -1662,28 +1876,125 @@ function finishDrag(event) {
1662
1876
  dragFrame = 0;
1663
1877
  }
1664
1878
  viewport.classList.remove("is-dragging");
1879
+ suppressCanvasClick = true;
1665
1880
  if (pending.captureTarget?.hasPointerCapture?.(pending.pointerId)) {
1666
1881
  pending.captureTarget.releasePointerCapture(pending.pointerId);
1667
1882
  }
1668
- if (pending.kind === "move") {
1669
- pending.target.classList.remove("editor-drag-target");
1670
- restoreTransform(pending.target, pending.baseTransform);
1671
- const dx = snap(pending.dx);
1672
- const dy = snap(pending.dy);
1673
- if (dx || dy) {
1674
- if (applyResult(architecture.move(pending.ref, dx, dy))) settleElement(pending.ref);
1675
- }
1676
- return;
1677
- }
1678
1883
  for (const item of pending.targets) {
1679
1884
  item.target.classList.remove("editor-drag-target");
1680
1885
  restoreTransform(item.target, item.baseTransform);
1681
1886
  }
1887
+ if (cancelled || !pending.moved) {
1888
+ if (!cancelled && pending.kind === "move") selectOnly(pending.ref);
1889
+ renderAll();
1890
+ return;
1891
+ }
1892
+ if (pending.kind === "move") {
1893
+ const blocked = pending.roots.find((root) => !architecture.describe(root.id).movable);
1894
+ if (blocked) {
1895
+ renderAll();
1896
+ announce(`${blocked.id} cannot move because it is managed by a layout.`, "error");
1897
+ return;
1898
+ }
1899
+ const { dx, dy } = moveDelta(pending.roots, pending.dx, pending.dy);
1900
+ if (dx || dy) {
1901
+ if (applyResult(architecture.moveMany(pending.refs, dx, dy))) {
1902
+ pending.roots.forEach((root) => settleElement(root.id));
1903
+ }
1904
+ } else renderAll();
1905
+ return;
1906
+ }
1682
1907
  const next = resizedBox(pending, true);
1683
1908
  const changed = Object.entries(next).some(
1684
1909
  ([key, value]) => value !== pending.box[key],
1685
1910
  );
1686
1911
  if (changed && applyResult(architecture.resize(pending.ref, next))) settleElement(pending.ref);
1912
+ else renderAll();
1913
+ }
1914
+
1915
+ function beginMarquee(event) {
1916
+ const point = architecturePoint(event.clientX, event.clientY);
1917
+ if (!point) return;
1918
+ event.preventDefault();
1919
+ const rect = document.createElementNS(SVG_NS, "rect");
1920
+ rect.classList.add("editor-marquee");
1921
+ rect.setAttribute("aria-hidden", "true");
1922
+ surface.querySelector("svg").appendChild(rect);
1923
+ marquee = {
1924
+ pointerId: event.pointerId,
1925
+ point,
1926
+ clientX: event.clientX,
1927
+ clientY: event.clientY,
1928
+ startX: event.clientX,
1929
+ startY: event.clientY,
1930
+ initial: [...selectedRefs],
1931
+ primary: selectedRef,
1932
+ additive: event.ctrlKey || event.metaKey,
1933
+ rect,
1934
+ moved: false,
1935
+ };
1936
+ viewport.setPointerCapture?.(event.pointerId);
1937
+ viewport.classList.add("is-selecting");
1938
+ }
1939
+
1940
+ function updateMarquee(event) {
1941
+ if (!marquee || marquee.pointerId !== event.pointerId) return;
1942
+ marquee.clientX = event.clientX;
1943
+ marquee.clientY = event.clientY;
1944
+ const point = architecturePoint(event.clientX, event.clientY);
1945
+ if (!point) return;
1946
+ if (Math.hypot(event.clientX - marquee.startX, event.clientY - marquee.startY) > 3) marquee.moved = true;
1947
+ if (!marquee.moved) return;
1948
+ const x = Math.min(point.x, marquee.point.x);
1949
+ const y = Math.min(point.y, marquee.point.y);
1950
+ const right = Math.max(point.x, marquee.point.x);
1951
+ const bottom = Math.max(point.y, marquee.point.y);
1952
+ for (const [key, value] of Object.entries({ x, y, width: right - x, height: bottom - y })) {
1953
+ marquee.rect.setAttribute(key, String(value));
1954
+ }
1955
+ const refs = architecture.model.elements.filter((element) =>
1956
+ element.type !== "connector" && element.x >= x && element.y >= y &&
1957
+ element.x + element.width <= right && element.y + element.height <= bottom,
1958
+ ).map((element) => element.id);
1959
+ setSelection(marquee.additive ? [...marquee.initial, ...refs] : refs);
1960
+ for (const node of surface.querySelectorAll("[data-editor-ref]")) {
1961
+ node.dataset.editorSelected = String(selectedRefs.has(node.dataset.editorRef));
1962
+ }
1963
+ for (const node of tree.querySelectorAll("[data-ref]")) {
1964
+ node.setAttribute("aria-selected", String(selectedRefs.has(node.dataset.ref)));
1965
+ }
1966
+ }
1967
+
1968
+ function finishMarquee(event, cancelled = false) {
1969
+ if (!marquee || marquee.pointerId !== event.pointerId) return;
1970
+ if (!cancelled) updateMarquee(event);
1971
+ const pending = marquee;
1972
+ marquee = null;
1973
+ pending.rect.remove();
1974
+ viewport.classList.remove("is-selecting");
1975
+ suppressCanvasClick = true;
1976
+ if (viewport.hasPointerCapture?.(pending.pointerId)) viewport.releasePointerCapture(pending.pointerId);
1977
+ if (cancelled) setSelection(pending.initial, pending.primary);
1978
+ else if (!pending.moved && !pending.additive) selectOnly(null);
1979
+ renderAll();
1980
+ if (!cancelled) {
1981
+ const target = selectedRef
1982
+ ? surface.querySelector(`[data-editor-ref="${CSS.escape(selectedRef)}"]`)
1983
+ : viewport;
1984
+ target?.focus({ preventScroll: true });
1985
+ announce(`${selectedRefs.size} elements selected.`);
1986
+ }
1987
+ }
1988
+
1989
+ function cancelGestures() {
1990
+ if (drag) finishDrag({ pointerId: drag.pointerId }, true);
1991
+ if (marquee) finishMarquee({ pointerId: marquee.pointerId }, true);
1992
+ if (pan) {
1993
+ const pending = pan;
1994
+ pan = null;
1995
+ viewport.classList.remove("is-panning");
1996
+ if (viewport.hasPointerCapture?.(pending.pointerId)) viewport.releasePointerCapture(pending.pointerId);
1997
+ }
1687
1998
  }
1688
1999
 
1689
2000
  function onElementKeyDown(event) {
@@ -1700,6 +2011,7 @@ function onElementKeyDown(event) {
1700
2011
  }
1701
2012
  if (event.key.startsWith("Arrow") && modelFor(ref)?.type !== "connector") {
1702
2013
  event.preventDefault();
2014
+ if (!selectedRefs.has(ref)) selectOnly(ref);
1703
2015
  const step = event.shiftKey ? 1 : SNAP_SIZE;
1704
2016
  const delta = {
1705
2017
  ArrowLeft: [-step, 0],
@@ -1707,7 +2019,16 @@ function onElementKeyDown(event) {
1707
2019
  ArrowUp: [0, -step],
1708
2020
  ArrowDown: [0, step],
1709
2021
  }[event.key];
1710
- if (delta) applyResult(architecture.move(ref, delta[0], delta[1]));
2022
+ if (delta) {
2023
+ const roots = selectionRoots();
2024
+ const blocked = roots.find((root) => !architecture.describe(root.id).movable);
2025
+ if (blocked) {
2026
+ announce(`${blocked.id} cannot move because it is managed by a layout.`, "error");
2027
+ return;
2028
+ }
2029
+ const { dx, dy } = moveDelta(roots, delta[0], delta[1], !event.shiftKey);
2030
+ applyResult(architecture.moveMany([...selectedRefs], dx, dy));
2031
+ }
1711
2032
  }
1712
2033
  }
1713
2034
 
@@ -1774,6 +2095,7 @@ async function reloadFromMarkdown() {
1774
2095
  }
1775
2096
 
1776
2097
  function setZoom(value, { fit = false } = {}) {
2098
+ cancelGestures();
1777
2099
  zoom = Math.min(2.5, Math.max(0.3, value));
1778
2100
  fitToViewport = fit;
1779
2101
  renderSurface();
@@ -1849,29 +2171,37 @@ function addPosition(point, parentId, width, height) {
1849
2171
  }
1850
2172
 
1851
2173
  function invokeAction(action, context = {}) {
2174
+ cancelGestures();
1852
2175
  const ref = Object.hasOwn(context, "ref") ? context.ref : selectedRef;
2176
+ if (selectedRefs.size > 1 &&
2177
+ ["order-front", "order-back", "release-layout", "set-layout", "start-connector"].includes(action)) {
2178
+ announce("Select a single element for this action.", "error");
2179
+ return;
2180
+ }
1853
2181
  if (action === "undo") {
2182
+ setSelection([...selectedRefs].filter((item) => modelFor(item)?.type !== "connector"));
1854
2183
  applyResult(architecture.undo(), { quiet: true });
1855
2184
  } else if (action === "redo") {
2185
+ setSelection([...selectedRefs].filter((item) => modelFor(item)?.type !== "connector"));
1856
2186
  applyResult(architecture.redo(), { quiet: true });
1857
2187
  } else if (action === "toggle-shape-palette") {
1858
2188
  if (shapePalette.hidden) openShapePalette();
1859
2189
  else closeShapePalette({ restoreFocus: true });
1860
2190
  } else if (action === "add-node") {
1861
- const parentId = entryFor(ref)?.element.type === "group" ? ref : null;
2191
+ const parentId = selectedRefs.size <= 1 && entryFor(ref)?.element.type === "group" ? ref : null;
1862
2192
  applyResult(architecture.addNode({
1863
2193
  parentId,
1864
2194
  shape: SHAPES.includes(context.shape) ? context.shape : "rounded-rect",
1865
2195
  ...addPosition(context.point, parentId, 260, 140),
1866
2196
  }));
1867
2197
  } else if (action === "add-group") {
1868
- const parentId = entryFor(ref)?.element.type === "group" ? ref : null;
2198
+ const parentId = selectedRefs.size <= 1 && entryFor(ref)?.element.type === "group" ? ref : null;
1869
2199
  applyResult(architecture.addGroup({
1870
2200
  parentId,
1871
2201
  ...addPosition(context.point, parentId, 520, 320),
1872
2202
  }));
1873
2203
  } else if (action === "add-image") {
1874
- const parentId = entryFor(ref)?.element.type === "group" ? ref : null;
2204
+ const parentId = selectedRefs.size <= 1 && entryFor(ref)?.element.type === "group" ? ref : null;
1875
2205
  openAssetPicker({
1876
2206
  mode: "add-image",
1877
2207
  parentId,
@@ -1885,15 +2215,20 @@ function invokeAction(action, context = {}) {
1885
2215
  } else if (action === "start-connector" && ref) {
1886
2216
  const element = modelFor(ref);
1887
2217
  if (!element || element.type === "connector") return;
1888
- selectedRef = ref;
2218
+ selectOnly(ref);
1889
2219
  connectorTool = { from: element.id };
1890
2220
  announce(`Set ${element.id} as the source. Select a target.`);
1891
2221
  renderAll();
1892
2222
  } else if (action === "duplicate" && ref) {
1893
- applyResult(architecture.duplicate(ref));
2223
+ applyResult(selectedRefs.size > 1
2224
+ ? architecture.duplicateMany([...selectedRefs])
2225
+ : architecture.duplicate(ref));
1894
2226
  } else if (action === "delete" && ref) {
1895
- const deleted = ref;
1896
- if (applyResult(architecture.remove(ref), { select: null })) {
2227
+ const deleted = selectedRefs.size > 1 ? `${selectedRefs.size} elements` : ref;
2228
+ const result = selectedRefs.size > 1
2229
+ ? architecture.removeMany([...selectedRefs])
2230
+ : architecture.remove(ref);
2231
+ if (applyResult(result, { select: null })) {
1897
2232
  announce(`Deleted ${deleted}.`);
1898
2233
  }
1899
2234
  } else if (action === "release-layout" && ref) {
@@ -1923,7 +2258,8 @@ function wireControls() {
1923
2258
  document.querySelectorAll("[data-action]").forEach((button) => {
1924
2259
  button.addEventListener("click", () => {
1925
2260
  const insideMore = toolbarMoreMenu.contains(button);
1926
- if (insideMore) closeToolbarMore({ restoreFocus: true });
2261
+ const keepMoreOpen = ["zoom-out", "zoom-in"].includes(button.dataset.action);
2262
+ if (insideMore && !keepMoreOpen) closeToolbarMore({ restoreFocus: true });
1927
2263
  invokeAction(
1928
2264
  button.dataset.action,
1929
2265
  insideMore ? { returnFocus: toolbarMoreButton } : {},
@@ -2019,7 +2355,12 @@ function wireControls() {
2019
2355
  });
2020
2356
  document.addEventListener("pointermove", updateDrag);
2021
2357
  document.addEventListener("pointerup", finishDrag);
2022
- document.addEventListener("pointercancel", finishDrag);
2358
+ document.addEventListener("pointercancel", (event) => finishDrag(event, true));
2359
+ document.addEventListener("lostpointercapture", (event) => finishDrag(event, true));
2360
+ document.addEventListener("pointermove", updateMarquee);
2361
+ document.addEventListener("pointerup", finishMarquee);
2362
+ document.addEventListener("pointercancel", (event) => finishMarquee(event, true));
2363
+ document.addEventListener("lostpointercapture", (event) => finishMarquee(event, true));
2023
2364
  assetSearch.addEventListener("input", renderAssetLibrary);
2024
2365
  assetImportButton.addEventListener("click", () => assetFileInput.click());
2025
2366
  assetFileInput.addEventListener("change", () => {
@@ -2126,12 +2467,20 @@ function wireControls() {
2126
2467
  origin: "canvas",
2127
2468
  });
2128
2469
  });
2470
+ viewport.addEventListener("pointerdown", () => { suppressCanvasClick = false; }, true);
2129
2471
  viewport.addEventListener("pointerdown", (event) => {
2130
2472
  const interactiveTarget = event.target.closest(
2131
2473
  "[data-editor-ref], .editor-resize-handle, button, input, select, textarea, a",
2132
2474
  );
2475
+ const bounds = viewport.getBoundingClientRect();
2476
+ if (event.clientX >= bounds.left + viewport.clientWidth ||
2477
+ event.clientY >= bounds.top + viewport.clientHeight) return;
2133
2478
  const primaryBlankDrag = event.button === 0 && !interactiveTarget;
2134
- if (event.button !== 1 && !(event.button === 0 && spacePressed) && !primaryBlankDrag) return;
2479
+ const panning = event.button === 1 || (event.button === 0 && spacePressed);
2480
+ if (!panning) {
2481
+ if (primaryBlankDrag) beginMarquee(event);
2482
+ return;
2483
+ }
2135
2484
  event.preventDefault();
2136
2485
  viewport.setPointerCapture?.(event.pointerId);
2137
2486
  pan = {
@@ -2140,7 +2489,6 @@ function wireControls() {
2140
2489
  y: event.clientY,
2141
2490
  left: viewport.scrollLeft,
2142
2491
  top: viewport.scrollTop,
2143
- moved: false,
2144
2492
  };
2145
2493
  viewport.classList.add("is-panning");
2146
2494
  });
@@ -2148,13 +2496,12 @@ function wireControls() {
2148
2496
  if (!pan || pan.pointerId !== event.pointerId) return;
2149
2497
  const dx = event.clientX - pan.x;
2150
2498
  const dy = event.clientY - pan.y;
2151
- if (Math.abs(dx) > 3 || Math.abs(dy) > 3) pan.moved = true;
2152
2499
  viewport.scrollLeft = pan.left - dx;
2153
2500
  viewport.scrollTop = pan.top - dy;
2154
2501
  });
2155
2502
  const finishPan = (event) => {
2156
2503
  if (!pan || pan.pointerId !== event.pointerId) return;
2157
- suppressCanvasClick = pan.moved;
2504
+ suppressCanvasClick = true;
2158
2505
  pan = null;
2159
2506
  viewport.classList.remove("is-panning");
2160
2507
  if (viewport.hasPointerCapture?.(event.pointerId)) {
@@ -2165,7 +2512,7 @@ function wireControls() {
2165
2512
  viewport.addEventListener("pointercancel", finishPan);
2166
2513
  viewport.addEventListener("lostpointercapture", (event) => {
2167
2514
  if (pan?.pointerId !== event.pointerId) return;
2168
- suppressCanvasClick = pan.moved;
2515
+ suppressCanvasClick = true;
2169
2516
  pan = null;
2170
2517
  viewport.classList.remove("is-panning");
2171
2518
  });
@@ -2175,6 +2522,9 @@ function wireControls() {
2175
2522
  event.preventDefault();
2176
2523
  event.stopPropagation();
2177
2524
  }, true);
2525
+ viewport.addEventListener("auxclick", (event) => {
2526
+ if (event.button === 1) event.preventDefault();
2527
+ });
2178
2528
  viewport.addEventListener("scroll", () => closeContextMenu(), { passive: true });
2179
2529
  tree.closest(".editor-sidebar")?.addEventListener("scroll", () => closeContextMenu(), {
2180
2530
  passive: true,
@@ -2191,9 +2541,18 @@ function wireControls() {
2191
2541
  window.addEventListener("keydown", (event) => {
2192
2542
  if (assetDialog.open) return;
2193
2543
  const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName);
2194
- if (event.code === "Space" && !editable) spacePressed = true;
2544
+ if (event.code === "Space" && !editable) {
2545
+ spacePressed = true;
2546
+ if (viewport.contains(event.target) || tree.contains(event.target) ||
2547
+ event.target === document.body) event.preventDefault();
2548
+ }
2195
2549
  const modifier = event.ctrlKey || event.metaKey;
2196
2550
  const key = event.key.toLowerCase();
2551
+ if (event.key === "Escape" && (drag || marquee || pan)) {
2552
+ event.preventDefault();
2553
+ cancelGestures();
2554
+ return;
2555
+ }
2197
2556
  if (event.key === "Escape" && !toolbarMoreMenu.hidden) {
2198
2557
  event.preventDefault();
2199
2558
  closeToolbarMore({ restoreFocus: true });
@@ -2230,7 +2589,7 @@ function wireControls() {
2230
2589
  invokeAction("delete");
2231
2590
  } else if (event.key === "Escape") {
2232
2591
  connectorTool = null;
2233
- selectedRef = null;
2592
+ selectOnly(null);
2234
2593
  renderAll();
2235
2594
  }
2236
2595
  });
@@ -2238,7 +2597,11 @@ function wireControls() {
2238
2597
  if (event.code === "Space") spacePressed = false;
2239
2598
  });
2240
2599
  window.addEventListener("resize", () => closeContextMenu(), { passive: true });
2241
- window.addEventListener("blur", () => closeContextMenu());
2600
+ window.addEventListener("blur", () => {
2601
+ spacePressed = false;
2602
+ cancelGestures();
2603
+ closeContextMenu();
2604
+ });
2242
2605
  window.addEventListener("beforeunload", (event) => {
2243
2606
  if (!dirty) return;
2244
2607
  event.preventDefault();
@@ -2263,7 +2626,8 @@ async function refreshState() {
2263
2626
  targetGeneration = stateGeneration;
2264
2627
  draftRevision = stateRevision;
2265
2628
  architecture = createArchitectureDocument(state.source);
2266
- selectedRef = null;
2629
+ cancelGestures();
2630
+ selectOnly(null);
2267
2631
  setDirty(state.dirty);
2268
2632
  renderAll();
2269
2633
  return;
@@ -2276,7 +2640,8 @@ async function refreshState() {
2276
2640
  draftRevision = stateRevision;
2277
2641
  if (!architecture || architecture.source !== state.source) {
2278
2642
  architecture = createArchitectureDocument(state.source);
2279
- selectedRef = null;
2643
+ cancelGestures();
2644
+ selectOnly(null);
2280
2645
  setDirty(state.dirty);
2281
2646
  renderAll();
2282
2647
  } else {