@miragon/modeler-shell 0.0.0-stage → 0.1.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.
Files changed (126) hide show
  1. package/API.md +1902 -0
  2. package/LICENSE +21 -0
  3. package/README.md +435 -2
  4. package/THIRD_PARTY_NOTICES.md +52 -0
  5. package/dist/appearance-D3FUcbu6.d.cts +53 -0
  6. package/dist/appearance-D3FUcbu6.d.ts +53 -0
  7. package/dist/chunk-5JSZ4VGA.js +551 -0
  8. package/dist/chunk-5JSZ4VGA.js.map +1 -0
  9. package/dist/chunk-6LQOFACK.js +1576 -0
  10. package/dist/chunk-6LQOFACK.js.map +1 -0
  11. package/dist/chunk-N4QXFF3A.js +23 -0
  12. package/dist/chunk-N4QXFF3A.js.map +1 -0
  13. package/dist/chunk-RJRFR6N3.js +136 -0
  14. package/dist/chunk-RJRFR6N3.js.map +1 -0
  15. package/dist/core.cjs +619 -0
  16. package/dist/core.cjs.map +1 -0
  17. package/dist/core.d.cts +171 -0
  18. package/dist/core.d.ts +171 -0
  19. package/dist/core.js +91 -0
  20. package/dist/core.js.map +1 -0
  21. package/dist/diagram-js.cjs +1115 -0
  22. package/dist/diagram-js.cjs.map +1 -0
  23. package/dist/diagram-js.d.cts +163 -0
  24. package/dist/diagram-js.d.ts +163 -0
  25. package/dist/diagram-js.js +867 -0
  26. package/dist/diagram-js.js.map +1 -0
  27. package/dist/index.cjs +2175 -0
  28. package/dist/index.cjs.map +1 -0
  29. package/dist/index.d.cts +20 -0
  30. package/dist/index.d.ts +20 -0
  31. package/dist/index.js +103 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/keymap-CObQE6q6.d.cts +151 -0
  34. package/dist/keymap-DORzHnK-.d.ts +151 -0
  35. package/dist/protocol-DhYBakWL.d.cts +15 -0
  36. package/dist/protocol-IeW8bf4f.d.ts +15 -0
  37. package/dist/shell.css +914 -0
  38. package/dist/testing-vscode-stub.cjs +332 -0
  39. package/dist/testing-vscode-stub.cjs.map +1 -0
  40. package/dist/testing-vscode-stub.d.cts +65 -0
  41. package/dist/testing-vscode-stub.d.ts +65 -0
  42. package/dist/testing-vscode-stub.js +154 -0
  43. package/dist/testing-vscode-stub.js.map +1 -0
  44. package/dist/testing.cjs +2364 -0
  45. package/dist/testing.cjs.map +1 -0
  46. package/dist/testing.d.cts +165 -0
  47. package/dist/testing.d.ts +165 -0
  48. package/dist/testing.js +2297 -0
  49. package/dist/testing.js.map +1 -0
  50. package/dist/types-B6NfkiUG.d.cts +66 -0
  51. package/dist/types-B6NfkiUG.d.ts +66 -0
  52. package/dist/types-DnZn9CPw.d.ts +284 -0
  53. package/dist/types-OuFlqZp0.d.cts +284 -0
  54. package/dist/vscode-extension.cjs +215 -0
  55. package/dist/vscode-extension.cjs.map +1 -0
  56. package/dist/vscode-extension.d.cts +103 -0
  57. package/dist/vscode-extension.d.ts +103 -0
  58. package/dist/vscode-extension.js +24 -0
  59. package/dist/vscode-extension.js.map +1 -0
  60. package/dist/vscode-webview.cjs +2198 -0
  61. package/dist/vscode-webview.cjs.map +1 -0
  62. package/dist/vscode-webview.d.cts +82 -0
  63. package/dist/vscode-webview.d.ts +82 -0
  64. package/dist/vscode-webview.js +250 -0
  65. package/dist/vscode-webview.js.map +1 -0
  66. package/package.json +145 -4
  67. package/src/core/appearance.ts +104 -0
  68. package/src/core/fit.ts +205 -0
  69. package/src/core/index.ts +84 -0
  70. package/src/core/keymap.ts +275 -0
  71. package/src/core/layout.ts +69 -0
  72. package/src/core/messages.ts +88 -0
  73. package/src/core/sizes.ts +85 -0
  74. package/src/core/types.ts +84 -0
  75. package/src/core/zoom.ts +74 -0
  76. package/src/core.ts +2 -0
  77. package/src/diagram-js/connect.ts +75 -0
  78. package/src/diagram-js/index.ts +16 -0
  79. package/src/diagram-js/module.ts +20 -0
  80. package/src/diagram-js/palette-skin.ts +267 -0
  81. package/src/diagram-js/palette.css +269 -0
  82. package/src/diagram-js/service.ts +240 -0
  83. package/src/diagram-js/types.ts +67 -0
  84. package/src/diagram-js/viewport.ts +564 -0
  85. package/src/diagram-js.ts +2 -0
  86. package/src/dom/element.ts +51 -0
  87. package/src/dom/hint.ts +51 -0
  88. package/src/dom/icons.ts +309 -0
  89. package/src/dom/index.ts +29 -0
  90. package/src/dom/inspector.ts +35 -0
  91. package/src/dom/keys.ts +59 -0
  92. package/src/dom/ledger.ts +87 -0
  93. package/src/dom/legend.ts +85 -0
  94. package/src/dom/menu.ts +393 -0
  95. package/src/dom/mount.ts +461 -0
  96. package/src/dom/problems.ts +72 -0
  97. package/src/dom/shell-dom.css +966 -0
  98. package/src/dom/status.ts +72 -0
  99. package/src/dom/tooltip.ts +237 -0
  100. package/src/dom/types.ts +277 -0
  101. package/src/dom/zoom-panel.ts +154 -0
  102. package/src/index.ts +3 -0
  103. package/src/shell.css +6 -0
  104. package/src/testing/cases.ts +1012 -0
  105. package/src/testing/checks.ts +832 -0
  106. package/src/testing/facts.ts +587 -0
  107. package/src/testing/geometry.ts +472 -0
  108. package/src/testing/harness.ts +138 -0
  109. package/src/testing/index.ts +15 -0
  110. package/src/testing/page-tools.ts +434 -0
  111. package/src/testing/page.ts +17 -0
  112. package/src/testing/vscode-stub.ts +237 -0
  113. package/src/testing-vscode-stub.ts +2 -0
  114. package/src/testing.ts +2 -0
  115. package/src/vscode/extension/html.ts +107 -0
  116. package/src/vscode/extension/index.ts +8 -0
  117. package/src/vscode/extension/reveal.ts +9 -0
  118. package/src/vscode/extension/theme-bridge.ts +85 -0
  119. package/src/vscode/extension/types.ts +36 -0
  120. package/src/vscode/protocol.ts +31 -0
  121. package/src/vscode/webview/appearance.ts +141 -0
  122. package/src/vscode/webview/index.ts +8 -0
  123. package/src/vscode/webview/mount.ts +76 -0
  124. package/src/vscode/webview/state.ts +116 -0
  125. package/src/vscode-extension.ts +2 -0
  126. package/src/vscode-webview.ts +2 -0
