@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.
- package/API.md +1902 -0
- package/LICENSE +21 -0
- package/README.md +435 -2
- package/THIRD_PARTY_NOTICES.md +52 -0
- package/dist/appearance-D3FUcbu6.d.cts +53 -0
- package/dist/appearance-D3FUcbu6.d.ts +53 -0
- package/dist/chunk-5JSZ4VGA.js +551 -0
- package/dist/chunk-5JSZ4VGA.js.map +1 -0
- package/dist/chunk-6LQOFACK.js +1576 -0
- package/dist/chunk-6LQOFACK.js.map +1 -0
- package/dist/chunk-N4QXFF3A.js +23 -0
- package/dist/chunk-N4QXFF3A.js.map +1 -0
- package/dist/chunk-RJRFR6N3.js +136 -0
- package/dist/chunk-RJRFR6N3.js.map +1 -0
- package/dist/core.cjs +619 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +171 -0
- package/dist/core.d.ts +171 -0
- package/dist/core.js +91 -0
- package/dist/core.js.map +1 -0
- package/dist/diagram-js.cjs +1115 -0
- package/dist/diagram-js.cjs.map +1 -0
- package/dist/diagram-js.d.cts +163 -0
- package/dist/diagram-js.d.ts +163 -0
- package/dist/diagram-js.js +867 -0
- package/dist/diagram-js.js.map +1 -0
- package/dist/index.cjs +2175 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +103 -0
- package/dist/index.js.map +1 -0
- package/dist/keymap-CObQE6q6.d.cts +151 -0
- package/dist/keymap-DORzHnK-.d.ts +151 -0
- package/dist/protocol-DhYBakWL.d.cts +15 -0
- package/dist/protocol-IeW8bf4f.d.ts +15 -0
- package/dist/shell.css +914 -0
- package/dist/testing-vscode-stub.cjs +332 -0
- package/dist/testing-vscode-stub.cjs.map +1 -0
- package/dist/testing-vscode-stub.d.cts +65 -0
- package/dist/testing-vscode-stub.d.ts +65 -0
- package/dist/testing-vscode-stub.js +154 -0
- package/dist/testing-vscode-stub.js.map +1 -0
- package/dist/testing.cjs +2364 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +165 -0
- package/dist/testing.d.ts +165 -0
- package/dist/testing.js +2297 -0
- package/dist/testing.js.map +1 -0
- package/dist/types-B6NfkiUG.d.cts +66 -0
- package/dist/types-B6NfkiUG.d.ts +66 -0
- package/dist/types-DnZn9CPw.d.ts +284 -0
- package/dist/types-OuFlqZp0.d.cts +284 -0
- package/dist/vscode-extension.cjs +215 -0
- package/dist/vscode-extension.cjs.map +1 -0
- package/dist/vscode-extension.d.cts +103 -0
- package/dist/vscode-extension.d.ts +103 -0
- package/dist/vscode-extension.js +24 -0
- package/dist/vscode-extension.js.map +1 -0
- package/dist/vscode-webview.cjs +2198 -0
- package/dist/vscode-webview.cjs.map +1 -0
- package/dist/vscode-webview.d.cts +82 -0
- package/dist/vscode-webview.d.ts +82 -0
- package/dist/vscode-webview.js +250 -0
- package/dist/vscode-webview.js.map +1 -0
- package/package.json +145 -4
- package/src/core/appearance.ts +104 -0
- package/src/core/fit.ts +205 -0
- package/src/core/index.ts +84 -0
- package/src/core/keymap.ts +275 -0
- package/src/core/layout.ts +69 -0
- package/src/core/messages.ts +88 -0
- package/src/core/sizes.ts +85 -0
- package/src/core/types.ts +84 -0
- package/src/core/zoom.ts +74 -0
- package/src/core.ts +2 -0
- package/src/diagram-js/connect.ts +75 -0
- package/src/diagram-js/index.ts +16 -0
- package/src/diagram-js/module.ts +20 -0
- package/src/diagram-js/palette-skin.ts +267 -0
- package/src/diagram-js/palette.css +269 -0
- package/src/diagram-js/service.ts +240 -0
- package/src/diagram-js/types.ts +67 -0
- package/src/diagram-js/viewport.ts +564 -0
- package/src/diagram-js.ts +2 -0
- package/src/dom/element.ts +51 -0
- package/src/dom/hint.ts +51 -0
- package/src/dom/icons.ts +309 -0
- package/src/dom/index.ts +29 -0
- package/src/dom/inspector.ts +35 -0
- package/src/dom/keys.ts +59 -0
- package/src/dom/ledger.ts +87 -0
- package/src/dom/legend.ts +85 -0
- package/src/dom/menu.ts +393 -0
- package/src/dom/mount.ts +461 -0
- package/src/dom/problems.ts +72 -0
- package/src/dom/shell-dom.css +966 -0
- package/src/dom/status.ts +72 -0
- package/src/dom/tooltip.ts +237 -0
- package/src/dom/types.ts +277 -0
- package/src/dom/zoom-panel.ts +154 -0
- package/src/index.ts +3 -0
- package/src/shell.css +6 -0
- package/src/testing/cases.ts +1012 -0
- package/src/testing/checks.ts +832 -0
- package/src/testing/facts.ts +587 -0
- package/src/testing/geometry.ts +472 -0
- package/src/testing/harness.ts +138 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/page-tools.ts +434 -0
- package/src/testing/page.ts +17 -0
- package/src/testing/vscode-stub.ts +237 -0
- package/src/testing-vscode-stub.ts +2 -0
- package/src/testing.ts +2 -0
- package/src/vscode/extension/html.ts +107 -0
- package/src/vscode/extension/index.ts +8 -0
- package/src/vscode/extension/reveal.ts +9 -0
- package/src/vscode/extension/theme-bridge.ts +85 -0
- package/src/vscode/extension/types.ts +36 -0
- package/src/vscode/protocol.ts +31 -0
- package/src/vscode/webview/appearance.ts +141 -0
- package/src/vscode/webview/index.ts +8 -0
- package/src/vscode/webview/mount.ts +76 -0
- package/src/vscode/webview/state.ts +116 -0
- package/src/vscode-extension.ts +2 -0
- package/src/vscode-webview.ts +2 -0
package/API.md
ADDED
|
@@ -0,0 +1,1902 @@
|
|
|
1
|
+
# @miragon/modeler-shell 0.1 — public API
|
|
2
|
+
|
|
3
|
+
The shared, framework-free implementation of everything around a Miragon diagram-js modeler: menu
|
|
4
|
+
button with dropdown, horizontal palette skin, chrome tooltip, zoom panel, zoom and fit keys, fit,
|
|
5
|
+
problems pill, status box, empty-file hint, legend and inspector slots, and the plumbing to a VS Code
|
|
6
|
+
webview. Planned in [Miragon/modeler-api#4](https://github.com/Miragon/modeler-api/issues/4)
|
|
7
|
+
(Phase 4); the first consumer is the Event Storming modeler.
|
|
8
|
+
|
|
9
|
+
- **Look and behaviour** come from the Miragon design system, guide `modeler-design-system.md`
|
|
10
|
+
(skill `miragon-brand:modeler-tool-design`), §5.1 to §5.15, §6, §8, §9, §11, §12 and §13.3 to
|
|
11
|
+
§13.5. Section numbers below (§x.y) refer to it.
|
|
12
|
+
- **Mechanics** (diagram-js services, VS Code quirks) are in
|
|
13
|
+
[`docs/shell/implementation-notes.md`](../../docs/shell/implementation-notes.md).
|
|
14
|
+
- **Zero runtime dependencies.** The shell never imports `diagram-js`, `didi`, `vscode`, Lucide or
|
|
15
|
+
React, not even for types: diagram-js services are typed structurally, the `vscode` namespace and
|
|
16
|
+
Playwright's `page`/`test` are passed in.
|
|
17
|
+
- **Tokens are the consumer's.** The modeler vendors `cd-tokens.generated.css` unchanged; the shell
|
|
18
|
+
CSS reads only `var(--cd-ui-*)` roles, `var(--cd-shell-*)` sizes and the mode-independent
|
|
19
|
+
non-colour tokens `--cd-radius-*`, `--cd-shadow-*`, `--cd-motion-*`, `--cd-ease`, `--cd-ease-out`.
|
|
20
|
+
It contains no colour literal and never a fixed palette token (`--cd-blau`, `--cd-schwarz`, …).
|
|
21
|
+
- **English by default.** Every text comes from one messages table (`ShellMessages`), every number
|
|
22
|
+
goes through `Intl` in the UI language.
|
|
23
|
+
|
|
24
|
+
## Entry points
|
|
25
|
+
|
|
26
|
+
| Entry | For | Needs a DOM |
|
|
27
|
+
| -------------------------------------------- | ----------------------------------------------------------- | ------------------------------ |
|
|
28
|
+
| `@miragon/modeler-shell` | the chrome (`mountShell`), icons, plus all of `./core` | yes (at call time) |
|
|
29
|
+
| `@miragon/modeler-shell/core` | fit math, keymap, zoom readout, appearance, sizes, messages | no |
|
|
30
|
+
| `@miragon/modeler-shell/diagram-js` | `shellModule`, viewport, `connectDiagramJs` | yes |
|
|
31
|
+
| `@miragon/modeler-shell/vscode/extension` | webview HTML, nonce, theme bridge (extension host) | no |
|
|
32
|
+
| `@miragon/modeler-shell/vscode/webview` | `mountVsCodeShell`, appearance handler, webview state | yes |
|
|
33
|
+
| `@miragon/modeler-shell/testing` | Playwright-driven shell conformance kit | runs in Node, drives a browser |
|
|
34
|
+
| `@miragon/modeler-shell/testing/vscode-stub` | a stand-in for VS Code around a webview test page | yes (in the test page) |
|
|
35
|
+
| `@miragon/modeler-shell/shell.css` | every style of the shell | — |
|
|
36
|
+
|
|
37
|
+
All JavaScript entries ship as ESM and CJS with types (`typesVersions` covers `moduleResolution:
|
|
38
|
+
node10`); importing any of them has no side effects. The package also ships `src/` without its tests,
|
|
39
|
+
the files the sourcemaps point at, so stack traces and test locations resolve to real files. Every
|
|
40
|
+
optional input field also accepts an explicit `undefined`, so consumers compiling with
|
|
41
|
+
`exactOptionalPropertyTypes` can forward optional values (`{ view: viewport.getState() }`,
|
|
42
|
+
`{ open: stored.legendOpen }`); the shell compiles with it too.
|
|
43
|
+
|
|
44
|
+
### Stylesheet order
|
|
45
|
+
|
|
46
|
+
The tokens come first, unless the renderer's stylesheet already imports them (Event Storming's
|
|
47
|
+
`event-storming.css` does, in a layer), and are loaded exactly once:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import "./theme/cd-tokens.generated.css"; // vendored, unchanged, first; left out when the renderer CSS brings it
|
|
51
|
+
import "@fontsource-variable/geist/wght.css";
|
|
52
|
+
import "@fontsource-variable/geist-mono/wght.css"; // shortcut chips
|
|
53
|
+
import "diagram-js/assets/diagram-js.css"; // usually through the renderer's CSS
|
|
54
|
+
import "@miragon/<notation>-renderer/…css"; // the renderer
|
|
55
|
+
import "@miragon/modeler-shell/shell.css"; // last
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A bundle ships the fonts as files, not as data URLs (see `./vscode/extension`).
|
|
59
|
+
|
|
60
|
+
## `./core` — DOM-free
|
|
61
|
+
|
|
62
|
+
Compiles without the DOM library (`tsconfig.core.json`), usable in the extension host and in workers.
|
|
63
|
+
|
|
64
|
+
### Geometry and shared types
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
interface Point {
|
|
68
|
+
x: number;
|
|
69
|
+
y: number;
|
|
70
|
+
}
|
|
71
|
+
interface Size {
|
|
72
|
+
width: number;
|
|
73
|
+
height: number;
|
|
74
|
+
}
|
|
75
|
+
// content bounds and viewboxes are in diagram coordinates
|
|
76
|
+
interface Bounds {
|
|
77
|
+
x: number;
|
|
78
|
+
y: number;
|
|
79
|
+
width: number;
|
|
80
|
+
height: number;
|
|
81
|
+
}
|
|
82
|
+
interface ScaledViewbox extends Bounds {
|
|
83
|
+
scale: number;
|
|
84
|
+
}
|
|
85
|
+
// px from the edges of the modeler's container
|
|
86
|
+
interface Insets {
|
|
87
|
+
top: number;
|
|
88
|
+
right: number;
|
|
89
|
+
bottom: number;
|
|
90
|
+
left: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type ShellHost = "ide" | "web" | "embedded";
|
|
94
|
+
type ChromeIsland = "menu" | "palette" | "primary" | "zoom" | "problems" | "legend" | "inspector" | "status";
|
|
95
|
+
const CHROME_ISLANDS: readonly ChromeIsland[];
|
|
96
|
+
type PaletteRow = "top" | "second-row";
|
|
97
|
+
|
|
98
|
+
interface ZoomTarget {
|
|
99
|
+
zoom(): number; // 1 = 100 %
|
|
100
|
+
stepZoom(direction: 1 | -1): void; // zoomScroll raster, around the container centre
|
|
101
|
+
zoomToActual(): void; // 100 %, a user move
|
|
102
|
+
fit(): void; // explicit fit: animated unless reduced motion; view becomes automatic
|
|
103
|
+
canFit(): boolean; // false while the content bounds are empty
|
|
104
|
+
onChange(listener: () => void): () => void; // every viewbox change and canFit change; unsubscribe
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// JSON-serializable, for VS Code setState
|
|
108
|
+
interface ViewportState {
|
|
109
|
+
viewbox: Bounds;
|
|
110
|
+
userMoved: boolean; // zoomed or panned since the last fit: a resize no longer refits
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`ShellHost` is the host column of §5.2: `ide` (VS Code webview, later IntelliJ), `web` (a Miragon web
|
|
115
|
+
app owning its page), `embedded` (a foreign page such as designIQ; geometry like `ide`).
|
|
116
|
+
|
|
117
|
+
### Sizes
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const SHELL_TOKENS: {
|
|
121
|
+
edge: "--cd-shell-rand";
|
|
122
|
+
gap: "--cd-shell-luecke";
|
|
123
|
+
row: "--cd-shell-zeile";
|
|
124
|
+
target: "--cd-shell-ziel";
|
|
125
|
+
entry: "--cd-shell-eintrag";
|
|
126
|
+
free: "--cd-shell-frei";
|
|
127
|
+
menuMinWidth: "--cd-shell-menue-breite";
|
|
128
|
+
inspectorWidth: "--cd-shell-inspector-breite";
|
|
129
|
+
fitInsetTop: "--cd-shell-einpassen-oben";
|
|
130
|
+
fitInsetTopSecondRow: "--cd-shell-einpassen-oben-schmal";
|
|
131
|
+
fitInsetSide: "--cd-shell-einpassen-seite";
|
|
132
|
+
fitInsetBottom: "--cd-shell-einpassen-unten";
|
|
133
|
+
fitInsetBottomWeb: "--cd-shell-einpassen-unten-web";
|
|
134
|
+
narrow: "--cd-shell-schmal";
|
|
135
|
+
veryNarrow: "--cd-shell-sehr-schmal";
|
|
136
|
+
low: "--cd-shell-niedrig";
|
|
137
|
+
layerIslands: "--cd-shell-ebene-inseln";
|
|
138
|
+
layerInspector: "--cd-shell-ebene-inspector";
|
|
139
|
+
layerTopRow: "--cd-shell-ebene-reihe";
|
|
140
|
+
layerPopups: "--cd-shell-ebene-aufklapper";
|
|
141
|
+
layerDialog: "--cd-shell-ebene-dialog";
|
|
142
|
+
layerToast: "--cd-shell-ebene-toast";
|
|
143
|
+
};
|
|
144
|
+
type ShellSizeName = keyof typeof SHELL_TOKENS;
|
|
145
|
+
type ShellSizes = Readonly<Record<ShellSizeName, number>>;
|
|
146
|
+
const SHELL: ShellSizes; // edge 12, gap 8, row 40, target 32, entry 36, free 64, menuMinWidth 248,
|
|
147
|
+
// inspectorWidth 288, fitInsetTop 88, fitInsetTopSecondRow 140, fitInsetSide 24, fitInsetBottom 64,
|
|
148
|
+
// fitInsetBottomWeb 92, narrow 760, veryNarrow 440, low 820, layers 20, 25, 30, 40, 50, 60
|
|
149
|
+
function shellSizesFromTokens(shellGroup: Readonly<Record<string, string>>): ShellSizes;
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`SHELL` equals `parseFloat` of the `shell` group of `cd-tokens.generated.json`; a drift test pins it
|
|
153
|
+
to the token fixture. `shellSizesFromTokens` reads a vendored JSON's `shell` group and throws, naming
|
|
154
|
+
the token, when a size is missing or neither `"<n>px"` nor a plain number (so `"12em"` fails instead
|
|
155
|
+
of silently becoming 12); unknown tokens are ignored.
|
|
156
|
+
|
|
157
|
+
A modeler pins its own vendored JSON the same way, in a unit test (Vitest or `node:test`), never in
|
|
158
|
+
its e2e suite: the check is pure data. Both expectations, because the fit math reads `SHELL` and the
|
|
159
|
+
stylesheet reads the tokens:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
expect(SHELL).toEqual(shellSizesFromTokens(vendoredTokens.shell));
|
|
163
|
+
expect(Object.keys(vendoredTokens.shell)).toEqual(expect.arrayContaining(Object.values(SHELL_TOKENS)));
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Fit
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const MAX_FIT_ZOOM = 1;
|
|
170
|
+
const FIT_ANIMATION_MS = 250;
|
|
171
|
+
const VIEW_TOLERANCE = 1e-3;
|
|
172
|
+
|
|
173
|
+
function fitViewbox(
|
|
174
|
+
bounds: Bounds,
|
|
175
|
+
container: Size,
|
|
176
|
+
insets: Insets,
|
|
177
|
+
maxZoom?: number,
|
|
178
|
+
): ScaledViewbox | undefined;
|
|
179
|
+
|
|
180
|
+
interface FitLayout {
|
|
181
|
+
container: Size;
|
|
182
|
+
host: ShellHost;
|
|
183
|
+
paletteRow: PaletteRow;
|
|
184
|
+
problemsVisible: boolean;
|
|
185
|
+
legendWidth?: number; // open legend's rendered width, undefined when closed/absent
|
|
186
|
+
inspector?: { sheetTop?: number }; // present = open; sheetTop measured when it is a bottom sheet
|
|
187
|
+
}
|
|
188
|
+
function fitInsets(layout: FitLayout): Insets;
|
|
189
|
+
function freeRectCenter(container: Size, insets: Insets): Point | undefined; // px from the container's top left
|
|
190
|
+
|
|
191
|
+
function sameView(
|
|
192
|
+
a: Pick<ScaledViewbox, "x" | "y" | "scale">,
|
|
193
|
+
b: Pick<ScaledViewbox, "x" | "y" | "scale">,
|
|
194
|
+
tolerance?: number,
|
|
195
|
+
): boolean;
|
|
196
|
+
function interpolateViewbox(from: ScaledViewbox, to: ScaledViewbox, progress: number): ScaledViewbox;
|
|
197
|
+
function easeStandard(progress: number): number; // cubic-bezier(0.4, 0, 0.2, 1)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
- `fitViewbox` centres `bounds` in the free rectangle (container minus insets) at the largest scale
|
|
201
|
+
that fits, capped at `maxZoom`, and returns a box with the container's aspect ratio, so
|
|
202
|
+
`canvas.viewbox(box)` shows exactly that view (§5.9). `undefined` for a container with a 0 side,
|
|
203
|
+
for bounds without width and height, or for a non-finite result; a zero width or height scales by
|
|
204
|
+
the other dimension. An axis whose insets leave no room (a container lower or narrower than its
|
|
205
|
+
chrome) centres in the whole container.
|
|
206
|
+
- `fitInsets` (§5.9): top 88, 140 in the second row; left and right 24; bottom 64, in the `web` host
|
|
207
|
+
with a visible pill 92; right 324 (12 + 288 + 24) with an open side inspector (container wider
|
|
208
|
+
than 760); left 12 + legend width + 24 with an open legend in containers wider than 440
|
|
209
|
+
(`legendWidth <= 0` counts as closed); bottom `container.height − sheetTop + 12` with an open
|
|
210
|
+
bottom sheet (up to 760). The sheet hides zoom panel, pill and legend (§5.11), so while it is open
|
|
211
|
+
neither the legend nor the web pill counts; without a measured `sheetTop` the bottom stays 64.
|
|
212
|
+
- `freeRectCenter` is the centre of the free rectangle, where `fitViewbox` centres the content and
|
|
213
|
+
keyboard creation places an element (§5.5), in pixels from the container's top left; an axis whose
|
|
214
|
+
insets leave no room centres in the whole container, as in `fitViewbox`. `undefined` for a
|
|
215
|
+
container with a 0 side. `Viewport.freeRectCenter()` turns it into diagram coordinates.
|
|
216
|
+
- `sameView` compares `scale` and the screen translation (`x · scale`, `y · scale`) within
|
|
217
|
+
`tolerance` relative to `max(1, |value|)`; width and height never count, so a resize is no move.
|
|
218
|
+
- `interpolateViewbox` interpolates the scale in log space and the centre linearly.
|
|
219
|
+
|
|
220
|
+
### Zoom readout
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
const ZOOM_MIN = 0.2;
|
|
224
|
+
const ZOOM_MAX = 4;
|
|
225
|
+
function canZoomOut(zoom: number): boolean; // zoom > 0.2 · 1.001
|
|
226
|
+
function canZoomIn(zoom: number): boolean; // zoom < 4 · 0.999
|
|
227
|
+
function formatPercent(zoom: number, lang: string): string; // "86%", German "86 %" (U+00A0)
|
|
228
|
+
function formatNumber(value: number, lang: string): string; // a tag Intl rejects ("") formats as "en"
|
|
229
|
+
interface ZoomReadout {
|
|
230
|
+
text: string;
|
|
231
|
+
ariaLabel: string;
|
|
232
|
+
canZoomOut: boolean;
|
|
233
|
+
canZoomIn: boolean;
|
|
234
|
+
}
|
|
235
|
+
function zoomReadout(
|
|
236
|
+
zoom: number,
|
|
237
|
+
lang: string,
|
|
238
|
+
messages: Pick<ShellMessages, "zoomToActualNow">,
|
|
239
|
+
): ZoomReadout;
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Keymap
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
type ChromeKey = "zoomIn" | "zoomOut" | "zoomToActual" | "fit";
|
|
246
|
+
interface KeyLike {
|
|
247
|
+
key: string;
|
|
248
|
+
code: string;
|
|
249
|
+
metaKey: boolean;
|
|
250
|
+
ctrlKey: boolean;
|
|
251
|
+
shiftKey: boolean;
|
|
252
|
+
altKey: boolean;
|
|
253
|
+
isComposing?: boolean;
|
|
254
|
+
}
|
|
255
|
+
function matchChromeKey(event: KeyLike): ChromeKey | undefined;
|
|
256
|
+
function isUndoRedoKey(event: KeyLike): boolean;
|
|
257
|
+
const TEXT_FIELD_SELECTOR = "input, textarea, select, [contenteditable]:not([contenteditable='false'])";
|
|
258
|
+
|
|
259
|
+
interface KeyContext {
|
|
260
|
+
host: ShellHost;
|
|
261
|
+
inTextField: boolean; // target matches TEXT_FIELD_SELECTOR or isContentEditable
|
|
262
|
+
menuOpen: boolean; // the shell's dropdown
|
|
263
|
+
dialogOpen: boolean; // any shown [role="dialog"], [role="alertdialog"] or <dialog open>, modal or not
|
|
264
|
+
modalOpen: boolean; // one of them with aria-modal="true" or :modal
|
|
265
|
+
defaultPrevented: boolean; // the canvas or the modeler handled the key
|
|
266
|
+
}
|
|
267
|
+
type KeyAction = ChromeKey | "closeMenu";
|
|
268
|
+
interface KeyDecision {
|
|
269
|
+
action?: KeyAction;
|
|
270
|
+
preventDefault: boolean;
|
|
271
|
+
stopPropagation: boolean;
|
|
272
|
+
}
|
|
273
|
+
function decideKey(event: KeyLike, context: KeyContext): KeyDecision;
|
|
274
|
+
|
|
275
|
+
type Platform = "mac" | "other";
|
|
276
|
+
interface NavigatorLike {
|
|
277
|
+
platform?: string;
|
|
278
|
+
userAgentData?: { platform?: string };
|
|
279
|
+
}
|
|
280
|
+
function detectPlatform(navigator?: NavigatorLike): Platform;
|
|
281
|
+
function shortcutLabel(key: ChromeKey, platform: Platform, keyNames: ShellMessages["keyNames"]): string;
|
|
282
|
+
function ariaKeyShortcuts(key: ChromeKey, platform: Platform): string;
|
|
283
|
+
function ariaKeyShortcutsFromLabel(label: string): string | undefined;
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`matchChromeKey` (§5.8) — Cmd/Ctrl is `metaKey || ctrlKey` on every platform, nothing matches with
|
|
287
|
+
`altKey` (AltGr):
|
|
288
|
+
|
|
289
|
+
| Chrome key | Matches |
|
|
290
|
+
| -------------- | -------------------------------------------------------------------------- |
|
|
291
|
+
| `zoomIn` | Cmd/Ctrl + `key` `+` or `=` (with or without Shift), or `code` `NumpadAdd` |
|
|
292
|
+
| `zoomOut` | Cmd/Ctrl + `key` `-` or `_`, or `code` `NumpadSubtract` |
|
|
293
|
+
| `zoomToActual` | Cmd/Ctrl + `key` `0`, or `code` `Numpad0` (also with NumLock off) |
|
|
294
|
+
| `fit` | `code` `Digit1` with Shift, without Cmd, Ctrl and Alt (every layout) |
|
|
295
|
+
|
|
296
|
+
`decideKey` holds every key rule of §5.8 and §13.5; the shell's one `keydown` listener only applies
|
|
297
|
+
it. `stopPropagation` is only ever `true` in the `ide` host.
|
|
298
|
+
|
|
299
|
+
| Case | Action | preventDefault | stopPropagation (`ide`) |
|
|
300
|
+
| --------------------------------------------------------- | ----------- | -------------- | ----------------------- |
|
|
301
|
+
| Escape (no modifier), menu open, not composing | `closeMenu` | yes | yes |
|
|
302
|
+
| Escape, popover or dialog open, or menu open while IME | — | no | yes |
|
|
303
|
+
| zoom key, not composing, not in a text field, no modal | the key | yes | yes |
|
|
304
|
+
| zoom key while composing, in a text field or with a modal | — | no | yes |
|
|
305
|
+
| Shift+1, not composing, not in a text field, no modal | `fit` | yes | yes |
|
|
306
|
+
| Shift+1 otherwise | — | no | no (types "!") |
|
|
307
|
+
| undo/redo key in a text field | — | no | yes |
|
|
308
|
+
| any other key with `defaultPrevented` | — | no | yes |
|
|
309
|
+
| everything else | — | no | no |
|
|
310
|
+
|
|
311
|
+
- "No modifier" means no Meta, Ctrl, Alt or Shift. During IME composition Escape belongs to the IME.
|
|
312
|
+
- Zoom keys act whatever `defaultPrevented` says (diagram-js's keyboard prevents every key a listener
|
|
313
|
+
answered, the shell's own guard included); a blocked Shift+1 is never stopped.
|
|
314
|
+
- `isUndoRedoKey` (Cmd/Ctrl + Z, Shift+Z, Y) decides by the typed character when it is ASCII, so
|
|
315
|
+
QWERTZ and Dvorak work, and falls back to `code` `KeyZ`/`KeyY` only for non-ASCII layouts
|
|
316
|
+
(Cyrillic, Greek). Cmd/Ctrl+Shift+Y is not redo: VS Code toggles its Debug Console with it.
|
|
317
|
+
|
|
318
|
+
Shortcut labels (§5.8): macOS `⌘+`, `⌘−` (U+2212), `⌘0`, `⇧1`; elsewhere `Ctrl+Plus`, `Ctrl+Minus`,
|
|
319
|
+
`Ctrl+0`, `Shift+1` from `keyNames`. `aria-keyshortcuts`: `Meta+= Meta+Plus`, `Meta+-`, `Meta+0`,
|
|
320
|
+
`Shift+1`; outside macOS `Control` for `Meta`. The platform is read in the page itself
|
|
321
|
+
(`detectPlatform(navigator)`, `userAgentData.platform || platform`, so an empty `userAgentData`
|
|
322
|
+
platform falls back), never in the extension host (VS Code Remote).
|
|
323
|
+
|
|
324
|
+
`ariaKeyShortcutsFromLabel` turns a shortcut chip label into `aria-keyshortcuts` (§5.6) for palette
|
|
325
|
+
entries and menu actions: `"L"` → `"L"`, `"⇧L"` → `"Shift+L"`, `"⌘⇧Z"` → `"Meta+Shift+Z"`,
|
|
326
|
+
`"Ctrl+Shift+E"` → `"Control+Shift+E"`, `"Del"` → `"Delete"`, `"Ctrl++"` → `"Control+Plus"`; a label
|
|
327
|
+
that is not one key combination (`"G then D"`, `"1–9"`) gives `undefined`.
|
|
328
|
+
|
|
329
|
+
### Appearance
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
type Theme = "light" | "dark";
|
|
333
|
+
type Kontrast = "hoch";
|
|
334
|
+
interface Appearance {
|
|
335
|
+
theme: Theme;
|
|
336
|
+
kontrast?: Kontrast;
|
|
337
|
+
}
|
|
338
|
+
function resolveAppearance(requested: Appearance, themes: readonly Theme[]): Appearance;
|
|
339
|
+
function appearanceFromColorThemeKind(kind: number): Appearance;
|
|
340
|
+
type ColorThemeSetting = "automatic" | "light" | "dark";
|
|
341
|
+
function requestedAppearance(kind: number, setting?: ColorThemeSetting): Appearance;
|
|
342
|
+
function appearanceFromBodyClasses(classes: Iterable<string>): Appearance | undefined;
|
|
343
|
+
function reducedMotionFromBodyClasses(classes: Iterable<string>): boolean; // vscode-reduce-motion
|
|
344
|
+
const REDUCED_MOTION_CLASS = "cd-shell-reduce-motion"; // on <html>, mirrored by mountVsCodeShell
|
|
345
|
+
interface AppearanceAttributes {
|
|
346
|
+
"data-cd-theme": Theme;
|
|
347
|
+
"data-cd-kontrast"?: Kontrast;
|
|
348
|
+
}
|
|
349
|
+
function appearanceAttributes(appearance: Appearance): AppearanceAttributes;
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
- `resolveAppearance` is the one decision of §13.5: a theme outside `themes` becomes `themes[0]`, so
|
|
353
|
+
a modeler declaring `["light"]` is never half-dark; `kontrast: "hoch"` always passes (VS Code High
|
|
354
|
+
Contrast on a light-only modeler gives `{ theme: "light", kontrast: "hoch" }`), any other value is
|
|
355
|
+
dropped, since appearances arrive by `postMessage`. Throws for empty `themes`. `themes` is a static
|
|
356
|
+
value of the modeler package, known before the first paint.
|
|
357
|
+
- `appearanceFromColorThemeKind`: 1 → light, 2 → dark, 3 → dark + hoch, 4 → light + hoch, other →
|
|
358
|
+
light. `requestedAppearance` lets the setting's theme win unless it is `automatic`; the contrast
|
|
359
|
+
always comes from the kind.
|
|
360
|
+
- `appearanceFromBodyClasses` checks `vscode-high-contrast-light` before `vscode-high-contrast`.
|
|
361
|
+
- `reducedMotionFromBodyClasses` reads VS Code's `workbench.reduceMotion` from the body class
|
|
362
|
+
`vscode-reduce-motion`; while `<html>` carries `REDUCED_MOTION_CLASS`, the shell's animations stop
|
|
363
|
+
and an explicit fit jumps, as under `prefers-reduced-motion`.
|
|
364
|
+
|
|
365
|
+
### Messages
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
interface ShellMessages {
|
|
369
|
+
menu: string; // "Menu"
|
|
370
|
+
tools: string; // "Tools"
|
|
371
|
+
canvas: string; // "Diagram": aria-label of the focusable canvas <svg>, name the notation
|
|
372
|
+
zoom: string; // "Zoom"
|
|
373
|
+
zoomOut: string; // "Zoom out"
|
|
374
|
+
zoomIn: string; // "Zoom in"
|
|
375
|
+
zoomToActual(percent: string): string; // "Zoom to 100%"
|
|
376
|
+
zoomToActualNow(percent: string, now: string): string; // "Zoom to 100% (now 86%)"
|
|
377
|
+
fit: string; // "Fit to view"
|
|
378
|
+
legend: string; // "Legend"
|
|
379
|
+
closeLegend: string; // "Close legend"
|
|
380
|
+
problems(count: number, formattedCount: string): string; // "1 problem", "2 problems"
|
|
381
|
+
problemsLabel(count: number, errors: number, formattedCount: string, formattedErrors: string): string;
|
|
382
|
+
// "2 problems, 1 error", "2 problems, no errors"
|
|
383
|
+
showProblems: string; // "Show in Problems view"
|
|
384
|
+
emptyHint: string; // "Pick a tool from the palette to start."
|
|
385
|
+
insertExample: string; // "Insert example"
|
|
386
|
+
invalidFile(diagram: string): string;
|
|
387
|
+
// "This file isn't a valid <diagram> right now. The diagram shows the last valid version and is read-only until you fix the text."
|
|
388
|
+
keyNames: { ctrl: string; shift: string; plus: string; minus: string }; // Ctrl, Shift, Plus, Minus
|
|
389
|
+
}
|
|
390
|
+
// every key optional; an undefined value keeps the default
|
|
391
|
+
type ShellMessageOverrides = {
|
|
392
|
+
[Key in Exclude<keyof ShellMessages, "keyNames">]?: ShellMessages[Key] | undefined;
|
|
393
|
+
} & { keyNames?: { [Key in keyof ShellMessages["keyNames"]]?: string | undefined } | undefined };
|
|
394
|
+
const defaultMessages: ShellMessages;
|
|
395
|
+
function resolveMessages(overrides?: ShellMessageOverrides): ShellMessages;
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
A localized modeler overrides the whole table (guide §2: one language per tool) and passes `lang`.
|
|
399
|
+
|
|
400
|
+
### Layout decisions
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
const PALETTE_CLEARANCE = 16;
|
|
404
|
+
const TOOLTIP_DELAY_MS = 300;
|
|
405
|
+
const INVALID_STATUS_DELAY_MS = 800;
|
|
406
|
+
interface PaletteRowInput {
|
|
407
|
+
containerWidth: number;
|
|
408
|
+
paletteWidth: number;
|
|
409
|
+
menuRight: number;
|
|
410
|
+
primaryLeft?: number;
|
|
411
|
+
}
|
|
412
|
+
function decidePaletteRow(input: PaletteRowInput): PaletteRow;
|
|
413
|
+
function legendStartsOpen(containerHeight: number): boolean; // > 820
|
|
414
|
+
interface TooltipPlacementInput {
|
|
415
|
+
anchor: Bounds;
|
|
416
|
+
island: Bounds;
|
|
417
|
+
tooltip: Size;
|
|
418
|
+
container: Size;
|
|
419
|
+
}
|
|
420
|
+
function placeTooltip(input: TooltipPlacementInput): Point;
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
`decidePaletteRow` (§5.15): `second-row` up to 760px container width, or when the centred palette
|
|
424
|
+
would keep less than 16px to the menu trigger (or to the primary action); `paletteWidth` is the
|
|
425
|
+
intrinsic border-box width (`offsetWidth − clientWidth + scrollWidth`: the content including the
|
|
426
|
+
borders), so the decision does not flip once the palette scrolls in the second row.
|
|
427
|
+
|
|
428
|
+
`placeTooltip` (§5.6): below an island in the upper half at `island.bottom + 8` (top row 60, second
|
|
429
|
+
row 112), above one in the lower half ending 8px above it (bottom row: `bottom: 60px`), centred on
|
|
430
|
+
`anchor`, shifted to keep 12px to the sides.
|
|
431
|
+
|
|
432
|
+
## `.` — the chrome
|
|
433
|
+
|
|
434
|
+
Exports everything of `./core` plus:
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
function mountShell(root: HTMLElement, options: ShellOptions): ShellHandle;
|
|
438
|
+
|
|
439
|
+
interface ShellOptions {
|
|
440
|
+
host: ShellHost;
|
|
441
|
+
canvas?: HTMLElement; // diagram-js's container; omitted: the shell creates it (handle.canvas)
|
|
442
|
+
lang?: string; // see below; the embedded root carries lang itself
|
|
443
|
+
messages?: ShellMessageOverrides;
|
|
444
|
+
platform?: Platform; // default detectPlatform(navigator)
|
|
445
|
+
keyScope?: "document" | "root"; // default "document", "root" for embedded
|
|
446
|
+
themes?: readonly Theme[]; // default ["light"]; read in the embedded host only, see below
|
|
447
|
+
menu?: MenuGroups;
|
|
448
|
+
problems?: ProblemsOptions;
|
|
449
|
+
legend?: LegendOptions;
|
|
450
|
+
inspector?: InspectorOptions;
|
|
451
|
+
emptyHint?: EmptyHintOptions;
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`mountShell` turns `root` into the shell: class `cd-shell`, `container: cd-shell / size`,
|
|
456
|
+
`isolation: isolate`. `root` must be sized from outside, otherwise size containment collapses it to 0:
|
|
457
|
+
in an own document a root that is a direct child of `<body>` gets `height: 100%` from `shell.css`
|
|
458
|
+
(a zero-specificity rule), any other root and an embedded one need an explicit size. In the `ide` and
|
|
459
|
+
`web` hosts `<html>` gets `cd-shell-document` (own-document reset). The shell observes the root and
|
|
460
|
+
the registered palette with a `ResizeObserver` (palette row, legend start state; 0 × 0 is skipped)
|
|
461
|
+
and installs exactly one `keydown` listener (bubble phase) on `document` or on the root.
|
|
462
|
+
|
|
463
|
+
`lang` is the tool's UI language for `Intl` (guide §2: one language per tool). Default in an own
|
|
464
|
+
document (`ide`, `web`): the closest `[lang]` of the root (the `<html lang>` the host wrote), else
|
|
465
|
+
`"en"`. In the `embedded` host the page's language is not the tool's, so the default is the root's
|
|
466
|
+
own `lang` attribute, else `"en"`; the embedded root carries `lang` itself. A localized modeler
|
|
467
|
+
passes `lang` together with its messages.
|
|
468
|
+
|
|
469
|
+
**The embedded root's mode** (§13.3). The token CSS resolves the roles on the nearest
|
|
470
|
+
`[data-cd-theme]`, so a root without one would take the mode of a themed host page (designIQ, a web
|
|
471
|
+
app on `system` under a dark OS) and be half-dark against its light-drawn diagram. The `embedded`
|
|
472
|
+
root therefore carries `data-cd-theme="<themes[0]>"` itself (until the contract's `appearance`
|
|
473
|
+
surface, planned for a later 1.x minor of `@miragon/modeler-api`, there is no channel to ask an
|
|
474
|
+
embedded modeler for another mode, so it stays in its first one), and mirrors the host's
|
|
475
|
+
`data-cd-kontrast` from the nearest mode carrier above it for as long as it lives (a
|
|
476
|
+
`MutationObserver` on the document's `data-cd-theme`/`data-cd-kontrast` attributes), because its own
|
|
477
|
+
`data-cd-theme` would otherwise drop the host's high contrast. In an own document (`ide`, `web`) the
|
|
478
|
+
host writes the mode on `<html>` and `themes` is not read. An empty `themes` throws before anything
|
|
479
|
+
is mounted.
|
|
480
|
+
|
|
481
|
+
**Order with document listeners.** Listeners on one target run in registration order. A modeler
|
|
482
|
+
whose own keyboard listens on `document` (Event Storming does) constructs its diagram **before**
|
|
483
|
+
`mountShell`, so the shell's listener runs after it and sees `defaultPrevented`; a renderer that
|
|
484
|
+
creates its diagram lazily (Event Storming's `Modeler` on the first `get`) calls
|
|
485
|
+
`modeler.get("canvas")` first. Listeners on the canvas `<svg>` (diagram-js `keyboard`) have no
|
|
486
|
+
ordering constraint. Such a document keyboard must leave alone what is not meant for the canvas:
|
|
487
|
+
keys with `defaultPrevented` and keys whose target lies outside the canvas container (the menu
|
|
488
|
+
stops the keys it consumes, the palette its own, but the zoom panel's buttons do not); in the IDE
|
|
489
|
+
host it binds no undo or redo, and on the canvas it handles Cmd/Ctrl+A (see `./diagram-js`,
|
|
490
|
+
Keyboard).
|
|
491
|
+
|
|
492
|
+
### Menu (§5.4)
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
interface MenuGroups {
|
|
496
|
+
model?: readonly MenuEntry[];
|
|
497
|
+
export?: readonly MenuEntry[];
|
|
498
|
+
help?: readonly MenuEntry[];
|
|
499
|
+
}
|
|
500
|
+
type MenuEntry = MenuActionEntry | MenuDialogEntry | MenuCheckboxEntry | MenuRadioGroup;
|
|
501
|
+
|
|
502
|
+
interface MenuItemBase {
|
|
503
|
+
id: string;
|
|
504
|
+
label: string;
|
|
505
|
+
icon?: ShellIconName;
|
|
506
|
+
disabled?: () => boolean;
|
|
507
|
+
keepEnabledWhenReadOnly?: boolean; // a Model entry that is pure view state; default false
|
|
508
|
+
}
|
|
509
|
+
interface MenuActionEntry extends MenuItemBase {
|
|
510
|
+
kind: "action";
|
|
511
|
+
shortcut?: string;
|
|
512
|
+
ariaKeyShortcuts?: string; // default ariaKeyShortcutsFromLabel(shortcut); "" sets none
|
|
513
|
+
danger?: boolean;
|
|
514
|
+
onSelect(): void;
|
|
515
|
+
}
|
|
516
|
+
interface MenuDialogEntry extends MenuItemBase {
|
|
517
|
+
kind: "dialog";
|
|
518
|
+
onSelect(): void;
|
|
519
|
+
}
|
|
520
|
+
interface MenuCheckboxEntry extends MenuItemBase {
|
|
521
|
+
kind: "checkbox";
|
|
522
|
+
checked(): boolean;
|
|
523
|
+
onToggle(checked: boolean): void;
|
|
524
|
+
}
|
|
525
|
+
interface MenuRadioGroup {
|
|
526
|
+
kind: "radio";
|
|
527
|
+
id: string;
|
|
528
|
+
label: string;
|
|
529
|
+
options: readonly MenuRadioOption[];
|
|
530
|
+
value(): string | undefined;
|
|
531
|
+
onSelect(value: string): void;
|
|
532
|
+
disabled?: () => boolean;
|
|
533
|
+
keepEnabledWhenReadOnly?: boolean;
|
|
534
|
+
}
|
|
535
|
+
interface MenuRadioOption {
|
|
536
|
+
value: string;
|
|
537
|
+
label: string;
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
- Groups render in the fixed order Model, Export, Help with separators; empty groups collapse. The
|
|
542
|
+
File and Appearance groups of the web app are not part of 0.1. With a legend (and
|
|
543
|
+
`legend.menuEntry !== false`) the shell appends the checkbox "Legend" to the Model group.
|
|
544
|
+
- `action` and `dialog`: the menu closes, focus returns to the trigger, then `onSelect` runs (a host
|
|
545
|
+
dialog then hands focus back to a visible control). `dialog` adds `aria-haspopup="dialog"`; its
|
|
546
|
+
label ends with "…" (U+2026), and in 0.1 the modeler renders the dialog itself (see
|
|
547
|
+
[Modeler dialogs](#modeler-dialogs)).
|
|
548
|
+
- `checkbox` and `radio` toggle and keep the menu open. State is read through `checked()`, `value()`
|
|
549
|
+
and `disabled()` whenever the menu opens and on `menu.refresh()`.
|
|
550
|
+
- Icons only from the common set and only where the guide gives one; the icon column stays 16px wide
|
|
551
|
+
without an icon. An action with a `shortcut` carries `aria-keyshortcuts`.
|
|
552
|
+
- On a read-only canvas (`setReadOnly(true)`, §13.5) every Model entry is `aria-disabled` unless it
|
|
553
|
+
sets `keepEnabledWhenReadOnly`; disabled entries stay focusable and do nothing. View toggles stay
|
|
554
|
+
usable on a read-only canvas: the shell's Legend checkbox carries `keepEnabledWhenReadOnly` itself,
|
|
555
|
+
and a modeler sets it on its own pure view state (Event Storming's "Type captions"), never on an
|
|
556
|
+
entry that changes the document (Level, "Map size…"). Export and Help entries are never locked.
|
|
557
|
+
Guide §13.5 (design system 1.13.0): only Model entries that change the document are `aria-disabled`.
|
|
558
|
+
- The trigger is `hidden` while no group has an entry (no menu groups and no Legend entry).
|
|
559
|
+
- Keys: Enter, Space and ↓ on the trigger open on the first entry, ↑ on the last; ↑/↓ wrap, Home/End
|
|
560
|
+
jump, a letter jumps to the next entry starting with it; Escape and Tab close (Escape returns focus
|
|
561
|
+
to the trigger). Every key the menu consumes (those, plus Enter, Space, Delete and Backspace in the
|
|
562
|
+
dropdown) is stopped at the menu in every host, so a modeler's keyboard on `document` never acts
|
|
563
|
+
on it (Event Storming would move the selection on ↓); Escape, Tab, modified keys and the chrome
|
|
564
|
+
keys go on (Shift+1 fits, Cmd/Ctrl + `+` zooms with the menu open).
|
|
565
|
+
- A `pointerdown` outside (capture phase on `document`) closes without moving focus and without
|
|
566
|
+
stopping the event, so it still acts on its target. So does the window losing the focus (a click
|
|
567
|
+
into the VS Code workbench or another iframe).
|
|
568
|
+
|
|
569
|
+
### Modeler dialogs
|
|
570
|
+
|
|
571
|
+
0.1 has no dialog component (a 0.2 candidate, see [Out of scope for 0.1](#out-of-scope-for-01)), so a
|
|
572
|
+
modeler renders its dialogs, the "Keyboard shortcuts…" dialog of the Help group (§5.4) first among
|
|
573
|
+
them, and meets §8 on its own:
|
|
574
|
+
|
|
575
|
+
- **Layer.** `z-index: var(--cd-shell-ebene-dialog)` (50) applies only to a dialog outside the top
|
|
576
|
+
layer, inside `shell.root`. A `<dialog>` opened with `showModal()` or an element of the popover API
|
|
577
|
+
lies in the browser's top layer above everything, whatever its `z-index`. The shell's keys count it
|
|
578
|
+
either way: any shown `<dialog>`, `[role="dialog"]` or `[role="alertdialog"]` in the document
|
|
579
|
+
(embedded: in the root) is open, and a modal one blocks the zoom keys.
|
|
580
|
+
- **Panel and backdrop** (§8): panel `--cd-ui-flaeche-hoch` with 1px `--cd-ui-linie`, radius `md`,
|
|
581
|
+
`shadow-3`, `width: min(560px, 100cqw - 32px)`, `max-height: calc(100cqh - 32px)` with a scrolling
|
|
582
|
+
body; header (padding `16px 16px 8px 24px`, title 21px/600) with Close as a 32 × 32 ghost icon
|
|
583
|
+
button (Lucide `x` 16px, `aria-label` "Close"); backdrop
|
|
584
|
+
`background: var(--cd-ui-backdrop); backdrop-filter: blur(3px) grayscale(1)` in both modes. The
|
|
585
|
+
shortcut chips look like the tooltip's (§5.6).
|
|
586
|
+
- **High contrast** (`[data-cd-kontrast="hoch"]`, §6, §5.14): no shadow, border `--cd-ui-kontur`,
|
|
587
|
+
focus ring 3px.
|
|
588
|
+
- **Forced colours** (`@media (forced-colors: active)`): border `CanvasText`, focus ring `Highlight`.
|
|
589
|
+
The shell's own high-contrast and forced-colours rules match only `cd-shell-*` classes, so they
|
|
590
|
+
do not reach a modeler's dialog.
|
|
591
|
+
- **Focus** (§8): it moves into the dialog and stays there, Escape and a click on the backdrop close
|
|
592
|
+
it, and it returns to the control that opened it (the menu trigger after a menu entry).
|
|
593
|
+
- **Read-only:** the shortcuts dialog stays usable on a read-only canvas (§13.5).
|
|
594
|
+
- **Teardown:** the dialog is the modeler's content in `shell.root`, removed by the modeler (see
|
|
595
|
+
`destroy()` below).
|
|
596
|
+
|
|
597
|
+
### Problems, legend, inspector, empty-file hint
|
|
598
|
+
|
|
599
|
+
```ts
|
|
600
|
+
interface ProblemsOptions {
|
|
601
|
+
onActivate(): void; // the IDE host opens its Problems view
|
|
602
|
+
}
|
|
603
|
+
interface LegendOptions {
|
|
604
|
+
render(body: HTMLElement): void; // once; a throw unwinds the whole mount
|
|
605
|
+
open?: boolean; // remembered start state; default (also undefined) legendStartsOpen(container height)
|
|
606
|
+
onToggle?(open: boolean): void; // every change (close button, checkbox, setOpen, toggle); persist it
|
|
607
|
+
menuEntry?: boolean; // default true
|
|
608
|
+
}
|
|
609
|
+
interface InspectorOptions {
|
|
610
|
+
label: string; // aria-label of the <aside>
|
|
611
|
+
}
|
|
612
|
+
interface EmptyHintOptions {
|
|
613
|
+
onInsertExample?(): void; // shows "Insert example"; the modeler writes the example as one edit
|
|
614
|
+
}
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### Handle
|
|
618
|
+
|
|
619
|
+
```ts
|
|
620
|
+
interface ShellHandle {
|
|
621
|
+
readonly root: HTMLElement;
|
|
622
|
+
readonly canvas: HTMLElement;
|
|
623
|
+
readonly host: ShellHost;
|
|
624
|
+
readonly lang: string;
|
|
625
|
+
readonly platform: Platform;
|
|
626
|
+
readonly messages: ShellMessages;
|
|
627
|
+
readonly menu: MenuHandle;
|
|
628
|
+
readonly tooltip: TooltipHandle;
|
|
629
|
+
readonly legend: LegendHandle | undefined;
|
|
630
|
+
readonly inspector: InspectorHandle | undefined;
|
|
631
|
+
setZoomTarget(target: ZoomTarget | null): void;
|
|
632
|
+
fitInsets(): Insets;
|
|
633
|
+
registerPalette(palette: PaletteRegistration): () => void;
|
|
634
|
+
setProblems(summary: ProblemSummary): void;
|
|
635
|
+
setStatus(status: StatusMessage | null, options?: { delayMs?: number }): void;
|
|
636
|
+
setEmptyHint(visible: boolean): void;
|
|
637
|
+
setReadOnly(readOnly: boolean, options?: { delayMs?: number }): void;
|
|
638
|
+
isReadOnly(): boolean;
|
|
639
|
+
destroy(): void;
|
|
640
|
+
}
|
|
641
|
+
interface MenuHandle {
|
|
642
|
+
readonly trigger: HTMLButtonElement;
|
|
643
|
+
readonly dropdown: HTMLElement;
|
|
644
|
+
isOpen(): boolean;
|
|
645
|
+
open(focus?: "first" | "last"): void;
|
|
646
|
+
close(options?: { restoreFocus?: boolean }): void;
|
|
647
|
+
refresh(): void;
|
|
648
|
+
setGroups(groups: MenuGroups): void;
|
|
649
|
+
}
|
|
650
|
+
interface TooltipContent {
|
|
651
|
+
label: string;
|
|
652
|
+
shortcut?: string;
|
|
653
|
+
}
|
|
654
|
+
interface TooltipHandle {
|
|
655
|
+
attach(control: HTMLElement, content: TooltipContent | (() => TooltipContent | null)): () => void;
|
|
656
|
+
hide(): void;
|
|
657
|
+
}
|
|
658
|
+
interface LegendHandle {
|
|
659
|
+
readonly element: HTMLElement;
|
|
660
|
+
readonly body: HTMLElement;
|
|
661
|
+
isOpen(): boolean;
|
|
662
|
+
setOpen(open: boolean): void;
|
|
663
|
+
toggle(): void;
|
|
664
|
+
}
|
|
665
|
+
interface InspectorHandle {
|
|
666
|
+
readonly element: HTMLElement;
|
|
667
|
+
isOpen(): boolean;
|
|
668
|
+
open(): void;
|
|
669
|
+
close(): void;
|
|
670
|
+
}
|
|
671
|
+
interface PaletteRegistration {
|
|
672
|
+
element: HTMLElement; // .djs-palette
|
|
673
|
+
entryTooltip(entry: HTMLElement): TooltipContent | null;
|
|
674
|
+
setReadOnly(state: PaletteReadOnlyState): void; // on registration and on every change
|
|
675
|
+
}
|
|
676
|
+
interface PaletteReadOnlyState {
|
|
677
|
+
locked: boolean; // veto every entry but Lasso and Hand, from the invalid import on
|
|
678
|
+
shown: boolean; // mark them aria-disabled and muted, once the delay has passed
|
|
679
|
+
}
|
|
680
|
+
interface ProblemSummary {
|
|
681
|
+
count: number;
|
|
682
|
+
errors: number;
|
|
683
|
+
}
|
|
684
|
+
type StatusSeverity = "danger" | "warning";
|
|
685
|
+
interface StatusMessage {
|
|
686
|
+
severity: StatusSeverity;
|
|
687
|
+
text: string;
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
- `setZoomTarget` binds zoom panel, readout and chrome keys; the readout reads `zoom()` at most once
|
|
692
|
+
per animation frame after `onChange`. With `null` the three buttons are `aria-disabled`; the readout
|
|
693
|
+
never is (§5.8), its click does nothing then. Buttons and keys never call `stepZoom` past a limit
|
|
694
|
+
(zoom out at or below 20 % never zooms in), never `zoomToActual` at exactly 100 % and never `fit`
|
|
695
|
+
while `canFit()` is false.
|
|
696
|
+
- `fitInsets()` is `fitInsets(layout)` of `./core` for what is open and measured right now (palette
|
|
697
|
+
row, pill, legend width, side inspector or measured sheet top).
|
|
698
|
+
- `registerPalette` is the adapter's API (`connectDiagramJs` calls it, possibly from inside
|
|
699
|
+
diagram-js's `palette.create`, before any entry exists); a later registration replaces the
|
|
700
|
+
earlier one, and the returned unregister function is safe to call twice and after `destroy()`.
|
|
701
|
+
- `setProblems`: the pill shows for `count > 0` with `Intl` numbers in `lang` and the icon by severity;
|
|
702
|
+
its tooltip "Show in Problems view" exists only in the `ide` host (`aria-describedby`, since it
|
|
703
|
+
says more than the label).
|
|
704
|
+
- `setStatus(status, { delayMs })`: shows after `delayMs` (an invalid file: `INVALID_STATUS_DELAY_MS`,
|
|
705
|
+
so typing does not flicker and `role="alert"` does not re-announce), hides at once with `null`.
|
|
706
|
+
Repeated calls while the delay runs keep the first timer (the box appears once the text stayed
|
|
707
|
+
invalid for 800ms), `null` drops it, and an unchanged text is not written again.
|
|
708
|
+
- `setReadOnly(true, { delayMs })` (invalid file, §13.5) locks at once: `isReadOnly()`, the palette
|
|
709
|
+
veto (every entry but Lasso and Hand) and the Model entries of the menu without
|
|
710
|
+
`keepEnabledWhenReadOnly`. The marks follow after `delayMs` (pass `INVALID_STATUS_DELAY_MS`, as for
|
|
711
|
+
the status box): `data-readonly` on the root and the palette entries `aria-disabled`, muted,
|
|
712
|
+
previews in `grayscale(1)`. Repeated calls keep the first timer; `setReadOnly(false)` unlocks and
|
|
713
|
+
hides at once. The legend is a view toggle: its Legend checkbox and its close button keep working
|
|
714
|
+
under the lock (guide §13.5, design system 1.13.0).
|
|
715
|
+
- The canvas is the modeler's to lock, from the same invalid import on (§13.5, what the read-only
|
|
716
|
+
state blocks): no context pad, no move, resize, delete or paste, and no label editor (also not on
|
|
717
|
+
a double-click). Selection, zoom, pan, the export of the last valid state and the "Keyboard
|
|
718
|
+
shortcuts…" dialog stay. The modeler reads `isReadOnly()` or keeps a lock of its own next to
|
|
719
|
+
`setReadOnly` (Event Storming's `eventStormingReadOnly` service); the kit's `readOnly` case checks
|
|
720
|
+
each of these.
|
|
721
|
+
- `setEmptyHint` shows or hides the hint; `connectDiagramJs` keeps it in sync with the diagram. When
|
|
722
|
+
the hint (its "Insert example") or the closing inspector holds the focus, the focus moves to the
|
|
723
|
+
canvas `<svg>` (else the menu trigger) instead of falling to `<body>`.
|
|
724
|
+
- `tooltip.attach` shows the chrome tooltip after `TOOLTIP_DELAY_MS` hover or at once on keyboard
|
|
725
|
+
focus (`:focus-visible`, also on `aria-disabled` buttons); after a hover-hide a neighbour shows at
|
|
726
|
+
once for the same delay. It hides on click (until the pointer leaves), Escape, leave, blur and menu
|
|
727
|
+
open, never on touch. It sets `aria-describedby` only when its text says more than the control's
|
|
728
|
+
name, which a shortcut chip does unless the control names its keys in `aria-keyshortcuts`. The
|
|
729
|
+
shell attaches it to its own buttons; the palette gets it through `registerPalette`.
|
|
730
|
+
- The legend's start state comes from `legend.open` or, at the first non-zero container size, from
|
|
731
|
+
`legendStartsOpen`; that automatic decision does not call `onToggle`. Its close button returns the
|
|
732
|
+
focus to the menu trigger.
|
|
733
|
+
- `destroy()` removes every element, class, attribute, listener, observer and timer the shell added
|
|
734
|
+
and restores what was there before the mount: a passed `canvas` stays where it is, without
|
|
735
|
+
`cd-shell-canvas`, and a `cd-shell-document` class or a `lang` the page already had stays (the
|
|
736
|
+
class `renderWebviewHtml` writes on `<html>` included). A second call and every later handle call
|
|
737
|
+
are no-ops. A mount that throws (a modeler's `legend.render`) undoes the same before the error
|
|
738
|
+
propagates. Content a modeler adds to `shell.root` (a dialog, a popover) is the modeler's: it
|
|
739
|
+
removes it in its own teardown, before or with `shell.destroy()`, for example on `diagram.destroy`,
|
|
740
|
+
because the shell removes only what it added.
|
|
741
|
+
- Element ids (`cd-shell-menu-N`, `cd-shell-tooltip-N`, `cd-shell-legend-title-N`) count on one
|
|
742
|
+
page-global counter (`Symbol.for("@miragon/modeler-shell.mounts")`), so two modelers that each
|
|
743
|
+
bundle the shell never share an id on one foreign page.
|
|
744
|
+
|
|
745
|
+
### Icons
|
|
746
|
+
|
|
747
|
+
```ts
|
|
748
|
+
type IconNode = ReadonlyArray<readonly [tag: string, attributes: Readonly<Record<string, string>>]>;
|
|
749
|
+
type ShellIconName =
|
|
750
|
+
| "menu"
|
|
751
|
+
| "minus"
|
|
752
|
+
| "plus"
|
|
753
|
+
| "maximize"
|
|
754
|
+
| "check"
|
|
755
|
+
| "x"
|
|
756
|
+
| "file-plus"
|
|
757
|
+
| "folder-open"
|
|
758
|
+
| "import"
|
|
759
|
+
| "file-image"
|
|
760
|
+
| "file-code-corner"
|
|
761
|
+
| "keyboard"
|
|
762
|
+
| "share-2"
|
|
763
|
+
| "circle-x"
|
|
764
|
+
| "triangle-alert"
|
|
765
|
+
| "square-dashed-mouse-pointer"
|
|
766
|
+
| "hand"
|
|
767
|
+
| "line-segment"
|
|
768
|
+
| "unfold-horizontal"
|
|
769
|
+
| "workflow";
|
|
770
|
+
const ICONS: Readonly<Record<ShellIconName, IconNode>>; // lucide-static 1.59.0, unchanged (ISC); line-segment = spline with a straight segment
|
|
771
|
+
interface IconOptions {
|
|
772
|
+
size?: number; // default 16
|
|
773
|
+
strokeWidth?: number; // default 2.5
|
|
774
|
+
className?: string;
|
|
775
|
+
}
|
|
776
|
+
const PALETTE_TOOL_ICON: Readonly<IconOptions>; // { size: 20, strokeWidth: 2 }
|
|
777
|
+
function iconMarkup(icon: ShellIconName | IconNode, options?: IconOptions): string;
|
|
778
|
+
function iconElement(icon: ShellIconName | IconNode, options?: IconOptions): SVGSVGElement;
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
`iconMarkup` renders decorative SVG (`aria-hidden="true"`, `focusable="false"`, `fill="none"`,
|
|
782
|
+
`stroke="currentColor"`, round caps and joins) and lets only Lucide's elements and geometry
|
|
783
|
+
attributes pass, so palette providers can put it into an entry's `html`.
|
|
784
|
+
|
|
785
|
+
The geometry is Lucide's (ISC, © Lucide Icons and Contributors); `check`, `circle-x`, `maximize`,
|
|
786
|
+
`minus`, `plus`, `triangle-alert` and `x` derive from Feather (MIT, © Cole Bemis), as Lucide's
|
|
787
|
+
LICENSE lists them (two by their Feather names, `x-circle` and `alert-triangle`). `src/dom/icons.ts`
|
|
788
|
+
opens with a legal comment (`/*! … @license … */`) naming both, which esbuild keeps in the `dist`
|
|
789
|
+
chunk that carries the icons (ESM and CJS). A consumer's bundler may drop or move legal comments (its
|
|
790
|
+
`legalComments` setting), so a VSIX or a web bundle that contains the shell carries the Lucide and
|
|
791
|
+
Feather notices of `THIRD_PARTY_NOTICES.md` itself.
|
|
792
|
+
|
|
793
|
+
### DOM, `data-chrome` and classes
|
|
794
|
+
|
|
795
|
+
```html
|
|
796
|
+
<html class="cd-shell-document" lang="en" data-cd-theme="light"> <!-- own document: ide, web -->
|
|
797
|
+
<div class="cd-shell" data-cd-shell-host="ide" data-palette="top"
|
|
798
|
+
data-legend="open" data-inspector="open" data-problems="shown" data-readonly> <!-- state attributes only while true -->
|
|
799
|
+
<!-- embedded: lang="en" data-cd-theme="light" [data-cd-kontrast="hoch"] on the root itself -->
|
|
800
|
+
<button class="cd-shell-menu-trigger" data-chrome="menu" aria-label="Menu" aria-haspopup="menu"
|
|
801
|
+
aria-expanded="false" aria-controls="cd-shell-menu-1">…</button>
|
|
802
|
+
<div class="cd-shell-canvas"> <!-- diagram-js container -->
|
|
803
|
+
<div class="djs-container">
|
|
804
|
+
<div class="djs-palette" data-chrome="palette"> <!-- moved before the <svg> -->
|
|
805
|
+
<div class="djs-palette-entries" role="toolbar" aria-label="Tools">
|
|
806
|
+
<div class="group" data-group="tools">
|
|
807
|
+
<div class="entry" data-action="lasso-tool" role="button" aria-label="…" tabindex="0" aria-pressed="false" aria-keyshortcuts="L">…</div>
|
|
808
|
+
<svg tabindex="0" role="application" aria-label="Diagram">…</svg>
|
|
809
|
+
<div class="cd-shell-status" data-chrome="status" data-severity="danger" role="alert">…</div>
|
|
810
|
+
<div class="cd-shell-hint"><p class="cd-shell-hint-text">…</p><button class="cd-shell-hint-action">…</button></div>
|
|
811
|
+
<aside class="cd-shell-inspector" data-chrome="inspector" aria-label="…">…</aside>
|
|
812
|
+
<button class="cd-shell-problems" data-chrome="problems" aria-label="2 problems, 1 error">…</button>
|
|
813
|
+
<section class="cd-shell-legend" data-chrome="legend" aria-labelledby="…">…</section>
|
|
814
|
+
<div class="cd-shell-zoom" data-chrome="zoom" role="group" aria-label="Zoom">
|
|
815
|
+
<button data-zoom="out"> <button class="cd-shell-zoom-readout" data-zoom="actual"> <button data-zoom="in">
|
|
816
|
+
<span class="cd-shell-zoom-separator"> <button data-zoom="fit">
|
|
817
|
+
</div>
|
|
818
|
+
<div class="cd-shell-layer"> <!-- z 40: dropdown and tooltip -->
|
|
819
|
+
<div class="cd-shell-menu" id="cd-shell-menu-1" role="menu" aria-label="Menu">…</div>
|
|
820
|
+
<div class="cd-shell-tooltip" role="tooltip"><span class="cd-shell-tooltip-label">Zoom in</span><kbd class="cd-shell-kbd">⌘+</kbd></div>
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
The DOM order is the tab order of §5.14 (menu trigger, palette, canvas, status box, hint, inspector,
|
|
826
|
+
pill, legend, zoom panel); nothing has a positive `tabindex`, and a renderer adds no tab stop of its
|
|
827
|
+
own around the canvas (no `tabindex` on `.djs-container`). Islands that are not shown are `hidden`.
|
|
828
|
+
|
|
829
|
+
| `data-chrome` | Element | Notes |
|
|
830
|
+
| ------------- | ------------------------------ | ------------------------------------------ |
|
|
831
|
+
| `menu` | menu trigger | icon only, Lucide `menu` |
|
|
832
|
+
| `palette` | diagram-js `.djs-palette` | set by `shellModule` |
|
|
833
|
+
| `primary` | reserved for the web app (0.2) | never rendered in 0.1 |
|
|
834
|
+
| `zoom` | zoom panel | `[−][NN%][+] \| [Fit]` |
|
|
835
|
+
| `problems` | problems pill | `hidden` at 0 |
|
|
836
|
+
| `legend` | legend card | only with `legend` |
|
|
837
|
+
| `inspector` | inspector `<aside>` | only with `inspector` |
|
|
838
|
+
| `status` | status box under the palette | `role="alert"` (danger) or `role="status"` |
|
|
839
|
+
|
|
840
|
+
| Class | Part |
|
|
841
|
+
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
|
|
842
|
+
| `cd-shell`, `cd-shell-document`, `cd-shell-canvas`, `cd-shell-layer` | root, own document, canvas slot, layer |
|
|
843
|
+
| `cd-shell-island`, `cd-shell-target` | glass island, 32px target in an island |
|
|
844
|
+
| `cd-shell-menu-trigger`, `cd-shell-menu`, `cd-shell-menu-item`, `cd-shell-menu-item-icon`, `cd-shell-menu-item-label`, `cd-shell-menu-item-end`, `cd-shell-menu-separator`, `cd-shell-menu-heading`, `cd-shell-menu-group` | menu |
|
|
845
|
+
| `cd-shell-menu-shortcut`, `cd-shell-menu-check` | shortcut text and check mark of an entry |
|
|
846
|
+
| `cd-shell-tooltip`, `cd-shell-tooltip-label`, `cd-shell-kbd` | tooltip and shortcut chip |
|
|
847
|
+
| `cd-shell-zoom`, `cd-shell-zoom-readout`, `cd-shell-zoom-separator` | zoom panel |
|
|
848
|
+
| `cd-shell-problems`, `cd-shell-problems-icon`, `cd-shell-problems-text` | pill |
|
|
849
|
+
| `cd-shell-status`, `cd-shell-status-icon`, `cd-shell-status-text` | status box (bar as `::before`) |
|
|
850
|
+
| `cd-shell-hint`, `cd-shell-hint-text`, `cd-shell-hint-action` | empty-file hint |
|
|
851
|
+
| `cd-shell-legend`, `cd-shell-legend-header`, `cd-shell-legend-title`, `cd-shell-legend-close`, `cd-shell-legend-body` | legend card |
|
|
852
|
+
| `cd-shell-legend-row`, `cd-shell-legend-preview`, `cd-shell-legend-separator` | rows the modeler renders into the body |
|
|
853
|
+
| `cd-shell-inspector` | inspector |
|
|
854
|
+
| `cd-shell-no-transition` | on `<html>` for one frame of a mode switch |
|
|
855
|
+
|
|
856
|
+
Internal attributes: `data-severity` on pill and status box, `data-danger` on a dangerous menu
|
|
857
|
+
entry, `data-menu-id` on entries and radio groups (the shell's Legend entry is `data-menu-id="legend"`,
|
|
858
|
+
so a modeler does not use the id `legend`), `data-menu-group` (`model`, `export`, `help`) on entries
|
|
859
|
+
and radio groups, which the kit's `readOnly` case reads, `data-menu-value` on radio options,
|
|
860
|
+
`data-zoom` on the zoom buttons.
|
|
861
|
+
|
|
862
|
+
Modelers style only their own content (legend rows, inspector fields); every other class is
|
|
863
|
+
internal to the shell CSS and may change in a minor release.
|
|
864
|
+
|
|
865
|
+
### Geometry
|
|
866
|
+
|
|
867
|
+
Relative to the shell root's padding box (a border a foreign page gives the root does not count),
|
|
868
|
+
±1px (§5.2 to §5.15). `ide` and `embedded` share the IDE column.
|
|
869
|
+
|
|
870
|
+
| Island | Box |
|
|
871
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
872
|
+
| `menu` | 40 × 40 at `top: 12px; left: 12px` |
|
|
873
|
+
| `palette` | 40 high at `top: 12px`, horizontally centred; second row `top: 64px`, at most `100cqw − 24px` wide |
|
|
874
|
+
| `zoom` | 173 × 40 (56px readout) at `right: 12px; bottom: 12px` |
|
|
875
|
+
| `problems` | 40 high at `left: 12px; bottom: 12px` (web `40px`) |
|
|
876
|
+
| `legend` | `left: 12px`, `bottom: 12px` without pill, `60px` with pill (web `40px`, `88px`); up to 440px width at least `60px` |
|
|
877
|
+
| `inspector` | 288 wide at `top: 64px; right: 12px`; up to 760px width a bottom sheet with 12px left, right and bottom |
|
|
878
|
+
| `status` | centred at `top: 64px` (second row `116px`); with an open side inspector centred in the strip left of it; 1px border and 4px bar in the status colour |
|
|
879
|
+
| dropdown | `top: 60px; left: 12px`, at least 248px wide |
|
|
880
|
+
| tooltip | top row `top: 60px`, second row `top: 112px`, bottom row `bottom: 60px` |
|
|
881
|
+
|
|
882
|
+
Layers (§5.13): the canvas slot is layer 0 with `isolation: isolate` (palette `z-index: 110` above
|
|
883
|
+
context pad and label editor at 100); islands `--cd-shell-ebene-inseln` (20), inspector 25, menu
|
|
884
|
+
trigger 30, layer 40, modeler dialogs 50. The 50 holds only outside the top layer; a `showModal()`
|
|
885
|
+
`<dialog>` or a popover-API element lies above everything (see [Modeler dialogs](#modeler-dialogs)).
|
|
886
|
+
|
|
887
|
+
Inside the canvas slot, persistent overlays a modeler adds (element toolbars, its own context pad,
|
|
888
|
+
the label editor) stay at `z-index` 100 or below, under the palette at 110. Transient popups the user
|
|
889
|
+
opens from them (diagram-js's popup menu at 200, a colour picker) may cover the palette while they are
|
|
890
|
+
open. All of them stay inside `.djs-container`, so they never cover an island. This is the shell's
|
|
891
|
+
rule of guide §5.13 (design system 1.13.0).
|
|
892
|
+
|
|
893
|
+
## `./diagram-js`
|
|
894
|
+
|
|
895
|
+
```ts
|
|
896
|
+
// a Diagram, a renderer's Modeler, a didi injector
|
|
897
|
+
interface InjectorLike {
|
|
898
|
+
get<T>(name: string): T;
|
|
899
|
+
}
|
|
900
|
+
interface ModuleDeclarationLike {
|
|
901
|
+
__init__?: string[];
|
|
902
|
+
__depends__?: ModuleDeclarationLike[];
|
|
903
|
+
[name: string]: unknown;
|
|
904
|
+
}
|
|
905
|
+
type DiagramEventListener = (event: never) => unknown;
|
|
906
|
+
interface EventBusLike {
|
|
907
|
+
on(events: string | string[], priority: number, listener: DiagramEventListener): void;
|
|
908
|
+
on(events: string | string[], listener: DiagramEventListener): void;
|
|
909
|
+
off(events: string | string[], listener?: DiagramEventListener): void;
|
|
910
|
+
}
|
|
911
|
+
interface DiagramViewbox extends ScaledViewbox {
|
|
912
|
+
inner: Bounds;
|
|
913
|
+
outer: Size;
|
|
914
|
+
}
|
|
915
|
+
interface CanvasLike {
|
|
916
|
+
getContainer(): HTMLElement;
|
|
917
|
+
getSize(): Size;
|
|
918
|
+
viewbox(): DiagramViewbox;
|
|
919
|
+
viewbox(box: Bounds): void;
|
|
920
|
+
zoom(): number;
|
|
921
|
+
zoom(scale: number, center?: Point): number;
|
|
922
|
+
resized(): void;
|
|
923
|
+
}
|
|
924
|
+
interface ZoomScrollLike {
|
|
925
|
+
stepZoom(delta: number, position?: Point): void;
|
|
926
|
+
}
|
|
927
|
+
interface KeyboardLike {
|
|
928
|
+
addListener(priority: number, listener: (context: { keyEvent: KeyboardEvent }) => unknown): void;
|
|
929
|
+
}
|
|
930
|
+
interface PaletteLike {
|
|
931
|
+
getEntries(): Record<
|
|
932
|
+
string,
|
|
933
|
+
{ title?: string; shortcut?: string; ariaKeyShortcuts?: string; group?: string }
|
|
934
|
+
>;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const shellModule: ModuleDeclarationLike;
|
|
938
|
+
|
|
939
|
+
interface ConnectOptions {
|
|
940
|
+
contentBounds(): Bounds | undefined; // tight, no padding: the fit insets are the only margin (§5.9)
|
|
941
|
+
isEmpty?(): boolean; // §13.5 empty file (no element); default: the content bounds are empty
|
|
942
|
+
prefersReducedMotion?(): boolean;
|
|
943
|
+
}
|
|
944
|
+
interface DiagramJsConnection {
|
|
945
|
+
readonly viewport: Viewport;
|
|
946
|
+
destroy(): void;
|
|
947
|
+
}
|
|
948
|
+
function connectDiagramJs(
|
|
949
|
+
shell: ShellHandle,
|
|
950
|
+
diagram: InjectorLike,
|
|
951
|
+
options: ConnectOptions,
|
|
952
|
+
): DiagramJsConnection;
|
|
953
|
+
|
|
954
|
+
interface ViewportOptions {
|
|
955
|
+
contentBounds(): Bounds | undefined;
|
|
956
|
+
insets(): Insets;
|
|
957
|
+
prefersReducedMotion?(): boolean;
|
|
958
|
+
}
|
|
959
|
+
interface Viewport extends ZoomTarget {
|
|
960
|
+
fit(options?: { animate?: boolean }): void;
|
|
961
|
+
freeRectCenter(): Point | undefined; // diagram coordinates; undefined at 0 × 0 and after destroy
|
|
962
|
+
afterImport(): void;
|
|
963
|
+
restore(viewbox: Bounds): void;
|
|
964
|
+
getViewbox(): Bounds | undefined;
|
|
965
|
+
isAutomatic(): boolean;
|
|
966
|
+
getState(): ViewportState | undefined;
|
|
967
|
+
setState(state: ViewportState): void;
|
|
968
|
+
destroy(): void;
|
|
969
|
+
}
|
|
970
|
+
function createViewport(diagram: InjectorLike, options: ViewportOptions): Viewport;
|
|
971
|
+
```
|
|
972
|
+
|
|
973
|
+
**`shellModule`** goes into the modeler's modules after the renderer's own (`additionalModules`). It
|
|
974
|
+
registers one internal didi service named `modelerShell` (a modeler does not reuse the name):
|
|
975
|
+
|
|
976
|
+
- `paletteTooltip: ["value", null]` replaces diagram-js's (and a renderer's) palette tooltip; the
|
|
977
|
+
chrome tooltip takes over with `title` and `shortcut` from `palette.getEntries()`.
|
|
978
|
+
- Palette skin: on `palette.create` it sets `data-chrome="palette"` and moves the container in front
|
|
979
|
+
of the canvas `<svg>` (`canvas.getContainer()`'s child), so tab order puts the palette first. After
|
|
980
|
+
every `palette.changed` it re-applies `role="toolbar"` and `aria-label` (`messages.tools`) on
|
|
981
|
+
`.djs-palette-entries`, `role="button"` on every entry, `aria-keyshortcuts` from the entry's
|
|
982
|
+
`ariaKeyShortcuts` or `ariaKeyShortcutsFromLabel(shortcut)` (§5.6; split the shortcut off the
|
|
983
|
+
title: `title: "Domain event", shortcut: "E"`), roving `tabindex` (the last focused entry keeps the
|
|
984
|
+
tab stop across rebuilds) and `aria-pressed` for the tool of the last
|
|
985
|
+
`tool-manager.update` (group `tools`, `data-action="<tool>-tool"`), styled through
|
|
986
|
+
`[aria-pressed="true"]`: diagram-js's `highlighted-entry` goes stale after a rebuild, and its
|
|
987
|
+
`two-column` class is ignored. `separator` entries become vertical rules.
|
|
988
|
+
- Palette keys, without modifiers: ← and → move and wrap, Home and End jump, Enter and Space trigger
|
|
989
|
+
the entry once (repeats ignored). They are stopped at the palette, so a modeler's document listener
|
|
990
|
+
and VS Code never see them; chrome keys still bubble to the shell.
|
|
991
|
+
- Enter and Space trigger through a synthetic `click`, and that click is a contract: a `MouseEvent`
|
|
992
|
+
with `detail === 0` (a pointer click has 1 or more), `clientX`/`clientY` at the entry's centre,
|
|
993
|
+
bubbling and cancelable, dispatched on the entry. It goes through diagram-js's click delegate and
|
|
994
|
+
`palette.trigger` like a pointer click, so under the read-only lock it is vetoed the same way. A
|
|
995
|
+
provider tells the keyboard apart by `detail === 0` (see Palette keyboard creation below).
|
|
996
|
+
- Read-only (§13.5): only `lasso-tool` and `hand-tool` stay active. Every other entry, also tools of
|
|
997
|
+
group `tools` such as draw, space or connect, is vetoed on `palette.trigger` from the lock on (a
|
|
998
|
+
vetoed `dragstart` is `preventDefault`-ed) and marked `aria-disabled` once the shell shows the
|
|
999
|
+
read-only state; an entry's own `aria-disabled` comes back afterwards. Without an entry element
|
|
1000
|
+
in the event only group `tools` passes.
|
|
1001
|
+
- Keyboard: `keyboard.addListener(2000, …)` returns `true` for every `matchChromeKey` hit, so
|
|
1002
|
+
`KeyboardBindings` and the modeler's keyboard modules (priority 1000) never act on them; the
|
|
1003
|
+
shell's `keydown` listener executes them exactly once. It returns `false` (stops them without
|
|
1004
|
+
`preventDefault`) for a chrome key from a text field inside the `<svg>`, which stays the field's,
|
|
1005
|
+
and, while the attached shell's host is `ide`, for undo and redo (`isUndoRedoKey`): the canvas
|
|
1006
|
+
`commandStack` never takes them, VS Code undoes the document (§13.5). A modeler's own DOM undo
|
|
1007
|
+
binding must go in the IDE host as well; the kit's `keys` case checks it.
|
|
1008
|
+
- Keys the canvas uses beyond the chrome keys are the modeler's (§13.5). Cmd/Ctrl+A on the focused
|
|
1009
|
+
canvas selects every element, or is at least `preventDefault`-ed: diagram-js 15 binds no select
|
|
1010
|
+
all, and an unprevented key reaches VS Code, whose Select All then selects the chrome's text. The
|
|
1011
|
+
shell stops a prevented key in the IDE host; the kit's `keys` case presses Control+A and Meta+A on
|
|
1012
|
+
the canvas.
|
|
1013
|
+
- While a shell is attached, the canvas `<svg>` is `role="application"` with `aria-label`
|
|
1014
|
+
`messages.canvas` (WCAG 4.1.2; screen readers switch to focus mode, so single-key commands reach the
|
|
1015
|
+
canvas); a role or name the modeler set stays, and both are restored on detach.
|
|
1016
|
+
- It buffers the palette until `connectDiagramJs`, so the diagram may exist before the shell.
|
|
1017
|
+
|
|
1018
|
+
**`connectDiagramJs`** creates the viewport with `insets: shell.fitInsets`, binds it
|
|
1019
|
+
(`shell.setZoomTarget`), registers the palette and calls `canvas.resized()` (the canvas element has
|
|
1020
|
+
moved). After every `import.done` and `elements.changed` it calls `shell.setEmptyHint(isEmpty())`,
|
|
1021
|
+
so the first element the user creates on the canvas hides the hint (and its "Insert example").
|
|
1022
|
+
`isEmpty` defaults to empty content bounds; a notation whose bounds are never empty (Wardley's plot)
|
|
1023
|
+
passes its own. It throws when `shellModule` is missing. A later connection to another shell takes
|
|
1024
|
+
the palette over; `destroy()` (idempotent, also run on `diagram.destroy`) unregisters the palette,
|
|
1025
|
+
unbinds the zoom target and the hint sync and destroys the viewport, while the keyboard guard stays
|
|
1026
|
+
until `diagram.destroy`. `contentBounds` returns the tight bounds of the content, without a padding
|
|
1027
|
+
of its own (Event Storming: `boardBounds.contentBounds(0)`, not its default of 120px): the shell's fit
|
|
1028
|
+
insets are the only margin (§5.9), and a padding would add to them.
|
|
1029
|
+
|
|
1030
|
+
**`Viewport`** (§5.8, §5.9):
|
|
1031
|
+
|
|
1032
|
+
- `stepZoom` is `zoomScroll.stepZoom(±1)` (the wheel's raster, snapping to the nearest step);
|
|
1033
|
+
`zoomToActual` is `canvas.zoom(1)`; both around the container centre. No rounding of its own. A
|
|
1034
|
+
step past a limit is refused (`canZoomOut`/`canZoomIn`), so zoom out never zooms in, whatever the
|
|
1035
|
+
caller checks; without a `zoomScroll` service it steps on the same raster itself.
|
|
1036
|
+
- `fit()` uses `fitViewbox(contentBounds(), canvas.getSize(), insets())` and `canvas.viewbox(box)`,
|
|
1037
|
+
never `canvas.zoom("fit-viewport")`; animated over 250ms in `requestAnimationFrame` with
|
|
1038
|
+
`easeStandard` unless `animate: false` or reduced motion (`prefers-reduced-motion`, or
|
|
1039
|
+
`REDUCED_MOTION_CLASS` on `<html>`); a `wheel`, `pointerdown` or zoom key cancels the animation. A
|
|
1040
|
+
no-op when the bounds are empty.
|
|
1041
|
+
- `canFit()` reads `contentBounds()` once per content change (`elements.changed`, `shape.added`,
|
|
1042
|
+
`shape.removed`, `connection.added`, `connection.removed`, `diagram.clear`, `import.done` and the
|
|
1043
|
+
viewport's own `afterImport`, `restore`, `setState`), not on every frame the zoom panel renders.
|
|
1044
|
+
- `afterImport()` jump-fits the first import whose bounds are non-empty (also after an empty file got
|
|
1045
|
+
its first content), unless the user moved the view or a stored view was set with `setState` while
|
|
1046
|
+
there was content. `restore` does not block it: a modeler restores on every re-import, and an
|
|
1047
|
+
empty file must still fit its first content ("Insert example"). Content the user creates on the
|
|
1048
|
+
canvas does (§5.9: an element created on the canvas never fits): an `elements.changed` outside an
|
|
1049
|
+
import window after the first import, with content, ends the wait for the first fit, so the next
|
|
1050
|
+
external change keeps the view. A renderer that changes elements through the command stack while
|
|
1051
|
+
it imports therefore fires `import.parse.start` or `import.render.start` first.
|
|
1052
|
+
- An empty file shows 100 %, whatever zoom the renderer's import left: on the first import with
|
|
1053
|
+
empty bounds, without a stored view (`setState`) and without a user move, `afterImport()` zooms to
|
|
1054
|
+
1 around the centre of the free rectangle (at 0 × 0 once the container has a size). That write is
|
|
1055
|
+
the viewport's own, not a user move, so the first content still fits; later re-imports and
|
|
1056
|
+
`restore`s of the empty file keep the view (guide §5.9 and §13.5, design system 1.13.0).
|
|
1057
|
+
- `freeRectCenter()` is the centre of the free rectangle (the container minus `insets()`, as fit
|
|
1058
|
+
uses them) in diagram coordinates, for the current viewbox; `undefined` while the container is
|
|
1059
|
+
0 × 0 and after `destroy`. It is where keyboard creation places an element.
|
|
1060
|
+
- A `ResizeObserver` on `canvas.getContainer()`, coalesced per frame, skips 0 × 0 (hidden tab) and
|
|
1061
|
+
compares with the size the view was last set for, so a tab coming back at the same size neither
|
|
1062
|
+
refits nor calls `resized()`. On a real change it calls `canvas.resized()` and refits while the view
|
|
1063
|
+
is automatic. A `restore` or `setState` made at 0 × 0 waits for a size and is not refitted then.
|
|
1064
|
+
- User moves: every `canvas.viewbox.changed` (and `canvas.viewbox.changing` for the readout under
|
|
1065
|
+
`deferUpdate`) is compared with the last view the viewport set (`sameView`; during an animation
|
|
1066
|
+
also with the last written frame); a difference is a user move. Changes between
|
|
1067
|
+
`import.parse.start`/`import.render.start` and `import.done` are not (renderers set the viewbox
|
|
1068
|
+
while importing); that window also ends on `afterImport`, `restore`, `setState` and user input.
|
|
1069
|
+
Fit makes the view automatic again; `restore` updates the reference without resetting it.
|
|
1070
|
+
- `getViewbox()` takes width and height from the fresh container size; `getState()` is `undefined`
|
|
1071
|
+
before the first `afterImport`, `restore` or `setState`. `onChange` fires on
|
|
1072
|
+
`canvas.viewbox.changing`/`.changed`, `elements.changed` (so Fit enables with the first element),
|
|
1073
|
+
`import.done` and the viewport's own calls.
|
|
1074
|
+
- The viewport destroys itself on `diagram.destroy`; afterwards `zoom()` returns the last zoom,
|
|
1075
|
+
`canFit()` false and `getViewbox()`/`getState()` `undefined`, so a shell still bound to it never
|
|
1076
|
+
calls into a destroyed canvas.
|
|
1077
|
+
- diagram-js events used: `canvas.init`, `palette.create`, `palette.changed`, `tool-manager.update`,
|
|
1078
|
+
`palette.trigger`, `keyboard.keydown` (through `keyboard.addListener`), `canvas.viewbox.changing`,
|
|
1079
|
+
`canvas.viewbox.changed`, `elements.changed`, `shape.added`, `shape.removed`, `connection.added`,
|
|
1080
|
+
`connection.removed`, `diagram.clear`, `import.parse.start`, `import.render.start`, `import.done`,
|
|
1081
|
+
`diagram.destroy`. The shell fires no events of its own.
|
|
1082
|
+
|
|
1083
|
+
**Palette keyboard creation** (§5.5). Enter or Space on an entry that creates something places the
|
|
1084
|
+
element in the centre of the free rectangle, selects it, opens the label editor where the notation
|
|
1085
|
+
has labels and puts the focus on the canvas; tool entries only switch the tool. diagram-js's create
|
|
1086
|
+
tool follows the pointer and never ends without one, so the provider branches on the synthetic click
|
|
1087
|
+
and asks the viewport for the point. The provider exists before `connectDiagramJs`, so it gets a
|
|
1088
|
+
function and reads the viewport only when the entry fires:
|
|
1089
|
+
|
|
1090
|
+
```ts
|
|
1091
|
+
// the renderer's palette provider, given freeRectCenter: () => viewport.freeRectCenter()
|
|
1092
|
+
const create = (event: Event) => {
|
|
1093
|
+
const shape = elementFactory.createShape({ type: "domain-event" });
|
|
1094
|
+
const keyboard = event.type === "click" && (event as MouseEvent).detail === 0;
|
|
1095
|
+
const center = keyboard ? freeRectCenter() : undefined;
|
|
1096
|
+
if (!center) return createTool.start(event, shape); // a pointer: the shape follows it
|
|
1097
|
+
const created = modeling.createShape(shape, center, canvas.getRootElement());
|
|
1098
|
+
selection.select(created);
|
|
1099
|
+
canvas.focus();
|
|
1100
|
+
directEditing.activate(created); // where the notation has labels
|
|
1101
|
+
};
|
|
1102
|
+
entries["create.domain-event"] = {
|
|
1103
|
+
group: "events",
|
|
1104
|
+
title: "Domain event",
|
|
1105
|
+
shortcut: "E",
|
|
1106
|
+
action: { click: create, dragstart: create },
|
|
1107
|
+
};
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
When the centre is already taken, the provider steps 24px diagonally until it is free (§5.5); a
|
|
1111
|
+
shared helper for that is a 0.2 candidate. The kit's `create` case presses Enter on the first
|
|
1112
|
+
creating entry and expects exactly one new, selected shape inside the free rectangle.
|
|
1113
|
+
|
|
1114
|
+
## `./vscode/extension`
|
|
1115
|
+
|
|
1116
|
+
```ts
|
|
1117
|
+
interface DisposableLike {
|
|
1118
|
+
dispose(): unknown;
|
|
1119
|
+
}
|
|
1120
|
+
interface ColorThemeLike {
|
|
1121
|
+
readonly kind: number;
|
|
1122
|
+
}
|
|
1123
|
+
interface VsCodeLike {
|
|
1124
|
+
readonly window: {
|
|
1125
|
+
readonly activeColorTheme: ColorThemeLike;
|
|
1126
|
+
onDidChangeActiveColorTheme(listener: (theme: ColorThemeLike) => unknown): DisposableLike;
|
|
1127
|
+
};
|
|
1128
|
+
readonly workspace: {
|
|
1129
|
+
getConfiguration(section?: string): { get<T>(key: string): T | undefined };
|
|
1130
|
+
onDidChangeConfiguration(
|
|
1131
|
+
listener: (event: { affectsConfiguration(section: string): boolean }) => unknown,
|
|
1132
|
+
): DisposableLike;
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
interface WebviewPanelLike {
|
|
1136
|
+
readonly webview: { postMessage(message: unknown): unknown };
|
|
1137
|
+
readonly visible: boolean;
|
|
1138
|
+
onDidChangeViewState(
|
|
1139
|
+
listener: (event: { readonly webviewPanel: { readonly visible: boolean } }) => unknown,
|
|
1140
|
+
): DisposableLike;
|
|
1141
|
+
onDidDispose(listener: () => unknown): DisposableLike;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
interface WebviewHtmlOptions {
|
|
1145
|
+
cspSource: string;
|
|
1146
|
+
scriptUri: string;
|
|
1147
|
+
styleUris?: readonly string[];
|
|
1148
|
+
title: string;
|
|
1149
|
+
lang: string;
|
|
1150
|
+
appearance: Appearance;
|
|
1151
|
+
body?: string;
|
|
1152
|
+
nonce?: string;
|
|
1153
|
+
inlineStyles?: boolean; // default true: style-src … 'unsafe-inline'
|
|
1154
|
+
}
|
|
1155
|
+
function createNonce(): string;
|
|
1156
|
+
function renderWebviewHtml(options: WebviewHtmlOptions): string;
|
|
1157
|
+
|
|
1158
|
+
interface ThemeBridgeOptions {
|
|
1159
|
+
themes: readonly Theme[];
|
|
1160
|
+
settingKey?: string;
|
|
1161
|
+
}
|
|
1162
|
+
function currentAppearance(vscode: VsCodeLike, options: ThemeBridgeOptions): Appearance;
|
|
1163
|
+
function connectThemeBridge(
|
|
1164
|
+
vscode: VsCodeLike,
|
|
1165
|
+
panel: WebviewPanelLike,
|
|
1166
|
+
options: ThemeBridgeOptions,
|
|
1167
|
+
): DisposableLike;
|
|
1168
|
+
|
|
1169
|
+
function revealFileLabel(platform: string): string;
|
|
1170
|
+
|
|
1171
|
+
const APPEARANCE_MESSAGE_TYPE = "modelerShell.appearance";
|
|
1172
|
+
interface AppearanceMessage {
|
|
1173
|
+
type: typeof APPEARANCE_MESSAGE_TYPE;
|
|
1174
|
+
appearance: Appearance;
|
|
1175
|
+
}
|
|
1176
|
+
function appearanceMessage(appearance: Appearance): AppearanceMessage;
|
|
1177
|
+
function isAppearanceMessage(value: unknown): value is AppearanceMessage;
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
- The real `vscode` namespace is assignable to `VsCodeLike` and a `WebviewPanel` to
|
|
1181
|
+
`WebviewPanelLike` (pinned by a type test against `@types/vscode` 1.96).
|
|
1182
|
+
- The entry needs a global `crypto` (Node 20 or later, so VS Code 1.90 or later; the types are tested
|
|
1183
|
+
against 1.96). A modeler declares `engines.vscode` accordingly and targets `node20` in its
|
|
1184
|
+
extension-host bundle. There is no `node:crypto` fallback: the entry imports nothing, so a web
|
|
1185
|
+
extension host (vscode.dev) can bundle it as well, and `createNonce` stays synchronous.
|
|
1186
|
+
- `createNonce()`: 32 characters `[A-Za-z0-9]` from `crypto.getRandomValues`, unbiased.
|
|
1187
|
+
- `renderWebviewHtml` writes the appearance attributes on `<html>` before the first paint, always
|
|
1188
|
+
(also `light`), and the CSP (`default-src 'none'`, the nonce'd script, fonts from `cspSource` and
|
|
1189
|
+
`data:`). Its `style-src` allows `'unsafe-inline'` by default because renderers write
|
|
1190
|
+
`style` attributes into markup (palette entries, SVG strings); the shell and diagram-js themselves
|
|
1191
|
+
style only through the CSSOM and run without it, so a modeler whose renderer does too passes
|
|
1192
|
+
`inlineStyles: false`. `lang` is the tool's UI language: English modelers pass `"en"`, not
|
|
1193
|
+
`vscode.env.language`. Title and attribute values are escaped, `body` is inserted as markup; the
|
|
1194
|
+
script is a classic `<script nonce src>` (an IIFE bundle). It throws for a `nonce` that is not a
|
|
1195
|
+
CSP base64 value.
|
|
1196
|
+
|
|
1197
|
+
```text
|
|
1198
|
+
<html lang="en" class="cd-shell-document" data-cd-theme="light" data-cd-kontrast="hoch">
|
|
1199
|
+
Content-Security-Policy: default-src 'none'; img-src {cspSource} data: blob:;
|
|
1200
|
+
style-src {cspSource} 'unsafe-inline'; font-src {cspSource} data:; script-src 'nonce-{nonce}'
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
- `currentAppearance` resolves `requestedAppearance(kind, setting)` against `themes`; a setting value
|
|
1204
|
+
outside `automatic | light | dark` counts as `automatic`.
|
|
1205
|
+
- `connectThemeBridge` posts nothing on connect (the HTML carries the appearance) and then
|
|
1206
|
+
`appearanceMessage(currentAppearance(…))` on `onDidChangeActiveColorTheme`, on
|
|
1207
|
+
`onDidChangeConfiguration` affecting `settingKey` (no configuration listener without one), and on
|
|
1208
|
+
`onDidChangeViewState` when the panel goes from hidden to visible (not on active or column
|
|
1209
|
+
changes). It disposes itself with the panel (`onDidDispose`) and throws for empty `themes`. The
|
|
1210
|
+
returned disposable only ends it earlier (twice is safe), or goes into a panel-scoped list that is
|
|
1211
|
+
disposed with the panel; never into `context.subscriptions`, which keeps one dead bridge per
|
|
1212
|
+
opened editor until the extension deactivates.
|
|
1213
|
+
- `revealFileLabel(process.platform)`: "Reveal in Finder" (`darwin`), "Reveal in File Explorer"
|
|
1214
|
+
(`win32`), "Open Containing Folder" (other), for the export notification (§13.5); English, the
|
|
1215
|
+
labels of VS Code itself (a localized modeler translates them with `vscode.l10n`).
|
|
1216
|
+
|
|
1217
|
+
What the webview bundle needs under this CSP:
|
|
1218
|
+
|
|
1219
|
+
- **Fonts as files.** Bundle Geist and Geist Mono as files next to the bundle, not as data URLs:
|
|
1220
|
+
esbuild `loader: { ".woff2": "file", ".woff": "file" }` with `assetNames: "fonts/[name]-[hash]"`.
|
|
1221
|
+
The browser then fetches only the subsets a page's `unicode-range` needs (Geist Mono alone brings
|
|
1222
|
+
six, which inlined roughly double the webview CSS), `font-src {cspSource}` allows them, and `dist`
|
|
1223
|
+
must be in the panel's `localResourceRoots`. `data:` stays allowed for renderers that inline
|
|
1224
|
+
fonts.
|
|
1225
|
+
- **zod 4 without eval.** `script-src` admits only the nonce'd script, so eval and `new Function` are
|
|
1226
|
+
blocked (correctly, there is no `'unsafe-eval'`). zod 4 probes once for eval when it builds its
|
|
1227
|
+
first object schema and catches the error, but the browser still reports that `new Function` as a
|
|
1228
|
+
`securitypolicyviolation`; under `jitless` zod skips the probe. A webview that bundles zod 4 runs
|
|
1229
|
+
`z.config({ jitless: true })` in a module it imports before any schema module (Event Storming:
|
|
1230
|
+
`import "./zod-jitless"`, the first line of `main.ts`).
|
|
1231
|
+
- **A smoke test of the production bundle.** A test page served by Vite runs the webview from source
|
|
1232
|
+
without a CSP, so a loader or CSP mistake of the shipped bundle shows only behind the real HTML.
|
|
1233
|
+
Build the bundle as shipped, serve it under a fake origin behind `renderWebviewHtml`, and fail on
|
|
1234
|
+
any CSP violation, page error or missing font:
|
|
1235
|
+
|
|
1236
|
+
```ts
|
|
1237
|
+
const ORIGIN = "https://webview.test";
|
|
1238
|
+
const NONCE = "c21va2V0ZXN0bm9uY2Uw"; // fixed, so the stub's script can carry it
|
|
1239
|
+
await page.route(`${ORIGIN}/**`, async (route) => {
|
|
1240
|
+
const { pathname } = new URL(route.request().url());
|
|
1241
|
+
if (pathname === "/") {
|
|
1242
|
+
const html = renderWebviewHtml({
|
|
1243
|
+
cspSource: ORIGIN,
|
|
1244
|
+
nonce: NONCE,
|
|
1245
|
+
scriptUri: `${ORIGIN}/webview.js`,
|
|
1246
|
+
styleUris: [`${ORIGIN}/webview.css`],
|
|
1247
|
+
title: "Event Storming Board",
|
|
1248
|
+
lang: "en",
|
|
1249
|
+
appearance: { theme: "light" },
|
|
1250
|
+
// runs before the webview: installs the VS Code stub and records every violation
|
|
1251
|
+
body: `<div id="app"></div><script nonce="${NONCE}" src="${ORIGIN}/stub.js"></script>`,
|
|
1252
|
+
});
|
|
1253
|
+
return route.fulfill({ contentType: "text/html", body: html });
|
|
1254
|
+
}
|
|
1255
|
+
if (pathname === "/stub.js") return route.fulfill({ contentType: "text/javascript", body: stubIife });
|
|
1256
|
+
return route.fulfill({ path: join(dist, pathname) }); // webview.js, webview.css, fonts/
|
|
1257
|
+
});
|
|
1258
|
+
await page.goto(`${ORIGIN}/`);
|
|
1259
|
+
// post the document's init message, wait for the first shape and open the menu, then:
|
|
1260
|
+
expect(await page.evaluate(() => window.cspViolations)).toEqual([]);
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
`stubIife` is the esbuild IIFE bundle of a small entry that adds a `securitypolicyviolation`
|
|
1264
|
+
listener on `document` (pushing `violatedDirective` and `blockedURI` into `window.cspViolations`)
|
|
1265
|
+
and then calls `installVsCodeWebviewStub(…)`.
|
|
1266
|
+
|
|
1267
|
+
## `./vscode/webview`
|
|
1268
|
+
|
|
1269
|
+
```ts
|
|
1270
|
+
function applyAppearance(appearance: Appearance, element?: HTMLElement): void; // default <html>
|
|
1271
|
+
interface AppearanceListenerOptions {
|
|
1272
|
+
themes: readonly Theme[];
|
|
1273
|
+
}
|
|
1274
|
+
function connectAppearance(options: AppearanceListenerOptions): () => void;
|
|
1275
|
+
function connectReducedMotion(): () => void;
|
|
1276
|
+
|
|
1277
|
+
interface VsCodeShellOptions
|
|
1278
|
+
extends Omit<ShellOptions, "host" | "keyScope" | "themes">, AppearanceListenerOptions {}
|
|
1279
|
+
function mountVsCodeShell(root: HTMLElement, options: VsCodeShellOptions): ShellHandle;
|
|
1280
|
+
|
|
1281
|
+
interface WebviewStateApi {
|
|
1282
|
+
getState(): unknown;
|
|
1283
|
+
setState(state: unknown): void;
|
|
1284
|
+
}
|
|
1285
|
+
interface ShellWebviewState {
|
|
1286
|
+
legendOpen?: boolean;
|
|
1287
|
+
view?: ViewportState;
|
|
1288
|
+
}
|
|
1289
|
+
const SHELL_STATE_KEY = "modelerShell";
|
|
1290
|
+
function readShellState(api: WebviewStateApi): ShellWebviewState;
|
|
1291
|
+
function writeShellState(api: WebviewStateApi, patch: ShellWebviewState): void;
|
|
1292
|
+
interface PersistableView {
|
|
1293
|
+
onChange(listener: () => void): () => void;
|
|
1294
|
+
getState(): ViewportState | undefined;
|
|
1295
|
+
}
|
|
1296
|
+
interface PersistViewOptions {
|
|
1297
|
+
delayMs?: number; // default 250
|
|
1298
|
+
}
|
|
1299
|
+
function persistView(
|
|
1300
|
+
api: WebviewStateApi,
|
|
1301
|
+
viewport: PersistableView,
|
|
1302
|
+
options?: PersistViewOptions,
|
|
1303
|
+
): () => void;
|
|
1304
|
+
|
|
1305
|
+
// re-exported from the shared protocol, as in ./vscode/extension:
|
|
1306
|
+
// APPEARANCE_MESSAGE_TYPE, AppearanceMessage, appearanceMessage, isAppearanceMessage
|
|
1307
|
+
```
|
|
1308
|
+
|
|
1309
|
+
- `applyAppearance` sets `data-cd-theme` and `data-cd-kontrast` in one task, with
|
|
1310
|
+
`cd-shell-no-transition` on `<html>` until a nested `requestAnimationFrame` (no colour fades; after
|
|
1311
|
+
several quick switches only the last one lifts it).
|
|
1312
|
+
- `connectAppearance` applies every `AppearanceMessage` arriving on `window`, resolved against its own
|
|
1313
|
+
`themes` once more (what this bundle can show wins over what the extension passed); a message that
|
|
1314
|
+
changes nothing leaves `<html>` alone. A page whose `<html>` has no `data-cd-theme` at connect (no
|
|
1315
|
+
`renderWebviewHtml`) falls back to the body classes until the first message: a `MutationObserver`
|
|
1316
|
+
applies `resolveAppearance(appearanceFromBodyClasses(…), themes)`, at once and on every change.
|
|
1317
|
+
With the attributes of `renderWebviewHtml` there is no fallback: the bridge knows the `colorTheme`
|
|
1318
|
+
setting, the body classes do not, and VS Code changes them before the bridge's message arrives from
|
|
1319
|
+
the extension host, which would flash the mode without the setting. The body classes are never
|
|
1320
|
+
used for styling. Throws for empty `themes`.
|
|
1321
|
+
- `connectReducedMotion` mirrors the body class `vscode-reduce-motion` (VS Code's
|
|
1322
|
+
`workbench.reduceMotion`, kept up to date by VS Code) as `REDUCED_MOTION_CLASS` on `<html>` for the
|
|
1323
|
+
whole session; its disconnect removes the class.
|
|
1324
|
+
- `mountVsCodeShell` is `mountShell` with `host: "ide"` and `keyScope: "document"` (both always win)
|
|
1325
|
+
plus `connectAppearance(options)` and `connectReducedMotion()`, connected first, so empty `themes`
|
|
1326
|
+
throw before anything mounts (and disconnected again if `mountShell` throws). It returns a handle
|
|
1327
|
+
that passes every member through and whose `destroy()` disconnects both too. This wires the IDE
|
|
1328
|
+
key interception: handled keys are stopped on `document`, before VS Code's listener on `window`
|
|
1329
|
+
forwards them to the workbench.
|
|
1330
|
+
- `readShellState`/`writeShellState` keep the shell's part (legend state, view) under
|
|
1331
|
+
`SHELL_STATE_KEY` of `vscode.getState()`. `readShellState` returns `{}` for a missing or malformed
|
|
1332
|
+
part and only valid fields (a view with finite `x`/`y`, positive width and height and a boolean
|
|
1333
|
+
`userMoved`), as copies. `writeShellState` merges: every other top-level key and unknown keys in its
|
|
1334
|
+
own part stay, an `undefined` or malformed value leaves the stored one alone (so
|
|
1335
|
+
`{ view: viewport.getState() }` before the first import erases nothing), and nothing is written
|
|
1336
|
+
when nothing changed. The modeler owns `acquireVsCodeApi()` (callable once) and passes its API
|
|
1337
|
+
object.
|
|
1338
|
+
- `persistView(api, viewport)` writes `{ view: viewport.getState() }` once the view has rested for
|
|
1339
|
+
`delayMs` (trailing), instead of one `vscode.setState` IPC message per frame of a pan, a wheel zoom
|
|
1340
|
+
or a fit animation; the returned stop function writes a pending change at once. The legend state
|
|
1341
|
+
is written from `legend.onToggle` (see the usage below).
|
|
1342
|
+
- The custom editor is registered with `webviewOptions: { retainContextWhenHidden: true }`: a hidden
|
|
1343
|
+
tab keeps document, view and selection, reports a 0 × 0 container and neither fits nor measures.
|
|
1344
|
+
|
|
1345
|
+
## `./testing`
|
|
1346
|
+
|
|
1347
|
+
```ts
|
|
1348
|
+
// Playwright's Page satisfies it
|
|
1349
|
+
interface PageLike {
|
|
1350
|
+
evaluate<R, Arg>(pageFunction: (arg: Arg) => R | Promise<R>, arg: Arg): Promise<R>;
|
|
1351
|
+
setViewportSize(size: { width: number; height: number }): Promise<void>;
|
|
1352
|
+
keyboard: { press(key: string): Promise<void> };
|
|
1353
|
+
mouse: {
|
|
1354
|
+
click(x: number, y: number): Promise<void>;
|
|
1355
|
+
dblclick(x: number, y: number): Promise<void>;
|
|
1356
|
+
move(x: number, y: number): Promise<void>;
|
|
1357
|
+
down(): Promise<void>;
|
|
1358
|
+
up(): Promise<void>;
|
|
1359
|
+
};
|
|
1360
|
+
waitForTimeout(timeout: number): Promise<void>;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
interface OpenContext {
|
|
1364
|
+
size: Size; // the shell container's size (grow the page by any frame around an embedded root)
|
|
1365
|
+
requested: Appearance; // what the host requests; the harness opens the page with it (embedded: see below)
|
|
1366
|
+
}
|
|
1367
|
+
interface ShowHooks<P extends PageLike = PageLike> {
|
|
1368
|
+
problems?(page: P): Promise<void>;
|
|
1369
|
+
status?(page: P): Promise<void>;
|
|
1370
|
+
legend?(page: P): Promise<void>;
|
|
1371
|
+
inspector?(page: P): Promise<void>;
|
|
1372
|
+
invalid?(page: P): Promise<void>; // the invalid-file state, caused the way the host causes it
|
|
1373
|
+
}
|
|
1374
|
+
type ShowHook = keyof ShowHooks; // "problems" | "status" | "legend" | "inspector" | "invalid"
|
|
1375
|
+
interface ShellConformanceHarness<P extends PageLike = PageLike> {
|
|
1376
|
+
host: ShellHost;
|
|
1377
|
+
themes: readonly Theme[];
|
|
1378
|
+
open(page: P, context: OpenContext): Promise<void>;
|
|
1379
|
+
destroy?(page: P): Promise<void>;
|
|
1380
|
+
root?: string; // default ".cd-shell"
|
|
1381
|
+
show?: ShowHooks<P>;
|
|
1382
|
+
absent?: readonly ShowHook[]; // islands and states the modeler does not have
|
|
1383
|
+
sizes?: readonly Size[]; // default 1280 × 900, 720 × 700, 420 × 700
|
|
1384
|
+
}
|
|
1385
|
+
interface ShellSkip {
|
|
1386
|
+
hook: ShowHook | "destroy" | "open"; // the harness field that would feed the skipped part
|
|
1387
|
+
reason: string;
|
|
1388
|
+
}
|
|
1389
|
+
interface ShellCaseOutcome {
|
|
1390
|
+
skipped?: readonly ShellSkip[];
|
|
1391
|
+
}
|
|
1392
|
+
interface ShellConformanceCase {
|
|
1393
|
+
name: string;
|
|
1394
|
+
run<P extends PageLike>(page: P, harness: ShellConformanceHarness<P>): Promise<ShellCaseOutcome | void>;
|
|
1395
|
+
}
|
|
1396
|
+
function shellConformanceCases(): ShellConformanceCase[];
|
|
1397
|
+
type ShellConformanceTest<P extends PageLike = PageLike> = (
|
|
1398
|
+
name: string,
|
|
1399
|
+
body: (fixtures: { page: P }) => Promise<void>,
|
|
1400
|
+
) => unknown;
|
|
1401
|
+
interface RegisterShellConformanceOptions {
|
|
1402
|
+
onSkip?(caseName: string, skips: readonly ShellSkip[]): void; // default: console.warn, joined by "; "
|
|
1403
|
+
}
|
|
1404
|
+
function registerShellConformance<P extends PageLike>(
|
|
1405
|
+
harness: ShellConformanceHarness<P>,
|
|
1406
|
+
test: ShellConformanceTest<P>,
|
|
1407
|
+
options?: RegisterShellConformanceOptions,
|
|
1408
|
+
): void;
|
|
1409
|
+
// "theme=dark&kontrast=hoch[&colorTheme=…][&reduceMotion]" for a page with the VS Code stub
|
|
1410
|
+
function vsCodeStubSearch(
|
|
1411
|
+
requested: Appearance,
|
|
1412
|
+
extra?: { colorTheme?: ColorThemeSetting; reduceMotion?: boolean },
|
|
1413
|
+
): string;
|
|
1414
|
+
|
|
1415
|
+
const GEOMETRY_TOLERANCE_PX = 1;
|
|
1416
|
+
interface IslandBox {
|
|
1417
|
+
left: number;
|
|
1418
|
+
top: number;
|
|
1419
|
+
width: number;
|
|
1420
|
+
height: number;
|
|
1421
|
+
}
|
|
1422
|
+
interface ChromeMeasurement {
|
|
1423
|
+
container: Size;
|
|
1424
|
+
paletteRow: PaletteRow;
|
|
1425
|
+
islands: Partial<Record<ChromeIsland, IslandBox>>;
|
|
1426
|
+
open: { legend: boolean; inspector: boolean; problems: boolean };
|
|
1427
|
+
}
|
|
1428
|
+
interface ExpectedBox {
|
|
1429
|
+
left?: number;
|
|
1430
|
+
top?: number;
|
|
1431
|
+
right?: number;
|
|
1432
|
+
bottom?: number;
|
|
1433
|
+
width?: number;
|
|
1434
|
+
height?: number;
|
|
1435
|
+
centerX?: number;
|
|
1436
|
+
}
|
|
1437
|
+
function measureChrome(page: PageLike, root?: string): Promise<ChromeMeasurement>;
|
|
1438
|
+
function expectedChromeBoxes(
|
|
1439
|
+
measurement: ChromeMeasurement,
|
|
1440
|
+
host: ShellHost,
|
|
1441
|
+
): Partial<Record<ChromeIsland, ExpectedBox>>;
|
|
1442
|
+
```
|
|
1443
|
+
|
|
1444
|
+
`IslandBox` and `ExpectedBox` are relative to the root's padding box (`getBoundingClientRect()` minus
|
|
1445
|
+
the root's box and border), where the islands anchor.
|
|
1446
|
+
|
|
1447
|
+
Cases (each named `shell conformance: <name>`; failures throw plain `Error`s listing every problem):
|
|
1448
|
+
|
|
1449
|
+
| Case | Checks |
|
|
1450
|
+
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1451
|
+
| `geometry` | per size, as opened and after each show hook in turn (legend, problems, status, inspector): every visible `data-chrome` island against the geometry table, ±1px; the palette row decision (second row up to 760px, 16px clearance); the guide's limits (legend ≥ 184px, status ≤ `min(560px, 100cqw − 24px)`, side inspector ≥ 64px above the bottom, sheet ≤ 45 %, second-row palette ≤ `100cqw − 24px`); root state attributes against what is visible; zoom, pill and legend hidden under a bottom sheet; the dropdown at 60/12 and 248 to `min(320px, 100cqw − 24px)` wide; tooltips on the menu trigger, a palette entry and zoom in at 60, 112 and bottom 60, centred and 12px from the sides; an `absent` island is never visible |
|
|
1452
|
+
| `aria` | trigger `aria-haspopup`/`-expanded`/`-controls` → `role="menu"`; the menu keyboard (Enter and ↓ open on the first entry, ↑ on the last, wrap, Home/End, Escape returns focus), whose keys never reach a `keydown` listener on `document`; named items, `aria-checked`, labelled radio groups, dialog entries ending in "…"; palette `role="toolbar"` with roving tabindex driven by ←/→/Home/End, `aria-pressed` on tools and `aria-keyshortcuts` on every entry whose tooltip shows a shortcut (each entry focused by keyboard); Tab from the menu trigger lands on the palette's one stop, then on the canvas `<svg>`, nothing in between; the canvas `<svg>` has an accessible name; zoom `role="group"`, readout `aria-label` with the shown percentage, `aria-keyshortcuts` for the page's platform, `aria-disabled` (never native `disabled`) at 400 % and 20 % reached by clicking; roles of pill, status, legend and inspector (an `absent` one is never visible); DOM order; no positive `tabindex`; no native `title`; `lang` on `<html>` or on an embedded root |
|
|
1453
|
+
| `keys` | on the canvas, each chrome key changes the zoom exactly once (Ctrl, Meta, Shift and numpad variants 100 % → 135 % → 100 %, 0 and Numpad0 to 100 %, Shift+1 fits at most 100 %); unmodified `+`/`-` and Alt combinations do nothing; Escape without menu or dialog passes untouched; input, textarea and editable-label stand-ins block the zoom keys and keep undo/redo (stopped, not prevented), modal and popover stand-ins block, Escape with the open menu closes it; Control+A and Meta+A on the focused canvas are handled by the canvas (select all, or at least prevented). `ide`: a `keydown` listener on `window` never sees a handled or blocked key (select all included) while F1 still arrives, a `document` listener registered after the shell's sees handled keys prevented and blocked ones (Cmd/Ctrl+Z in a text field) not, and Cmd/Ctrl+Z, Shift+Z and Ctrl+Y on the canvas reach `window` once and unprevented (the document's history undoes, §13.5); other hosts: every key reaches `window`, the handled ones prevented |
|
|
1454
|
+
| `create` | §5.5: the palette focused by keyboard and walked with →/Home to the first entry that is not a tool (outside group `tools`, `data-action` not ending in `-tool`), then Enter: exactly one new `.djs-shape[data-element-id]`, selected, its centre inside the free rectangle (the container minus the fit insets recomputed from the measured chrome), the focus on the canvas (its `<svg>` or a label-editor field); Escape afterwards. A notation without a creating entry passes with nothing to verify |
|
|
1455
|
+
| `readOnly` | §13.5, after `show.invalid` and `INVALID_STATUS_DELAY_MS`: `data-readonly` on the root, the status box visible with `role="alert"`; every palette entry but `lasso-tool` and `hand-tool` `aria-disabled`, those two not, and the disabled tool entries and their icons in `--cd-ui-text-leise`; Enter on a creating entry adds no shape; the shell's Legend checkbox and every Export and Help entry that was enabled before stay enabled; on a shape a pointer reaches: no `.djs-context-pad` entry after a click, a 40px drag changes neither its position nor its size, a double-click opens no text field inside `.djs-container`, Delete and Backspace on the canvas keep it. Skips with `hook: "invalid"` without the hook (unless `absent` lists it) and with `hook: "open"` when the opened document shows no such shape |
|
|
1456
|
+
| `appearance` | the harness opens with dark, dark + high contrast and light + high contrast requested. Own document: the mode on `<html>` is `resolveAppearance(requested, themes)`, recomputed by the kit (a light-only modeler stays light, `hoch` passes). Embedded: the kit puts the foreign page's `<html>` in the requested mode, and the root carries its own `data-cd-theme="<themes[0]>"` with the page's `data-cd-kontrast` (§13.3). Both: no themed descendant; `cd-shell-document` on `<html>` in `ide`/`web`, never in a foreign page; tokens loaded; `color-scheme` and four `--cd-ui-*` roles equal on root, canvas, every island, the open dropdown and the visible tooltip; the painted surround, the first non-transparent `background-color` from `.djs-container` out through `.cd-shell-canvas` to the root, equals `--cd-ui-grund` (§10.1); every palette tool entry (group `tools` or `data-action` ending in `-tool`) and its icon (`<svg>` or `::before`) compute to `--cd-ui-text` at rest and pressed and to `--cd-ui-interaktion` hovered, each tool hovered in turn and Lasso armed by a click, which sets `aria-pressed="true"`; notation previews are exempt (§5.5). Never half-dark |
|
|
1457
|
+
| `cleanup` | with a tooltip timer pending: after `destroy` and 800ms, a resize and chrome keys that must arrive unprevented, no `cd-shell*` class or element, no `data-chrome`, no root state attribute (embedded: also no `data-cd-theme`/`data-cd-kontrast`) and no page error is left; skips with `hook: "destroy"` without a `destroy` |
|
|
1458
|
+
|
|
1459
|
+
- **Skips.** A case the harness cannot feed reports what it skipped as `ShellSkip`s, one per missing
|
|
1460
|
+
harness field, through `onSkip` (default `console.warn`). A hook that is neither in `show` nor in
|
|
1461
|
+
`absent` is skipped with `hook` set to its name, a missing `destroy` with `hook: "destroy"`, and a
|
|
1462
|
+
document that lacks what a case needs (a shape a pointer reaches, for `readOnly`) with
|
|
1463
|
+
`hook: "open"`. `absent` lists what the modeler does not have (an island or the `invalid` state),
|
|
1464
|
+
which is then never reported, while `geometry` and `aria` assert that the island never shows. Listing a hook in both `show` and `absent` fails every case that uses
|
|
1465
|
+
it. A suite that lists what it lacks can fail on every remaining skip:
|
|
1466
|
+
`onSkip(name, skips) { throw new Error(…) }`.
|
|
1467
|
+
- **`show.invalid`** makes the file invalid the way the host does (in a webview: a document update
|
|
1468
|
+
the parser rejects), not by calling `shell.setReadOnly` alone: the `readOnly` case also checks the
|
|
1469
|
+
modeler's own canvas lock, which its invalid-import path sets. The case waits
|
|
1470
|
+
`INVALID_STATUS_DELAY_MS` and a margin itself.
|
|
1471
|
+
- For the `embedded` host the kit writes `data-cd-theme` and `data-cd-kontrast` on the page's
|
|
1472
|
+
`<html>` itself after `open`: every consumer suite then proves that the modeler stays in its own
|
|
1473
|
+
mode inside a dark host page, the case a foreign page meets in practice (designIQ, a web app on
|
|
1474
|
+
`system` under a dark OS).
|
|
1475
|
+
- The second-row tooltip of the `geometry` case may stand centred on its entry or in the container:
|
|
1476
|
+
guide §5.15 says "mittig im Container", and which of the two it means is an open question; the
|
|
1477
|
+
shell centres it on its entry.
|
|
1478
|
+
- The kit never imports Playwright; a modeler's suite passes its `page` and `test`
|
|
1479
|
+
(`registerShellConformance(harness, test)` from `@playwright/test`). It installs one page global,
|
|
1480
|
+
`__modelerShellConformance`, with `keydown` recorders on `window` and `document`, and briefly
|
|
1481
|
+
injects stand-ins marked `data-modeler-shell-conformance` (an input, a textarea, an editable label,
|
|
1482
|
+
a modal dialog, a popover, a focus target) into the shell root.
|
|
1483
|
+
- `geometry` takes about 10s per host; wrap the registration in
|
|
1484
|
+
`test.describe.configure({ timeout: 60_000 })`.
|
|
1485
|
+
- `registerShellConformance` calls `test` inside the kit, so a runner reports every case at the
|
|
1486
|
+
kit's source (the package ships `src/`, so that location resolves). For locations in the suite's
|
|
1487
|
+
own spec file, register the cases there:
|
|
1488
|
+
|
|
1489
|
+
```ts
|
|
1490
|
+
for (const conformanceCase of shellConformanceCases()) {
|
|
1491
|
+
test(`shell conformance: ${conformanceCase.name}`, async ({ page }) => {
|
|
1492
|
+
const outcome = await conformanceCase.run(page, harness);
|
|
1493
|
+
if (outcome?.skipped?.length) onSkip(conformanceCase.name, outcome.skipped);
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
```
|
|
1497
|
+
|
|
1498
|
+
### `./testing/vscode-stub` — the `ide` host without VS Code
|
|
1499
|
+
|
|
1500
|
+
```ts
|
|
1501
|
+
type ColorThemeKind = 1 | 2 | 3 | 4;
|
|
1502
|
+
function colorThemeKindOf(appearance: Appearance): ColorThemeKind;
|
|
1503
|
+
const STUB_SETTING_KEY = "miragon.modeler.colorTheme";
|
|
1504
|
+
interface VsCodeWebviewStubOptions {
|
|
1505
|
+
requested: Appearance; // VS Code's color theme
|
|
1506
|
+
themes: readonly Theme[]; // what the modeler's extension passes to the bridge
|
|
1507
|
+
lang?: string; // default "en"
|
|
1508
|
+
colorTheme?: ColorThemeSetting;
|
|
1509
|
+
settingKey?: string; // default STUB_SETTING_KEY
|
|
1510
|
+
reduceMotion?: boolean; // workbench.reduceMotion
|
|
1511
|
+
state?: unknown; // what acquireVsCodeApi().getState() returns at first
|
|
1512
|
+
}
|
|
1513
|
+
interface StubbedVsCodeApi {
|
|
1514
|
+
getState(): unknown;
|
|
1515
|
+
setState(state: unknown): void;
|
|
1516
|
+
postMessage(message: unknown): void;
|
|
1517
|
+
}
|
|
1518
|
+
interface VsCodeWebviewStub {
|
|
1519
|
+
readonly kind: ColorThemeKind;
|
|
1520
|
+
setColorThemeKind(kind: ColorThemeKind): void; // body classes first, then the bridge posts
|
|
1521
|
+
setColorTheme(setting: ColorThemeSetting | undefined): void;
|
|
1522
|
+
setReduceMotion(reduceMotion: boolean): void;
|
|
1523
|
+
pendingMessages(): number; // posted by the bridge, not yet delivered
|
|
1524
|
+
readonly posted: readonly unknown[]; // what the webview posted
|
|
1525
|
+
state(): unknown;
|
|
1526
|
+
}
|
|
1527
|
+
type StubWindow = Window & {
|
|
1528
|
+
__modelerShellVsCodeStub?: VsCodeWebviewStub;
|
|
1529
|
+
acquireVsCodeApi?: () => StubbedVsCodeApi;
|
|
1530
|
+
};
|
|
1531
|
+
function stubOptionsFromSearch(
|
|
1532
|
+
search: string,
|
|
1533
|
+
): Pick<VsCodeWebviewStubOptions, "requested" | "colorTheme" | "reduceMotion">;
|
|
1534
|
+
function installVsCodeWebviewStub(options: VsCodeWebviewStubOptions): VsCodeWebviewStub;
|
|
1535
|
+
```
|
|
1536
|
+
|
|
1537
|
+
Runs in a test page, before the modeler's webview code: VS Code's default webview stylesheet in
|
|
1538
|
+
`@layer vscode-default` (with foreign values, so a leak shows), `<html>` with the attributes
|
|
1539
|
+
`renderWebviewHtml` writes, `<body>` with the host page's theme and reduce-motion classes,
|
|
1540
|
+
`acquireVsCodeApi()` (once, as in VS Code) and the real `connectThemeBridge` against a fake `vscode`
|
|
1541
|
+
namespace whose panel posts to the window. The stub is also `window.__modelerShellVsCodeStub`, so a
|
|
1542
|
+
Playwright suite switches the color theme or the setting at runtime.
|
|
1543
|
+
|
|
1544
|
+
A modeler's e2e suite serves `e2e/webview/` as the root of a Vite server, with the test page
|
|
1545
|
+
`e2e/webview/webview-test.html` (`<div id="app">` and `<script type="module" src="./webview-test.ts">`)
|
|
1546
|
+
and this entry:
|
|
1547
|
+
|
|
1548
|
+
```ts
|
|
1549
|
+
// e2e/webview/webview-test.ts
|
|
1550
|
+
import { installVsCodeWebviewStub, stubOptionsFromSearch } from "@miragon/modeler-shell/testing/vscode-stub";
|
|
1551
|
+
|
|
1552
|
+
// before the webview runs, which calls acquireVsCodeApi() and mounts the shell
|
|
1553
|
+
installVsCodeWebviewStub({ ...stubOptionsFromSearch(location.search), themes: ["light"] });
|
|
1554
|
+
const { webview } = await import("../../src/webview/main"); // exports { shell, modeler, connection, ready }
|
|
1555
|
+
|
|
1556
|
+
// for the harness only: the shipped bundle publishes nothing on window
|
|
1557
|
+
window.shell = webview.shell;
|
|
1558
|
+
window.modeler = webview.modeler;
|
|
1559
|
+
window.connection = webview.connection;
|
|
1560
|
+
void webview.ready.then(() => (window.webviewReady = true)); // the first import settled
|
|
1561
|
+
|
|
1562
|
+
// there is no extension host: post the document the way the extension would
|
|
1563
|
+
window.postMessage({ type: "init", text: EXAMPLE_BOARD }, "*");
|
|
1564
|
+
```
|
|
1565
|
+
|
|
1566
|
+
The harness opens it with ``page.goto(`/webview-test.html?${vsCodeStubSearch(requested)}`)`` and waits
|
|
1567
|
+
for `window.webviewReady`. `ready`, `webviewReady` and the window globals are the modeler's, not the
|
|
1568
|
+
shell's: its webview module exports `ready`, resolved once the first `init` import settled, and the
|
|
1569
|
+
test page turns it into the flag (the Event Storming `main.ts` below exports it).
|
|
1570
|
+
|
|
1571
|
+
## `./shell.css`
|
|
1572
|
+
|
|
1573
|
+
One stylesheet for everything above, loaded last. Containment: `.cd-shell { container: cd-shell /
|
|
1574
|
+
size }`; breakpoints are `@container cd-shell (max-width: 760px)`, `(max-width: 440px)` with literal
|
|
1575
|
+
values (custom properties are not allowed in container conditions), sizes inside are `cqw`/`cqh`,
|
|
1576
|
+
never viewport units, and no element inside gets a `container-type` of its own. It covers glass
|
|
1577
|
+
islands (`color-mix(in srgb, var(--cd-ui-flaeche-hoch) var(--cd-ui-chrome-deckkraft), transparent)`
|
|
1578
|
+
with `backdrop-filter: blur(8px)`), opaque popups, focus rings (inset in dense containers),
|
|
1579
|
+
`[data-cd-kontrast="hoch"]` (no shadows, 3px focus), `@media (forced-colors: active)` (rings in
|
|
1580
|
+
`Highlight`, status bar and island borders in `CanvasText`), `prefers-reduced-motion` and
|
|
1581
|
+
`html.cd-shell-reduce-motion`, `html.cd-shell-no-transition` (no transitions for the frame of a mode
|
|
1582
|
+
switch), the canvas `<svg>`'s focus ring (2px, 3px in high contrast, inside the slot), and under
|
|
1583
|
+
`html.cd-shell-document` the own-document reset: no body padding, `--cd-ui-grund` background, Geist
|
|
1584
|
+
13px/1.5, own `kbd`, `code`, links, focus and scrollbars, never `var(--vscode-*)`. The reset is
|
|
1585
|
+
unlayered, so it wins over VS Code's `@layer vscode-default`; its button and field font reset has
|
|
1586
|
+
zero specificity, so the chrome's own rules (the readout's 13px/600) win over it. In a foreign page
|
|
1587
|
+
the chrome's own islands, controls and texts reset font, colour, letter spacing and text transform
|
|
1588
|
+
at class specificity, so the page's element rules (`button { text-transform: uppercase }`,
|
|
1589
|
+
`p { font-size: 18px }`) do not reach them. The canvas slot's layout (`.cd-shell >
|
|
1590
|
+
.cd-shell-canvas`: absolute, `inset: 0`, layer 0, `isolation: isolate`) wins over a renderer's own
|
|
1591
|
+
container class, its paint (`background-color: var(--cd-ui-grund)`) has zero specificity, so a
|
|
1592
|
+
renderer's paper (Event Storming's dot grid) shows. The paper's colour stays `--cd-ui-grund` in
|
|
1593
|
+
every mode, the dot grid being a background image in `--cd-ui-linie` (§10.1); the kit's `appearance`
|
|
1594
|
+
case checks the painted colour. Palette tool entries set their colour in every
|
|
1595
|
+
state (rest and pressed `--cd-ui-text`, hovered `--cd-ui-interaktion`, read-only
|
|
1596
|
+
`--cd-ui-text-leise`), and their icons (`> svg`, `::before`) inherit it, so a renderer's or
|
|
1597
|
+
diagram-js's rule on the icon or on the stale `highlighted-entry` does not leak in; notation
|
|
1598
|
+
previews keep their own colours (§5.5). Private helper properties are named `--modeler-shell-*`.
|
|
1599
|
+
|
|
1600
|
+
The shell bundles no font: the modeler loads Geist and Geist Mono (`@fontsource-variable/geist`,
|
|
1601
|
+
`@fontsource-variable/geist-mono`), as the stylesheet order above shows, as font files (see
|
|
1602
|
+
`./vscode/extension`).
|
|
1603
|
+
|
|
1604
|
+
## Usage: Event Storming in VS Code
|
|
1605
|
+
|
|
1606
|
+
The Event Storming extension, the pilot of 0.1. Extension host
|
|
1607
|
+
(`EventStormingEditorProvider.resolveCustomTextEditor`):
|
|
1608
|
+
|
|
1609
|
+
```ts
|
|
1610
|
+
import * as vscode from "vscode";
|
|
1611
|
+
import {
|
|
1612
|
+
connectThemeBridge,
|
|
1613
|
+
currentAppearance,
|
|
1614
|
+
renderWebviewHtml,
|
|
1615
|
+
revealFileLabel,
|
|
1616
|
+
} from "@miragon/modeler-shell/vscode/extension";
|
|
1617
|
+
|
|
1618
|
+
const THEMES = ["light"] as const; // until the shared dark-mode release of all modelers
|
|
1619
|
+
|
|
1620
|
+
// activate(): a hidden tab keeps document, view and selection (and reports 0 × 0 meanwhile)
|
|
1621
|
+
vscode.window.registerCustomEditorProvider(viewType, provider, {
|
|
1622
|
+
webviewOptions: { retainContextWhenHidden: true },
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
// resolveCustomTextEditor():
|
|
1626
|
+
const dist = vscode.Uri.joinPath(extensionUri, "dist");
|
|
1627
|
+
webview.options = { enableScripts: true, localResourceRoots: [dist] }; // webview.js, webview.css, fonts/
|
|
1628
|
+
const asset = (file: string) => webview.asWebviewUri(vscode.Uri.joinPath(dist, file)).toString();
|
|
1629
|
+
webview.html = renderWebviewHtml({
|
|
1630
|
+
cspSource: webview.cspSource,
|
|
1631
|
+
scriptUri: asset("webview.js"),
|
|
1632
|
+
styleUris: [asset("webview.css")],
|
|
1633
|
+
title: "Event Storming Board",
|
|
1634
|
+
lang: "en",
|
|
1635
|
+
appearance: currentAppearance(vscode, { themes: THEMES }),
|
|
1636
|
+
});
|
|
1637
|
+
connectThemeBridge(vscode, webviewPanel, { themes: THEMES }); // disposes itself with the panel
|
|
1638
|
+
|
|
1639
|
+
// after an export (exportImage.ts):
|
|
1640
|
+
const reveal = revealFileLabel(process.platform);
|
|
1641
|
+
if ((await vscode.window.showInformationMessage(`Exported ${name}.`, reveal)) === reveal) {
|
|
1642
|
+
void vscode.commands.executeCommand("revealFileInOS", target);
|
|
1643
|
+
}
|
|
1644
|
+
```
|
|
1645
|
+
|
|
1646
|
+
The two bundles (`esbuild.config.mjs`):
|
|
1647
|
+
|
|
1648
|
+
```js
|
|
1649
|
+
const host = {
|
|
1650
|
+
entryPoints: ["src/extension.ts"],
|
|
1651
|
+
outfile: "dist/extension.cjs",
|
|
1652
|
+
bundle: true,
|
|
1653
|
+
platform: "node",
|
|
1654
|
+
format: "cjs",
|
|
1655
|
+
target: "node20", // VS Code 1.90 or later: the global crypto of createNonce
|
|
1656
|
+
external: ["vscode"],
|
|
1657
|
+
};
|
|
1658
|
+
const web = {
|
|
1659
|
+
entryPoints: ["src/webview/main.ts"],
|
|
1660
|
+
outfile: "dist/webview.js", // and dist/webview.css
|
|
1661
|
+
bundle: true,
|
|
1662
|
+
platform: "browser",
|
|
1663
|
+
format: "iife", // renderWebviewHtml writes a classic <script nonce>
|
|
1664
|
+
loader: { ".woff2": "file", ".woff": "file" }, // font files: a page loads only the subsets it needs
|
|
1665
|
+
assetNames: "fonts/[name]-[hash]",
|
|
1666
|
+
};
|
|
1667
|
+
```
|
|
1668
|
+
|
|
1669
|
+
Webview (`src/webview/main.ts`):
|
|
1670
|
+
|
|
1671
|
+
```ts
|
|
1672
|
+
import "./zod-jitless"; // first: z.config({ jitless: true }) before any schema module (CSP)
|
|
1673
|
+
import "@fontsource-variable/geist/wght.css";
|
|
1674
|
+
import "@fontsource-variable/geist-mono/wght.css";
|
|
1675
|
+
// the renderer's stylesheet brings diagram-js.css and the tokens, so they are not imported again
|
|
1676
|
+
import {
|
|
1677
|
+
createCollaborativeModeler,
|
|
1678
|
+
type BoardBounds,
|
|
1679
|
+
type EventStormingReadOnly,
|
|
1680
|
+
type EventStormingViewOptions,
|
|
1681
|
+
} from "@miragon/event-storming-renderer";
|
|
1682
|
+
import "./style.css"; // the modeler's own content: the "Keyboard shortcuts…" dialog
|
|
1683
|
+
import "@miragon/modeler-shell/shell.css";
|
|
1684
|
+
import { INVALID_STATUS_DELAY_MS } from "@miragon/modeler-shell";
|
|
1685
|
+
import { connectDiagramJs, shellModule } from "@miragon/modeler-shell/diagram-js";
|
|
1686
|
+
import { mountVsCodeShell, persistView, readShellState } from "@miragon/modeler-shell/vscode/webview";
|
|
1687
|
+
import { createShortcutsDialog } from "./shortcutsDialog";
|
|
1688
|
+
|
|
1689
|
+
const vscode = acquireVsCodeApi();
|
|
1690
|
+
const stored = readShellState(vscode);
|
|
1691
|
+
|
|
1692
|
+
// 1. The diagram first: Event Storming's keyboard listens on document and must run before the
|
|
1693
|
+
// shell's, and the renderer builds its diagram lazily, so get() a service. width/height: the
|
|
1694
|
+
// shell's slot sizes it.
|
|
1695
|
+
const collaborative = createCollaborativeModeler({
|
|
1696
|
+
additionalModules: [shellModule],
|
|
1697
|
+
// VS Code's document history owns Cmd/Ctrl+Z (§13.5); the kit's keys case checks it
|
|
1698
|
+
undoRedoKeys: false,
|
|
1699
|
+
// where Enter or Space on a creating palette entry places the element (§5.5): the renderer takes
|
|
1700
|
+
// the fit insets and computes the centre of the free rectangle itself, the same point as
|
|
1701
|
+
// viewport.freeRectCenter(); a renderer may take that function instead
|
|
1702
|
+
freeRectInsets: () => shell.fitInsets(),
|
|
1703
|
+
});
|
|
1704
|
+
const modeler = collaborative.diagram;
|
|
1705
|
+
modeler.get("canvas");
|
|
1706
|
+
const viewOptions = modeler.get<EventStormingViewOptions>("eventStormingViewOptions");
|
|
1707
|
+
const levels = modeler.get<{ getLevel(): string; setLevel(level: string): void }>("eventStormingModeling");
|
|
1708
|
+
// the canvas lock of an invalid file: context pad, move, resize, delete, paste, label editor
|
|
1709
|
+
const canvasLock = modeler.get<EventStormingReadOnly>("eventStormingReadOnly");
|
|
1710
|
+
const boardBounds = modeler.get<BoardBounds>("boardBounds");
|
|
1711
|
+
|
|
1712
|
+
// 2. The chrome around it.
|
|
1713
|
+
const shell = mountVsCodeShell(document.getElementById("app")!, {
|
|
1714
|
+
themes: ["light"],
|
|
1715
|
+
canvas: modeler.container,
|
|
1716
|
+
messages: { canvas: "Event Storming board" },
|
|
1717
|
+
menu: {
|
|
1718
|
+
model: [
|
|
1719
|
+
{
|
|
1720
|
+
kind: "radio",
|
|
1721
|
+
id: "level",
|
|
1722
|
+
label: "Level",
|
|
1723
|
+
options: [
|
|
1724
|
+
{ value: "big-picture", label: "Big picture" },
|
|
1725
|
+
{ value: "process", label: "Process modeling" },
|
|
1726
|
+
{ value: "design", label: "Software design" },
|
|
1727
|
+
],
|
|
1728
|
+
value: () => levels.getLevel(),
|
|
1729
|
+
onSelect: (level) => levels.setLevel(level),
|
|
1730
|
+
},
|
|
1731
|
+
{
|
|
1732
|
+
kind: "checkbox",
|
|
1733
|
+
id: "type-captions",
|
|
1734
|
+
label: "Type captions",
|
|
1735
|
+
keepEnabledWhenReadOnly: true, // a view toggle, not board content: usable while read-only
|
|
1736
|
+
checked: () => viewOptions.typeCaptionsVisible(),
|
|
1737
|
+
onToggle: (visible) => viewOptions.setTypeCaptionsVisible(visible),
|
|
1738
|
+
},
|
|
1739
|
+
],
|
|
1740
|
+
export: [
|
|
1741
|
+
{
|
|
1742
|
+
kind: "action",
|
|
1743
|
+
id: "export-png",
|
|
1744
|
+
label: "Export as PNG",
|
|
1745
|
+
icon: "file-image",
|
|
1746
|
+
onSelect: () => void exportPng(),
|
|
1747
|
+
},
|
|
1748
|
+
{
|
|
1749
|
+
kind: "action",
|
|
1750
|
+
id: "export-svg",
|
|
1751
|
+
label: "Export as SVG",
|
|
1752
|
+
icon: "file-code-corner",
|
|
1753
|
+
onSelect: () => void exportSvg(),
|
|
1754
|
+
},
|
|
1755
|
+
],
|
|
1756
|
+
help: [
|
|
1757
|
+
{
|
|
1758
|
+
kind: "dialog",
|
|
1759
|
+
id: "keyboard-shortcuts",
|
|
1760
|
+
label: "Keyboard shortcuts…",
|
|
1761
|
+
icon: "keyboard",
|
|
1762
|
+
onSelect: () => shortcutsDialog.open(),
|
|
1763
|
+
},
|
|
1764
|
+
],
|
|
1765
|
+
},
|
|
1766
|
+
problems: { onActivate: () => vscode.postMessage({ type: "showProblems" }) },
|
|
1767
|
+
emptyHint: { onInsertExample: () => vscode.postMessage({ type: "insertExample" }) },
|
|
1768
|
+
});
|
|
1769
|
+
// The modeler's own dialog in shell.root (see Modeler dialogs); it goes with the diagram.
|
|
1770
|
+
const shortcutsDialog = createShortcutsDialog(shell);
|
|
1771
|
+
modeler.on("diagram.destroy", () => shortcutsDialog.destroy());
|
|
1772
|
+
|
|
1773
|
+
// 3. Viewport, palette, keys and the empty-file hint. Cheap tests only: canFit() is cached, but
|
|
1774
|
+
// exportMap() validates the whole board.
|
|
1775
|
+
const registry = modeler.get<{ getAll(): Array<{ parent?: unknown; waypoints?: unknown }> }>(
|
|
1776
|
+
"elementRegistry",
|
|
1777
|
+
);
|
|
1778
|
+
const isEmpty = () => !registry.getAll().some((element) => element.parent && !element.waypoints); // §13.5
|
|
1779
|
+
const connection = connectDiagramJs(shell, modeler, {
|
|
1780
|
+
isEmpty,
|
|
1781
|
+
// no padding of its own (the default pads by 120px): the fit insets are the only margin (§5.9)
|
|
1782
|
+
contentBounds: () => (isEmpty() ? undefined : boardBounds.contentBounds(0)),
|
|
1783
|
+
});
|
|
1784
|
+
const { viewport } = connection;
|
|
1785
|
+
persistView(vscode, viewport); // vscode.setState once the view rested, not on every frame
|
|
1786
|
+
modeler.on("commandStack.changed", () => shell.menu.refresh()); // level undo/redo
|
|
1787
|
+
|
|
1788
|
+
// 4. Every import from the document (init and external updates), strictly one after another.
|
|
1789
|
+
async function importText(text: string, first: boolean): Promise<void> {
|
|
1790
|
+
const keep = first ? undefined : viewport.getViewbox();
|
|
1791
|
+
try {
|
|
1792
|
+
await collaborative.importText(text); // silent: no change event comes from an import
|
|
1793
|
+
canvasLock.setReadOnly(false);
|
|
1794
|
+
shell.setReadOnly(false);
|
|
1795
|
+
shell.setStatus(null);
|
|
1796
|
+
if (keep) viewport.restore(keep);
|
|
1797
|
+
else if (first && stored.view) viewport.setState(stored.view);
|
|
1798
|
+
viewport.afterImport(); // first content fits, an empty file shows 100 %, the hint follows import.done
|
|
1799
|
+
shell.menu.refresh();
|
|
1800
|
+
} catch {
|
|
1801
|
+
// locked at once, the canvas included; marks and status box show after 800ms without a valid import
|
|
1802
|
+
canvasLock.setReadOnly(true);
|
|
1803
|
+
shell.setReadOnly(true, { delayMs: INVALID_STATUS_DELAY_MS });
|
|
1804
|
+
shell.setStatus(
|
|
1805
|
+
{ severity: "danger", text: shell.messages.invalidFile("Event Storming board") },
|
|
1806
|
+
{ delayMs: INVALID_STATUS_DELAY_MS },
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
let imports = Promise.resolve();
|
|
1811
|
+
const enqueueImport = (text: string, first = false) =>
|
|
1812
|
+
(imports = imports.then(() => importText(text, first)));
|
|
1813
|
+
|
|
1814
|
+
// 5. Messages from the extension: the document, the diagnostics and the Command Palette commands,
|
|
1815
|
+
// which run the same handlers as the menu.
|
|
1816
|
+
let firstImportSettled = () => {};
|
|
1817
|
+
const ready = new Promise<void>((resolve) => (firstImportSettled = resolve));
|
|
1818
|
+
window.addEventListener("message", (event) => {
|
|
1819
|
+
const message = event.data;
|
|
1820
|
+
if (message?.type === "init") void enqueueImport(message.text, true).then(firstImportSettled);
|
|
1821
|
+
if (message?.type === "update") void enqueueImport(message.text);
|
|
1822
|
+
if (message?.type === "problems") shell.setProblems({ count: message.count, errors: message.errors });
|
|
1823
|
+
if (message?.type === "command" && message.name === "fitView") viewport.fit();
|
|
1824
|
+
});
|
|
1825
|
+
vscode.postMessage({ type: "ready" });
|
|
1826
|
+
|
|
1827
|
+
// For the e2e test page (see ./testing/vscode-stub); the bundle publishes nothing on window.
|
|
1828
|
+
export const webview = { shell, modeler: collaborative, connection, ready };
|
|
1829
|
+
```
|
|
1830
|
+
|
|
1831
|
+
`zod-jitless.ts` is one line, `config({ jitless: true })` from `zod`, in a module of its own so that
|
|
1832
|
+
it runs before any module that builds a schema.
|
|
1833
|
+
|
|
1834
|
+
A modeler with a legend persists it the same way: `legend: { render, open: stored.legendOpen,
|
|
1835
|
+
onToggle: (open) => writeShellState(vscode, { legendOpen: open }) }`.
|
|
1836
|
+
|
|
1837
|
+
What the Event Storming renderer changes for the shell (the kit's `aria`, `keys`, `create` and
|
|
1838
|
+
`readOnly` cases check each one):
|
|
1839
|
+
|
|
1840
|
+
- palette entries carry `title` and `shortcut` separately (`title: "Domain event", shortcut: "E"`),
|
|
1841
|
+
not `"Domain event — E"`;
|
|
1842
|
+
- Enter or Space on a creating entry (a click with `detail === 0`) creates the sticky at the
|
|
1843
|
+
centre of the free rectangle (from `freeRectInsets`, the point `viewport.freeRectCenter()`
|
|
1844
|
+
returns), 24px further diagonally while that spot is taken, selects it, focuses the canvas and
|
|
1845
|
+
opens the label editor;
|
|
1846
|
+
- no `tabindex` on `canvas.getContainer()` (an extra, unnamed tab stop before the palette); its
|
|
1847
|
+
keyboard listens on `document` anyway;
|
|
1848
|
+
- the document keyboard returns early on `event.defaultPrevented` and for targets outside the canvas
|
|
1849
|
+
container (the zoom panel's buttons, the inspector), selects every element on Cmd/Ctrl+A, and in
|
|
1850
|
+
the webview binds no Cmd/Ctrl+Z, Shift+Z or Y (`undoRedoKeys: false`): VS Code's document history
|
|
1851
|
+
owns undo (§13.5), the shell only keeps diagram-js's `KeyboardBindings` off them;
|
|
1852
|
+
- `eventStormingReadOnly` locks every edit rule (move, resize, create, connect, delete), and the
|
|
1853
|
+
keyboard, context pad, label editor and drawing handles consult it;
|
|
1854
|
+
- its own overlays inside the canvas (the note toolbar, the colour picker) stay at `z-index` 100 or
|
|
1855
|
+
below, so the palette at 110 covers them (see Layers above);
|
|
1856
|
+
- `boardBounds.contentBounds(0)`: the tight bounds of the board, since the fit insets are the margin.
|
|
1857
|
+
|
|
1858
|
+
Document sync, echo suppression, minimal edits and undo stay in the modeler for 0.1 (see below).
|
|
1859
|
+
|
|
1860
|
+
## Out of scope for 0.1
|
|
1861
|
+
|
|
1862
|
+
0.2 candidates unless noted:
|
|
1863
|
+
|
|
1864
|
+
- **VS Code document plumbing**: two-way sync with echo suppression, minimal `WorkspaceEdit`s,
|
|
1865
|
+
undo/redo through the document's history, "Insert example" as one edit, diagnostics to the
|
|
1866
|
+
Problems view (the pill only means something with a `DiagnosticCollection`), shared webview→host
|
|
1867
|
+
messages for the pill ("Show in Problems view") and "Insert example" with their host handlers,
|
|
1868
|
+
export save dialog. The modelers keep their own code; the shell keeps diagram-js's
|
|
1869
|
+
`KeyboardBindings` off undo/redo in the IDE host, stops undo/redo keys in text fields and stops
|
|
1870
|
+
keys the canvas handled.
|
|
1871
|
+
- An ES-module webview bundle (`renderWebviewHtml` writes a classic script; a `scriptType` option).
|
|
1872
|
+
- **Web-app chrome**: primary action (`data-chrome="primary"`) with label collapse, legal line,
|
|
1873
|
+
File and Appearance menu groups, problems popover, toasts, empty-state card, drop zone, the
|
|
1874
|
+
`localStorage` mode script. `host: "web"` in 0.1 covers keys (page zoom), fit insets and the
|
|
1875
|
+
pill/legend offsets only.
|
|
1876
|
+
- **Dialog component and the shared shortcuts dialog** (§5.4, §8): an exported `cd-shell-dialog`
|
|
1877
|
+
class (panel, header with Close, body, backdrop with `grayscale(1)`, the high-contrast and
|
|
1878
|
+
forced-colours rules, the top layer through `showModal()`, focus trap and focus return), and on
|
|
1879
|
+
it `shell.showShortcutsDialog(groups: ShortcutGroup[])` with one table per group and the chips of
|
|
1880
|
+
the tooltip's `kbd`. The shell fills the View group itself from the chrome keys. New
|
|
1881
|
+
`ShellMessages`: `shortcutsTitle` ("Keyboard shortcuts"), `close` ("Close") and the group names
|
|
1882
|
+
"View", "Edit" and "Tools". Popovers (§5.4) follow on the same class. Until then a modeler meets
|
|
1883
|
+
the rules of [Modeler dialogs](#modeler-dialogs) itself; one component lets every modeler migrate
|
|
1884
|
+
once.
|
|
1885
|
+
- **Placement helper** for palette keyboard creation: the 24px diagonal steps when the centre of the
|
|
1886
|
+
free rectangle is taken (§5.5), next to `Viewport.freeRectCenter()`.
|
|
1887
|
+
- the `appearance` surface of the modeler-api contract (a later 1.x minor) for embedded hosts and
|
|
1888
|
+
the `miragon.<modeler>.colorTheme` setting UI (the bridge already honours `settingKey`).
|
|
1889
|
+
- **Kit**:
|
|
1890
|
+
- cases for reduced motion and forced colours (they need `page.emulateMedia`, which `PageLike`
|
|
1891
|
+
does not have yet); the shell's CSS covers both, the e2e suite checks VS Code's reduce motion;
|
|
1892
|
+
- a CSP helper: serve the production bundle under `renderWebviewHtml`'s CSP inside the VS Code
|
|
1893
|
+
stub, record every `securitypolicyviolation` and fail on any (until then the recipe under
|
|
1894
|
+
`./vscode/extension`);
|
|
1895
|
+
- a harness field `documents?: readonly string[]` that hands `open` a document key (for example
|
|
1896
|
+
`["example", "style-dark", "empty"]`), so `appearance` runs per document; until then a modeler
|
|
1897
|
+
registers a second harness whose `open` loads the variant;
|
|
1898
|
+
- on those documents, a check that an empty one opens at 100 % with the empty-file hint;
|
|
1899
|
+
- a check that selects an element and reports positioned descendants of `.djs-container` above
|
|
1900
|
+
`z-index` 100 that are neither the palette, diagram-js's popup menu nor `.djs-hover-tooltip`.
|
|
1901
|
+
- IntelliJ/JCEF hosts, Electron, Tauri, Theia.
|
|
1902
|
+
- Canvas theming (`cd-canvas.css`, `cd-c-*` classes), light-only export, the token files themselves.
|