@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.
- package/package.json +1 -1
- package/shared/README.md +35 -15
- package/shared/architecture-editor/editor.css +116 -20
- package/shared/architecture-editor/editor.js +577 -42
- package/shared/architecture-editor/index.html +53 -24
- package/shared/renderer/architecture.mjs +96 -15
- package/shared/renderer/index.html +11 -0
- package/shared/renderer/renderer.js +142 -4
- package/shared/renderer/slides.css +5 -2
- package/shared/runtime/pptx-package.mjs +9 -1
- package/shared/schema/architecture-v1.schema.json +11 -1
|
@@ -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 = [
|
|
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
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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 (
|
|
1345
|
-
|
|
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.
|
|
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)
|
|
1673
|
+
if (dx || dy) {
|
|
1674
|
+
if (applyResult(architecture.move(pending.ref, dx, dy))) settleElement(pending.ref);
|
|
1675
|
+
}
|
|
1358
1676
|
return;
|
|
1359
1677
|
}
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
1377
|
-
|
|
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
|
-
|
|
1839
|
+
const explicitPoint = Boolean(point);
|
|
1840
|
+
const target = point || visibleCanvasCenter();
|
|
1481
1841
|
const parent = parentId ? modelFor(parentId) : null;
|
|
1482
|
-
|
|
1483
|
-
x: snap(
|
|
1484
|
-
y: snap(
|
|
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", () =>
|
|
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
|
-
|
|
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
|
-
|
|
1658
|
-
|
|
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
|
-
|
|
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(
|
|
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"));
|