@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.
- package/README.md +25 -13
- package/package.json +1 -1
- package/shared/README.md +47 -10
- package/shared/architecture-editor/editor.css +9 -5
- package/shared/architecture-editor/editor.js +440 -75
- package/shared/architecture-editor/index.html +2 -2
- package/shared/docs/custom-theme-authoring.md +61 -4
- package/shared/markdown-deck.mjs +9 -5
- package/shared/renderer/architecture-document.mjs +169 -10
- package/shared/renderer/index.html +24 -1
- package/shared/renderer/mermaid-scene.mjs +6725 -197
- package/shared/renderer/renderer.js +260 -55
- package/shared/renderer/scene-graph.mjs +83 -13
- package/shared/renderer/scene-pptx.mjs +154 -1
- package/shared/renderer/scene-svg.mjs +227 -11
- package/shared/renderer/slide-background.mjs +22 -0
- package/shared/renderer/slides.css +32 -6
- package/shared/renderer/theme.mjs +328 -12
- package/shared/runtime/browser.mjs +75 -5
- package/shared/runtime/deck-session.mjs +35 -9
- package/shared/runtime/output-paths.mjs +7 -0
- package/shared/runtime/output.mjs +4 -2
- package/shared/runtime/pptx-package.mjs +103 -22
- package/shared/runtime/presentation-server.mjs +410 -95
- package/shared/runtime/slide-backgrounds.mjs +44 -0
- package/shared/schema/theme-metadata-v1.schema.json +25 -0
- package/shared/schema/theme-v1.json +3 -3
- package/src/cli.mjs +101 -21
- package/src/commands/export.mjs +2 -0
- package/src/commands/present.mjs +133 -143
- package/src/deck.mjs +4 -0
- package/src/skills.mjs +12 -8
|
@@ -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)
|
|
348
|
-
else if (result.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
1313
|
+
applyResult(setSelectedProperty("style.dash", "6 3"));
|
|
1158
1314
|
return;
|
|
1159
1315
|
}
|
|
1160
1316
|
applyResult(
|
|
1161
|
-
|
|
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(
|
|
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, {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1690
|
+
selectFromEvent(ref, event);
|
|
1521
1691
|
renderAll();
|
|
1522
1692
|
}
|
|
1523
1693
|
|
|
1524
1694
|
function beginMove(event) {
|
|
1525
|
-
if (event.button !== 0 || event.
|
|
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.
|
|
1539
|
-
|
|
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
|
-
|
|
1545
|
-
|
|
1546
|
-
baseTransform:
|
|
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
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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,
|
|
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
|
-
|
|
1643
|
-
drag.
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
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", () =>
|
|
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
|
-
|
|
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
|
-
|
|
2643
|
+
cancelGestures();
|
|
2644
|
+
selectOnly(null);
|
|
2280
2645
|
setDirty(state.dirty);
|
|
2281
2646
|
renderAll();
|
|
2282
2647
|
} else {
|