@markdstage/markdstage 0.1.1 → 2.3.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 +21 -6
- package/package.json +3 -1
- package/shared/README.md +29 -12
- package/shared/architecture-editor/editor.css +155 -0
- package/shared/architecture-editor/editor.js +1775 -0
- package/shared/architecture-editor/index.html +99 -0
- package/shared/markdstage-guide.mjs +1 -1
- package/shared/renderer/architecture.mjs +233 -0
- package/shared/renderer/index.html +7 -6
- package/shared/renderer/renderer.js +1163 -16
- package/shared/renderer/slides.css +42 -2
- package/shared/runtime/architecture-editor-server.mjs +651 -0
- package/shared/runtime/architecture-source.mjs +195 -0
- package/shared/runtime/browser.mjs +71 -4
- package/shared/runtime/deck-session.mjs +3 -1
- package/shared/runtime/output-paths.mjs +18 -1
- package/shared/runtime/output.mjs +267 -4
- package/shared/runtime/pptx-package.mjs +1088 -0
- package/shared/runtime/presentation-server.mjs +268 -12
- package/src/cli.mjs +42 -4
- package/src/commands/export.mjs +31 -11
- package/src/commands/present.mjs +138 -49
- package/src/deck.mjs +2 -0
- package/src/runtime.mjs +8 -2
- package/src/skills.mjs +9 -3
|
@@ -0,0 +1,1775 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ICONS,
|
|
3
|
+
LAYOUTS,
|
|
4
|
+
LAYOUT_DIRECTIONS,
|
|
5
|
+
THEME_TOKENS,
|
|
6
|
+
parseArchitecture,
|
|
7
|
+
renderArchitectureBlock,
|
|
8
|
+
} from "../renderer/architecture.mjs";
|
|
9
|
+
import { createArchitectureDocument } from "../renderer/architecture-document.mjs";
|
|
10
|
+
|
|
11
|
+
const COLORS = [...Object.keys(THEME_TOKENS), "black", "white", "transparent", "none"];
|
|
12
|
+
const PORTS = ["auto", "top", "right", "bottom", "left"];
|
|
13
|
+
const ROUTING = ["straight", "orthogonal", "polyline"];
|
|
14
|
+
const LABEL_LAYERS = [
|
|
15
|
+
{ value: "front", label: "In front of boxes" },
|
|
16
|
+
{ value: "behind", label: "Behind boxes" },
|
|
17
|
+
];
|
|
18
|
+
const SHAPES = ["rect", "rounded-rect", "ellipse"];
|
|
19
|
+
const IMAGE_FITS = ["contain", "cover", "stretch"];
|
|
20
|
+
const ASSET_MAX_BYTES = 10 * 1024 * 1024;
|
|
21
|
+
const SNAP_SIZE = 10;
|
|
22
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
23
|
+
const SHARED_LAYOUT_KEYS = ["gap", "rowGap", "columnGap", "padding"];
|
|
24
|
+
|
|
25
|
+
const sourceLabel = document.getElementById("sourceLabel");
|
|
26
|
+
const dirtyBadge = document.getElementById("dirtyBadge");
|
|
27
|
+
const tree = document.getElementById("elementTree");
|
|
28
|
+
const inspector = document.getElementById("inspector");
|
|
29
|
+
const viewport = document.getElementById("viewport");
|
|
30
|
+
const surface = document.getElementById("canvasSurface");
|
|
31
|
+
const status = document.getElementById("status");
|
|
32
|
+
const zoomStatus = document.getElementById("zoomStatus");
|
|
33
|
+
const snapToggle = document.getElementById("snapToggle");
|
|
34
|
+
const contextMenu = document.getElementById("contextMenu");
|
|
35
|
+
const assetDialog = document.getElementById("assetDialog");
|
|
36
|
+
const assetDialogTitle = document.getElementById("assetDialogTitle");
|
|
37
|
+
const assetDialogClose = document.getElementById("assetDialogClose");
|
|
38
|
+
const assetSearch = document.getElementById("assetSearch");
|
|
39
|
+
const assetImportButton = document.getElementById("assetImportButton");
|
|
40
|
+
const assetFileInput = document.getElementById("assetFileInput");
|
|
41
|
+
const assetList = document.getElementById("assetList");
|
|
42
|
+
const assetPreviewImageHost = document.getElementById("assetPreviewImageHost");
|
|
43
|
+
const assetPreviewEmpty = document.getElementById("assetPreviewEmpty");
|
|
44
|
+
const assetPreviewPath = document.getElementById("assetPreviewPath");
|
|
45
|
+
const assetDialogStatus = document.getElementById("assetDialogStatus");
|
|
46
|
+
const assetCancelButton = document.getElementById("assetCancelButton");
|
|
47
|
+
const assetChooseButton = document.getElementById("assetChooseButton");
|
|
48
|
+
|
|
49
|
+
let architecture = null;
|
|
50
|
+
let selectedRef = null;
|
|
51
|
+
let sourcePath = "";
|
|
52
|
+
let blockIndex = 0;
|
|
53
|
+
let dirty = false;
|
|
54
|
+
let zoom = 1;
|
|
55
|
+
let draftRevision = 0;
|
|
56
|
+
let targetGeneration = null;
|
|
57
|
+
let draftQueue = Promise.resolve();
|
|
58
|
+
let drag = null;
|
|
59
|
+
let pan = null;
|
|
60
|
+
let spacePressed = false;
|
|
61
|
+
let connectorTool = null;
|
|
62
|
+
let serverVersion = -1;
|
|
63
|
+
let contextMenuState = null;
|
|
64
|
+
let assetPickerState = null;
|
|
65
|
+
let availableAssets = [];
|
|
66
|
+
let selectedAssetPath = "";
|
|
67
|
+
let assetUploadPending = false;
|
|
68
|
+
let assetLibraryRequest = 0;
|
|
69
|
+
|
|
70
|
+
function announce(message, kind = "info") {
|
|
71
|
+
status.textContent = message;
|
|
72
|
+
status.dataset.kind = kind;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function setDirty(value) {
|
|
76
|
+
dirty = Boolean(value);
|
|
77
|
+
dirtyBadge.hidden = !dirty;
|
|
78
|
+
document.querySelector('[data-action="save"]').disabled = !dirty;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function snap(value) {
|
|
82
|
+
return snapToggle.checked ? Math.round(value / SNAP_SIZE) * SNAP_SIZE : value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function viewBoxScale(svg) {
|
|
86
|
+
const ctm = svg?.getScreenCTM?.();
|
|
87
|
+
if (ctm && ctm.a && ctm.d) return { x: ctm.a, y: ctm.d };
|
|
88
|
+
return { x: 1, y: 1 };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function rawEntries(raw) {
|
|
92
|
+
const entries = [];
|
|
93
|
+
const walk = (items, depth, parentId, prefix) => {
|
|
94
|
+
if (!Array.isArray(items)) return;
|
|
95
|
+
items.forEach((element, index) => {
|
|
96
|
+
const sourcePath = `${prefix}[${index}]`;
|
|
97
|
+
entries.push({
|
|
98
|
+
element,
|
|
99
|
+
depth,
|
|
100
|
+
parentId,
|
|
101
|
+
sourcePath,
|
|
102
|
+
ref: element.id || sourcePath,
|
|
103
|
+
});
|
|
104
|
+
if (element.type === "group") {
|
|
105
|
+
walk(element.children, depth + 1, element.id, `${sourcePath}.children`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
walk(raw.elements, 0, null, "elements");
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function entryFor(ref) {
|
|
114
|
+
return rawEntries(architecture.raw).find(
|
|
115
|
+
(entry) => entry.ref === ref || entry.sourcePath === ref,
|
|
116
|
+
) || null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function modelFor(ref) {
|
|
120
|
+
return architecture.model.elements.find(
|
|
121
|
+
(element) => element.id === ref || element.sourcePath === ref,
|
|
122
|
+
) || null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function endpointOptions() {
|
|
126
|
+
return architecture.model.elements
|
|
127
|
+
.filter((element) => element.type !== "connector")
|
|
128
|
+
.map((element) => ({ value: element.id, label: element.id }));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function queueDraft() {
|
|
132
|
+
const source = architecture.source;
|
|
133
|
+
const revision = ++draftRevision;
|
|
134
|
+
const generation = targetGeneration;
|
|
135
|
+
setDirty(true);
|
|
136
|
+
draftQueue = draftQueue
|
|
137
|
+
.catch(() => {})
|
|
138
|
+
.then(async () => {
|
|
139
|
+
const response = await fetch("./draft", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "Content-Type": "application/json" },
|
|
142
|
+
body: JSON.stringify({ source, revision, generation }),
|
|
143
|
+
});
|
|
144
|
+
const result = await response.json().catch(() => ({}));
|
|
145
|
+
if (!response.ok || result.ok !== true) {
|
|
146
|
+
throw new Error(result.message || "Could not retain draft");
|
|
147
|
+
}
|
|
148
|
+
serverVersion = Math.max(serverVersion, result.version ?? serverVersion);
|
|
149
|
+
return result;
|
|
150
|
+
})
|
|
151
|
+
.catch((error) => {
|
|
152
|
+
announce(`Could not retain edits: ${error.message}`, "error");
|
|
153
|
+
throw error;
|
|
154
|
+
});
|
|
155
|
+
return draftQueue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function applyResult(result, { select = undefined, quiet = false } = {}) {
|
|
159
|
+
if (!result?.ok) {
|
|
160
|
+
const message =
|
|
161
|
+
result?.reason === "layout-managed"
|
|
162
|
+
? `The layout of ${result.layoutOwner} controls the placement of ${result.id}.`
|
|
163
|
+
: result?.message || `Could not apply action (${result?.reason || "unknown"})`;
|
|
164
|
+
announce(message, "error");
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
if (select !== undefined) selectedRef = select;
|
|
168
|
+
else if (result.ref) selectedRef = result.ref;
|
|
169
|
+
renderAll();
|
|
170
|
+
queueDraft();
|
|
171
|
+
if (!quiet) announce("Changed. The update will not affect the Markdown until you save.");
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function iconFor(type) {
|
|
176
|
+
if (type === "node") return "▣";
|
|
177
|
+
if (type === "group") return "▤";
|
|
178
|
+
if (type === "image") return "▧";
|
|
179
|
+
return "→";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function labelFor(entry) {
|
|
183
|
+
const item = entry.element;
|
|
184
|
+
if (item.type === "connector") return `${item.from} → ${item.to}`;
|
|
185
|
+
const detail = item.text
|
|
186
|
+
? String(item.text).split("\n")[0]
|
|
187
|
+
: item.title || (item.type === "image" ? item.src : "");
|
|
188
|
+
return item.id + (detail ? ` — ${detail}` : "");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function contextMenuReturnTarget(state) {
|
|
192
|
+
if (!state?.ref) return null;
|
|
193
|
+
const selector = CSS.escape(state.ref);
|
|
194
|
+
return state.origin === "tree"
|
|
195
|
+
? tree.querySelector(`.tree-item[data-ref="${selector}"]`)
|
|
196
|
+
: surface.querySelector(`[data-editor-ref="${selector}"]`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function closeContextMenu({ restoreFocus = false } = {}) {
|
|
200
|
+
const previous = contextMenuState;
|
|
201
|
+
contextMenuState = null;
|
|
202
|
+
contextMenu.hidden = true;
|
|
203
|
+
contextMenu.replaceChildren();
|
|
204
|
+
if (restoreFocus) contextMenuReturnTarget(previous)?.focus();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function releaseLayoutAvailable(ref) {
|
|
208
|
+
const entry = entryFor(ref);
|
|
209
|
+
return entry?.element.type === "group" && Boolean(entry.element.layout);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function layoutTypeFor(ref) {
|
|
213
|
+
const layout = entryFor(ref)?.element.layout;
|
|
214
|
+
return typeof layout === "string" ? layout : layout?.type || "";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function updateGroupLayout(ref, type) {
|
|
218
|
+
const entry = entryFor(ref);
|
|
219
|
+
if (entry?.element.type !== "group") {
|
|
220
|
+
return { ok: false, message: "Layouts can be changed only on groups." };
|
|
221
|
+
}
|
|
222
|
+
if (!type) return architecture.releaseLayout(ref);
|
|
223
|
+
|
|
224
|
+
const current =
|
|
225
|
+
typeof entry.element.layout === "string"
|
|
226
|
+
? { type: entry.element.layout }
|
|
227
|
+
: entry.element.layout || {};
|
|
228
|
+
const next = { type };
|
|
229
|
+
for (const key of SHARED_LAYOUT_KEYS) {
|
|
230
|
+
if (current[key] !== undefined) next[key] = current[key];
|
|
231
|
+
}
|
|
232
|
+
if (type === "grid" && current.columns !== undefined) next.columns = current.columns;
|
|
233
|
+
if (type === "layered" && current.direction !== undefined) {
|
|
234
|
+
next.direction = current.direction;
|
|
235
|
+
}
|
|
236
|
+
return architecture.setGroupLayout(ref, next);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function applyGroupLayout(ref, type) {
|
|
240
|
+
const changed = applyResult(updateGroupLayout(ref, type), { quiet: true });
|
|
241
|
+
if (changed) {
|
|
242
|
+
announce(
|
|
243
|
+
type
|
|
244
|
+
? `Changed the layout of ${ref} to ${type}.`
|
|
245
|
+
: `Removed the layout from ${ref}.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return changed;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function menuItemsFor({ ref, point }) {
|
|
252
|
+
const entry = ref ? entryFor(ref) : null;
|
|
253
|
+
if (!entry) {
|
|
254
|
+
const suffix = point ? " here" : "";
|
|
255
|
+
return [
|
|
256
|
+
{ label: `Add node${suffix}`, action: "add-node" },
|
|
257
|
+
{ label: `Add group${suffix}`, action: "add-group" },
|
|
258
|
+
{ label: `Add image${suffix}`, action: "add-image" },
|
|
259
|
+
{ separator: true },
|
|
260
|
+
{ label: "Undo", action: "undo", shortcut: "Ctrl+Z", disabled: !architecture.canUndo },
|
|
261
|
+
{ label: "Redo", action: "redo", shortcut: "Ctrl+Y", disabled: !architecture.canRedo },
|
|
262
|
+
{ separator: true },
|
|
263
|
+
{ label: "Save to Markdown", action: "save", shortcut: "Ctrl+S", disabled: !dirty },
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const items = [];
|
|
268
|
+
if (entry.element.type === "group") {
|
|
269
|
+
const suffix = point ? " here" : "";
|
|
270
|
+
items.push(
|
|
271
|
+
{ label: `Add child node${suffix}`, action: "add-node" },
|
|
272
|
+
{ label: `Add child group${suffix}`, action: "add-group" },
|
|
273
|
+
{ label: `Add child image${suffix}`, action: "add-image" },
|
|
274
|
+
{ separator: true },
|
|
275
|
+
{
|
|
276
|
+
label: "Layout",
|
|
277
|
+
submenu: "layout",
|
|
278
|
+
choices: [
|
|
279
|
+
{ label: "None", value: "" },
|
|
280
|
+
...[...LAYOUTS].map((type) => ({ label: type, value: type })),
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
{ separator: true },
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (entry.element.type !== "connector") {
|
|
287
|
+
items.push({ label: "Start connector here", action: "start-connector" }, { separator: true });
|
|
288
|
+
}
|
|
289
|
+
items.push(
|
|
290
|
+
{ label: "Duplicate", action: "duplicate", shortcut: "Ctrl+D" },
|
|
291
|
+
{ label: "Bring forward", action: "order-front" },
|
|
292
|
+
{ label: "Send backward", action: "order-back" },
|
|
293
|
+
);
|
|
294
|
+
items.push({ separator: true }, { label: "Delete", action: "delete", shortcut: "Delete", danger: true });
|
|
295
|
+
return items;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function activateContextMenuItem(button) {
|
|
299
|
+
if (button.disabled || !contextMenuState) return;
|
|
300
|
+
const action = button.dataset.action;
|
|
301
|
+
const actionContext = {
|
|
302
|
+
ref: contextMenuState.ref,
|
|
303
|
+
point: contextMenuState.point,
|
|
304
|
+
returnFocus: contextMenuReturnTarget(contextMenuState),
|
|
305
|
+
};
|
|
306
|
+
if (action === "set-layout") actionContext.layoutType = button.dataset.layoutType || "";
|
|
307
|
+
closeContextMenu();
|
|
308
|
+
invokeAction(action, actionContext);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function directMenuItems(menu) {
|
|
312
|
+
return [...menu.children]
|
|
313
|
+
.map((child) =>
|
|
314
|
+
child.matches(".context-menu-item")
|
|
315
|
+
? child
|
|
316
|
+
: child.querySelector(":scope > .context-menu-item"),
|
|
317
|
+
)
|
|
318
|
+
.filter((item) => item && !item.disabled);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function closeContextSubmenu({ restoreFocus = false } = {}) {
|
|
322
|
+
const trigger = contextMenu.querySelector('.context-menu-item[aria-expanded="true"]');
|
|
323
|
+
if (!trigger) return;
|
|
324
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
325
|
+
const submenu = trigger.parentElement.querySelector(":scope > .context-submenu");
|
|
326
|
+
if (submenu) submenu.hidden = true;
|
|
327
|
+
if (restoreFocus) trigger.focus();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function positionContextSubmenu(trigger, submenu) {
|
|
331
|
+
submenu.style.left = "0px";
|
|
332
|
+
submenu.style.top = "0px";
|
|
333
|
+
const margin = 8;
|
|
334
|
+
const gap = 4;
|
|
335
|
+
const triggerBounds = trigger.getBoundingClientRect();
|
|
336
|
+
const openRight = triggerBounds.right + gap + submenu.offsetWidth <= window.innerWidth - margin;
|
|
337
|
+
const left = openRight
|
|
338
|
+
? triggerBounds.right + gap
|
|
339
|
+
: triggerBounds.left - submenu.offsetWidth - gap;
|
|
340
|
+
const top = Math.min(
|
|
341
|
+
triggerBounds.top,
|
|
342
|
+
window.innerHeight - submenu.offsetHeight - margin,
|
|
343
|
+
);
|
|
344
|
+
submenu.style.left = `${Math.max(margin, left)}px`;
|
|
345
|
+
submenu.style.top = `${Math.max(margin, top)}px`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function openContextSubmenu(trigger, { focusFirst = false } = {}) {
|
|
349
|
+
if (trigger.disabled) return;
|
|
350
|
+
closeContextSubmenu();
|
|
351
|
+
const submenu = trigger.parentElement.querySelector(":scope > .context-submenu");
|
|
352
|
+
if (!submenu) return;
|
|
353
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
354
|
+
submenu.hidden = false;
|
|
355
|
+
positionContextSubmenu(trigger, submenu);
|
|
356
|
+
if (focusFirst) directMenuItems(submenu)[0]?.focus();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function createContextMenuButton(item, role = "menuitem") {
|
|
360
|
+
const button = document.createElement("button");
|
|
361
|
+
button.type = "button";
|
|
362
|
+
button.className = "context-menu-item";
|
|
363
|
+
if (item.action) button.dataset.action = item.action;
|
|
364
|
+
button.dataset.danger = item.danger ? "true" : "false";
|
|
365
|
+
button.setAttribute("role", role);
|
|
366
|
+
button.disabled = Boolean(item.disabled);
|
|
367
|
+
button.setAttribute("aria-disabled", button.disabled ? "true" : "false");
|
|
368
|
+
const label = document.createElement("span");
|
|
369
|
+
label.textContent = item.label;
|
|
370
|
+
button.appendChild(label);
|
|
371
|
+
if (item.shortcut) {
|
|
372
|
+
const shortcut = document.createElement("span");
|
|
373
|
+
shortcut.className = "context-menu-shortcut";
|
|
374
|
+
shortcut.textContent = item.shortcut;
|
|
375
|
+
shortcut.setAttribute("aria-hidden", "true");
|
|
376
|
+
button.appendChild(shortcut);
|
|
377
|
+
}
|
|
378
|
+
return button;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function appendContextSubmenu(item, ref) {
|
|
382
|
+
const host = document.createElement("div");
|
|
383
|
+
host.className = "context-menu-submenu-host";
|
|
384
|
+
host.setAttribute("role", "none");
|
|
385
|
+
const trigger = createContextMenuButton(item);
|
|
386
|
+
trigger.dataset.submenu = item.submenu;
|
|
387
|
+
trigger.setAttribute("aria-haspopup", "menu");
|
|
388
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
389
|
+
const indicator = document.createElement("span");
|
|
390
|
+
indicator.className = "context-menu-submenu-indicator";
|
|
391
|
+
indicator.textContent = "›";
|
|
392
|
+
indicator.setAttribute("aria-hidden", "true");
|
|
393
|
+
trigger.appendChild(indicator);
|
|
394
|
+
|
|
395
|
+
const submenu = document.createElement("div");
|
|
396
|
+
submenu.className = "context-menu context-submenu";
|
|
397
|
+
submenu.setAttribute("role", "menu");
|
|
398
|
+
submenu.setAttribute("aria-label", `Layout for ${ref}`);
|
|
399
|
+
submenu.hidden = true;
|
|
400
|
+
const selected = layoutTypeFor(ref);
|
|
401
|
+
for (const choice of item.choices) {
|
|
402
|
+
const button = createContextMenuButton(
|
|
403
|
+
{ label: choice.label, action: "set-layout" },
|
|
404
|
+
"menuitemradio",
|
|
405
|
+
);
|
|
406
|
+
button.dataset.layoutType = choice.value;
|
|
407
|
+
const checked = choice.value === selected;
|
|
408
|
+
button.setAttribute("aria-checked", checked ? "true" : "false");
|
|
409
|
+
const mark = document.createElement("span");
|
|
410
|
+
mark.className = "context-menu-check";
|
|
411
|
+
mark.textContent = checked ? "✓" : "";
|
|
412
|
+
mark.setAttribute("aria-hidden", "true");
|
|
413
|
+
button.prepend(mark);
|
|
414
|
+
button.addEventListener("click", () => activateContextMenuItem(button));
|
|
415
|
+
submenu.appendChild(button);
|
|
416
|
+
}
|
|
417
|
+
trigger.addEventListener("click", () => openContextSubmenu(trigger, { focusFirst: true }));
|
|
418
|
+
trigger.addEventListener("pointerenter", () => openContextSubmenu(trigger));
|
|
419
|
+
host.addEventListener("pointerleave", () => {
|
|
420
|
+
closeContextSubmenu({ restoreFocus: submenu.contains(document.activeElement) });
|
|
421
|
+
});
|
|
422
|
+
host.append(trigger, submenu);
|
|
423
|
+
contextMenu.appendChild(host);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function openContextMenu({ clientX, clientY, ref = null, point = null, origin = "canvas" }) {
|
|
427
|
+
closeContextMenu();
|
|
428
|
+
contextMenuState = { ref, point, origin };
|
|
429
|
+
contextMenu.setAttribute(
|
|
430
|
+
"aria-label",
|
|
431
|
+
ref ? `Editing actions for ${labelFor(entryFor(ref))}` : "Drawing area editing actions",
|
|
432
|
+
);
|
|
433
|
+
for (const item of menuItemsFor({ ref, point })) {
|
|
434
|
+
if (item.separator) {
|
|
435
|
+
const separator = document.createElement("hr");
|
|
436
|
+
separator.className = "context-menu-separator";
|
|
437
|
+
separator.setAttribute("role", "separator");
|
|
438
|
+
contextMenu.appendChild(separator);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (item.submenu) {
|
|
442
|
+
appendContextSubmenu(item, ref);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const button = createContextMenuButton(item);
|
|
446
|
+
button.addEventListener("click", () => activateContextMenuItem(button));
|
|
447
|
+
button.addEventListener("pointerenter", () => closeContextSubmenu());
|
|
448
|
+
contextMenu.appendChild(button);
|
|
449
|
+
}
|
|
450
|
+
contextMenu.hidden = false;
|
|
451
|
+
contextMenu.style.left = "0px";
|
|
452
|
+
contextMenu.style.top = "0px";
|
|
453
|
+
const margin = 8;
|
|
454
|
+
const left = Math.min(clientX, window.innerWidth - contextMenu.offsetWidth - margin);
|
|
455
|
+
const top = Math.min(clientY, window.innerHeight - contextMenu.offsetHeight - margin);
|
|
456
|
+
contextMenu.style.left = `${Math.max(margin, left)}px`;
|
|
457
|
+
contextMenu.style.top = `${Math.max(margin, top)}px`;
|
|
458
|
+
contextMenu.querySelector(".context-menu-item:not(:disabled)")?.focus();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function eventMenuPosition(target) {
|
|
462
|
+
const bounds = target.getBoundingClientRect();
|
|
463
|
+
return {
|
|
464
|
+
clientX: bounds.left + Math.min(bounds.width, 40),
|
|
465
|
+
clientY: bounds.top + Math.min(bounds.height, 24),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function architecturePoint(clientX, clientY) {
|
|
470
|
+
const svg = surface.querySelector("svg");
|
|
471
|
+
const matrix = svg?.getScreenCTM?.();
|
|
472
|
+
if (!svg || !matrix) return null;
|
|
473
|
+
const source = svg.createSVGPoint();
|
|
474
|
+
source.x = clientX;
|
|
475
|
+
source.y = clientY;
|
|
476
|
+
const point = source.matrixTransform(matrix.inverse());
|
|
477
|
+
return { x: point.x, y: point.y };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function openElementContextMenu(ref, options) {
|
|
481
|
+
const point =
|
|
482
|
+
options.origin === "diagram"
|
|
483
|
+
? architecturePoint(options.clientX, options.clientY)
|
|
484
|
+
: null;
|
|
485
|
+
selectedRef = ref;
|
|
486
|
+
connectorTool = null;
|
|
487
|
+
renderAll();
|
|
488
|
+
openContextMenu({ ...options, ref, point });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function openBlankContextMenu(options) {
|
|
492
|
+
const point =
|
|
493
|
+
options.origin === "canvas"
|
|
494
|
+
? architecturePoint(options.clientX, options.clientY)
|
|
495
|
+
: null;
|
|
496
|
+
selectedRef = null;
|
|
497
|
+
connectorTool = null;
|
|
498
|
+
renderAll();
|
|
499
|
+
openContextMenu({ ...options, point });
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function setAssetDialogStatus(message, kind = "info") {
|
|
503
|
+
assetDialogStatus.textContent = message;
|
|
504
|
+
assetDialogStatus.dataset.kind = kind;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function selectAsset(path) {
|
|
508
|
+
selectedAssetPath = path || "";
|
|
509
|
+
assetChooseButton.disabled = !selectedAssetPath || assetUploadPending;
|
|
510
|
+
assetList.querySelectorAll(".asset-option").forEach((option) => {
|
|
511
|
+
option.setAttribute(
|
|
512
|
+
"aria-selected",
|
|
513
|
+
option.dataset.path === selectedAssetPath ? "true" : "false",
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
assetPreviewImageHost.replaceChildren();
|
|
517
|
+
assetPreviewEmpty.hidden = Boolean(selectedAssetPath);
|
|
518
|
+
assetPreviewPath.textContent = selectedAssetPath;
|
|
519
|
+
if (selectedAssetPath) {
|
|
520
|
+
const image = document.createElement("img");
|
|
521
|
+
image.src = `./${selectedAssetPath}`;
|
|
522
|
+
image.alt = "";
|
|
523
|
+
assetPreviewImageHost.appendChild(image);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function renderAssetLibrary() {
|
|
528
|
+
const query = assetSearch.value.trim().toLocaleLowerCase();
|
|
529
|
+
const matches = availableAssets.filter((asset) =>
|
|
530
|
+
asset.path.toLocaleLowerCase().includes(query),
|
|
531
|
+
);
|
|
532
|
+
assetList.replaceChildren();
|
|
533
|
+
if (!matches.length) {
|
|
534
|
+
const empty = document.createElement("p");
|
|
535
|
+
empty.className = "asset-list-empty";
|
|
536
|
+
empty.textContent = query
|
|
537
|
+
? "No images match the search."
|
|
538
|
+
: "No images are available in assets/.";
|
|
539
|
+
assetList.appendChild(empty);
|
|
540
|
+
}
|
|
541
|
+
for (const asset of matches) {
|
|
542
|
+
const option = document.createElement("button");
|
|
543
|
+
option.type = "button";
|
|
544
|
+
option.className = "asset-option";
|
|
545
|
+
option.dataset.path = asset.path;
|
|
546
|
+
option.setAttribute("role", "option");
|
|
547
|
+
option.setAttribute("aria-selected", asset.path === selectedAssetPath ? "true" : "false");
|
|
548
|
+
option.title = asset.path;
|
|
549
|
+
const image = document.createElement("img");
|
|
550
|
+
image.src = `./${asset.path}`;
|
|
551
|
+
image.alt = "";
|
|
552
|
+
const label = document.createElement("span");
|
|
553
|
+
label.textContent = asset.path;
|
|
554
|
+
option.append(image, label);
|
|
555
|
+
option.addEventListener("click", () => selectAsset(asset.path));
|
|
556
|
+
option.addEventListener("dblclick", () => {
|
|
557
|
+
selectAsset(asset.path);
|
|
558
|
+
confirmAssetSelection();
|
|
559
|
+
});
|
|
560
|
+
assetList.appendChild(option);
|
|
561
|
+
}
|
|
562
|
+
setAssetDialogStatus(`${matches.length} images`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function loadAssetLibrary(preferredPath = "", request = assetLibraryRequest) {
|
|
566
|
+
setAssetDialogStatus("Loading images…");
|
|
567
|
+
assetChooseButton.disabled = true;
|
|
568
|
+
try {
|
|
569
|
+
const response = await fetch("./asset-library");
|
|
570
|
+
const result = await response.json().catch(() => ({}));
|
|
571
|
+
if (request !== assetLibraryRequest || !assetDialog.open) return false;
|
|
572
|
+
if (!response.ok || result.ok !== true || !Array.isArray(result.assets)) {
|
|
573
|
+
throw new Error(result.message || "Could not retrieve the image list.");
|
|
574
|
+
}
|
|
575
|
+
availableAssets = result.assets;
|
|
576
|
+
selectedAssetPath =
|
|
577
|
+
preferredPath && availableAssets.some((asset) => asset.path === preferredPath)
|
|
578
|
+
? preferredPath
|
|
579
|
+
: "";
|
|
580
|
+
renderAssetLibrary();
|
|
581
|
+
selectAsset(selectedAssetPath);
|
|
582
|
+
return true;
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if (request !== assetLibraryRequest || !assetDialog.open) return false;
|
|
585
|
+
availableAssets = [];
|
|
586
|
+
selectedAssetPath = "";
|
|
587
|
+
renderAssetLibrary();
|
|
588
|
+
setAssetDialogStatus(error.message, "error");
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function restoreAssetPickerFocus(state) {
|
|
594
|
+
if (state?.returnFocus?.isConnected) {
|
|
595
|
+
state.returnFocus.focus();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (selectedRef) {
|
|
599
|
+
tree.querySelector(`.tree-item[data-ref="${CSS.escape(selectedRef)}"]`)?.focus();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function closeAssetPicker() {
|
|
604
|
+
if (assetDialog.open) assetDialog.close();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function openAssetPicker(state) {
|
|
608
|
+
assetPickerState = {
|
|
609
|
+
...state,
|
|
610
|
+
returnFocus: state.returnFocus || document.activeElement,
|
|
611
|
+
};
|
|
612
|
+
assetDialogTitle.textContent =
|
|
613
|
+
state.mode === "add-image"
|
|
614
|
+
? "Add image"
|
|
615
|
+
: state.mode === "node-icon"
|
|
616
|
+
? "Select node image"
|
|
617
|
+
: "Replace image";
|
|
618
|
+
assetSearch.value = "";
|
|
619
|
+
availableAssets = [];
|
|
620
|
+
selectedAssetPath = "";
|
|
621
|
+
selectAsset("");
|
|
622
|
+
assetDialog.showModal();
|
|
623
|
+
const request = ++assetLibraryRequest;
|
|
624
|
+
void loadAssetLibrary(state.currentPath || "", request).then((loaded) => {
|
|
625
|
+
if (loaded && assetDialog.open && request === assetLibraryRequest) assetSearch.focus();
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function confirmAssetSelection() {
|
|
630
|
+
if (!assetPickerState || !selectedAssetPath || assetUploadPending) return;
|
|
631
|
+
const state = assetPickerState;
|
|
632
|
+
let result;
|
|
633
|
+
if (state.mode === "add-image") {
|
|
634
|
+
result = architecture.addImage({
|
|
635
|
+
parentId: state.parentId,
|
|
636
|
+
src: selectedAssetPath,
|
|
637
|
+
...addPosition(state.point, state.parentId, 340, 220),
|
|
638
|
+
});
|
|
639
|
+
} else if (state.mode === "node-icon") {
|
|
640
|
+
result = architecture.setElement(state.ref, "icon", selectedAssetPath);
|
|
641
|
+
} else {
|
|
642
|
+
result = architecture.setElement(state.ref, "src", selectedAssetPath);
|
|
643
|
+
}
|
|
644
|
+
if (applyResult(result)) closeAssetPicker();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function setAssetUploadPending(value) {
|
|
648
|
+
assetUploadPending = value;
|
|
649
|
+
assetImportButton.disabled = value;
|
|
650
|
+
assetSearch.disabled = value;
|
|
651
|
+
assetChooseButton.disabled = value || !selectedAssetPath;
|
|
652
|
+
assetList.querySelectorAll("button").forEach((button) => {
|
|
653
|
+
button.disabled = value;
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function uploadAsset(file) {
|
|
658
|
+
if (!file) return;
|
|
659
|
+
if (file.size > ASSET_MAX_BYTES) {
|
|
660
|
+
setAssetDialogStatus("Images must be 10 MB or smaller.", "error");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
setAssetUploadPending(true);
|
|
664
|
+
setAssetDialogStatus(`Importing ${file.name}…`);
|
|
665
|
+
try {
|
|
666
|
+
const response = await fetch(`./asset-upload?name=${encodeURIComponent(file.name)}`, {
|
|
667
|
+
method: "POST",
|
|
668
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
669
|
+
body: file,
|
|
670
|
+
});
|
|
671
|
+
const result = await response.json().catch(() => ({}));
|
|
672
|
+
if (!response.ok || result.ok !== true || !result.asset?.path) {
|
|
673
|
+
throw new Error(result.message || "Could not import the image.");
|
|
674
|
+
}
|
|
675
|
+
const request = ++assetLibraryRequest;
|
|
676
|
+
const loaded = await loadAssetLibrary(result.asset.path, request);
|
|
677
|
+
if (loaded) setAssetDialogStatus(`Imported as ${result.asset.path}.`);
|
|
678
|
+
} catch (error) {
|
|
679
|
+
setAssetDialogStatus(error.message, "error");
|
|
680
|
+
} finally {
|
|
681
|
+
setAssetUploadPending(false);
|
|
682
|
+
assetFileInput.value = "";
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function renderTree() {
|
|
687
|
+
tree.replaceChildren();
|
|
688
|
+
for (const entry of rawEntries(architecture.raw)) {
|
|
689
|
+
const button = document.createElement("button");
|
|
690
|
+
button.type = "button";
|
|
691
|
+
button.className = "tree-item";
|
|
692
|
+
button.style.paddingLeft = `${10 + entry.depth * 16}px`;
|
|
693
|
+
button.dataset.ref = entry.ref;
|
|
694
|
+
button.setAttribute("role", "treeitem");
|
|
695
|
+
button.setAttribute("aria-level", String(entry.depth + 1));
|
|
696
|
+
button.setAttribute("aria-selected", entry.ref === selectedRef ? "true" : "false");
|
|
697
|
+
button.setAttribute("aria-haspopup", "menu");
|
|
698
|
+
const icon = document.createElement("span");
|
|
699
|
+
icon.className = "tree-icon";
|
|
700
|
+
icon.textContent = iconFor(entry.element.type);
|
|
701
|
+
const label = document.createElement("span");
|
|
702
|
+
label.className = "tree-label";
|
|
703
|
+
label.textContent = labelFor(entry);
|
|
704
|
+
button.append(icon, label);
|
|
705
|
+
button.addEventListener("click", () => {
|
|
706
|
+
selectedRef = entry.ref;
|
|
707
|
+
connectorTool = null;
|
|
708
|
+
renderAll();
|
|
709
|
+
});
|
|
710
|
+
button.addEventListener("contextmenu", (event) => {
|
|
711
|
+
event.preventDefault();
|
|
712
|
+
event.stopPropagation();
|
|
713
|
+
openElementContextMenu(entry.ref, {
|
|
714
|
+
clientX: event.clientX,
|
|
715
|
+
clientY: event.clientY,
|
|
716
|
+
origin: "tree",
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
button.addEventListener("keydown", (event) => {
|
|
720
|
+
if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
|
|
721
|
+
event.preventDefault();
|
|
722
|
+
event.stopPropagation();
|
|
723
|
+
openElementContextMenu(entry.ref, {
|
|
724
|
+
...eventMenuPosition(event.currentTarget),
|
|
725
|
+
origin: "tree",
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
tree.appendChild(button);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function addResizeHandles(svg, element) {
|
|
733
|
+
if (!element || element.type === "connector") return;
|
|
734
|
+
if (!architecture.describe(element.id).movable) return;
|
|
735
|
+
const corners = {
|
|
736
|
+
nw: [element.x, element.y],
|
|
737
|
+
ne: [element.x + element.width, element.y],
|
|
738
|
+
sw: [element.x, element.y + element.height],
|
|
739
|
+
se: [element.x + element.width, element.y + element.height],
|
|
740
|
+
};
|
|
741
|
+
for (const [corner, [cx, cy]] of Object.entries(corners)) {
|
|
742
|
+
const handle = document.createElementNS(SVG_NS, "circle");
|
|
743
|
+
handle.classList.add("editor-resize-handle");
|
|
744
|
+
handle.dataset.corner = corner;
|
|
745
|
+
handle.dataset.ref = element.id;
|
|
746
|
+
handle.setAttribute("cx", String(cx));
|
|
747
|
+
handle.setAttribute("cy", String(cy));
|
|
748
|
+
handle.setAttribute("r", "10");
|
|
749
|
+
handle.setAttribute("tabindex", "0");
|
|
750
|
+
handle.setAttribute("role", "button");
|
|
751
|
+
handle.setAttribute("aria-label", `${element.id} ${corner} resize handle`);
|
|
752
|
+
handle.addEventListener("pointerdown", beginResize);
|
|
753
|
+
handle.addEventListener("contextmenu", (event) => {
|
|
754
|
+
event.preventDefault();
|
|
755
|
+
event.stopPropagation();
|
|
756
|
+
openElementContextMenu(element.id, {
|
|
757
|
+
clientX: event.clientX,
|
|
758
|
+
clientY: event.clientY,
|
|
759
|
+
origin: "diagram",
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
svg.appendChild(handle);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function decorateDiagram(svg) {
|
|
767
|
+
const byOrder = new Map(
|
|
768
|
+
architecture.model.elements.map((element) => [
|
|
769
|
+
String(element.order),
|
|
770
|
+
element.id || element.sourcePath,
|
|
771
|
+
]),
|
|
772
|
+
);
|
|
773
|
+
svg.removeAttribute("tabindex");
|
|
774
|
+
svg.querySelectorAll("[data-architecture-type]").forEach((node) => {
|
|
775
|
+
const ref =
|
|
776
|
+
node.dataset.architectureId ||
|
|
777
|
+
byOrder.get(node.dataset.architectureOrder || "") ||
|
|
778
|
+
"";
|
|
779
|
+
if (!ref) return;
|
|
780
|
+
const element = modelFor(ref);
|
|
781
|
+
node.dataset.editorRef = ref;
|
|
782
|
+
node.dataset.editorMovable =
|
|
783
|
+
element?.type !== "connector" && architecture.describe(ref).movable ? "true" : "false";
|
|
784
|
+
node.setAttribute("tabindex", "0");
|
|
785
|
+
node.setAttribute("aria-haspopup", "menu");
|
|
786
|
+
if (ref === selectedRef) node.dataset.editorSelected = "true";
|
|
787
|
+
node.addEventListener("click", (event) => {
|
|
788
|
+
event.stopPropagation();
|
|
789
|
+
chooseElement(ref);
|
|
790
|
+
});
|
|
791
|
+
node.addEventListener("pointerdown", beginMove);
|
|
792
|
+
node.addEventListener("keydown", onElementKeyDown);
|
|
793
|
+
node.addEventListener("contextmenu", (event) => {
|
|
794
|
+
event.preventDefault();
|
|
795
|
+
event.stopPropagation();
|
|
796
|
+
openElementContextMenu(ref, {
|
|
797
|
+
clientX: event.clientX,
|
|
798
|
+
clientY: event.clientY,
|
|
799
|
+
origin: "diagram",
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
if (connectorTool?.from) {
|
|
804
|
+
svg
|
|
805
|
+
.querySelector(`[data-editor-ref="${CSS.escape(connectorTool.from)}"]`)
|
|
806
|
+
?.classList.add("connector-source");
|
|
807
|
+
}
|
|
808
|
+
addResizeHandles(svg, modelFor(selectedRef));
|
|
809
|
+
svg.addEventListener("click", (event) => {
|
|
810
|
+
if (event.target === svg) {
|
|
811
|
+
selectedRef = null;
|
|
812
|
+
renderAll();
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function renderSurface() {
|
|
818
|
+
const wrapper = renderArchitectureBlock(architecture.source, document);
|
|
819
|
+
wrapper.style.width = `${960 * zoom}px`;
|
|
820
|
+
surface.replaceChildren(wrapper);
|
|
821
|
+
decorateDiagram(wrapper.querySelector("svg"));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function section(title) {
|
|
825
|
+
const container = document.createElement("section");
|
|
826
|
+
container.className = "inspector-section";
|
|
827
|
+
const heading = document.createElement("h3");
|
|
828
|
+
heading.textContent = title;
|
|
829
|
+
container.appendChild(heading);
|
|
830
|
+
inspector.appendChild(container);
|
|
831
|
+
return container;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function readValue(path) {
|
|
835
|
+
const entry = entryFor(selectedRef);
|
|
836
|
+
let value = entry?.element;
|
|
837
|
+
for (const part of path.split(".")) value = value?.[part];
|
|
838
|
+
return value;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function addField(container, {
|
|
842
|
+
label,
|
|
843
|
+
path,
|
|
844
|
+
value,
|
|
845
|
+
type = "text",
|
|
846
|
+
options = null,
|
|
847
|
+
min,
|
|
848
|
+
max,
|
|
849
|
+
step,
|
|
850
|
+
multiline = false,
|
|
851
|
+
suggestions = null,
|
|
852
|
+
onChange,
|
|
853
|
+
}) {
|
|
854
|
+
const id = `field-${path.replace(/[^A-Za-z0-9_-]/g, "-")}-${container.children.length}`;
|
|
855
|
+
const caption = document.createElement("label");
|
|
856
|
+
caption.htmlFor = id;
|
|
857
|
+
caption.textContent = label;
|
|
858
|
+
let input;
|
|
859
|
+
if (multiline) input = document.createElement("textarea");
|
|
860
|
+
else if (options) {
|
|
861
|
+
input = document.createElement("select");
|
|
862
|
+
for (const option of options) {
|
|
863
|
+
const item = document.createElement("option");
|
|
864
|
+
item.value = typeof option === "string" ? option : option.value;
|
|
865
|
+
item.textContent = typeof option === "string" ? option : option.label;
|
|
866
|
+
input.appendChild(item);
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
input = document.createElement("input");
|
|
870
|
+
input.type = type;
|
|
871
|
+
if (suggestions?.length) {
|
|
872
|
+
const list = document.createElement("datalist");
|
|
873
|
+
list.id = `${id}-list`;
|
|
874
|
+
for (const suggestion of suggestions) {
|
|
875
|
+
const option = document.createElement("option");
|
|
876
|
+
option.value = suggestion;
|
|
877
|
+
list.appendChild(option);
|
|
878
|
+
}
|
|
879
|
+
input.setAttribute("list", list.id);
|
|
880
|
+
container.appendChild(list);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
input.id = id;
|
|
884
|
+
if (min !== undefined) input.min = String(min);
|
|
885
|
+
if (max !== undefined) input.max = String(max);
|
|
886
|
+
if (step !== undefined) input.step = String(step);
|
|
887
|
+
if (type === "checkbox") input.checked = Boolean(value);
|
|
888
|
+
else input.value = value ?? "";
|
|
889
|
+
input.addEventListener("change", () => {
|
|
890
|
+
let next;
|
|
891
|
+
if (type === "checkbox") next = input.checked;
|
|
892
|
+
else if (type === "number") next = input.value === "" ? undefined : Number(input.value);
|
|
893
|
+
else next = input.value === "" ? undefined : input.value;
|
|
894
|
+
if (onChange) onChange(next, input);
|
|
895
|
+
else applyResult(architecture.setElement(selectedRef, path, next));
|
|
896
|
+
});
|
|
897
|
+
container.append(caption, input);
|
|
898
|
+
return input;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function addInspectorAction(container, label, onClick) {
|
|
902
|
+
const row = document.createElement("div");
|
|
903
|
+
row.className = "inspector-action-row";
|
|
904
|
+
const button = document.createElement("button");
|
|
905
|
+
button.type = "button";
|
|
906
|
+
button.textContent = label;
|
|
907
|
+
button.addEventListener("click", onClick);
|
|
908
|
+
row.appendChild(button);
|
|
909
|
+
container.appendChild(row);
|
|
910
|
+
return button;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function addStyleFields(container) {
|
|
914
|
+
for (const [label, path] of [
|
|
915
|
+
["Fill", "style.fill"],
|
|
916
|
+
["Stroke", "style.stroke"],
|
|
917
|
+
["Text color", "style.textColor"],
|
|
918
|
+
]) {
|
|
919
|
+
addField(container, {
|
|
920
|
+
label,
|
|
921
|
+
path,
|
|
922
|
+
value: readValue(path),
|
|
923
|
+
suggestions: COLORS,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
for (const [label, path, min, max, step] of [
|
|
927
|
+
["Stroke width", "style.strokeWidth", 0.5, 20, 0.5],
|
|
928
|
+
["Font size", "style.fontSize", 8, 160, 1],
|
|
929
|
+
["Opacity", "style.opacity", 0, 1, 0.05],
|
|
930
|
+
["Corner radius", "style.cornerRadius", 0, 200, 1],
|
|
931
|
+
]) {
|
|
932
|
+
addField(container, {
|
|
933
|
+
label,
|
|
934
|
+
path,
|
|
935
|
+
value: readValue(path),
|
|
936
|
+
type: "number",
|
|
937
|
+
min,
|
|
938
|
+
max,
|
|
939
|
+
step,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
addField(container, {
|
|
943
|
+
label: "Dash pattern",
|
|
944
|
+
path: "style.dash",
|
|
945
|
+
value: readValue("style.dash"),
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function renderRootInspector() {
|
|
950
|
+
const raw = architecture.raw;
|
|
951
|
+
const general = section("Diagram");
|
|
952
|
+
addField(general, {
|
|
953
|
+
label: "Title",
|
|
954
|
+
path: "root-title",
|
|
955
|
+
value: raw.title,
|
|
956
|
+
onChange: (value) => applyResult(architecture.setRoot("title", value), { select: null }),
|
|
957
|
+
});
|
|
958
|
+
addField(general, {
|
|
959
|
+
label: "Description",
|
|
960
|
+
path: "root-description",
|
|
961
|
+
value: raw.description,
|
|
962
|
+
multiline: true,
|
|
963
|
+
onChange: (value) => applyResult(architecture.setRoot("description", value), { select: null }),
|
|
964
|
+
});
|
|
965
|
+
addField(general, {
|
|
966
|
+
label: "Width",
|
|
967
|
+
path: "canvas-width",
|
|
968
|
+
value: raw.canvas?.width ?? architecture.model.canvas.width,
|
|
969
|
+
type: "number",
|
|
970
|
+
min: 320,
|
|
971
|
+
max: 4000,
|
|
972
|
+
onChange: (value) => applyResult(architecture.setRoot("canvas.width", value), { select: null }),
|
|
973
|
+
});
|
|
974
|
+
addField(general, {
|
|
975
|
+
label: "Height",
|
|
976
|
+
path: "canvas-height",
|
|
977
|
+
value: raw.canvas?.height ?? architecture.model.canvas.height,
|
|
978
|
+
type: "number",
|
|
979
|
+
min: 180,
|
|
980
|
+
max: 4000,
|
|
981
|
+
onChange: (value) => applyResult(architecture.setRoot("canvas.height", value), { select: null }),
|
|
982
|
+
});
|
|
983
|
+
const help = document.createElement("p");
|
|
984
|
+
help.className = "inspector-help";
|
|
985
|
+
help.textContent = "Select an element to edit properties specific to its type.";
|
|
986
|
+
general.appendChild(help);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function renderInspector() {
|
|
990
|
+
inspector.replaceChildren();
|
|
991
|
+
const entry = entryFor(selectedRef);
|
|
992
|
+
const model = modelFor(selectedRef);
|
|
993
|
+
if (!entry || !model) {
|
|
994
|
+
renderRootInspector();
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const general = section(entry.element.type);
|
|
999
|
+
if (entry.element.type !== "connector") {
|
|
1000
|
+
addField(general, { label: "ID", path: "id", value: entry.element.id });
|
|
1001
|
+
addField(general, {
|
|
1002
|
+
label: "Parent",
|
|
1003
|
+
path: "parent",
|
|
1004
|
+
value: entry.parentId || "",
|
|
1005
|
+
options: [
|
|
1006
|
+
{ value: "", label: "(root)" },
|
|
1007
|
+
...architecture.model.elements
|
|
1008
|
+
.filter(
|
|
1009
|
+
(element) =>
|
|
1010
|
+
element.type === "group" &&
|
|
1011
|
+
element.id !== entry.element.id &&
|
|
1012
|
+
!element.sourcePath.startsWith(`${entry.sourcePath}.children`),
|
|
1013
|
+
)
|
|
1014
|
+
.map((element) => ({ value: element.id, label: element.id })),
|
|
1015
|
+
],
|
|
1016
|
+
onChange: (value) =>
|
|
1017
|
+
applyResult(architecture.reparent(selectedRef, value || null), {
|
|
1018
|
+
select: entry.element.id,
|
|
1019
|
+
}),
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (entry.element.type === "node") {
|
|
1024
|
+
addField(general, { label: "Text", path: "text", value: entry.element.text, multiline: true });
|
|
1025
|
+
addField(general, {
|
|
1026
|
+
label: "Shape",
|
|
1027
|
+
path: "shape",
|
|
1028
|
+
value: entry.element.shape || "rect",
|
|
1029
|
+
options: SHAPES,
|
|
1030
|
+
});
|
|
1031
|
+
addField(general, {
|
|
1032
|
+
label: "Icon",
|
|
1033
|
+
path: "icon",
|
|
1034
|
+
value: entry.element.icon,
|
|
1035
|
+
suggestions: [...ICONS],
|
|
1036
|
+
});
|
|
1037
|
+
addInspectorAction(general, "Select image from assets/", (event) => {
|
|
1038
|
+
openAssetPicker({
|
|
1039
|
+
mode: "node-icon",
|
|
1040
|
+
ref: selectedRef,
|
|
1041
|
+
currentPath: entry.element.icon,
|
|
1042
|
+
returnFocus: event.currentTarget,
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
} else if (entry.element.type === "image") {
|
|
1046
|
+
addField(general, {
|
|
1047
|
+
label: "Image",
|
|
1048
|
+
path: "src",
|
|
1049
|
+
value: entry.element.src,
|
|
1050
|
+
});
|
|
1051
|
+
addInspectorAction(general, "Replace image", (event) => {
|
|
1052
|
+
openAssetPicker({
|
|
1053
|
+
mode: "image-src",
|
|
1054
|
+
ref: selectedRef,
|
|
1055
|
+
currentPath: entry.element.src,
|
|
1056
|
+
returnFocus: event.currentTarget,
|
|
1057
|
+
});
|
|
1058
|
+
});
|
|
1059
|
+
addField(general, {
|
|
1060
|
+
label: "Display mode",
|
|
1061
|
+
path: "fit",
|
|
1062
|
+
value: entry.element.fit || "contain",
|
|
1063
|
+
options: IMAGE_FITS,
|
|
1064
|
+
});
|
|
1065
|
+
} else if (entry.element.type === "group") {
|
|
1066
|
+
addField(general, { label: "Title", path: "title", value: entry.element.title });
|
|
1067
|
+
addField(general, {
|
|
1068
|
+
label: "Layout",
|
|
1069
|
+
path: "layout",
|
|
1070
|
+
value:
|
|
1071
|
+
typeof entry.element.layout === "string"
|
|
1072
|
+
? entry.element.layout
|
|
1073
|
+
: entry.element.layout?.type || "",
|
|
1074
|
+
options: [{ value: "", label: "(None)" }, ...LAYOUTS],
|
|
1075
|
+
onChange: (value) => {
|
|
1076
|
+
applyGroupLayout(selectedRef, value);
|
|
1077
|
+
},
|
|
1078
|
+
});
|
|
1079
|
+
if (entry.element.layout) {
|
|
1080
|
+
const layout = typeof entry.element.layout === "string" ? { type: entry.element.layout } : entry.element.layout;
|
|
1081
|
+
for (const [label, key, min, max] of [
|
|
1082
|
+
["Gap", "gap", 0, 240],
|
|
1083
|
+
["Row gap", "rowGap", 0, 240],
|
|
1084
|
+
["Column gap", "columnGap", 0, 240],
|
|
1085
|
+
["Padding", "padding", 0, 400],
|
|
1086
|
+
]) {
|
|
1087
|
+
addField(general, {
|
|
1088
|
+
label,
|
|
1089
|
+
path: `layout.${key}`,
|
|
1090
|
+
value: layout[key],
|
|
1091
|
+
type: "number",
|
|
1092
|
+
min,
|
|
1093
|
+
max,
|
|
1094
|
+
onChange: (value) =>
|
|
1095
|
+
applyResult(
|
|
1096
|
+
architecture.setElement(selectedRef, "layout", {
|
|
1097
|
+
...layout,
|
|
1098
|
+
[key]: value,
|
|
1099
|
+
}),
|
|
1100
|
+
),
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
if (layout.type === "grid") {
|
|
1104
|
+
addField(general, {
|
|
1105
|
+
label: "Columns",
|
|
1106
|
+
path: "layout.columns",
|
|
1107
|
+
value: layout.columns,
|
|
1108
|
+
type: "number",
|
|
1109
|
+
min: 1,
|
|
1110
|
+
max: 12,
|
|
1111
|
+
onChange: (value) =>
|
|
1112
|
+
applyResult(
|
|
1113
|
+
architecture.setElement(selectedRef, "layout", {
|
|
1114
|
+
...layout,
|
|
1115
|
+
columns: value,
|
|
1116
|
+
}),
|
|
1117
|
+
),
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
if (layout.type === "layered") {
|
|
1121
|
+
addField(general, {
|
|
1122
|
+
label: "Direction",
|
|
1123
|
+
path: "layout.direction",
|
|
1124
|
+
value: layout.direction || "down",
|
|
1125
|
+
options: LAYOUT_DIRECTIONS,
|
|
1126
|
+
onChange: (value) =>
|
|
1127
|
+
applyResult(
|
|
1128
|
+
architecture.setElement(selectedRef, "layout", {
|
|
1129
|
+
...layout,
|
|
1130
|
+
direction: value,
|
|
1131
|
+
}),
|
|
1132
|
+
),
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
} else {
|
|
1137
|
+
addField(general, {
|
|
1138
|
+
label: "Source",
|
|
1139
|
+
path: "from",
|
|
1140
|
+
value: entry.element.from,
|
|
1141
|
+
options: endpointOptions(),
|
|
1142
|
+
});
|
|
1143
|
+
addField(general, {
|
|
1144
|
+
label: "Target",
|
|
1145
|
+
path: "to",
|
|
1146
|
+
value: entry.element.to,
|
|
1147
|
+
options: endpointOptions(),
|
|
1148
|
+
});
|
|
1149
|
+
addField(general, {
|
|
1150
|
+
label: "Source port",
|
|
1151
|
+
path: "fromPort",
|
|
1152
|
+
value: entry.element.fromPort || "auto",
|
|
1153
|
+
options: PORTS,
|
|
1154
|
+
});
|
|
1155
|
+
addField(general, {
|
|
1156
|
+
label: "Target port",
|
|
1157
|
+
path: "toPort",
|
|
1158
|
+
value: entry.element.toPort || "auto",
|
|
1159
|
+
options: PORTS,
|
|
1160
|
+
});
|
|
1161
|
+
addField(general, { label: "Label", path: "label", value: entry.element.label });
|
|
1162
|
+
addField(general, {
|
|
1163
|
+
label: "Label stacking",
|
|
1164
|
+
path: "labelLayer",
|
|
1165
|
+
value: entry.element.labelLayer || "front",
|
|
1166
|
+
options: LABEL_LAYERS,
|
|
1167
|
+
});
|
|
1168
|
+
addField(general, {
|
|
1169
|
+
label: "Routing",
|
|
1170
|
+
path: "routing",
|
|
1171
|
+
value: entry.element.routing || "orthogonal",
|
|
1172
|
+
options: ROUTING,
|
|
1173
|
+
});
|
|
1174
|
+
addField(general, {
|
|
1175
|
+
label: "Arrow",
|
|
1176
|
+
path: "arrow",
|
|
1177
|
+
value: entry.element.arrow !== false,
|
|
1178
|
+
type: "checkbox",
|
|
1179
|
+
});
|
|
1180
|
+
addField(general, {
|
|
1181
|
+
label: "Lane",
|
|
1182
|
+
path: "lane",
|
|
1183
|
+
value: entry.element.lane,
|
|
1184
|
+
type: "number",
|
|
1185
|
+
min: -12,
|
|
1186
|
+
max: 12,
|
|
1187
|
+
step: 1,
|
|
1188
|
+
});
|
|
1189
|
+
if (entry.element.routing === "polyline") {
|
|
1190
|
+
addField(general, {
|
|
1191
|
+
label: "Waypoints JSON",
|
|
1192
|
+
path: "points",
|
|
1193
|
+
value: JSON.stringify(entry.element.points || [], null, 2),
|
|
1194
|
+
multiline: true,
|
|
1195
|
+
onChange: (value, input) => {
|
|
1196
|
+
try {
|
|
1197
|
+
input.setCustomValidity("");
|
|
1198
|
+
applyResult(architecture.setElement(selectedRef, "points", JSON.parse(value || "[]")));
|
|
1199
|
+
} catch (_) {
|
|
1200
|
+
input.setCustomValidity("Enter a JSON array of points containing x/y values.");
|
|
1201
|
+
input.reportValidity();
|
|
1202
|
+
}
|
|
1203
|
+
},
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
if (entry.element.type !== "connector") {
|
|
1209
|
+
const geometry = section("Geometry");
|
|
1210
|
+
for (const [label, key, min, max] of [
|
|
1211
|
+
["X", "x", -4000, 4000],
|
|
1212
|
+
["Y", "y", -4000, 4000],
|
|
1213
|
+
["Width", "width", 1, 4000],
|
|
1214
|
+
["Height", "height", 1, 4000],
|
|
1215
|
+
]) {
|
|
1216
|
+
addField(geometry, {
|
|
1217
|
+
label,
|
|
1218
|
+
path: key,
|
|
1219
|
+
value: entry.element[key] ?? model[key],
|
|
1220
|
+
type: "number",
|
|
1221
|
+
min,
|
|
1222
|
+
max,
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const accessibility = section("Accessibility / order");
|
|
1228
|
+
addField(accessibility, {
|
|
1229
|
+
label: "Accessible name",
|
|
1230
|
+
path: "ariaLabel",
|
|
1231
|
+
value: entry.element.ariaLabel,
|
|
1232
|
+
multiline: true,
|
|
1233
|
+
});
|
|
1234
|
+
addField(accessibility, {
|
|
1235
|
+
label: "Z",
|
|
1236
|
+
path: "z",
|
|
1237
|
+
value: entry.element.z,
|
|
1238
|
+
type: "number",
|
|
1239
|
+
min: -100,
|
|
1240
|
+
max: 100,
|
|
1241
|
+
step: 1,
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
const style = section("Style");
|
|
1245
|
+
addStyleFields(style);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function refreshToolbar() {
|
|
1249
|
+
const entry = entryFor(selectedRef);
|
|
1250
|
+
document.querySelector('[data-action="undo"]').disabled = !architecture.canUndo;
|
|
1251
|
+
document.querySelector('[data-action="redo"]').disabled = !architecture.canRedo;
|
|
1252
|
+
for (const action of ["duplicate", "delete", "order-back", "order-front"]) {
|
|
1253
|
+
document.querySelector(`[data-action="${action}"]`).disabled = !entry;
|
|
1254
|
+
}
|
|
1255
|
+
document.querySelector('[data-action="release-layout"]').disabled =
|
|
1256
|
+
!selectedRef || !releaseLayoutAvailable(selectedRef);
|
|
1257
|
+
setDirty(dirty);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
function renderAll() {
|
|
1261
|
+
closeContextMenu();
|
|
1262
|
+
renderTree();
|
|
1263
|
+
renderSurface();
|
|
1264
|
+
renderInspector();
|
|
1265
|
+
refreshToolbar();
|
|
1266
|
+
zoomStatus.textContent = `${Math.round(zoom * 100)}%`;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function chooseElement(ref) {
|
|
1270
|
+
const element = modelFor(ref);
|
|
1271
|
+
if (connectorTool && element?.type !== "connector") {
|
|
1272
|
+
if (!connectorTool.from) {
|
|
1273
|
+
connectorTool.from = element.id;
|
|
1274
|
+
announce(`Set ${element.id} as the source. Select a target.`);
|
|
1275
|
+
renderAll();
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
if (connectorTool.from === element.id) {
|
|
1279
|
+
announce("Select a target different from the source.", "error");
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const from = connectorTool.from;
|
|
1283
|
+
connectorTool = null;
|
|
1284
|
+
applyResult(architecture.addConnector({ from, to: element.id }), { select: null });
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
connectorTool = null;
|
|
1288
|
+
selectedRef = ref;
|
|
1289
|
+
renderAll();
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function beginMove(event) {
|
|
1293
|
+
if (event.button !== 0 || event.currentTarget.dataset.architectureType === "connector") return;
|
|
1294
|
+
if (connectorTool) return;
|
|
1295
|
+
const ref = event.currentTarget.dataset.editorRef;
|
|
1296
|
+
if (!ref) return;
|
|
1297
|
+
selectedRef = ref;
|
|
1298
|
+
const placement = architecture.describe(ref);
|
|
1299
|
+
if (!placement.movable) {
|
|
1300
|
+
announce(`${ref} cannot move because it is managed by a layout.`, "error");
|
|
1301
|
+
renderAll();
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
event.preventDefault();
|
|
1305
|
+
event.stopPropagation();
|
|
1306
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
1307
|
+
drag = {
|
|
1308
|
+
kind: "move",
|
|
1309
|
+
ref,
|
|
1310
|
+
target: event.currentTarget,
|
|
1311
|
+
pointerId: event.pointerId,
|
|
1312
|
+
startX: event.clientX,
|
|
1313
|
+
startY: event.clientY,
|
|
1314
|
+
dx: 0,
|
|
1315
|
+
dy: 0,
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function beginResize(event) {
|
|
1320
|
+
event.preventDefault();
|
|
1321
|
+
event.stopPropagation();
|
|
1322
|
+
const ref = event.currentTarget.dataset.ref;
|
|
1323
|
+
const element = modelFor(ref);
|
|
1324
|
+
if (!element) return;
|
|
1325
|
+
drag = {
|
|
1326
|
+
kind: "resize",
|
|
1327
|
+
ref,
|
|
1328
|
+
corner: event.currentTarget.dataset.corner,
|
|
1329
|
+
pointerId: event.pointerId,
|
|
1330
|
+
startX: event.clientX,
|
|
1331
|
+
startY: event.clientY,
|
|
1332
|
+
box: { x: element.x, y: element.y, width: element.width, height: element.height },
|
|
1333
|
+
dx: 0,
|
|
1334
|
+
dy: 0,
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function updateDrag(event) {
|
|
1339
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
1340
|
+
const svg = surface.querySelector("svg");
|
|
1341
|
+
const scale = viewBoxScale(svg);
|
|
1342
|
+
drag.dx = (event.clientX - drag.startX) / scale.x;
|
|
1343
|
+
drag.dy = (event.clientY - drag.startY) / scale.y;
|
|
1344
|
+
if (drag.kind === "move") {
|
|
1345
|
+
drag.target.setAttribute("transform", `translate(${drag.dx} ${drag.dy})`);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function finishDrag(event) {
|
|
1350
|
+
if (!drag || event.pointerId !== drag.pointerId) return;
|
|
1351
|
+
const pending = drag;
|
|
1352
|
+
drag = null;
|
|
1353
|
+
if (pending.kind === "move") {
|
|
1354
|
+
pending.target.removeAttribute("transform");
|
|
1355
|
+
const dx = snap(pending.dx);
|
|
1356
|
+
const dy = snap(pending.dy);
|
|
1357
|
+
if (dx || dy) applyResult(architecture.move(pending.ref, dx, dy));
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
let { x, y, width, height } = pending.box;
|
|
1361
|
+
const right = x + width;
|
|
1362
|
+
const bottom = y + height;
|
|
1363
|
+
const nextX = snap(x + pending.dx);
|
|
1364
|
+
const nextY = snap(y + pending.dy);
|
|
1365
|
+
const nextRight = snap(right + pending.dx);
|
|
1366
|
+
const nextBottom = snap(bottom + pending.dy);
|
|
1367
|
+
if (pending.corner.includes("w")) {
|
|
1368
|
+
x = Math.min(nextX, right - 20);
|
|
1369
|
+
width = right - x;
|
|
1370
|
+
}
|
|
1371
|
+
if (pending.corner.includes("e")) width = Math.max(20, nextRight - x);
|
|
1372
|
+
if (pending.corner.includes("n")) {
|
|
1373
|
+
y = Math.min(nextY, bottom - 20);
|
|
1374
|
+
height = bottom - y;
|
|
1375
|
+
}
|
|
1376
|
+
if (pending.corner.includes("s")) height = Math.max(20, nextBottom - y);
|
|
1377
|
+
applyResult(architecture.resize(pending.ref, { x, y, width, height }));
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function onElementKeyDown(event) {
|
|
1381
|
+
if (["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName)) return;
|
|
1382
|
+
const ref = event.currentTarget.dataset.editorRef;
|
|
1383
|
+
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
|
|
1384
|
+
event.preventDefault();
|
|
1385
|
+
event.stopPropagation();
|
|
1386
|
+
openElementContextMenu(ref, {
|
|
1387
|
+
...eventMenuPosition(event.currentTarget),
|
|
1388
|
+
origin: "diagram",
|
|
1389
|
+
});
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (event.key.startsWith("Arrow") && modelFor(ref)?.type !== "connector") {
|
|
1393
|
+
event.preventDefault();
|
|
1394
|
+
const step = event.shiftKey ? 1 : SNAP_SIZE;
|
|
1395
|
+
const delta = {
|
|
1396
|
+
ArrowLeft: [-step, 0],
|
|
1397
|
+
ArrowRight: [step, 0],
|
|
1398
|
+
ArrowUp: [0, -step],
|
|
1399
|
+
ArrowDown: [0, step],
|
|
1400
|
+
}[event.key];
|
|
1401
|
+
if (delta) applyResult(architecture.move(ref, delta[0], delta[1]));
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
async function save() {
|
|
1406
|
+
try {
|
|
1407
|
+
await draftQueue;
|
|
1408
|
+
} catch (_) {
|
|
1409
|
+
announce("Cannot save because the draft could not be retained.", "error");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const generation = targetGeneration;
|
|
1413
|
+
const revision = draftRevision;
|
|
1414
|
+
announce("Saving to Markdown…");
|
|
1415
|
+
let response;
|
|
1416
|
+
try {
|
|
1417
|
+
response = await fetch("./save", {
|
|
1418
|
+
method: "POST",
|
|
1419
|
+
headers: { "Content-Type": "application/json" },
|
|
1420
|
+
body: JSON.stringify({ generation, revision }),
|
|
1421
|
+
});
|
|
1422
|
+
} catch (_) {
|
|
1423
|
+
announce("Could not connect to the Markdown save server.", "error");
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const result = await response.json().catch(() => ({}));
|
|
1427
|
+
if (!response.ok || result.ok !== true) {
|
|
1428
|
+
announce(result.message || "Could not save to Markdown.", "error");
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
if (generation !== targetGeneration || result.generation !== targetGeneration) return;
|
|
1432
|
+
draftRevision = Math.max(draftRevision, result.savedRevision ?? draftRevision);
|
|
1433
|
+
const hasNewerLocalDraft = draftRevision > (result.savedRevision ?? -1);
|
|
1434
|
+
setDirty(Boolean(result.dirty || hasNewerLocalDraft));
|
|
1435
|
+
announce(
|
|
1436
|
+
dirty
|
|
1437
|
+
? "Changes made while saving are still unsaved. Save again."
|
|
1438
|
+
: "Saved to the source Markdown.",
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function reloadFromMarkdown() {
|
|
1443
|
+
await draftQueue.catch(() => {});
|
|
1444
|
+
if (dirty && !window.confirm("Discard unsaved changes and reload from Markdown?")) return;
|
|
1445
|
+
announce("Reloading from Markdown…");
|
|
1446
|
+
let response;
|
|
1447
|
+
try {
|
|
1448
|
+
response = await fetch("./reload", {
|
|
1449
|
+
method: "POST",
|
|
1450
|
+
headers: { "Content-Type": "application/json" },
|
|
1451
|
+
body: JSON.stringify({ discard: dirty }),
|
|
1452
|
+
});
|
|
1453
|
+
} catch (_) {
|
|
1454
|
+
announce("Could not connect to the Markdown reload server.", "error");
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const result = await response.json().catch(() => ({}));
|
|
1458
|
+
if (!response.ok || result.ok !== true) {
|
|
1459
|
+
announce(result.message || "Could not reload from Markdown.", "error");
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
draftQueue = Promise.resolve();
|
|
1463
|
+
await refreshState();
|
|
1464
|
+
announce("Reloaded from the source Markdown.");
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function setZoom(value) {
|
|
1468
|
+
zoom = Math.min(2.5, Math.max(0.3, value));
|
|
1469
|
+
renderSurface();
|
|
1470
|
+
zoomStatus.textContent = `${Math.round(zoom * 100)}%`;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function fitZoom() {
|
|
1474
|
+
const available = Math.max(320, viewport.clientWidth - 96);
|
|
1475
|
+
setZoom(Math.min(1, available / 996));
|
|
1476
|
+
viewport.scrollTo({ left: 0, top: 0 });
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function addPosition(point, parentId, width, height) {
|
|
1480
|
+
if (!point) return {};
|
|
1481
|
+
const parent = parentId ? modelFor(parentId) : null;
|
|
1482
|
+
return {
|
|
1483
|
+
x: snap(point.x - (parent?.x || 0) - width / 2),
|
|
1484
|
+
y: snap(point.y - (parent?.y || 0) - height / 2),
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function invokeAction(action, context = {}) {
|
|
1489
|
+
const ref = Object.hasOwn(context, "ref") ? context.ref : selectedRef;
|
|
1490
|
+
if (action === "undo") {
|
|
1491
|
+
applyResult(architecture.undo(), { quiet: true });
|
|
1492
|
+
} else if (action === "redo") {
|
|
1493
|
+
applyResult(architecture.redo(), { quiet: true });
|
|
1494
|
+
} else if (action === "add-node") {
|
|
1495
|
+
const parentId = entryFor(ref)?.element.type === "group" ? ref : null;
|
|
1496
|
+
applyResult(architecture.addNode({
|
|
1497
|
+
parentId,
|
|
1498
|
+
...addPosition(context.point, parentId, 260, 140),
|
|
1499
|
+
}));
|
|
1500
|
+
} else if (action === "add-group") {
|
|
1501
|
+
const parentId = entryFor(ref)?.element.type === "group" ? ref : null;
|
|
1502
|
+
applyResult(architecture.addGroup({
|
|
1503
|
+
parentId,
|
|
1504
|
+
...addPosition(context.point, parentId, 520, 320),
|
|
1505
|
+
}));
|
|
1506
|
+
} else if (action === "add-image") {
|
|
1507
|
+
const parentId = entryFor(ref)?.element.type === "group" ? ref : null;
|
|
1508
|
+
openAssetPicker({
|
|
1509
|
+
mode: "add-image",
|
|
1510
|
+
parentId,
|
|
1511
|
+
point: context.point,
|
|
1512
|
+
returnFocus: context.returnFocus || document.activeElement,
|
|
1513
|
+
});
|
|
1514
|
+
} else if (action === "add-connector") {
|
|
1515
|
+
connectorTool = { from: null };
|
|
1516
|
+
announce("Select the connector source.");
|
|
1517
|
+
renderAll();
|
|
1518
|
+
} else if (action === "start-connector" && ref) {
|
|
1519
|
+
const element = modelFor(ref);
|
|
1520
|
+
if (!element || element.type === "connector") return;
|
|
1521
|
+
selectedRef = ref;
|
|
1522
|
+
connectorTool = { from: element.id };
|
|
1523
|
+
announce(`Set ${element.id} as the source. Select a target.`);
|
|
1524
|
+
renderAll();
|
|
1525
|
+
} else if (action === "duplicate" && ref) {
|
|
1526
|
+
applyResult(architecture.duplicate(ref));
|
|
1527
|
+
} else if (action === "delete" && ref) {
|
|
1528
|
+
const deleted = ref;
|
|
1529
|
+
if (applyResult(architecture.remove(ref), { select: null })) {
|
|
1530
|
+
announce(`Deleted ${deleted}.`);
|
|
1531
|
+
}
|
|
1532
|
+
} else if (action === "release-layout" && ref) {
|
|
1533
|
+
applyGroupLayout(ref, "");
|
|
1534
|
+
} else if (action === "set-layout" && ref) {
|
|
1535
|
+
applyGroupLayout(ref, context.layoutType || "");
|
|
1536
|
+
} else if (action === "order-back" && ref) {
|
|
1537
|
+
applyResult(architecture.reorder(ref, -1), { select: null });
|
|
1538
|
+
} else if (action === "order-front" && ref) {
|
|
1539
|
+
applyResult(architecture.reorder(ref, 1), { select: null });
|
|
1540
|
+
} else if (action === "zoom-out") {
|
|
1541
|
+
setZoom(zoom - 0.1);
|
|
1542
|
+
} else if (action === "zoom-in") {
|
|
1543
|
+
setZoom(zoom + 0.1);
|
|
1544
|
+
} else if (action === "zoom-fit") {
|
|
1545
|
+
fitZoom();
|
|
1546
|
+
} else if (action === "save") {
|
|
1547
|
+
void save();
|
|
1548
|
+
} else if (action === "reload") {
|
|
1549
|
+
void reloadFromMarkdown();
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function wireControls() {
|
|
1554
|
+
document.querySelectorAll("[data-action]").forEach((button) => {
|
|
1555
|
+
button.addEventListener("click", () => invokeAction(button.dataset.action));
|
|
1556
|
+
});
|
|
1557
|
+
document.addEventListener("pointermove", updateDrag);
|
|
1558
|
+
document.addEventListener("pointerup", finishDrag);
|
|
1559
|
+
document.addEventListener("pointercancel", finishDrag);
|
|
1560
|
+
assetSearch.addEventListener("input", renderAssetLibrary);
|
|
1561
|
+
assetImportButton.addEventListener("click", () => assetFileInput.click());
|
|
1562
|
+
assetFileInput.addEventListener("change", () => {
|
|
1563
|
+
void uploadAsset(assetFileInput.files?.[0]);
|
|
1564
|
+
});
|
|
1565
|
+
assetChooseButton.addEventListener("click", confirmAssetSelection);
|
|
1566
|
+
assetCancelButton.addEventListener("click", closeAssetPicker);
|
|
1567
|
+
assetDialogClose.addEventListener("click", closeAssetPicker);
|
|
1568
|
+
assetDialog.addEventListener("cancel", (event) => {
|
|
1569
|
+
event.preventDefault();
|
|
1570
|
+
closeAssetPicker();
|
|
1571
|
+
});
|
|
1572
|
+
assetDialog.addEventListener("close", () => {
|
|
1573
|
+
assetLibraryRequest += 1;
|
|
1574
|
+
const previous = assetPickerState;
|
|
1575
|
+
assetPickerState = null;
|
|
1576
|
+
availableAssets = [];
|
|
1577
|
+
selectedAssetPath = "";
|
|
1578
|
+
assetPreviewImageHost.replaceChildren();
|
|
1579
|
+
restoreAssetPickerFocus(previous);
|
|
1580
|
+
});
|
|
1581
|
+
document.addEventListener("pointerdown", (event) => {
|
|
1582
|
+
if (!contextMenu.hidden && !contextMenu.contains(event.target)) closeContextMenu();
|
|
1583
|
+
}, true);
|
|
1584
|
+
contextMenu.addEventListener("keydown", (event) => {
|
|
1585
|
+
const activeMenu = document.activeElement.closest('[role="menu"]');
|
|
1586
|
+
const items = activeMenu ? directMenuItems(activeMenu) : [];
|
|
1587
|
+
const current = items.indexOf(document.activeElement);
|
|
1588
|
+
let next = null;
|
|
1589
|
+
if (event.key === "ArrowDown") next = items[(current + 1 + items.length) % items.length];
|
|
1590
|
+
else if (event.key === "ArrowUp") next = items[(current - 1 + items.length) % items.length];
|
|
1591
|
+
else if (event.key === "Home") next = items[0];
|
|
1592
|
+
else if (event.key === "End") next = items.at(-1);
|
|
1593
|
+
else if (
|
|
1594
|
+
event.key === "ArrowRight" &&
|
|
1595
|
+
document.activeElement.matches('[aria-haspopup="menu"]')
|
|
1596
|
+
) {
|
|
1597
|
+
event.preventDefault();
|
|
1598
|
+
event.stopPropagation();
|
|
1599
|
+
openContextSubmenu(document.activeElement, { focusFirst: true });
|
|
1600
|
+
return;
|
|
1601
|
+
} else if (event.key === "ArrowLeft" && activeMenu?.classList.contains("context-submenu")) {
|
|
1602
|
+
event.preventDefault();
|
|
1603
|
+
event.stopPropagation();
|
|
1604
|
+
closeContextSubmenu({ restoreFocus: true });
|
|
1605
|
+
return;
|
|
1606
|
+
} else if (event.key === "Escape") {
|
|
1607
|
+
event.preventDefault();
|
|
1608
|
+
event.stopPropagation();
|
|
1609
|
+
closeContextMenu({ restoreFocus: true });
|
|
1610
|
+
return;
|
|
1611
|
+
} else if (event.key === "Tab") {
|
|
1612
|
+
closeContextMenu({ restoreFocus: true });
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
if (next) {
|
|
1616
|
+
event.preventDefault();
|
|
1617
|
+
event.stopPropagation();
|
|
1618
|
+
if (activeMenu === contextMenu && !next.matches('[aria-haspopup="menu"]')) {
|
|
1619
|
+
closeContextSubmenu();
|
|
1620
|
+
}
|
|
1621
|
+
next.focus();
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
event.stopPropagation();
|
|
1625
|
+
});
|
|
1626
|
+
tree.closest(".element-panel")?.addEventListener("contextmenu", (event) => {
|
|
1627
|
+
if (event.target.closest(".tree-item")) return;
|
|
1628
|
+
event.preventDefault();
|
|
1629
|
+
openBlankContextMenu({
|
|
1630
|
+
clientX: event.clientX,
|
|
1631
|
+
clientY: event.clientY,
|
|
1632
|
+
origin: "tree",
|
|
1633
|
+
});
|
|
1634
|
+
});
|
|
1635
|
+
viewport.addEventListener("contextmenu", (event) => {
|
|
1636
|
+
if (event.target.closest("[data-editor-ref]")) return;
|
|
1637
|
+
event.preventDefault();
|
|
1638
|
+
openBlankContextMenu({
|
|
1639
|
+
clientX: event.clientX,
|
|
1640
|
+
clientY: event.clientY,
|
|
1641
|
+
origin: "canvas",
|
|
1642
|
+
});
|
|
1643
|
+
});
|
|
1644
|
+
viewport.addEventListener("pointerdown", (event) => {
|
|
1645
|
+
if (event.button !== 1 && !(event.button === 0 && spacePressed)) return;
|
|
1646
|
+
event.preventDefault();
|
|
1647
|
+
pan = {
|
|
1648
|
+
pointerId: event.pointerId,
|
|
1649
|
+
x: event.clientX,
|
|
1650
|
+
y: event.clientY,
|
|
1651
|
+
left: viewport.scrollLeft,
|
|
1652
|
+
top: viewport.scrollTop,
|
|
1653
|
+
};
|
|
1654
|
+
});
|
|
1655
|
+
viewport.addEventListener("pointermove", (event) => {
|
|
1656
|
+
if (!pan || pan.pointerId !== event.pointerId) return;
|
|
1657
|
+
viewport.scrollLeft = pan.left - (event.clientX - pan.x);
|
|
1658
|
+
viewport.scrollTop = pan.top - (event.clientY - pan.y);
|
|
1659
|
+
});
|
|
1660
|
+
viewport.addEventListener("pointerup", () => {
|
|
1661
|
+
pan = null;
|
|
1662
|
+
});
|
|
1663
|
+
viewport.addEventListener("scroll", () => closeContextMenu(), { passive: true });
|
|
1664
|
+
tree.closest(".editor-sidebar")?.addEventListener("scroll", () => closeContextMenu(), {
|
|
1665
|
+
passive: true,
|
|
1666
|
+
});
|
|
1667
|
+
viewport.addEventListener(
|
|
1668
|
+
"wheel",
|
|
1669
|
+
(event) => {
|
|
1670
|
+
if (!event.ctrlKey) return;
|
|
1671
|
+
event.preventDefault();
|
|
1672
|
+
setZoom(zoom + (event.deltaY < 0 ? 0.1 : -0.1));
|
|
1673
|
+
},
|
|
1674
|
+
{ passive: false },
|
|
1675
|
+
);
|
|
1676
|
+
window.addEventListener("keydown", (event) => {
|
|
1677
|
+
if (assetDialog.open) return;
|
|
1678
|
+
const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName);
|
|
1679
|
+
if (event.code === "Space" && !editable) spacePressed = true;
|
|
1680
|
+
const modifier = event.ctrlKey || event.metaKey;
|
|
1681
|
+
const key = event.key.toLowerCase();
|
|
1682
|
+
if (modifier && key === "s") {
|
|
1683
|
+
event.preventDefault();
|
|
1684
|
+
if (editable) event.target.blur();
|
|
1685
|
+
void save();
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
if (editable) return;
|
|
1689
|
+
if (modifier && key === "z") {
|
|
1690
|
+
event.preventDefault();
|
|
1691
|
+
invokeAction(event.shiftKey ? "redo" : "undo");
|
|
1692
|
+
} else if (modifier && key === "y") {
|
|
1693
|
+
event.preventDefault();
|
|
1694
|
+
invokeAction("redo");
|
|
1695
|
+
} else if (modifier && key === "d") {
|
|
1696
|
+
event.preventDefault();
|
|
1697
|
+
invokeAction("duplicate");
|
|
1698
|
+
} else if (event.key === "Delete") {
|
|
1699
|
+
event.preventDefault();
|
|
1700
|
+
invokeAction("delete");
|
|
1701
|
+
} else if (event.key === "Escape") {
|
|
1702
|
+
connectorTool = null;
|
|
1703
|
+
selectedRef = null;
|
|
1704
|
+
renderAll();
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
window.addEventListener("keyup", (event) => {
|
|
1708
|
+
if (event.code === "Space") spacePressed = false;
|
|
1709
|
+
});
|
|
1710
|
+
window.addEventListener("resize", () => closeContextMenu(), { passive: true });
|
|
1711
|
+
window.addEventListener("blur", () => closeContextMenu());
|
|
1712
|
+
window.addEventListener("beforeunload", (event) => {
|
|
1713
|
+
if (!dirty) return;
|
|
1714
|
+
event.preventDefault();
|
|
1715
|
+
event.returnValue = "";
|
|
1716
|
+
});
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
async function refreshState() {
|
|
1720
|
+
const response = await fetch("./state", { cache: "no-store" });
|
|
1721
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1722
|
+
const state = await response.json();
|
|
1723
|
+
if (!Number.isInteger(state.version) || state.version < serverVersion) return;
|
|
1724
|
+
serverVersion = state.version;
|
|
1725
|
+
sourcePath = state.sourcePath;
|
|
1726
|
+
blockIndex = state.blockIndex;
|
|
1727
|
+
const stateRevision = state.draftRevision ?? 0;
|
|
1728
|
+
const stateGeneration = state.generation ?? 0;
|
|
1729
|
+
document.documentElement.dataset.theme = state.theme || "dark";
|
|
1730
|
+
sourceLabel.textContent = `${sourcePath} — diagram ${blockIndex + 1}`;
|
|
1731
|
+
if (targetGeneration !== stateGeneration) {
|
|
1732
|
+
targetGeneration = stateGeneration;
|
|
1733
|
+
draftRevision = stateRevision;
|
|
1734
|
+
architecture = createArchitectureDocument(state.source);
|
|
1735
|
+
selectedRef = null;
|
|
1736
|
+
setDirty(state.dirty);
|
|
1737
|
+
renderAll();
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
if (stateRevision < draftRevision) {
|
|
1741
|
+
setDirty(true);
|
|
1742
|
+
refreshToolbar();
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
draftRevision = stateRevision;
|
|
1746
|
+
if (!architecture || architecture.source !== state.source) {
|
|
1747
|
+
architecture = createArchitectureDocument(state.source);
|
|
1748
|
+
selectedRef = null;
|
|
1749
|
+
setDirty(state.dirty);
|
|
1750
|
+
renderAll();
|
|
1751
|
+
} else {
|
|
1752
|
+
setDirty(state.dirty);
|
|
1753
|
+
refreshToolbar();
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
async function init() {
|
|
1758
|
+
wireControls();
|
|
1759
|
+
await refreshState();
|
|
1760
|
+
fitZoom();
|
|
1761
|
+
announce("Select the diagram to edit it. The Markdown remains unchanged until you save.");
|
|
1762
|
+
const events = new EventSource("./events");
|
|
1763
|
+
events.onmessage = () => {
|
|
1764
|
+
void refreshState().catch((error) => announce(error.message, "error"));
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
init().catch((error) => {
|
|
1769
|
+
announce(`Could not start the editor: ${error.message}`, "error");
|
|
1770
|
+
try {
|
|
1771
|
+
parseArchitecture("{}");
|
|
1772
|
+
} catch (_) {
|
|
1773
|
+
// Keep the status visible; there is no editable document to render.
|
|
1774
|
+
}
|
|
1775
|
+
});
|