@@ -0,0 +1,393 @@
1
+ import { ariaKeyShortcutsFromLabel, matchChromeKey, type ShellMessages } from "../core/index.js";
2
+ import { createElement, toggleAttribute } from "./element.js";
3
+ import { iconElement, type ShellIconName } from "./icons.js";
4
+ import type { TooltipController } from "./tooltip.js";
5
+ import type { MenuCheckboxEntry, MenuEntry, MenuGroups, MenuHandle, MenuRadioGroup } from "./types.js";
6
+
7
+ type GroupName = keyof MenuGroups;
8
+ const GROUP_ORDER: readonly GroupName[] = ["model", "export", "help"];
9
+
10
+ interface Item {
11
+ /** Stable across renders, so a re-render keeps the focus on the same entry. */
12
+ key: string;
13
+ element: HTMLButtonElement;
14
+ label: string;
15
+ disabled: boolean;
16
+ activate(): void;
17
+ }
18
+
19
+ export interface MenuContext {
20
+ root: HTMLElement;
21
+ layer: HTMLElement;
22
+ id: string;
23
+ messages: ShellMessages;
24
+ tooltip: TooltipController;
25
+ groups: MenuGroups;
26
+ /** Entries the shell appends to the Model group (the Legend checkbox). */
27
+ shellModelEntries(): readonly MenuCheckboxEntry[];
28
+ isReadOnly(): boolean;
29
+ }
30
+
31
+ export interface MenuController extends MenuHandle {
32
+ destroy(): void;
33
+ }
34
+
35
+ function isPrintable(event: KeyboardEvent): boolean {
36
+ return event.key.length === 1 && /\S/.test(event.key) && !event.ctrlKey && !event.metaKey && !event.altKey;
37
+ }
38
+
39
+ export function createMenu(context: MenuContext): MenuController {
40
+ const { root, layer, id, messages, tooltip } = context;
41
+ const ownerDocument = root.ownerDocument;
42
+ let groups = context.groups;
43
+ let items: Item[] = [];
44
+ let destroyed = false;
45
+
46
+ const trigger = createElement(ownerDocument, "button", {
47
+ type: "button",
48
+ class: "cd-shell-menu-trigger cd-shell-island",
49
+ "data-chrome": "menu",
50
+ "aria-label": messages.menu,
51
+ "aria-haspopup": "menu",
52
+ "aria-expanded": "false",
53
+ "aria-controls": id,
54
+ });
55
+ trigger.append(iconElement("menu"));
56
+
57
+ const dropdown = createElement(ownerDocument, "div", {
58
+ class: "cd-shell-menu",
59
+ id,
60
+ role: "menu",
61
+ "aria-label": messages.menu,
62
+ });
63
+ dropdown.hidden = true;
64
+ layer.prepend(dropdown);
65
+
66
+ const isOpen = (): boolean => !dropdown.hidden;
67
+
68
+ function entriesOf(name: GroupName): readonly MenuEntry[] {
69
+ const own = groups[name] ?? [];
70
+ return name === "model" ? [...own, ...context.shellModelEntries()] : own;
71
+ }
72
+
73
+ function hasEntries(): boolean {
74
+ return GROUP_ORDER.some((name) => entriesOf(name).length > 0);
75
+ }
76
+
77
+ function updateTriggerVisibility(): void {
78
+ trigger.hidden = !hasEntries();
79
+ if (trigger.hidden) close();
80
+ }
81
+
82
+ // Guide §13.5: on a read-only canvas the Model entries that change the document are aria-disabled.
83
+ // View-only toggles (the shell's Legend, a modeler's Type captions) opt out and stay usable.
84
+ function isDisabled(entry: MenuEntry, group: GroupName): boolean {
85
+ if (group === "model" && context.isReadOnly() && entry.keepEnabledWhenReadOnly !== true) return true;
86
+ return entry.disabled?.() ?? false;
87
+ }
88
+
89
+ function itemElement(
90
+ role: "menuitem" | "menuitemcheckbox" | "menuitemradio",
91
+ label: string,
92
+ options: {
93
+ icon?: ShellIconName | undefined;
94
+ end?: Node | undefined;
95
+ disabled: boolean;
96
+ checked?: boolean;
97
+ },
98
+ ): HTMLButtonElement {
99
+ const iconSlot = createElement(ownerDocument, "span", { class: "cd-shell-menu-item-icon" });
100
+ if (options.icon) iconSlot.append(iconElement(options.icon));
101
+ const element = createElement(
102
+ ownerDocument,
103
+ "button",
104
+ { type: "button", class: "cd-shell-menu-item", role, tabindex: "-1" },
105
+ iconSlot,
106
+ createElement(ownerDocument, "span", { class: "cd-shell-menu-item-label" }, label),
107
+ options.end && createElement(ownerDocument, "span", { class: "cd-shell-menu-item-end" }, options.end),
108
+ );
109
+ toggleAttribute(element, "aria-disabled", options.disabled && "true");
110
+ if (options.checked !== undefined) element.setAttribute("aria-checked", String(options.checked));
111
+ return element;
112
+ }
113
+
114
+ function checkMark(): SVGSVGElement {
115
+ return iconElement("check", { className: "cd-shell-menu-check" });
116
+ }
117
+
118
+ function renderEntry(entry: Exclude<MenuEntry, MenuRadioGroup>, group: GroupName): Item {
119
+ const disabled = isDisabled(entry, group);
120
+ if (entry.kind === "checkbox") {
121
+ const checked = entry.checked();
122
+ const element = itemElement("menuitemcheckbox", entry.label, {
123
+ icon: entry.icon,
124
+ end: checkMark(),
125
+ disabled,
126
+ checked,
127
+ });
128
+ element.dataset.menuId = entry.id;
129
+ element.dataset.menuGroup = group;
130
+ return {
131
+ key: entry.id,
132
+ element,
133
+ label: entry.label,
134
+ disabled,
135
+ activate() {
136
+ entry.onToggle(!entry.checked());
137
+ refresh();
138
+ },
139
+ };
140
+ }
141
+ const shortcut =
142
+ entry.kind === "action" && entry.shortcut
143
+ ? createElement(ownerDocument, "span", { class: "cd-shell-menu-shortcut" }, entry.shortcut)
144
+ : undefined;
145
+ const element = itemElement("menuitem", entry.label, { icon: entry.icon, end: shortcut, disabled });
146
+ element.dataset.menuId = entry.id;
147
+ element.dataset.menuGroup = group;
148
+ if (entry.kind === "action") {
149
+ const keyShortcuts =
150
+ entry.ariaKeyShortcuts ?? (entry.shortcut ? ariaKeyShortcutsFromLabel(entry.shortcut) : undefined);
151
+ toggleAttribute(element, "aria-keyshortcuts", keyShortcuts || null);
152
+ }
153
+ if (entry.kind === "dialog") element.setAttribute("aria-haspopup", "dialog");
154
+ if (entry.kind === "action" && entry.danger) element.dataset.danger = "";
155
+ return {
156
+ key: entry.id,
157
+ element,
158
+ label: entry.label,
159
+ disabled,
160
+ activate() {
161
+ close({ restoreFocus: true });
162
+ entry.onSelect();
163
+ },
164
+ };
165
+ }
166
+
167
+ function renderRadioGroup(
168
+ entry: MenuRadioGroup,
169
+ group: GroupName,
170
+ ): { element: HTMLElement; items: Item[] } {
171
+ const disabled = isDisabled(entry, group);
172
+ const value = entry.value();
173
+ const container = createElement(
174
+ ownerDocument,
175
+ "div",
176
+ {
177
+ class: "cd-shell-menu-group",
178
+ role: "group",
179
+ "aria-label": entry.label,
180
+ "data-menu-id": entry.id,
181
+ "data-menu-group": group,
182
+ },
183
+ // The group's aria-label carries the heading; a menu holds no loose text.
184
+ createElement(
185
+ ownerDocument,
186
+ "div",
187
+ { class: "cd-shell-menu-heading", "aria-hidden": "true" },
188
+ entry.label,
189
+ ),
190
+ );
191
+ const radioItems = entry.options.map((option): Item => {
192
+ const element = itemElement("menuitemradio", option.label, {
193
+ end: checkMark(),
194
+ disabled,
195
+ checked: option.value === value,
196
+ });
197
+ element.dataset.menuValue = option.value;
198
+ container.append(element);
199
+ return {
200
+ key: `${entry.id}\u0000${option.value}`,
201
+ element,
202
+ label: option.label,
203
+ disabled,
204
+ activate() {
205
+ if (entry.value() !== option.value) entry.onSelect(option.value);
206
+ refresh();
207
+ },
208
+ };
209
+ });
210
+ return { element: container, items: radioItems };
211
+ }
212
+
213
+ function render(): void {
214
+ const focusedKey = items.find((item) => item.element === ownerDocument.activeElement)?.key;
215
+ const nodes: HTMLElement[] = [];
216
+ const rendered: Item[] = [];
217
+ for (const name of GROUP_ORDER) {
218
+ const entries = entriesOf(name);
219
+ if (entries.length === 0) continue;
220
+ if (nodes.length > 0) {
221
+ nodes.push(
222
+ createElement(ownerDocument, "div", { class: "cd-shell-menu-separator", role: "separator" }),
223
+ );
224
+ }
225
+ for (const entry of entries) {
226
+ if (entry.kind === "radio") {
227
+ const radio = renderRadioGroup(entry, name);
228
+ nodes.push(radio.element);
229
+ rendered.push(...radio.items);
230
+ } else {
231
+ const item = renderEntry(entry, name);
232
+ nodes.push(item.element);
233
+ rendered.push(item);
234
+ }
235
+ }
236
+ }
237
+ for (const item of rendered) {
238
+ item.element.addEventListener("click", () => {
239
+ if (!item.disabled) item.activate();
240
+ });
241
+ }
242
+ dropdown.replaceChildren(...nodes);
243
+ items = rendered;
244
+ if (focusedKey !== undefined && isOpen()) {
245
+ (items.find((item) => item.key === focusedKey) ?? items[0])?.element.focus();
246
+ }
247
+ }
248
+
249
+ function focusAt(index: number): void {
250
+ if (items.length === 0) return;
251
+ items[(index + items.length) % items.length]?.element.focus();
252
+ }
253
+
254
+ function currentIndex(): number {
255
+ return items.findIndex((item) => item.element === ownerDocument.activeElement);
256
+ }
257
+
258
+ function typeAhead(character: string): void {
259
+ const start = currentIndex();
260
+ const lower = character.toLowerCase();
261
+ for (let step = 1; step <= items.length; step++) {
262
+ const index = (Math.max(start, -1) + step) % items.length;
263
+ if (items[index]?.label.toLowerCase().startsWith(lower)) {
264
+ focusAt(index);
265
+ return;
266
+ }
267
+ }
268
+ }
269
+
270
+ const onOutsidePointerDown = (event: PointerEvent): void => {
271
+ const target = event.target as Node | null;
272
+ if (target && (dropdown.contains(target) || trigger.contains(target))) return;
273
+ close();
274
+ };
275
+
276
+ // The focus left the document (a click into the VS Code workbench, another iframe): from the
277
+ // user's view a click outside, so the menu closes without moving the focus (guide §5.4).
278
+ const onWindowBlur = (): void => close();
279
+
280
+ function open(focus: "first" | "last" = "first"): void {
281
+ if (destroyed || !hasEntries()) return;
282
+ tooltip.hide();
283
+ const wasOpen = isOpen();
284
+ render();
285
+ if (!wasOpen) {
286
+ dropdown.hidden = false;
287
+ trigger.setAttribute("aria-expanded", "true");
288
+ // Capture phase: diagram-js stops pointer events on palette, context pad and canvas.
289
+ ownerDocument.addEventListener("pointerdown", onOutsidePointerDown, true);
290
+ ownerDocument.defaultView?.addEventListener("blur", onWindowBlur);
291
+ }
292
+ focusAt(focus === "last" ? items.length - 1 : 0);
293
+ }
294
+
295
+ function close(options: { restoreFocus?: boolean } = {}): void {
296
+ if (!isOpen()) return;
297
+ dropdown.hidden = true;
298
+ trigger.setAttribute("aria-expanded", "false");
299
+ ownerDocument.removeEventListener("pointerdown", onOutsidePointerDown, true);
300
+ ownerDocument.defaultView?.removeEventListener("blur", onWindowBlur);
301
+ if (options.restoreFocus && !trigger.hidden) trigger.focus();
302
+ }
303
+
304
+ function refresh(): void {
305
+ if (isOpen()) render();
306
+ }
307
+
308
+ trigger.addEventListener("click", () => {
309
+ if (isOpen()) close();
310
+ else open("first");
311
+ });
312
+
313
+ // Keys the menu consumes stop here, in every host like the palette's: a modeler's keyboard on
314
+ // document (Event Storming moves the selection on ↑/↓, deletes it on Delete, creates a sticky on
315
+ // E) must never act on them, and VS Code never sees them.
316
+ function consume(event: KeyboardEvent): void {
317
+ event.preventDefault();
318
+ event.stopPropagation();
319
+ }
320
+
321
+ trigger.addEventListener("keydown", (event) => {
322
+ if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
323
+ consume(event);
324
+ open("first");
325
+ } else if (event.key === "ArrowUp") {
326
+ consume(event);
327
+ open("last");
328
+ }
329
+ });
330
+
331
+ dropdown.addEventListener("keydown", (event) => {
332
+ // Zoom and fit keys still act with the menu open; the shell's key listener runs them.
333
+ if (matchChromeKey(event)) return;
334
+ switch (event.key) {
335
+ case "ArrowDown":
336
+ focusAt(currentIndex() + 1);
337
+ break;
338
+ case "ArrowUp": {
339
+ const index = currentIndex();
340
+ focusAt(index < 0 ? items.length - 1 : index - 1);
341
+ break;
342
+ }
343
+ case "Home":
344
+ focusAt(0);
345
+ break;
346
+ case "End":
347
+ focusAt(items.length - 1);
348
+ break;
349
+ case "Tab":
350
+ // The browser's Tab runs after this handler from wherever the focus is then: from the
351
+ // trigger it moves on to the palette instead of leaving the shell from the layer.
352
+ close({ restoreFocus: true });
353
+ return;
354
+ case "Escape":
355
+ // The shell's key listener closes the menu (and keeps Escape from VS Code in the IDE host).
356
+ return;
357
+ case "Enter":
358
+ case " ":
359
+ case "Delete":
360
+ case "Backspace":
361
+ // The entry's native click handles Enter and Space; nothing else may act on these keys.
362
+ event.stopPropagation();
363
+ return;
364
+ default:
365
+ if (!isPrintable(event)) return;
366
+ typeAhead(event.key);
367
+ }
368
+ consume(event);
369
+ });
370
+
371
+ updateTriggerVisibility();
372
+
373
+ return {
374
+ trigger,
375
+ dropdown,
376
+ isOpen,
377
+ open,
378
+ close,
379
+ refresh,
380
+ setGroups(next) {
381
+ groups = next;
382
+ updateTriggerVisibility();
383
+ refresh();
384
+ },
385
+ destroy() {
386
+ if (destroyed) return;
387
+ close();
388
+ destroyed = true;
389
+ trigger.remove();
390
+ dropdown.remove();
391
+ },
392
+ };
393
+ }