@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,564 @@
1
+ import {
2
+ FIT_ANIMATION_MS,
3
+ REDUCED_MOTION_CLASS,
4
+ VIEW_TOLERANCE,
5
+ ZOOM_MAX,
6
+ ZOOM_MIN,
7
+ canZoomIn,
8
+ canZoomOut,
9
+ easeStandard,
10
+ fitViewbox,
11
+ freeRectCenter,
12
+ interpolateViewbox,
13
+ matchChromeKey,
14
+ sameView,
15
+ type Bounds,
16
+ type Insets,
17
+ type Point,
18
+ type ScaledViewbox,
19
+ type Size,
20
+ type ViewportState,
21
+ type ZoomTarget,
22
+ } from "../core/index.js";
23
+ import type {
24
+ CanvasLike,
25
+ DiagramEventListener,
26
+ DiagramViewbox,
27
+ EventBusLike,
28
+ InjectorLike,
29
+ ZoomScrollLike,
30
+ } from "./types.js";
31
+
32
+ export interface ViewportOptions {
33
+ /**
34
+ * Content bounds in diagram coordinates, tight around the content: the fit insets are the only
35
+ * margin (guide §5.9). `undefined` = nothing to fit (empty file).
36
+ */
37
+ contentBounds(): Bounds | undefined;
38
+ /** Fit insets at the moment of a fit (`shell.fitInsets()`). */
39
+ insets(): Insets;
40
+ /**
41
+ * Default: `matchMedia("(prefers-reduced-motion: reduce)").matches`, or `<html>` carries
42
+ * `REDUCED_MOTION_CLASS` (VS Code's `workbench.reduceMotion`, mirrored by `mountVsCodeShell`).
43
+ */
44
+ prefersReducedMotion?: (() => boolean) | undefined;
45
+ }
46
+
47
+ /**
48
+ * The view of one diagram (guide §5.8, §5.9): zoom steps through `zoomScroll.stepZoom`, fit with
49
+ * insets and the 100 % cap, automatic refit on container resize until the user moves the view,
50
+ * user-move detection by comparison with the last box the viewport set itself.
51
+ */
52
+ export interface Viewport extends ZoomTarget {
53
+ /** Explicit fit; animated (250ms) unless `animate: false` or reduced motion. View becomes automatic. */
54
+ fit(options?: { animate?: boolean }): void;
55
+ /**
56
+ * Call after every successful import: fits (jump) the first time the content bounds are
57
+ * non-empty, unless a persisted view was set (`setState`) or the user moved it. When the first
58
+ * import is empty, under the same conditions, it shows 100 % around the centre of the free
59
+ * rectangle instead, whatever zoom the renderer's import left (an empty file opens at 100 %);
60
+ * that view is the viewport's own, not a user move, so the first content still fits. Otherwise
61
+ * a no-op. A `restore` keeps the view of an empty file, so its first content still fits; content
62
+ * the user created on the canvas does not (guide §5.9), so the next import keeps the view.
63
+ */
64
+ afterImport(): void;
65
+ /** Restores a viewbox after a re-import (external change): reference updated, user-move state kept. */
66
+ restore(viewbox: Bounds): void;
67
+ /** The current viewbox (x, y, width, height), `undefined` before the canvas has a size. */
68
+ getViewbox(): Bounds | undefined;
69
+ /**
70
+ * The centre of the free rectangle (the container minus `insets()`, guide §5.9) in diagram
71
+ * coordinates, where keyboard creation from the palette places an element (guide §5.5).
72
+ * `undefined` while the container is 0 × 0 and after `destroy`.
73
+ */
74
+ freeRectCenter(): Point | undefined;
75
+ /** True until the user zoomed or panned; a resize then refits. */
76
+ isAutomatic(): boolean;
77
+ /** For VS Code `setState`; `undefined` before the first import. */
78
+ getState(): ViewportState | undefined;
79
+ /** Restores a persisted view (it wins over the first automatic fit). */
80
+ setState(state: ViewportState): void;
81
+ /** Disconnects observers, listeners and a running animation; also runs on `diagram.destroy`. */
82
+ destroy(): void;
83
+ }
84
+
85
+ /** A viewport over `canvas` and `zoomScroll` of `diagram`, observing `canvas.getContainer()`. */
86
+ export function createViewport(diagram: InjectorLike, options: ViewportOptions): Viewport {
87
+ return new DiagramViewport(diagram, options);
88
+ }
89
+
90
+ type View = Pick<ScaledViewbox, "x" | "y" | "scale">;
91
+
92
+ /**
93
+ * Renderers replace the content between these events and may set the viewbox while doing so
94
+ * (Event Storming frames its board); those changes are not the user's.
95
+ */
96
+ const IMPORT_START_EVENTS = ["import.parse.start", "import.render.start"];
97
+ const IMPORT_DONE_EVENT = "import.done";
98
+
99
+ /** Events after which the content bounds may differ (importers add shapes without `elements.changed`). */
100
+ const CONTENT_EVENTS = [
101
+ "shape.added",
102
+ "shape.removed",
103
+ "connection.added",
104
+ "connection.removed",
105
+ "diagram.clear",
106
+ ];
107
+
108
+ /** diagram-js `ZoomScroll`: `NUM_STEPS = 10` over `RANGE`, in log space. */
109
+ const ZOOM_STEP = Math.log10(ZOOM_MAX / ZOOM_MIN) / 10;
110
+
111
+ const CAPTURE = { capture: true } as const;
112
+
113
+ export class DiagramViewport implements Viewport {
114
+ private readonly canvas: CanvasLike;
115
+ private readonly eventBus: EventBusLike;
116
+ private readonly container: HTMLElement;
117
+ private readonly listeners = new Set<() => void>();
118
+ private readonly subscriptions: Array<readonly [string | string[], DiagramEventListener]> = [];
119
+ private readonly observer: ResizeObserver | undefined;
120
+ private zoomScrollService: ZoomScrollLike | null | undefined;
121
+
122
+ /** The last view the viewport set as its own (fit target, restore); user moves differ from it. */
123
+ private reference: View | undefined;
124
+ /** The last view written, animation frames included. */
125
+ private lastWritten: View | undefined;
126
+ /** The container size the current view was set or last resized for. */
127
+ private lastSize: Size | undefined;
128
+ private writing = 0;
129
+ private userMoved = false;
130
+ private importing = false;
131
+ private imported = false;
132
+ private firstFitPending = true;
133
+ /** Nothing has decided the view yet, so an empty first import shows 100 %. */
134
+ private emptyViewPending = true;
135
+ /** The 100 % of an empty first import waits for the container to get a size (hidden tab). */
136
+ private emptyViewDue = false;
137
+ /** `canFit()` of the current content; the zoom panel asks every frame, `contentBounds` may be costly. */
138
+ private fittable: boolean | undefined;
139
+ private lastZoom = 1;
140
+ /** An automatic fit is due but the container had no size yet (hidden tab). */
141
+ private autoFitDue = false;
142
+ /** A restored view waits for the container to get a size. */
143
+ private pendingView: Bounds | undefined;
144
+ private animation: { frame: number } | undefined;
145
+ private resizeFrame: number | undefined;
146
+ private destroyed = false;
147
+
148
+ constructor(
149
+ private readonly diagram: InjectorLike,
150
+ private readonly options: ViewportOptions,
151
+ ) {
152
+ this.canvas = diagram.get<CanvasLike>("canvas");
153
+ this.eventBus = diagram.get<EventBusLike>("eventBus");
154
+ this.container = this.canvas.getContainer();
155
+
156
+ this.subscribe("canvas.viewbox.changing", this.notify);
157
+ this.subscribe("canvas.viewbox.changed", this.onViewboxChanged);
158
+ this.subscribe(IMPORT_START_EVENTS, this.onImportStart);
159
+ this.subscribe(IMPORT_DONE_EVENT, this.onImportDone);
160
+ this.subscribe("elements.changed", this.onElementsChanged);
161
+ this.subscribe(CONTENT_EVENTS, this.onContentChanged);
162
+ this.subscribe("diagram.destroy", () => this.destroy());
163
+
164
+ this.container.addEventListener("wheel", this.onPointerInput, { capture: true, passive: true });
165
+ this.container.addEventListener("pointerdown", this.onPointerInput, CAPTURE);
166
+ this.container.addEventListener("keydown", this.onKeyInput, CAPTURE);
167
+
168
+ if (typeof ResizeObserver === "function") {
169
+ this.observer = new ResizeObserver(this.onResizeObserved);
170
+ this.observer.observe(this.container);
171
+ }
172
+ }
173
+
174
+ zoom(): number {
175
+ if (!this.destroyed) this.lastZoom = this.canvas.zoom();
176
+ return this.lastZoom;
177
+ }
178
+
179
+ stepZoom(direction: 1 | -1): void {
180
+ if (this.destroyed) return;
181
+ this.beforeUserChange();
182
+ // Below 20 % ZoomScroll caps a step out to 20 %, which zooms in (guide §5.8).
183
+ const zoom = this.canvas.zoom();
184
+ if (direction < 0 ? !canZoomOut(zoom) : !canZoomIn(zoom)) return;
185
+ const zoomScroll = this.zoomScroll();
186
+ if (zoomScroll) zoomScroll.stepZoom(direction);
187
+ else this.canvas.zoom(stepOnRaster(zoom, direction));
188
+ }
189
+
190
+ zoomToActual(): void {
191
+ if (this.destroyed) return;
192
+ this.beforeUserChange();
193
+ this.canvas.zoom(1);
194
+ }
195
+
196
+ fit(options?: { animate?: boolean }): void {
197
+ if (this.destroyed) return;
198
+ this.importing = false;
199
+ this.fitTo(options?.animate !== false && !this.prefersReducedMotion());
200
+ }
201
+
202
+ canFit(): boolean {
203
+ if (this.destroyed) return false;
204
+ this.fittable ??= hasExtent(this.options.contentBounds());
205
+ return this.fittable;
206
+ }
207
+
208
+ onChange(listener: () => void): () => void {
209
+ this.listeners.add(listener);
210
+ return () => {
211
+ this.listeners.delete(listener);
212
+ };
213
+ }
214
+
215
+ afterImport(): void {
216
+ if (this.destroyed) return;
217
+ this.importing = false;
218
+ this.imported = true;
219
+ this.fittable = undefined;
220
+ if (this.firstFitPending && this.canFit()) {
221
+ this.firstFitPending = false;
222
+ this.emptyViewPending = false;
223
+ this.emptyViewDue = false;
224
+ if (!this.userMoved && !this.fitTo(false)) this.autoFitDue = true;
225
+ } else if (this.firstFitPending && this.emptyViewPending) {
226
+ this.emptyViewPending = false;
227
+ if (!this.userMoved && !this.showActualSize()) this.emptyViewDue = true;
228
+ }
229
+ this.notify();
230
+ }
231
+
232
+ restore(viewbox: Bounds): void {
233
+ if (this.destroyed) return;
234
+ this.importing = false;
235
+ this.imported = true;
236
+ this.fittable = undefined;
237
+ this.emptyViewPending = false;
238
+ this.emptyViewDue = false;
239
+ this.cancelAnimation();
240
+ this.show(viewbox);
241
+ this.notify();
242
+ }
243
+
244
+ freeRectCenter(): Point | undefined {
245
+ if (this.destroyed) return undefined;
246
+ const centre = freeRectCenter(this.canvas.getSize(), this.options.insets());
247
+ const view = this.currentView();
248
+ if (!centre || !view) return undefined;
249
+ return { x: view.x + centre.x / view.scale, y: view.y + centre.y / view.scale };
250
+ }
251
+
252
+ getViewbox(): Bounds | undefined {
253
+ if (this.destroyed) return undefined;
254
+ if (this.pendingView) return { ...this.pendingView };
255
+ const size = this.canvas.getSize();
256
+ if (!hasSize(size)) return undefined;
257
+ const { x, y, scale } = this.canvas.viewbox();
258
+ // The getter caches `outer` until the next change; the size is read fresh.
259
+ return { x, y, width: size.width / scale, height: size.height / scale };
260
+ }
261
+
262
+ isAutomatic(): boolean {
263
+ return !this.userMoved;
264
+ }
265
+
266
+ getState(): ViewportState | undefined {
267
+ if (!this.imported) return undefined;
268
+ const viewbox = this.getViewbox();
269
+ return viewbox ? { viewbox, userMoved: this.userMoved } : undefined;
270
+ }
271
+
272
+ setState(state: ViewportState): void {
273
+ if (this.destroyed) return;
274
+ this.importing = false;
275
+ this.imported = true;
276
+ this.fittable = undefined;
277
+ this.cancelAnimation();
278
+ this.userMoved = state.userMoved;
279
+ this.autoFitDue = false;
280
+ this.emptyViewPending = false;
281
+ this.emptyViewDue = false;
282
+ if (this.canFit()) this.firstFitPending = false;
283
+ this.show(state.viewbox);
284
+ this.notify();
285
+ }
286
+
287
+ /** `canvas.resized()`; viewbox changes it causes (keep-selection-visible) are not user moves. */
288
+ resized(): void {
289
+ if (this.destroyed) return;
290
+ this.writing++;
291
+ try {
292
+ this.canvas.resized();
293
+ } finally {
294
+ this.writing--;
295
+ }
296
+ }
297
+
298
+ destroy(): void {
299
+ if (this.destroyed) return;
300
+ this.destroyed = true;
301
+ this.cancelAnimation();
302
+ if (this.resizeFrame !== undefined) cancelAnimationFrame(this.resizeFrame);
303
+ this.resizeFrame = undefined;
304
+ this.observer?.disconnect();
305
+ this.container.removeEventListener("wheel", this.onPointerInput, CAPTURE);
306
+ this.container.removeEventListener("pointerdown", this.onPointerInput, CAPTURE);
307
+ this.container.removeEventListener("keydown", this.onKeyInput, CAPTURE);
308
+ for (const [events, listener] of this.subscriptions.splice(0)) this.eventBus.off(events, listener);
309
+ this.listeners.clear();
310
+ }
311
+
312
+ /** Fits now (jump) or animated; false when there is nothing to fit or no container size. */
313
+ private fitTo(animate: boolean): boolean {
314
+ this.cancelAnimation();
315
+ const bounds = this.options.contentBounds();
316
+ if (!bounds) return false;
317
+ const target = fitViewbox(bounds, this.canvas.getSize(), this.options.insets());
318
+ if (!target) return false;
319
+ this.userMoved = false;
320
+ this.firstFitPending = false;
321
+ this.autoFitDue = false;
322
+ this.emptyViewPending = false;
323
+ this.emptyViewDue = false;
324
+ this.pendingView = undefined;
325
+ const from = this.currentView();
326
+ if (animate && from && !sameView(from, target) && typeof requestAnimationFrame === "function") {
327
+ this.animate(from, target);
328
+ } else {
329
+ this.write(target);
330
+ }
331
+ return true;
332
+ }
333
+
334
+ private animate(from: ScaledViewbox, target: ScaledViewbox): void {
335
+ this.reference = toView(target);
336
+ const animation = { frame: 0 };
337
+ const startedAt = performance.now();
338
+ const step = (timestamp: number): void => {
339
+ if (this.animation !== animation) return;
340
+ if (!hasSize(this.canvas.getSize())) {
341
+ // Hidden mid-animation: fit again once the container is back.
342
+ this.cancelAnimation();
343
+ this.autoFitDue = true;
344
+ return;
345
+ }
346
+ const progress = Math.min(1, Math.max(0, (timestamp - startedAt) / FIT_ANIMATION_MS));
347
+ if (progress >= 1) {
348
+ this.animation = undefined;
349
+ this.write(target);
350
+ return;
351
+ }
352
+ this.write(interpolateViewbox(from, target, easeStandard(progress)), false);
353
+ animation.frame = requestAnimationFrame(step);
354
+ };
355
+ this.animation = animation;
356
+ animation.frame = requestAnimationFrame(step);
357
+ }
358
+
359
+ private cancelAnimation(): void {
360
+ const animation = this.animation;
361
+ if (!animation) return;
362
+ this.animation = undefined;
363
+ cancelAnimationFrame(animation.frame);
364
+ if (this.lastWritten) this.reference = this.lastWritten;
365
+ }
366
+
367
+ /**
368
+ * 100 % around the centre of the free rectangle, written as the viewport's own view; true when
369
+ * the view is at 100 % afterwards, false while the container has no size.
370
+ */
371
+ private showActualSize(): boolean {
372
+ const size = this.canvas.getSize();
373
+ const centre = freeRectCenter(size, this.options.insets());
374
+ const view = this.currentView();
375
+ if (!centre || !view) return false;
376
+ if (Math.abs(view.scale - 1) <= VIEW_TOLERANCE) return true;
377
+ this.cancelAnimation();
378
+ this.write({
379
+ x: view.x + centre.x / view.scale - centre.x,
380
+ y: view.y + centre.y / view.scale - centre.y,
381
+ width: size.width,
382
+ height: size.height,
383
+ });
384
+ return true;
385
+ }
386
+
387
+ /** Shows a restored view now, or once the container has a size. */
388
+ private show(viewbox: Bounds): void {
389
+ if (hasSize(this.canvas.getSize())) {
390
+ this.pendingView = undefined;
391
+ this.write(viewbox);
392
+ } else {
393
+ this.pendingView = { x: viewbox.x, y: viewbox.y, width: viewbox.width, height: viewbox.height };
394
+ }
395
+ }
396
+
397
+ /** Callers make sure the container has a size: diagram-js would set a scale of 0 otherwise. */
398
+ private write(box: Bounds, asReference = true): void {
399
+ const size = this.canvas.getSize();
400
+ // The scale diagram-js's setter derives from the box (`Canvas#viewbox`).
401
+ const view: View = {
402
+ x: box.x,
403
+ y: box.y,
404
+ scale: Math.min(size.width / box.width, size.height / box.height),
405
+ };
406
+ this.writing++;
407
+ try {
408
+ this.canvas.viewbox({ x: box.x, y: box.y, width: box.width, height: box.height });
409
+ } finally {
410
+ this.writing--;
411
+ }
412
+ this.lastWritten = view;
413
+ if (asReference) this.reference = view;
414
+ this.lastSize = { width: size.width, height: size.height };
415
+ }
416
+
417
+ private currentView(): ScaledViewbox | undefined {
418
+ const size = this.canvas.getSize();
419
+ const { x, y, scale } = this.canvas.viewbox();
420
+ if (!(scale > 0 && Number.isFinite(scale))) return undefined;
421
+ return { x, y, width: size.width / scale, height: size.height / scale, scale };
422
+ }
423
+
424
+ private beforeUserChange(): void {
425
+ this.importing = false;
426
+ this.cancelAnimation();
427
+ }
428
+
429
+ private zoomScroll(): ZoomScrollLike | null {
430
+ if (this.zoomScrollService === undefined) {
431
+ try {
432
+ this.zoomScrollService = this.diagram.get<ZoomScrollLike | null>("zoomScroll") ?? null;
433
+ } catch {
434
+ this.zoomScrollService = null;
435
+ }
436
+ }
437
+ return this.zoomScrollService;
438
+ }
439
+
440
+ private prefersReducedMotion(): boolean {
441
+ if (this.options.prefersReducedMotion) return this.options.prefersReducedMotion();
442
+ if (this.container.ownerDocument.documentElement.classList.contains(REDUCED_MOTION_CLASS)) return true;
443
+ return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
444
+ }
445
+
446
+ private isOwnView(current: View): boolean {
447
+ if (this.reference && sameView(current, this.reference)) return true;
448
+ return (
449
+ this.animation !== undefined && this.lastWritten !== undefined && sameView(current, this.lastWritten)
450
+ );
451
+ }
452
+
453
+ // Not a flag around the own writes alone: with `canvas.deferUpdate` the event of an own change
454
+ // arrives up to 300ms later, so it is recognised by its view.
455
+ private readonly onViewboxChanged = (event?: { viewbox?: DiagramViewbox }): void => {
456
+ if (this.writing === 0 && !this.importing) {
457
+ const current = event?.viewbox ?? this.canvas.viewbox();
458
+ if (!this.isOwnView(current)) {
459
+ this.cancelAnimation();
460
+ this.userMoved = true;
461
+ this.autoFitDue = false;
462
+ this.emptyViewDue = false;
463
+ }
464
+ }
465
+ this.notify();
466
+ };
467
+
468
+ private readonly onImportStart = (): void => {
469
+ this.importing = true;
470
+ };
471
+
472
+ private readonly onImportDone = (): void => {
473
+ this.importing = false;
474
+ this.fittable = undefined;
475
+ this.notify();
476
+ };
477
+
478
+ private readonly onContentChanged = (): void => {
479
+ this.fittable = undefined;
480
+ };
481
+
482
+ // Guide §5.9: an element the user creates on the canvas never fits. Once such content exists,
483
+ // only imports that keep the view follow, so the first automatic fit is no longer due.
484
+ private readonly onElementsChanged = (): void => {
485
+ this.fittable = undefined;
486
+ if (this.imported && !this.importing && this.firstFitPending && this.canFit()) {
487
+ this.firstFitPending = false;
488
+ }
489
+ this.notify();
490
+ };
491
+
492
+ // User input also ends an import window a failed import never closed with `import.done`.
493
+ private readonly onPointerInput = (): void => {
494
+ this.importing = false;
495
+ this.cancelAnimation();
496
+ };
497
+
498
+ private readonly onKeyInput = (event: KeyboardEvent): void => {
499
+ this.importing = false;
500
+ if (matchChromeKey(event)) this.cancelAnimation();
501
+ };
502
+
503
+ private readonly onResizeObserved = (): void => {
504
+ if (this.destroyed || this.resizeFrame !== undefined) return;
505
+ if (typeof requestAnimationFrame !== "function") {
506
+ this.onResize();
507
+ return;
508
+ }
509
+ this.resizeFrame = requestAnimationFrame(() => {
510
+ this.resizeFrame = undefined;
511
+ this.onResize();
512
+ });
513
+ };
514
+
515
+ private onResize(): void {
516
+ if (this.destroyed) return;
517
+ const size = this.canvas.getSize();
518
+ if (!hasSize(size)) return;
519
+ const changed = this.lastSize?.width !== size.width || this.lastSize.height !== size.height;
520
+ if (!changed && !this.pendingView && !this.autoFitDue && !this.emptyViewDue) return;
521
+ this.lastSize = { width: size.width, height: size.height };
522
+ this.resized();
523
+ const restored = this.pendingView;
524
+ if (restored) {
525
+ this.pendingView = undefined;
526
+ this.write(restored);
527
+ }
528
+ if (this.userMoved) return;
529
+ if (this.emptyViewDue) {
530
+ this.emptyViewDue = false;
531
+ this.showActualSize();
532
+ return;
533
+ }
534
+ // A view restored for this very moment is not refitted; a fit that was due still runs.
535
+ if (this.autoFitDue || (!restored && !this.firstFitPending)) this.fitTo(false);
536
+ }
537
+
538
+ private subscribe(events: string | string[], listener: (event: never) => unknown): void {
539
+ this.eventBus.on(events, listener);
540
+ this.subscriptions.push([events, listener]);
541
+ }
542
+
543
+ private readonly notify = (): void => {
544
+ for (const listener of [...this.listeners]) listener();
545
+ };
546
+ }
547
+
548
+ function toView(box: ScaledViewbox): View {
549
+ return { x: box.x, y: box.y, scale: box.scale };
550
+ }
551
+
552
+ function hasSize(size: Size): boolean {
553
+ return size.width > 0 && size.height > 0;
554
+ }
555
+
556
+ function hasExtent(bounds: Bounds | undefined): boolean {
557
+ return bounds !== undefined && (bounds.width > 0 || bounds.height > 0);
558
+ }
559
+
560
+ /** `ZoomScroll#_zoom` for a diagram without `zoomScroll`: snap to the raster, one step, cap. */
561
+ function stepOnRaster(zoom: number, direction: 1 | -1): number {
562
+ const level = Math.round(Math.log10(zoom) / ZOOM_STEP) * ZOOM_STEP + ZOOM_STEP * direction;
563
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, 10 ** level));
564
+ }
@@ -0,0 +1,2 @@
1
+ // "@miragon/modeler-shell/diagram-js": the diagram-js adapter (services typed structurally).
2
+ export * from "./diagram-js/index.js";
@@ -0,0 +1,51 @@
1
+ type Child = Node | string | undefined | null | false;
2
+
3
+ /** `document.createElement` with attributes and children; `undefined` attribute values are skipped. */
4
+ export function createElement<K extends keyof HTMLElementTagNameMap>(
5
+ ownerDocument: Document,
6
+ tag: K,
7
+ attributes: Readonly<Record<string, string | undefined>> = {},
8
+ ...children: Child[]
9
+ ): HTMLElementTagNameMap[K] {
10
+ const element = ownerDocument.createElement(tag);
11
+ for (const [name, value] of Object.entries(attributes)) {
12
+ if (value !== undefined) element.setAttribute(name, value);
13
+ }
14
+ for (const child of children) {
15
+ if (child === undefined || child === null || child === false) continue;
16
+ element.append(child);
17
+ }
18
+ return element;
19
+ }
20
+
21
+ /** Sets an attribute, or removes it for `null`/`false`. */
22
+ export function toggleAttribute(element: Element, name: string, value: string | null | false): void {
23
+ if (value === null || value === false) element.removeAttribute(name);
24
+ else element.setAttribute(name, value);
25
+ }
26
+
27
+ /** The element's box relative to the padding box of `root` (the containing block of the islands). */
28
+ export function boxRelativeTo(element: Element, root: HTMLElement) {
29
+ const box = element.getBoundingClientRect();
30
+ const origin = root.getBoundingClientRect();
31
+ return {
32
+ x: box.left - origin.left - root.clientLeft,
33
+ y: box.top - origin.top - root.clientTop,
34
+ width: box.width,
35
+ height: box.height,
36
+ };
37
+ }
38
+
39
+ /** The accessible name a control shows without its tooltip: `aria-label`, else its text. */
40
+ export function accessibleName(control: Element): string {
41
+ return (control.getAttribute("aria-label") ?? control.textContent ?? "").trim();
42
+ }
43
+
44
+ /** True for keyboard focus (`:focus-visible`); engines without the selector count every focus. */
45
+ export function hasVisibleFocus(element: Element): boolean {
46
+ try {
47
+ return element.matches(":focus-visible");
48
+ } catch {
49
+ return true;
50
+ }
51
+ }
@@ -0,0 +1,51 @@
1
+ import type { Insets, ShellMessages } from "../core/index.js";
2
+ import { createElement } from "./element.js";
3
+ import type { EmptyHintOptions } from "./types.js";
4
+
5
+ export interface EmptyHint {
6
+ readonly element: HTMLElement;
7
+ isShown(): boolean;
8
+ setShown(shown: boolean): void;
9
+ /** Centres the hint in the free rectangle of the fit (guide §13.5). */
10
+ place(insets: Insets): void;
11
+ destroy(): void;
12
+ }
13
+
14
+ export function createEmptyHint(
15
+ ownerDocument: Document,
16
+ messages: ShellMessages,
17
+ options: EmptyHintOptions,
18
+ ): EmptyHint {
19
+ const { onInsertExample } = options;
20
+ const action = onInsertExample
21
+ ? createElement(
22
+ ownerDocument,
23
+ "button",
24
+ { type: "button", class: "cd-shell-hint-action" },
25
+ messages.insertExample,
26
+ )
27
+ : undefined;
28
+ action?.addEventListener("click", () => onInsertExample?.());
29
+ const element = createElement(
30
+ ownerDocument,
31
+ "div",
32
+ { class: "cd-shell-hint" },
33
+ createElement(ownerDocument, "p", { class: "cd-shell-hint-text" }, messages.emptyHint),
34
+ action,
35
+ );
36
+ element.hidden = true;
37
+
38
+ return {
39
+ element,
40
+ isShown: () => !element.hidden,
41
+ setShown(shown) {
42
+ element.hidden = !shown;
43
+ },
44
+ place({ top, right, bottom, left }) {
45
+ element.style.inset = `${top}px ${right}px ${bottom}px ${left}px`;
46
+ },
47
+ destroy() {
48
+ element.remove();
49
+ },
50
+ };
51
+ }