@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/src/core/fit.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { SHELL } from "./sizes.js";
|
|
2
|
+
import type { Bounds, Insets, PaletteRow, Point, ScaledViewbox, ShellHost, Size } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** Fit never zooms above 100 % (guide §5.9). */
|
|
5
|
+
export const MAX_FIT_ZOOM = 1;
|
|
6
|
+
|
|
7
|
+
/** Duration of the explicit, animated fit (`--cd-motion-base`). */
|
|
8
|
+
export const FIT_ANIMATION_MS = 250;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The viewbox that shows `bounds` centred in the free rectangle (`container` minus `insets`) at the
|
|
12
|
+
* largest scale that fits, capped at `maxZoom`. The box has the container's aspect ratio, so
|
|
13
|
+
* diagram-js's `canvas.viewbox(box)` sets exactly this view. Returns `undefined` when the container
|
|
14
|
+
* is 0 × 0 or `bounds` has neither width nor height; a zero width or height scales by the other
|
|
15
|
+
* dimension. Where the insets leave no free room on an axis (a container lower or narrower than
|
|
16
|
+
* its chrome), that axis centres in the whole container instead.
|
|
17
|
+
*/
|
|
18
|
+
export function fitViewbox(
|
|
19
|
+
bounds: Bounds,
|
|
20
|
+
container: Size,
|
|
21
|
+
insets: Insets,
|
|
22
|
+
maxZoom: number = MAX_FIT_ZOOM,
|
|
23
|
+
): ScaledViewbox | undefined {
|
|
24
|
+
if (!(container.width > 0 && container.height > 0)) return undefined;
|
|
25
|
+
const hasWidth = bounds.width > 0;
|
|
26
|
+
const hasHeight = bounds.height > 0;
|
|
27
|
+
if (!hasWidth && !hasHeight) return undefined;
|
|
28
|
+
|
|
29
|
+
const horizontal = freeExtent(container.width, insets.left, insets.right);
|
|
30
|
+
const vertical = freeExtent(container.height, insets.top, insets.bottom);
|
|
31
|
+
const contentWidth = hasWidth ? bounds.width : 0;
|
|
32
|
+
const contentHeight = hasHeight ? bounds.height : 0;
|
|
33
|
+
const scale = Math.min(
|
|
34
|
+
maxZoom,
|
|
35
|
+
hasWidth ? horizontal.extent / contentWidth : Infinity,
|
|
36
|
+
hasHeight ? vertical.extent / contentHeight : Infinity,
|
|
37
|
+
);
|
|
38
|
+
const box: ScaledViewbox = {
|
|
39
|
+
x: bounds.x + contentWidth / 2 - (horizontal.start + horizontal.extent / 2) / scale,
|
|
40
|
+
y: bounds.y + contentHeight / 2 - (vertical.start + vertical.extent / 2) / scale,
|
|
41
|
+
width: container.width / scale,
|
|
42
|
+
height: container.height / scale,
|
|
43
|
+
scale,
|
|
44
|
+
};
|
|
45
|
+
return Object.values(box).every(Number.isFinite) && scale > 0 ? box : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function freeExtent(total: number, before: number, after: number): { start: number; extent: number } {
|
|
49
|
+
const extent = total - before - after;
|
|
50
|
+
return extent > 0 ? { start: before, extent } : { start: 0, extent: total };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The centre of the free rectangle (`container` minus `insets`) in pixels from the container's top
|
|
55
|
+
* left, where `fitViewbox` centres the content and keyboard creation places an element (guide
|
|
56
|
+
* §5.5). An axis the insets leave no room on centres in the whole container, as in `fitViewbox`.
|
|
57
|
+
* Returns `undefined` when the container is 0 × 0.
|
|
58
|
+
*/
|
|
59
|
+
export function freeRectCenter(container: Size, insets: Insets): Point | undefined {
|
|
60
|
+
if (!(container.width > 0 && container.height > 0)) return undefined;
|
|
61
|
+
const horizontal = freeExtent(container.width, insets.left, insets.right);
|
|
62
|
+
const vertical = freeExtent(container.height, insets.top, insets.bottom);
|
|
63
|
+
return {
|
|
64
|
+
x: horizontal.start + horizontal.extent / 2,
|
|
65
|
+
y: vertical.start + vertical.extent / 2,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What is open or shown when a fit runs (guide §5.9: every fit counts what is open right now). */
|
|
70
|
+
export interface FitLayout {
|
|
71
|
+
container: Size;
|
|
72
|
+
host: ShellHost;
|
|
73
|
+
paletteRow: PaletteRow;
|
|
74
|
+
/** The problems pill is shown (only the web app reserves room for it). */
|
|
75
|
+
problemsVisible: boolean;
|
|
76
|
+
/** Rendered width of the open legend; `undefined` when it is closed or the modeler has none. */
|
|
77
|
+
legendWidth?: number | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* The open inspector; `undefined` when closed. Above `SHELL.narrow` it is the side panel; up to it
|
|
80
|
+
* a bottom sheet whose top edge (`sheetTop`, px from the container top) must be measured.
|
|
81
|
+
*/
|
|
82
|
+
inspector?: { sheetTop?: number | undefined } | undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The fit insets of guide §5.9: top 88 (second row 140), sides 24, bottom 64 (web app with pill 92),
|
|
87
|
+
* right 324 with an open side inspector, left 12 + legend width + 24 with an open legend above
|
|
88
|
+
* `SHELL.veryNarrow`, bottom = distance to the sheet's top edge + 12 with an open bottom sheet.
|
|
89
|
+
*/
|
|
90
|
+
export function fitInsets(layout: FitLayout): Insets {
|
|
91
|
+
const { container, host, paletteRow, problemsVisible, legendWidth, inspector } = layout;
|
|
92
|
+
const insets: Insets = {
|
|
93
|
+
top: paletteRow === "second-row" ? SHELL.fitInsetTopSecondRow : SHELL.fitInsetTop,
|
|
94
|
+
right: SHELL.fitInsetSide,
|
|
95
|
+
bottom: host === "web" && problemsVisible ? SHELL.fitInsetBottomWeb : SHELL.fitInsetBottom,
|
|
96
|
+
left: SHELL.fitInsetSide,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const bottomSheetOpen = inspector !== undefined && container.width <= SHELL.narrow;
|
|
100
|
+
if (inspector !== undefined && !bottomSheetOpen) {
|
|
101
|
+
insets.right = SHELL.edge + SHELL.inspectorWidth + SHELL.fitInsetSide;
|
|
102
|
+
}
|
|
103
|
+
if (bottomSheetOpen) {
|
|
104
|
+
// Guide §5.11: the sheet hides zoom panel, pill and legend, so only the sheet itself counts.
|
|
105
|
+
insets.bottom =
|
|
106
|
+
inspector.sheetTop === undefined
|
|
107
|
+
? SHELL.fitInsetBottom
|
|
108
|
+
: container.height - inspector.sheetTop + SHELL.edge;
|
|
109
|
+
} else if (legendWidth !== undefined && legendWidth > 0 && container.width > SHELL.veryNarrow) {
|
|
110
|
+
insets.left = SHELL.edge + legendWidth + SHELL.fitInsetSide;
|
|
111
|
+
}
|
|
112
|
+
return insets;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Relative tolerance of `sameView` (diagram-js rounds the transform to three decimals). */
|
|
116
|
+
export const VIEW_TOLERANCE = 1e-3;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* True when two views are the same within `tolerance`: the scale and the translation in screen
|
|
120
|
+
* pixels (`x · scale`, `y · scale`) agree relative to `max(1, |value|)`. Width and height are
|
|
121
|
+
* ignored, so a container resize is never a difference.
|
|
122
|
+
*/
|
|
123
|
+
export function sameView(
|
|
124
|
+
a: Pick<ScaledViewbox, "x" | "y" | "scale">,
|
|
125
|
+
b: Pick<ScaledViewbox, "x" | "y" | "scale">,
|
|
126
|
+
tolerance: number = VIEW_TOLERANCE,
|
|
127
|
+
): boolean {
|
|
128
|
+
const close = (first: number, second: number): boolean =>
|
|
129
|
+
Math.abs(first - second) <= tolerance * Math.max(1, Math.abs(first), Math.abs(second));
|
|
130
|
+
return (
|
|
131
|
+
close(a.scale, b.scale) && close(a.x * a.scale, b.x * b.scale) && close(a.y * a.scale, b.y * b.scale)
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The view at `progress` (0…1) between two viewboxes of the same container: the scale is
|
|
137
|
+
* interpolated in log space, the centre linearly. Used per animation frame by the animated fit.
|
|
138
|
+
*/
|
|
139
|
+
export function interpolateViewbox(from: ScaledViewbox, to: ScaledViewbox, progress: number): ScaledViewbox {
|
|
140
|
+
if (!(progress > 0)) return { ...from };
|
|
141
|
+
if (progress >= 1) return { ...to };
|
|
142
|
+
const linear = (start: number, end: number): number => start + (end - start) * progress;
|
|
143
|
+
const scale =
|
|
144
|
+
from.scale > 0 && to.scale > 0
|
|
145
|
+
? Math.exp(linear(Math.log(from.scale), Math.log(to.scale)))
|
|
146
|
+
: linear(from.scale, to.scale);
|
|
147
|
+
const centreX = linear(from.x + from.width / 2, to.x + to.width / 2);
|
|
148
|
+
const centreY = linear(from.y + from.height / 2, to.y + to.height / 2);
|
|
149
|
+
const width = linear(from.width * from.scale, to.width * to.scale) / scale;
|
|
150
|
+
const height = linear(from.height * from.scale, to.height * to.scale) / scale;
|
|
151
|
+
return { x: centreX - width / 2, y: centreY - height / 2, width, height, scale };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `--cd-ease`, `cubic-bezier(0.4, 0, 0.2, 1)`, as a function of time 0…1. */
|
|
155
|
+
export function easeStandard(progress: number): number {
|
|
156
|
+
return STANDARD_EASING(progress);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const STANDARD_EASING = cubicBezier(0.4, 0, 0.2, 1);
|
|
160
|
+
|
|
161
|
+
/** A CSS `cubic-bezier(x1, y1, x2, y2)` timing function (`x1`, `x2` within 0…1, so x(t) is monotone). */
|
|
162
|
+
function cubicBezier(x1: number, y1: number, x2: number, y2: number): (progress: number) => number {
|
|
163
|
+
const polynomial = (first: number, second: number) => {
|
|
164
|
+
const c = 3 * first;
|
|
165
|
+
const b = 3 * (second - first) - c;
|
|
166
|
+
const a = 1 - c - b;
|
|
167
|
+
return {
|
|
168
|
+
at: (t: number): number => ((a * t + b) * t + c) * t,
|
|
169
|
+
slope: (t: number): number => (3 * a * t + 2 * b) * t + c,
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
const curveX = polynomial(x1, x2);
|
|
173
|
+
const curveY = polynomial(y1, y2);
|
|
174
|
+
const precision = 1e-7;
|
|
175
|
+
|
|
176
|
+
const parameterFor = (x: number): number => {
|
|
177
|
+
let t = x;
|
|
178
|
+
for (let iteration = 0; iteration < 8; iteration++) {
|
|
179
|
+
const error = curveX.at(t) - x;
|
|
180
|
+
if (Math.abs(error) < precision) return t;
|
|
181
|
+
const slope = curveX.slope(t);
|
|
182
|
+
if (Math.abs(slope) < 1e-6) break;
|
|
183
|
+
t -= error / slope;
|
|
184
|
+
if (t < 0 || t > 1) break;
|
|
185
|
+
}
|
|
186
|
+
// Newton left the curve or stalled on a flat stretch: bisection always converges.
|
|
187
|
+
let low = 0;
|
|
188
|
+
let high = 1;
|
|
189
|
+
t = x;
|
|
190
|
+
for (let iteration = 0; iteration < 60; iteration++) {
|
|
191
|
+
const value = curveX.at(t);
|
|
192
|
+
if (Math.abs(value - x) < precision) break;
|
|
193
|
+
if (value < x) low = t;
|
|
194
|
+
else high = t;
|
|
195
|
+
t = (low + high) / 2;
|
|
196
|
+
}
|
|
197
|
+
return t;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return (progress) => {
|
|
201
|
+
if (!(progress > 0)) return 0;
|
|
202
|
+
if (progress >= 1) return 1;
|
|
203
|
+
return curveY.at(parameterFor(progress));
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
Bounds,
|
|
3
|
+
ChromeIsland,
|
|
4
|
+
Insets,
|
|
5
|
+
PaletteRow,
|
|
6
|
+
Point,
|
|
7
|
+
ScaledViewbox,
|
|
8
|
+
ShellHost,
|
|
9
|
+
Size,
|
|
10
|
+
ViewportState,
|
|
11
|
+
ZoomTarget,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
export { CHROME_ISLANDS } from "./types.js";
|
|
14
|
+
|
|
15
|
+
export type { ShellSizeName, ShellSizes } from "./sizes.js";
|
|
16
|
+
export { SHELL, SHELL_TOKENS, shellSizesFromTokens } from "./sizes.js";
|
|
17
|
+
|
|
18
|
+
export type { FitLayout } from "./fit.js";
|
|
19
|
+
export {
|
|
20
|
+
FIT_ANIMATION_MS,
|
|
21
|
+
MAX_FIT_ZOOM,
|
|
22
|
+
VIEW_TOLERANCE,
|
|
23
|
+
easeStandard,
|
|
24
|
+
fitInsets,
|
|
25
|
+
fitViewbox,
|
|
26
|
+
freeRectCenter,
|
|
27
|
+
interpolateViewbox,
|
|
28
|
+
sameView,
|
|
29
|
+
} from "./fit.js";
|
|
30
|
+
|
|
31
|
+
export type { ZoomReadout } from "./zoom.js";
|
|
32
|
+
export {
|
|
33
|
+
ZOOM_MAX,
|
|
34
|
+
ZOOM_MIN,
|
|
35
|
+
canZoomIn,
|
|
36
|
+
canZoomOut,
|
|
37
|
+
formatNumber,
|
|
38
|
+
formatPercent,
|
|
39
|
+
zoomReadout,
|
|
40
|
+
} from "./zoom.js";
|
|
41
|
+
|
|
42
|
+
export type {
|
|
43
|
+
ChromeKey,
|
|
44
|
+
KeyAction,
|
|
45
|
+
KeyContext,
|
|
46
|
+
KeyDecision,
|
|
47
|
+
KeyLike,
|
|
48
|
+
NavigatorLike,
|
|
49
|
+
Platform,
|
|
50
|
+
} from "./keymap.js";
|
|
51
|
+
export {
|
|
52
|
+
TEXT_FIELD_SELECTOR,
|
|
53
|
+
ariaKeyShortcuts,
|
|
54
|
+
ariaKeyShortcutsFromLabel,
|
|
55
|
+
decideKey,
|
|
56
|
+
detectPlatform,
|
|
57
|
+
isUndoRedoKey,
|
|
58
|
+
matchChromeKey,
|
|
59
|
+
shortcutLabel,
|
|
60
|
+
} from "./keymap.js";
|
|
61
|
+
|
|
62
|
+
export type { Appearance, AppearanceAttributes, ColorThemeSetting, Kontrast, Theme } from "./appearance.js";
|
|
63
|
+
export {
|
|
64
|
+
REDUCED_MOTION_CLASS,
|
|
65
|
+
appearanceAttributes,
|
|
66
|
+
appearanceFromBodyClasses,
|
|
67
|
+
appearanceFromColorThemeKind,
|
|
68
|
+
reducedMotionFromBodyClasses,
|
|
69
|
+
requestedAppearance,
|
|
70
|
+
resolveAppearance,
|
|
71
|
+
} from "./appearance.js";
|
|
72
|
+
|
|
73
|
+
export type { ShellMessageOverrides, ShellMessages } from "./messages.js";
|
|
74
|
+
export { defaultMessages, resolveMessages } from "./messages.js";
|
|
75
|
+
|
|
76
|
+
export type { PaletteRowInput, TooltipPlacementInput } from "./layout.js";
|
|
77
|
+
export {
|
|
78
|
+
INVALID_STATUS_DELAY_MS,
|
|
79
|
+
PALETTE_CLEARANCE,
|
|
80
|
+
TOOLTIP_DELAY_MS,
|
|
81
|
+
decidePaletteRow,
|
|
82
|
+
legendStartsOpen,
|
|
83
|
+
placeTooltip,
|
|
84
|
+
} from "./layout.js";
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import type { ShellMessages } from "./messages.js";
|
|
2
|
+
import type { ShellHost } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** The only keys the chrome handles (guide §5.8). */
|
|
5
|
+
export type ChromeKey = "zoomIn" | "zoomOut" | "zoomToActual" | "fit";
|
|
6
|
+
|
|
7
|
+
/** The `KeyboardEvent` fields the keymap reads. */
|
|
8
|
+
export interface KeyLike {
|
|
9
|
+
key: string;
|
|
10
|
+
code: string;
|
|
11
|
+
metaKey: boolean;
|
|
12
|
+
ctrlKey: boolean;
|
|
13
|
+
shiftKey: boolean;
|
|
14
|
+
altKey: boolean;
|
|
15
|
+
isComposing?: boolean | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Which chrome key `event` is, ignoring where it was pressed:
|
|
20
|
+
* - `zoomIn`: Cmd/Ctrl + `+` or `=` (by `key`, with or without Shift), or `NumpadAdd` (by `code`);
|
|
21
|
+
* - `zoomOut`: Cmd/Ctrl + `-` or `_`, or `NumpadSubtract`;
|
|
22
|
+
* - `zoomToActual`: Cmd/Ctrl + `0`, or `Numpad0` (also with NumLock off);
|
|
23
|
+
* - `fit`: Shift + `Digit1` (by `code`, every layout) without Cmd, Ctrl or Alt.
|
|
24
|
+
* Cmd/Ctrl is `metaKey || ctrlKey` on every platform; nothing matches with `altKey` (AltGr).
|
|
25
|
+
*/
|
|
26
|
+
export function matchChromeKey(event: KeyLike): ChromeKey | undefined {
|
|
27
|
+
if (event.altKey) return undefined;
|
|
28
|
+
if (isCommand(event)) {
|
|
29
|
+
if (event.key === "+" || event.key === "=" || event.code === "NumpadAdd") return "zoomIn";
|
|
30
|
+
if (event.key === "-" || event.key === "_" || event.code === "NumpadSubtract") return "zoomOut";
|
|
31
|
+
if (event.key === "0" || event.code === "Numpad0") return "zoomToActual";
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return event.shiftKey && event.code === "Digit1" ? "fit" : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isCommand(event: KeyLike): boolean {
|
|
38
|
+
return event.metaKey || event.ctrlKey;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Cmd/Ctrl + Z, Cmd/Ctrl + Shift + Z or Cmd/Ctrl + Y, without Alt (by `key`, `code` as fallback).
|
|
43
|
+
* Cmd/Ctrl + Shift + Y is not redo (VS Code toggles its Debug Console with it).
|
|
44
|
+
*/
|
|
45
|
+
export function isUndoRedoKey(event: KeyLike): boolean {
|
|
46
|
+
if (event.altKey || !isCommand(event)) return false;
|
|
47
|
+
const letter = undoRedoLetter(event);
|
|
48
|
+
return letter === "z" || (letter === "y" && !event.shiftKey);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The produced character decides on Latin layouts (QWERTZ swaps the Y and Z keys); only where the
|
|
52
|
+
// layout produces no ASCII character (Cyrillic, Greek, …) the physical key stands in.
|
|
53
|
+
function undoRedoLetter(event: KeyLike): string | undefined {
|
|
54
|
+
if (event.key.length === 1 && event.key.charCodeAt(0) < 0x80) return event.key.toLowerCase();
|
|
55
|
+
if (event.code === "KeyZ") return "z";
|
|
56
|
+
if (event.code === "KeyY") return "y";
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A text field: every input, select list and editable text (label editor, inspector, dialogs). */
|
|
61
|
+
export const TEXT_FIELD_SELECTOR =
|
|
62
|
+
"input, textarea, select, [contenteditable]:not([contenteditable='false'])";
|
|
63
|
+
|
|
64
|
+
/** Where a key was pressed, as the shell's one `keydown` listener sees it. */
|
|
65
|
+
export interface KeyContext {
|
|
66
|
+
host: ShellHost;
|
|
67
|
+
/** The event target matches `TEXT_FIELD_SELECTOR` or is `isContentEditable`. */
|
|
68
|
+
inTextField: boolean;
|
|
69
|
+
/** The shell's menu dropdown is open. */
|
|
70
|
+
menuOpen: boolean;
|
|
71
|
+
/** A popover or dialog (`[role="dialog"]`, modal or not) is open. */
|
|
72
|
+
dialogOpen: boolean;
|
|
73
|
+
/** A modal dialog (`[role="dialog"][aria-modal="true"]`) is open: chrome keys do not act. */
|
|
74
|
+
modalOpen: boolean;
|
|
75
|
+
/** `event.defaultPrevented` when the listener ran: the canvas (or the modeler) handled the key. */
|
|
76
|
+
defaultPrevented: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type KeyAction = ChromeKey | "closeMenu";
|
|
80
|
+
|
|
81
|
+
export interface KeyDecision {
|
|
82
|
+
/** What the shell executes; `undefined` = nothing. */
|
|
83
|
+
action?: KeyAction;
|
|
84
|
+
preventDefault: boolean;
|
|
85
|
+
stopPropagation: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The key rules of guide §5.8 and §13.5 in one place:
|
|
90
|
+
* 1. Escape with the menu open: `closeMenu`, prevented; in the IDE host stopped whenever the menu
|
|
91
|
+
* or a dialog/popover is open. During IME composition Escape belongs to the IME: no `closeMenu`.
|
|
92
|
+
* 2. Zoom keys: executed and prevented unless composing, in a text field or with a modal dialog;
|
|
93
|
+
* in the IDE host always stopped, also when not executed (VS Code must never zoom the window).
|
|
94
|
+
* 3. Shift+1: `fit` under the same conditions; otherwise untouched (it types "!").
|
|
95
|
+
* 4. IDE host, text field, undo/redo key: stopped only (the field keeps its native undo).
|
|
96
|
+
* 5. IDE host, `defaultPrevented`: stopped (the canvas handled it; VS Code must not run it again).
|
|
97
|
+
* Everything else passes untouched. `stopPropagation` is only ever true in the IDE host.
|
|
98
|
+
*/
|
|
99
|
+
export function decideKey(event: KeyLike, context: KeyContext): KeyDecision {
|
|
100
|
+
const stop = context.host === "ide";
|
|
101
|
+
const composing = event.isComposing === true;
|
|
102
|
+
|
|
103
|
+
if (event.key === "Escape" && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) {
|
|
104
|
+
if (context.menuOpen && !composing) {
|
|
105
|
+
return { action: "closeMenu", preventDefault: true, stopPropagation: stop };
|
|
106
|
+
}
|
|
107
|
+
if (context.menuOpen || context.dialogOpen) return { preventDefault: false, stopPropagation: stop };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const chromeKey = matchChromeKey(event);
|
|
111
|
+
if (chromeKey !== undefined) {
|
|
112
|
+
const blocked = composing || context.inTextField || context.modalOpen;
|
|
113
|
+
if (!blocked) return { action: chromeKey, preventDefault: true, stopPropagation: stop };
|
|
114
|
+
// Blocked zoom keys are still kept from VS Code (it would zoom the window); a blocked Shift+1
|
|
115
|
+
// is a character and travels on like any other.
|
|
116
|
+
return { preventDefault: false, stopPropagation: chromeKey === "fit" ? false : stop };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (context.inTextField && isUndoRedoKey(event)) return { preventDefault: false, stopPropagation: stop };
|
|
120
|
+
if (context.defaultPrevented) return { preventDefault: false, stopPropagation: stop };
|
|
121
|
+
return { preventDefault: false, stopPropagation: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The keyboard the user sits at; decides the shortcut labels (guide §5.8). */
|
|
125
|
+
export type Platform = "mac" | "other";
|
|
126
|
+
|
|
127
|
+
/** The `navigator` fields `detectPlatform` reads. */
|
|
128
|
+
export interface NavigatorLike {
|
|
129
|
+
platform?: string | undefined;
|
|
130
|
+
userAgentData?: { platform?: string | undefined } | undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `mac` when `userAgentData.platform ?? platform` matches `/mac/i`, else `other`; `other` without a
|
|
135
|
+
* navigator. Read it in the webview or page itself, never from the extension host (VS Code Remote).
|
|
136
|
+
*/
|
|
137
|
+
export function detectPlatform(navigator?: NavigatorLike | undefined): Platform {
|
|
138
|
+
// An empty userAgentData.platform means "unknown", not "not a Mac": fall back to navigator.platform.
|
|
139
|
+
const platform = navigator?.userAgentData?.platform || navigator?.platform || "";
|
|
140
|
+
return /mac/i.test(platform) ? "mac" : "other";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The tooltip chip text: macOS `⌘+`, `⌘−` (U+2212), `⌘0`, `⇧1`; elsewhere `Ctrl+Plus`,
|
|
145
|
+
* `Ctrl+Minus`, `Ctrl+0`, `Shift+1` with the key names of the messages table.
|
|
146
|
+
*/
|
|
147
|
+
export function shortcutLabel(
|
|
148
|
+
key: ChromeKey,
|
|
149
|
+
platform: Platform,
|
|
150
|
+
keyNames: ShellMessages["keyNames"],
|
|
151
|
+
): string {
|
|
152
|
+
if (platform === "mac") return MAC_SHORTCUT_LABELS[key];
|
|
153
|
+
switch (key) {
|
|
154
|
+
case "zoomIn":
|
|
155
|
+
return `${keyNames.ctrl}+${keyNames.plus}`;
|
|
156
|
+
case "zoomOut":
|
|
157
|
+
return `${keyNames.ctrl}+${keyNames.minus}`;
|
|
158
|
+
case "zoomToActual":
|
|
159
|
+
return `${keyNames.ctrl}+0`;
|
|
160
|
+
case "fit":
|
|
161
|
+
return `${keyNames.shift}+1`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const MAC_SHORTCUT_LABELS: Readonly<Record<ChromeKey, string>> = {
|
|
166
|
+
zoomIn: "⌘+",
|
|
167
|
+
zoomOut: "⌘\u2212",
|
|
168
|
+
zoomToActual: "⌘0",
|
|
169
|
+
fit: "⇧1",
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The `aria-keyshortcuts` value (WAI-ARIA names): zoom in `Meta+= Meta+Plus`, zoom out `Meta+-`,
|
|
174
|
+
* 100 % `Meta+0`, fit `Shift+1`; outside macOS `Control` instead of `Meta`.
|
|
175
|
+
*/
|
|
176
|
+
export function ariaKeyShortcuts(key: ChromeKey, platform: Platform): string {
|
|
177
|
+
const command = platform === "mac" ? "Meta" : "Control";
|
|
178
|
+
switch (key) {
|
|
179
|
+
case "zoomIn":
|
|
180
|
+
return `${command}+= ${command}+Plus`;
|
|
181
|
+
case "zoomOut":
|
|
182
|
+
return `${command}+-`;
|
|
183
|
+
case "zoomToActual":
|
|
184
|
+
return `${command}+0`;
|
|
185
|
+
case "fit":
|
|
186
|
+
return "Shift+1";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const MAC_MODIFIER_SYMBOLS: Readonly<Record<string, string>> = {
|
|
191
|
+
"⌘": "Meta",
|
|
192
|
+
"⇧": "Shift",
|
|
193
|
+
"⌥": "Alt",
|
|
194
|
+
"⌃": "Control",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const MODIFIER_NAMES: Readonly<Record<string, string>> = {
|
|
198
|
+
cmd: "Meta",
|
|
199
|
+
command: "Meta",
|
|
200
|
+
meta: "Meta",
|
|
201
|
+
ctrl: "Control",
|
|
202
|
+
control: "Control",
|
|
203
|
+
strg: "Control",
|
|
204
|
+
shift: "Shift",
|
|
205
|
+
umschalt: "Shift",
|
|
206
|
+
alt: "Alt",
|
|
207
|
+
option: "Alt",
|
|
208
|
+
opt: "Alt",
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const NAMED_KEYS: Readonly<Record<string, string>> = {
|
|
212
|
+
esc: "Escape",
|
|
213
|
+
escape: "Escape",
|
|
214
|
+
del: "Delete",
|
|
215
|
+
delete: "Delete",
|
|
216
|
+
entf: "Delete",
|
|
217
|
+
backspace: "Backspace",
|
|
218
|
+
enter: "Enter",
|
|
219
|
+
return: "Enter",
|
|
220
|
+
tab: "Tab",
|
|
221
|
+
space: "Space",
|
|
222
|
+
home: "Home",
|
|
223
|
+
end: "End",
|
|
224
|
+
pageup: "PageUp",
|
|
225
|
+
pagedown: "PageDown",
|
|
226
|
+
arrowup: "ArrowUp",
|
|
227
|
+
arrowdown: "ArrowDown",
|
|
228
|
+
arrowleft: "ArrowLeft",
|
|
229
|
+
arrowright: "ArrowRight",
|
|
230
|
+
"↑": "ArrowUp",
|
|
231
|
+
"↓": "ArrowDown",
|
|
232
|
+
"←": "ArrowLeft",
|
|
233
|
+
"→": "ArrowRight",
|
|
234
|
+
plus: "Plus",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function ariaKeyName(key: string): string | undefined {
|
|
238
|
+
const named = NAMED_KEYS[key.toLowerCase()];
|
|
239
|
+
if (named) return named;
|
|
240
|
+
if (/^f\d{1,2}$/i.test(key)) return key.toUpperCase();
|
|
241
|
+
if (key.length !== 1 || MAC_MODIFIER_SYMBOLS[key]) return undefined;
|
|
242
|
+
if (key === "+") return "Plus";
|
|
243
|
+
if (key === " ") return "Space";
|
|
244
|
+
return key.toUpperCase();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The `aria-keyshortcuts` value (WAI-ARIA names, guide §5.6) for a shortcut chip label: `"L"` →
|
|
249
|
+
* `"L"`, `"⇧L"` → `"Shift+L"`, `"⌘⇧Z"` → `"Meta+Shift+Z"`, `"Ctrl+Shift+E"` → `"Control+Shift+E"`,
|
|
250
|
+
* `"Del"` → `"Delete"`, `"Ctrl++"` → `"Control+Plus"`. `undefined` for a label that is not one key
|
|
251
|
+
* combination (`"G then D"`, `"1–9"`, empty).
|
|
252
|
+
*/
|
|
253
|
+
export function ariaKeyShortcutsFromLabel(label: string): string | undefined {
|
|
254
|
+
let rest = label.trim();
|
|
255
|
+
const modifiers: string[] = [];
|
|
256
|
+
for (let symbol = MAC_MODIFIER_SYMBOLS[rest.charAt(0)]; symbol && rest.length > 1;) {
|
|
257
|
+
modifiers.push(symbol);
|
|
258
|
+
rest = rest.slice(1).trim();
|
|
259
|
+
symbol = MAC_MODIFIER_SYMBOLS[rest.charAt(0)];
|
|
260
|
+
}
|
|
261
|
+
if (!rest || /\s/.test(rest.replace(/\s*\+\s*/g, "+"))) return undefined;
|
|
262
|
+
const parts = rest.split("+").map((part) => part.trim());
|
|
263
|
+
let key = parts.pop() ?? "";
|
|
264
|
+
if (key === "" && parts.at(-1) === "") {
|
|
265
|
+
parts.pop();
|
|
266
|
+
key = "+";
|
|
267
|
+
}
|
|
268
|
+
for (const part of parts) {
|
|
269
|
+
const modifier = MODIFIER_NAMES[part.toLowerCase()];
|
|
270
|
+
if (!modifier) return undefined;
|
|
271
|
+
modifiers.push(modifier);
|
|
272
|
+
}
|
|
273
|
+
const name = ariaKeyName(key);
|
|
274
|
+
return name ? [...new Set(modifiers), name].join("+") : undefined;
|
|
275
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { SHELL } from "./sizes.js";
|
|
2
|
+
import type { Bounds, PaletteRow, Point, Size } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** Free space the top row needs between the palette and a neighbouring island (guide §5.15). */
|
|
5
|
+
export const PALETTE_CLEARANCE = 16;
|
|
6
|
+
|
|
7
|
+
/** Hover delay of the chrome tooltip, equal to diagram-js `HoverTooltip` `SHOW_DELAY`. */
|
|
8
|
+
export const TOOLTIP_DELAY_MS = 300;
|
|
9
|
+
|
|
10
|
+
/** The status box for an invalid file shows after this long without a valid import (guide §5.12). */
|
|
11
|
+
export const INVALID_STATUS_DELAY_MS = 800;
|
|
12
|
+
|
|
13
|
+
export interface PaletteRowInput {
|
|
14
|
+
containerWidth: number;
|
|
15
|
+
/**
|
|
16
|
+
* The palette's intrinsic border-box width (`scrollWidth` plus its borders), so the decision does
|
|
17
|
+
* not flip once the second row clips it.
|
|
18
|
+
*/
|
|
19
|
+
paletteWidth: number;
|
|
20
|
+
/** Right edge of the menu trigger, relative to the shell root. */
|
|
21
|
+
menuRight: number;
|
|
22
|
+
/** Left edge of the primary action (web app), relative to the shell root; absent in IDE hosts. */
|
|
23
|
+
primaryLeft?: number | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `second-row` when the container is at most `SHELL.narrow` wide or a centred palette would keep
|
|
28
|
+
* less than `PALETTE_CLEARANCE` to the menu trigger (or the primary action); `top` otherwise.
|
|
29
|
+
*/
|
|
30
|
+
export function decidePaletteRow(input: PaletteRowInput): PaletteRow {
|
|
31
|
+
const { containerWidth, paletteWidth, menuRight, primaryLeft } = input;
|
|
32
|
+
if (containerWidth <= SHELL.narrow) return "second-row";
|
|
33
|
+
const paletteLeft = (containerWidth - paletteWidth) / 2;
|
|
34
|
+
const paletteRight = (containerWidth + paletteWidth) / 2;
|
|
35
|
+
if (paletteLeft < menuRight + PALETTE_CLEARANCE) return "second-row";
|
|
36
|
+
if (primaryLeft !== undefined && paletteRight > primaryLeft - PALETTE_CLEARANCE) return "second-row";
|
|
37
|
+
return "top";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The legend starts open unless the container is at most `SHELL.low` high (guide §5.10). */
|
|
41
|
+
export function legendStartsOpen(containerHeight: number): boolean {
|
|
42
|
+
return containerHeight > SHELL.low;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TooltipPlacementInput {
|
|
46
|
+
/** The hovered or focused control, relative to the shell root (horizontal centre). */
|
|
47
|
+
anchor: Bounds;
|
|
48
|
+
/** The island the control sits in, relative to the shell root (vertical placement). */
|
|
49
|
+
island: Bounds;
|
|
50
|
+
tooltip: Size;
|
|
51
|
+
container: Size;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The tooltip's top-left corner relative to the shell root (guide §5.6): below an island in the
|
|
56
|
+
* upper half (`island.bottom + 8`: top row 60, second row 112), above one in the lower half
|
|
57
|
+
* (`island.top − 8 − height`: bottom row ends at `bottom: 60px`); centred on the anchor, shifted to
|
|
58
|
+
* keep `SHELL.edge` to the container's sides.
|
|
59
|
+
*/
|
|
60
|
+
export function placeTooltip(input: TooltipPlacementInput): Point {
|
|
61
|
+
const { anchor, island, tooltip, container } = input;
|
|
62
|
+
const inUpperHalf = island.y + island.height / 2 < container.height / 2;
|
|
63
|
+
const y = inUpperHalf ? island.y + island.height + SHELL.gap : island.y - SHELL.gap - tooltip.height;
|
|
64
|
+
const centred = anchor.x + anchor.width / 2 - tooltip.width / 2;
|
|
65
|
+
const rightmost = container.width - SHELL.edge - tooltip.width;
|
|
66
|
+
// In a container narrower than the tooltip plus both edges, the left edge wins.
|
|
67
|
+
const x = Math.max(SHELL.edge, Math.min(centred, rightmost));
|
|
68
|
+
return { x, y };
|
|
69
|
+
}
|