@miragon/modeler-shell 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/API.md +2869 -0
  2. package/LICENSE +21 -0
  3. package/README.md +566 -2
  4. package/THIRD_PARTY_NOTICES.md +52 -0
  5. package/dist/appearance-BBaM_4bD.d.ts +323 -0
  6. package/dist/appearance-D3FUcbu6.d.cts +53 -0
  7. package/dist/appearance-D3FUcbu6.d.ts +53 -0
  8. package/dist/appearance-XX3JC3pI.d.cts +323 -0
  9. package/dist/chunk-66HCSJNY.js +2792 -0
  10. package/dist/chunk-66HCSJNY.js.map +1 -0
  11. package/dist/chunk-KYDG5DMX.js +136 -0
  12. package/dist/chunk-KYDG5DMX.js.map +1 -0
  13. package/dist/chunk-N4QXFF3A.js +23 -0
  14. package/dist/chunk-N4QXFF3A.js.map +1 -0
  15. package/dist/chunk-OADW3GPA.js +785 -0
  16. package/dist/chunk-OADW3GPA.js.map +1 -0
  17. package/dist/core.cjs +873 -0
  18. package/dist/core.cjs.map +1 -0
  19. package/dist/core.d.cts +246 -0
  20. package/dist/core.d.ts +246 -0
  21. package/dist/core.js +131 -0
  22. package/dist/core.js.map +1 -0
  23. package/dist/diagram-js.cjs +1147 -0
  24. package/dist/diagram-js.cjs.map +1 -0
  25. package/dist/diagram-js.d.cts +170 -0
  26. package/dist/diagram-js.d.ts +170 -0
  27. package/dist/diagram-js.js +879 -0
  28. package/dist/diagram-js.js.map +1 -0
  29. package/dist/index.cjs +3632 -0
  30. package/dist/index.cjs.map +1 -0
  31. package/dist/index.d.cts +29 -0
  32. package/dist/index.d.ts +29 -0
  33. package/dist/index.js +143 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/protocol-DhYBakWL.d.cts +15 -0
  36. package/dist/protocol-IeW8bf4f.d.ts +15 -0
  37. package/dist/shell.css +1603 -0
  38. package/dist/testing-vscode-stub.cjs +332 -0
  39. package/dist/testing-vscode-stub.cjs.map +1 -0
  40. package/dist/testing-vscode-stub.d.cts +65 -0
  41. package/dist/testing-vscode-stub.d.ts +65 -0
  42. package/dist/testing-vscode-stub.js +154 -0
  43. package/dist/testing-vscode-stub.js.map +1 -0
  44. package/dist/testing.cjs +4663 -0
  45. package/dist/testing.cjs.map +1 -0
  46. package/dist/testing.d.cts +231 -0
  47. package/dist/testing.d.ts +231 -0
  48. package/dist/testing.js +4483 -0
  49. package/dist/testing.js.map +1 -0
  50. package/dist/toasts-Bp0REqdq.d.cts +142 -0
  51. package/dist/toasts-Bp0REqdq.d.ts +142 -0
  52. package/dist/types-CnRrINRs.d.cts +445 -0
  53. package/dist/types-PuDZczzy.d.ts +445 -0
  54. package/dist/vscode-extension.cjs +215 -0
  55. package/dist/vscode-extension.cjs.map +1 -0
  56. package/dist/vscode-extension.d.cts +103 -0
  57. package/dist/vscode-extension.d.ts +103 -0
  58. package/dist/vscode-extension.js +24 -0
  59. package/dist/vscode-extension.js.map +1 -0
  60. package/dist/vscode-webview.cjs +3561 -0
  61. package/dist/vscode-webview.cjs.map +1 -0
  62. package/dist/vscode-webview.d.cts +84 -0
  63. package/dist/vscode-webview.d.ts +84 -0
  64. package/dist/vscode-webview.js +259 -0
  65. package/dist/vscode-webview.js.map +1 -0
  66. package/package.json +145 -4
  67. package/src/core/appearance.ts +104 -0
  68. package/src/core/feedback/appearance.ts +141 -0
  69. package/src/core/feedback/problems.ts +23 -0
  70. package/src/core/feedback/toasts.ts +118 -0
  71. package/src/core/fit.ts +220 -0
  72. package/src/core/index.ts +127 -0
  73. package/src/core/keymap.ts +295 -0
  74. package/src/core/layout.ts +69 -0
  75. package/src/core/messages.ts +159 -0
  76. package/src/core/sizes.ts +85 -0
  77. package/src/core/types.ts +106 -0
  78. package/src/core/web-chrome/bottom-row.ts +37 -0
  79. package/src/core/web-chrome/drop.ts +78 -0
  80. package/src/core/web-chrome/top-row.ts +66 -0
  81. package/src/core/zoom.ts +74 -0
  82. package/src/core.ts +2 -0
  83. package/src/diagram-js/connect.ts +75 -0
  84. package/src/diagram-js/index.ts +16 -0
  85. package/src/diagram-js/module.ts +20 -0
  86. package/src/diagram-js/palette-skin.ts +267 -0
  87. package/src/diagram-js/palette.css +269 -0
  88. package/src/diagram-js/service.ts +243 -0
  89. package/src/diagram-js/types.ts +67 -0
  90. package/src/diagram-js/viewport.ts +583 -0
  91. package/src/diagram-js.ts +2 -0
  92. package/src/dom/buttons.css +124 -0
  93. package/src/dom/element.ts +51 -0
  94. package/src/dom/feedback/appearance.ts +184 -0
  95. package/src/dom/feedback/feedback.css +435 -0
  96. package/src/dom/feedback/problems-popover.ts +287 -0
  97. package/src/dom/feedback/toasts.ts +431 -0
  98. package/src/dom/hint.ts +51 -0
  99. package/src/dom/icons.ts +309 -0
  100. package/src/dom/index.ts +42 -0
  101. package/src/dom/inspector.ts +35 -0
  102. package/src/dom/keys.ts +67 -0
  103. package/src/dom/ledger.ts +87 -0
  104. package/src/dom/legend.ts +85 -0
  105. package/src/dom/menu.ts +404 -0
  106. package/src/dom/mount.ts +731 -0
  107. package/src/dom/problems.ts +73 -0
  108. package/src/dom/shell-dom.css +969 -0
  109. package/src/dom/status.ts +72 -0
  110. package/src/dom/tooltip.ts +237 -0
  111. package/src/dom/types.ts +459 -0
  112. package/src/dom/web/drop-zone.ts +181 -0
  113. package/src/dom/web/empty-state.ts +144 -0
  114. package/src/dom/web/legal.ts +75 -0
  115. package/src/dom/web/menu-groups.ts +69 -0
  116. package/src/dom/web/primary.ts +124 -0
  117. package/src/dom/web/web.css +386 -0
  118. package/src/dom/zoom-panel.ts +154 -0
  119. package/src/index.ts +3 -0
  120. package/src/shell.css +9 -0
  121. package/src/testing/case-tools.ts +164 -0
  122. package/src/testing/cases.ts +1077 -0
  123. package/src/testing/checks.ts +868 -0
  124. package/src/testing/facts.ts +641 -0
  125. package/src/testing/geometry.ts +652 -0
  126. package/src/testing/harness.ts +187 -0
  127. package/src/testing/index.ts +19 -0
  128. package/src/testing/page-tools.ts +457 -0
  129. package/src/testing/page.ts +27 -0
  130. package/src/testing/vscode-stub.ts +237 -0
  131. package/src/testing/web-cases.ts +913 -0
  132. package/src/testing/web-checks.ts +1049 -0
  133. package/src/testing/web-facts.ts +713 -0
  134. package/src/testing-vscode-stub.ts +2 -0
  135. package/src/testing.ts +2 -0
  136. package/src/vscode/extension/html.ts +107 -0
  137. package/src/vscode/extension/index.ts +8 -0
  138. package/src/vscode/extension/reveal.ts +9 -0
  139. package/src/vscode/extension/theme-bridge.ts +85 -0
  140. package/src/vscode/extension/types.ts +36 -0
  141. package/src/vscode/protocol.ts +31 -0
  142. package/src/vscode/webview/appearance.ts +141 -0
  143. package/src/vscode/webview/index.ts +8 -0
  144. package/src/vscode/webview/mount.ts +88 -0
  145. package/src/vscode/webview/state.ts +116 -0
  146. package/src/vscode-extension.ts +2 -0
  147. package/src/vscode-webview.ts +2 -0
@@ -0,0 +1,104 @@
1
+ /** The modes a Miragon modeler can show (guide §13.3). */
2
+ export type Theme = "light" | "dark";
3
+
4
+ /** High contrast; written as `data-cd-kontrast="hoch"` on the same element as `data-cd-theme`. */
5
+ export type Kontrast = "hoch";
6
+
7
+ export interface Appearance {
8
+ theme: Theme;
9
+ kontrast?: Kontrast | undefined;
10
+ }
11
+
12
+ /**
13
+ * The one appearance decision of guide §13.5: a theme the modeler cannot show is never shown
14
+ * (`themes[0]` wins, so `["light"]` stays light whatever the host requests); `kontrast` always
15
+ * passes through. Throws when `themes` is empty.
16
+ */
17
+ export function resolveAppearance(requested: Appearance, themes: readonly Theme[]): Appearance {
18
+ const [fallback] = themes;
19
+ if (fallback === undefined) {
20
+ throw new Error(
21
+ "@miragon/modeler-shell: resolveAppearance needs at least one theme the modeler can show",
22
+ );
23
+ }
24
+ const theme = themes.includes(requested.theme) ? requested.theme : fallback;
25
+ return withKontrast(theme, requested.kontrast);
26
+ }
27
+
28
+ // Appearances arrive through postMessage and settings, so only the one known value passes.
29
+ function withKontrast(theme: Theme, kontrast: unknown): Appearance {
30
+ return kontrast === "hoch" ? { theme, kontrast } : { theme };
31
+ }
32
+
33
+ /**
34
+ * The appearance VS Code requests for `vscode.window.activeColorTheme.kind`:
35
+ * 1 Light → light, 2 Dark → dark, 3 HighContrast → dark + hoch, 4 HighContrastLight → light + hoch;
36
+ * any other value → light.
37
+ */
38
+ export function appearanceFromColorThemeKind(kind: number): Appearance {
39
+ switch (kind) {
40
+ case 2:
41
+ return { theme: "dark" };
42
+ case 3:
43
+ return { theme: "dark", kontrast: "hoch" };
44
+ case 4:
45
+ return { theme: "light", kontrast: "hoch" };
46
+ default:
47
+ return { theme: "light" };
48
+ }
49
+ }
50
+
51
+ /** The values of the setting `miragon.<modeler>.colorTheme` (it ships once a modeler can show dark). */
52
+ export type ColorThemeSetting = "automatic" | "light" | "dark";
53
+
54
+ /**
55
+ * The requested appearance in an IDE host: the setting's theme unless it is `automatic` (or
56
+ * missing), the contrast always from the color theme kind.
57
+ */
58
+ export function requestedAppearance(kind: number, setting?: ColorThemeSetting | undefined): Appearance {
59
+ const fromKind = appearanceFromColorThemeKind(kind);
60
+ const theme = setting === "light" || setting === "dark" ? setting : fromKind.theme;
61
+ return withKontrast(theme, fromKind.kontrast);
62
+ }
63
+
64
+ /**
65
+ * The fallback signal inside a VS Code webview: the body classes `vscode-high-contrast-light`
66
+ * (checked first, VS Code adds `vscode-high-contrast` to it as well), `vscode-high-contrast`,
67
+ * `vscode-dark`, `vscode-light`. `undefined` when none is set. Never use them for styling.
68
+ */
69
+ export function appearanceFromBodyClasses(classes: Iterable<string>): Appearance | undefined {
70
+ const present = new Set(classes);
71
+ if (present.has("vscode-high-contrast-light")) return { theme: "light", kontrast: "hoch" };
72
+ if (present.has("vscode-high-contrast")) return { theme: "dark", kontrast: "hoch" };
73
+ if (present.has("vscode-dark")) return { theme: "dark" };
74
+ if (present.has("vscode-light")) return { theme: "light" };
75
+ return undefined;
76
+ }
77
+
78
+ /**
79
+ * VS Code's `workbench.reduceMotion` as the webview sees it: the body class `vscode-reduce-motion`
80
+ * (the webview host page sets it). Never use it for styling; the shell mirrors it as
81
+ * `REDUCED_MOTION_CLASS` on `<html>`.
82
+ */
83
+ export function reducedMotionFromBodyClasses(classes: Iterable<string>): boolean {
84
+ return new Set(classes).has("vscode-reduce-motion");
85
+ }
86
+
87
+ /**
88
+ * On `<html>` while the host asks for reduced motion although the OS does not (VS Code's
89
+ * `workbench.reduceMotion`): the shell's animations stop as under `prefers-reduced-motion`, and an
90
+ * explicit fit jumps.
91
+ */
92
+ export const REDUCED_MOTION_CLASS = "cd-shell-reduce-motion";
93
+
94
+ export interface AppearanceAttributes {
95
+ "data-cd-theme": Theme;
96
+ "data-cd-kontrast"?: Kontrast;
97
+ }
98
+
99
+ /** The attributes for `<html>` (own document) or the modeler container (foreign page). */
100
+ export function appearanceAttributes(appearance: Appearance): AppearanceAttributes {
101
+ return appearance.kontrast === "hoch"
102
+ ? { "data-cd-theme": appearance.theme, "data-cd-kontrast": "hoch" }
103
+ : { "data-cd-theme": appearance.theme };
104
+ }
@@ -0,0 +1,141 @@
1
+ import type { Theme } from "../appearance.js";
2
+
3
+ /** The web app's mode choice (guide §13.3): Appearance, System · Light · Dark. */
4
+ export type AppearanceChoice = "system" | "light" | "dark";
5
+
6
+ /** Where the web app keeps the choice: `light` or `dark`; "System" removes the entry (guide §13.3). */
7
+ export const APPEARANCE_STORAGE_KEY = "miragon.darstellung";
8
+
9
+ /**
10
+ * The two `theme-color` values (guide §11, §13.3): light is the app's own value (`#335DE5`, `fest`
11
+ * `--cd-blau` of the vendored `cd-tokens.generated.json`), dark `#1D1D1D` (`dunkel`
12
+ * `--cd-ui-grund`). The modeler reads both from its vendored JSON; the shell keeps no colour.
13
+ */
14
+ export interface ThemeColors {
15
+ light: string;
16
+ dark: string;
17
+ }
18
+
19
+ /** `"light"` and `"dark"` pass, everything else (a missing entry, a foreign value) is `"system"`. */
20
+ export function parseAppearanceChoice(stored: unknown): AppearanceChoice {
21
+ return stored === "light" || stored === "dark" ? stored : "system";
22
+ }
23
+
24
+ /** What `<html>` and the meta tags carry for a choice. */
25
+ export interface DocumentMode {
26
+ /** `data-cd-theme` on `<html>`. */
27
+ theme: "system" | Theme;
28
+ /** `<meta name="color-scheme" content>`. */
29
+ colorScheme: "light dark" | Theme;
30
+ /** The `content` of the `theme-color` metas with `media="(prefers-color-scheme: light|dark)"`. */
31
+ themeColor: ThemeColors;
32
+ }
33
+
34
+ /**
35
+ * The document mode for `choice` (guide §13.3). A modeler that cannot show dark (`themes` without
36
+ * `"dark"`) is `light` whatever the choice: theme `light`, color-scheme `light`, both theme
37
+ * colours `colors.light`. Dark-capable: `system` → theme `system`, color-scheme `light dark`,
38
+ * `colors` as they are; `light`/`dark` → that theme and color-scheme, both theme colours the
39
+ * chosen mode's, so the browser bar matches a fixed choice under the other system mode.
40
+ */
41
+ export function documentMode(
42
+ choice: AppearanceChoice,
43
+ themes: readonly Theme[],
44
+ colors: ThemeColors,
45
+ ): DocumentMode {
46
+ if (!themes.includes("dark")) return fixedMode("light", colors.light);
47
+ const parsed = parseAppearanceChoice(choice);
48
+ if (parsed === "system") {
49
+ return {
50
+ theme: "system",
51
+ colorScheme: "light dark",
52
+ themeColor: { light: colors.light, dark: colors.dark },
53
+ };
54
+ }
55
+ return fixedMode(parsed, colors[parsed]);
56
+ }
57
+
58
+ function fixedMode(theme: Theme, color: string): DocumentMode {
59
+ return { theme, colorScheme: theme, themeColor: { light: color, dark: color } };
60
+ }
61
+
62
+ export interface WebModeScriptOptions {
63
+ /** The modes the modeler can show, as passed to `mountShell`. */
64
+ themes: readonly Theme[];
65
+ themeColors: ThemeColors;
66
+ }
67
+
68
+ /**
69
+ * The body of the inline `<script>` in `<head>`, before the first stylesheet (guide §13.3). With
70
+ * `themes` including `"dark"`: the guide's script, which reads `APPEARANCE_STORAGE_KEY` inside
71
+ * `try`/`catch`, writes `data-cd-theme`, `color-scheme` and both `theme-color` metas for a fixed
72
+ * choice and sets `data-cd-kontrast="hoch"` under `(prefers-contrast: more)`. Light-only: the
73
+ * contrast line alone. Plain ES5 without the modeler's bundle, so it runs before the first paint.
74
+ */
75
+ export function webModeScript(options: WebModeScriptOptions): string {
76
+ const contrast = [
77
+ `if (window.matchMedia && window.matchMedia(${scriptString("(prefers-contrast: more)")}).matches)`,
78
+ ` document.documentElement.setAttribute("data-cd-kontrast", "hoch");`,
79
+ ];
80
+ if (!options.themes.includes("dark")) return contrast.join("\n");
81
+ const { light, dark } = options.themeColors;
82
+ return [
83
+ "(function () {",
84
+ " var html = document.documentElement;",
85
+ " var choice = null;",
86
+ " try {",
87
+ ` choice = window.localStorage.getItem(${scriptString(APPEARANCE_STORAGE_KEY)});`,
88
+ " } catch (error) {}",
89
+ ` if (choice === "light" || choice === "dark") {`,
90
+ ` var color = choice === "dark" ? ${scriptString(dark)} : ${scriptString(light)};`,
91
+ ` html.setAttribute("data-cd-theme", choice);`,
92
+ ` var scheme = document.querySelector("meta[name=color-scheme]");`,
93
+ ` if (scheme) scheme.setAttribute("content", choice);`,
94
+ ` var metas = document.querySelectorAll("meta[name=theme-color]");`,
95
+ ` for (var index = 0; index < metas.length; index++) metas[index].setAttribute("content", color);`,
96
+ " }",
97
+ ...contrast.map((line) => ` ${line}`),
98
+ "})();",
99
+ ].join("\n");
100
+ }
101
+
102
+ // The script sits inline in <head>: "<" never appears in a literal, so a value cannot end the
103
+ // element ("</script>"), and U+2028/U+2029 are escaped because ES5 forbids them in string literals.
104
+ function scriptString(value: string): string {
105
+ return JSON.stringify(value)
106
+ .replace(/</g, "\\u003c")
107
+ .replace(/\u2028/g, "\\u2028")
108
+ .replace(/\u2029/g, "\\u2029");
109
+ }
110
+
111
+ /**
112
+ * The `<head>` markup the web app's `index.html` carries for its mode, as one string (for a Vite
113
+ * `transformIndexHtml` hook or to paste): dark-capable `<meta name="color-scheme" content="light
114
+ * dark">`, two `theme-color` metas with `media` and the script; light-only one `theme-color`
115
+ * without `media` and the contrast script. `<html>` itself carries `lang` and
116
+ * `data-cd-theme="<documentMode("system", themes, colors).theme>"` statically.
117
+ */
118
+ export function webHeadMarkup(options: WebModeScriptOptions): string {
119
+ const mode = documentMode("system", options.themes, options.themeColors);
120
+ const script = `<script>\n${webModeScript(options)}\n</script>`;
121
+ if (mode.theme !== "system") {
122
+ return [`<meta name="theme-color" content="${escapeAttribute(mode.themeColor.light)}">`, script].join(
123
+ "\n",
124
+ );
125
+ }
126
+ return [
127
+ `<meta name="color-scheme" content="${escapeAttribute(mode.colorScheme)}">`,
128
+ `<meta name="theme-color" content="${escapeAttribute(mode.themeColor.light)}" media="(prefers-color-scheme: light)">`,
129
+ `<meta name="theme-color" content="${escapeAttribute(mode.themeColor.dark)}" media="(prefers-color-scheme: dark)">`,
130
+ script,
131
+ ].join("\n");
132
+ }
133
+
134
+ function escapeAttribute(value: string): string {
135
+ return value
136
+ .replace(/&/g, "&amp;")
137
+ .replace(/"/g, "&quot;")
138
+ .replace(/'/g, "&#39;")
139
+ .replace(/</g, "&lt;")
140
+ .replace(/>/g, "&gt;");
141
+ }
@@ -0,0 +1,23 @@
1
+ /** The severity of one problem; the pill and the list show `circle-x` or `triangle-alert` (guide §5.10). */
2
+ export type ProblemSeverity = "error" | "warning";
3
+
4
+ /** One row of the web app's problems list (guide §5.10). */
5
+ export interface ProblemItem {
6
+ severity: ProblemSeverity;
7
+ /** 13px `--cd-ui-text`: "Unknown sticky type “evnt”." */
8
+ message: string;
9
+ /** The place in 13px `--cd-ui-text-leise` below the message: "Line 12", "“Order Placed”". */
10
+ location?: string | undefined;
11
+ /** The diagram element a click on the row selects (`ProblemsOptions.onSelectItem`). */
12
+ elementId?: string | undefined;
13
+ }
14
+
15
+ /** `ShellHandle.setProblems` input built from the items: count, errors and the items themselves. */
16
+ export function problemSummary(items: readonly ProblemItem[]): {
17
+ count: number;
18
+ errors: number;
19
+ items: readonly ProblemItem[];
20
+ } {
21
+ const errors = items.filter((item) => item.severity === "error").length;
22
+ return { count: items.length, errors, items };
23
+ }
@@ -0,0 +1,118 @@
1
+ import { SHELL } from "../sizes.js";
2
+
3
+ /** The statuses of guide §3.3 and §9; each colours bar and dot through `--cd-ui-<status>`. */
4
+ export type ToastStatus = "success" | "info" | "warning" | "danger";
5
+
6
+ /** A ghost button before Close (guide §8); the toast then never closes by itself. */
7
+ export interface ToastAction {
8
+ label: string;
9
+ /** Runs after the toast closed. */
10
+ onSelect(): void;
11
+ }
12
+
13
+ export interface ToastRequest {
14
+ status: ToastStatus;
15
+ /** What happened and what you can do (guide §2): "Link copied", "Couldn't read this file. …". */
16
+ text: string;
17
+ action?: ToastAction | undefined;
18
+ /** Only to give a long text more time than `TOAST_DURATION_MS`; ignored with an `action`. */
19
+ durationMs?: number | undefined;
20
+ }
21
+
22
+ /** At most three toasts at once, the newest at the bottom (guide §8). */
23
+ export const MAX_TOASTS = 3;
24
+
25
+ /**
26
+ * How long a toast stays without hover or focus (guide §8: about 2.8 s, errors about 5 s). A
27
+ * warning asks you to read a sentence and act on it like an error, so it gets the error's 5 s.
28
+ */
29
+ export const TOAST_DURATION_MS: Readonly<Record<ToastStatus, number>> = {
30
+ success: 2800,
31
+ info: 2800,
32
+ warning: 5000,
33
+ danger: 5000,
34
+ };
35
+
36
+ /** The stack's widest toast: `min(480px, 100cqw − 24px)` (guide §8). */
37
+ export const TOAST_MAX_WIDTH = 480;
38
+
39
+ /**
40
+ * Milliseconds until the toast closes by itself: `undefined` with an `action` (it never closes by
41
+ * itself, guide §8), else `max(TOAST_DURATION_MS[status], durationMs ?? 0)`.
42
+ */
43
+ export function toastLifetime(request: ToastRequest): number | undefined {
44
+ if (request.action) return undefined;
45
+ const requested = request.durationMs;
46
+ // A NaN or an infinite duration would reach setTimeout, which fires at once for both.
47
+ const longer = requested !== undefined && Number.isFinite(requested) ? requested : 0;
48
+ return Math.max(TOAST_DURATION_MS[request.status], longer);
49
+ }
50
+
51
+ /** A toast in the stack as the eviction sees it, oldest first. */
52
+ export interface ToastSlot {
53
+ id: number;
54
+ hasAction: boolean;
55
+ /** Hovered or holding the focus. */
56
+ engaged: boolean;
57
+ }
58
+
59
+ /**
60
+ * Which toast leaves when a fourth arrives (`MAX_TOASTS`): the oldest that is neither engaged nor
61
+ * has an action, else the oldest that is not engaged, else the oldest. `undefined` while the
62
+ * stack has room.
63
+ */
64
+ export function toastToEvict(stack: readonly ToastSlot[]): number | undefined {
65
+ if (stack.length < MAX_TOASTS) return undefined;
66
+ const evicted =
67
+ stack.find((slot) => !slot.engaged && !slot.hasAction) ?? stack.find((slot) => !slot.engaged) ?? stack[0];
68
+ return evicted?.id;
69
+ }
70
+
71
+ /** Something in the bottom row the toast stack must clear, relative to the shell root. */
72
+ export interface BottomObstacle {
73
+ left: number;
74
+ right: number;
75
+ /** Distance of its top edge from the container's bottom edge. */
76
+ top: number;
77
+ }
78
+
79
+ export interface ToastStackInput {
80
+ containerWidth: number;
81
+ /** The visible zoom panel, legal line and problems pill (not the legend card). */
82
+ obstacles: readonly BottomObstacle[];
83
+ /**
84
+ * Measured width of the widest toast in the stack. The cards are sized to their text and centred
85
+ * in the stack, so only this column can touch an obstacle; absent = the whole `toastStackWidth`.
86
+ */
87
+ cardWidth?: number | undefined;
88
+ }
89
+
90
+ /** `min(TOAST_MAX_WIDTH, containerWidth − 2 · SHELL.edge)`. */
91
+ export function toastStackWidth(containerWidth: number): number {
92
+ return Math.max(0, Math.min(TOAST_MAX_WIDTH, containerWidth - 2 * SHELL.edge));
93
+ }
94
+
95
+ /**
96
+ * The stack's `bottom` in px. Guide §8 anchors it at `bottom: 12px`, centred; where the column of
97
+ * its widest card (`cardWidth`, at most `toastStackWidth`, centred) comes closer than `SHELL.gap`
98
+ * horizontally to an obstacle, the stack stands `SHELL.gap` above the highest such obstacle instead
99
+ * (above the legal line 40, above the zoom panel 60, above a raised zoom panel or the web pill 88).
100
+ * Guide §8 does not settle this collision of a narrow container; it is the shell's rule until it does.
101
+ */
102
+ export function toastStackBottom(input: ToastStackInput): number {
103
+ const stackWidth = toastStackWidth(input.containerWidth);
104
+ const { cardWidth } = input;
105
+ const width =
106
+ cardWidth !== undefined && Number.isFinite(cardWidth)
107
+ ? Math.min(stackWidth, Math.max(0, cardWidth))
108
+ : stackWidth;
109
+ const left = (input.containerWidth - width) / 2;
110
+ const right = left + width;
111
+ let highest: number | undefined;
112
+ for (const obstacle of input.obstacles) {
113
+ const distance = Math.max(obstacle.left - right, left - obstacle.right);
114
+ if (distance >= SHELL.gap) continue;
115
+ highest = highest === undefined ? obstacle.top : Math.max(highest, obstacle.top);
116
+ }
117
+ return highest === undefined ? SHELL.edge : Math.max(SHELL.edge, highest + SHELL.gap);
118
+ }
@@ -0,0 +1,220 @@
1
+ import { SHELL } from "./sizes.js";
2
+ import { LEGAL_LINE_HEIGHT, legalLineHeight } from "./web-chrome/bottom-row.js";
3
+ import type { Bounds, Insets, PaletteRow, Point, ScaledViewbox, ShellHost, Size } from "./types.js";
4
+
5
+ /** Fit never zooms above 100 % (guide §5.9). */
6
+ export const MAX_FIT_ZOOM = 1;
7
+
8
+ /** Duration of the explicit, animated fit (`--cd-motion-base`). */
9
+ export const FIT_ANIMATION_MS = 250;
10
+
11
+ /**
12
+ * The viewbox that shows `bounds` centred in the free rectangle (`container` minus `insets`) at the
13
+ * largest scale that fits, capped at `maxZoom`. The box has the container's aspect ratio, so
14
+ * diagram-js's `canvas.viewbox(box)` sets exactly this view. Returns `undefined` when the container
15
+ * is 0 × 0 or `bounds` has neither width nor height; a zero width or height scales by the other
16
+ * dimension. Where the insets leave no free room on an axis (a container lower or narrower than
17
+ * its chrome), that axis centres in the whole container instead.
18
+ */
19
+ export function fitViewbox(
20
+ bounds: Bounds,
21
+ container: Size,
22
+ insets: Insets,
23
+ maxZoom: number = MAX_FIT_ZOOM,
24
+ ): ScaledViewbox | undefined {
25
+ if (!(container.width > 0 && container.height > 0)) return undefined;
26
+ const hasWidth = bounds.width > 0;
27
+ const hasHeight = bounds.height > 0;
28
+ if (!hasWidth && !hasHeight) return undefined;
29
+
30
+ const horizontal = freeExtent(container.width, insets.left, insets.right);
31
+ const vertical = freeExtent(container.height, insets.top, insets.bottom);
32
+ const contentWidth = hasWidth ? bounds.width : 0;
33
+ const contentHeight = hasHeight ? bounds.height : 0;
34
+ const scale = Math.min(
35
+ maxZoom,
36
+ hasWidth ? horizontal.extent / contentWidth : Infinity,
37
+ hasHeight ? vertical.extent / contentHeight : Infinity,
38
+ );
39
+ const box: ScaledViewbox = {
40
+ x: bounds.x + contentWidth / 2 - (horizontal.start + horizontal.extent / 2) / scale,
41
+ y: bounds.y + contentHeight / 2 - (vertical.start + vertical.extent / 2) / scale,
42
+ width: container.width / scale,
43
+ height: container.height / scale,
44
+ scale,
45
+ };
46
+ return Object.values(box).every(Number.isFinite) && scale > 0 ? box : undefined;
47
+ }
48
+
49
+ function freeExtent(total: number, before: number, after: number): { start: number; extent: number } {
50
+ const extent = total - before - after;
51
+ return extent > 0 ? { start: before, extent } : { start: 0, extent: total };
52
+ }
53
+
54
+ /**
55
+ * The centre of the free rectangle (`container` minus `insets`) in pixels from the container's top
56
+ * left, where `fitViewbox` centres the content and keyboard creation places an element (guide
57
+ * §5.5). An axis the insets leave no room on centres in the whole container, as in `fitViewbox`.
58
+ * Returns `undefined` when the container is 0 × 0.
59
+ */
60
+ export function freeRectCenter(container: Size, insets: Insets): Point | undefined {
61
+ if (!(container.width > 0 && container.height > 0)) return undefined;
62
+ const horizontal = freeExtent(container.width, insets.left, insets.right);
63
+ const vertical = freeExtent(container.height, insets.top, insets.bottom);
64
+ return {
65
+ x: horizontal.start + horizontal.extent / 2,
66
+ y: vertical.start + vertical.extent / 2,
67
+ };
68
+ }
69
+
70
+ /** What is open or shown when a fit runs (guide §5.9: every fit counts what is open right now). */
71
+ export interface FitLayout {
72
+ container: Size;
73
+ host: ShellHost;
74
+ paletteRow: PaletteRow;
75
+ /** The problems pill is shown (only the web app reserves room for it). */
76
+ problemsVisible: boolean;
77
+ /**
78
+ * Web app: the zoom panel stands above the legal line (`bottom: 40px`) because the two would
79
+ * meet in the bottom row of a narrow container (`zoomRowRaised`); the bottom then keeps 92px as
80
+ * with the pill.
81
+ */
82
+ zoomRaised?: boolean | undefined;
83
+ /**
84
+ * Web app: the measured height of the legal line (`LEGAL_LINE_HEIGHT`, more where a narrow
85
+ * container wraps it); the raised bottom row stands on it. Default one line.
86
+ */
87
+ legalHeight?: number | undefined;
88
+ /** Rendered width of the open legend; `undefined` when it is closed or the modeler has none. */
89
+ legendWidth?: number | undefined;
90
+ /**
91
+ * The open inspector; `undefined` when closed. Above `SHELL.narrow` it is the side panel; up to it
92
+ * a bottom sheet whose top edge (`sheetTop`, px from the container top) must be measured.
93
+ */
94
+ inspector?: { sheetTop?: number | undefined } | undefined;
95
+ }
96
+
97
+ /**
98
+ * The fit insets of guide §5.9: top 88 (second row 140), sides 24, bottom 64 (web app with pill or
99
+ * with the zoom panel raised above the legal line 92, plus 20 per extra line of a wrapped legal line),
100
+ * right 324 with an open side inspector, left 12 + legend width + 24 with an open legend above
101
+ * `SHELL.veryNarrow`, bottom = distance to the sheet's top edge + 12 with an open bottom sheet.
102
+ */
103
+ export function fitInsets(layout: FitLayout): Insets {
104
+ const { container, host, paletteRow, problemsVisible, zoomRaised, legendWidth, inspector } = layout;
105
+ const raisedBottomRow = host === "web" && (problemsVisible || zoomRaised === true);
106
+ const wrappedLegalLines = legalLineHeight(layout.legalHeight) - LEGAL_LINE_HEIGHT;
107
+ const insets: Insets = {
108
+ top: paletteRow === "second-row" ? SHELL.fitInsetTopSecondRow : SHELL.fitInsetTop,
109
+ right: SHELL.fitInsetSide,
110
+ bottom: raisedBottomRow ? SHELL.fitInsetBottomWeb + wrappedLegalLines : SHELL.fitInsetBottom,
111
+ left: SHELL.fitInsetSide,
112
+ };
113
+
114
+ const bottomSheetOpen = inspector !== undefined && container.width <= SHELL.narrow;
115
+ if (inspector !== undefined && !bottomSheetOpen) {
116
+ insets.right = SHELL.edge + SHELL.inspectorWidth + SHELL.fitInsetSide;
117
+ }
118
+ if (bottomSheetOpen) {
119
+ // Guide §5.11: the sheet hides zoom panel, pill and legend, so only the sheet itself counts.
120
+ insets.bottom =
121
+ inspector.sheetTop === undefined
122
+ ? SHELL.fitInsetBottom
123
+ : container.height - inspector.sheetTop + SHELL.edge;
124
+ } else if (legendWidth !== undefined && legendWidth > 0 && container.width > SHELL.veryNarrow) {
125
+ insets.left = SHELL.edge + legendWidth + SHELL.fitInsetSide;
126
+ }
127
+ return insets;
128
+ }
129
+
130
+ /** Relative tolerance of `sameView` (diagram-js rounds the transform to three decimals). */
131
+ export const VIEW_TOLERANCE = 1e-3;
132
+
133
+ /**
134
+ * True when two views are the same within `tolerance`: the scale and the translation in screen
135
+ * pixels (`x · scale`, `y · scale`) agree relative to `max(1, |value|)`. Width and height are
136
+ * ignored, so a container resize is never a difference.
137
+ */
138
+ export function sameView(
139
+ a: Pick<ScaledViewbox, "x" | "y" | "scale">,
140
+ b: Pick<ScaledViewbox, "x" | "y" | "scale">,
141
+ tolerance: number = VIEW_TOLERANCE,
142
+ ): boolean {
143
+ const close = (first: number, second: number): boolean =>
144
+ Math.abs(first - second) <= tolerance * Math.max(1, Math.abs(first), Math.abs(second));
145
+ return (
146
+ close(a.scale, b.scale) && close(a.x * a.scale, b.x * b.scale) && close(a.y * a.scale, b.y * b.scale)
147
+ );
148
+ }
149
+
150
+ /**
151
+ * The view at `progress` (0…1) between two viewboxes of the same container: the scale is
152
+ * interpolated in log space, the centre linearly. Used per animation frame by the animated fit.
153
+ */
154
+ export function interpolateViewbox(from: ScaledViewbox, to: ScaledViewbox, progress: number): ScaledViewbox {
155
+ if (!(progress > 0)) return { ...from };
156
+ if (progress >= 1) return { ...to };
157
+ const linear = (start: number, end: number): number => start + (end - start) * progress;
158
+ const scale =
159
+ from.scale > 0 && to.scale > 0
160
+ ? Math.exp(linear(Math.log(from.scale), Math.log(to.scale)))
161
+ : linear(from.scale, to.scale);
162
+ const centreX = linear(from.x + from.width / 2, to.x + to.width / 2);
163
+ const centreY = linear(from.y + from.height / 2, to.y + to.height / 2);
164
+ const width = linear(from.width * from.scale, to.width * to.scale) / scale;
165
+ const height = linear(from.height * from.scale, to.height * to.scale) / scale;
166
+ return { x: centreX - width / 2, y: centreY - height / 2, width, height, scale };
167
+ }
168
+
169
+ /** `--cd-ease`, `cubic-bezier(0.4, 0, 0.2, 1)`, as a function of time 0…1. */
170
+ export function easeStandard(progress: number): number {
171
+ return STANDARD_EASING(progress);
172
+ }
173
+
174
+ const STANDARD_EASING = cubicBezier(0.4, 0, 0.2, 1);
175
+
176
+ /** A CSS `cubic-bezier(x1, y1, x2, y2)` timing function (`x1`, `x2` within 0…1, so x(t) is monotone). */
177
+ function cubicBezier(x1: number, y1: number, x2: number, y2: number): (progress: number) => number {
178
+ const polynomial = (first: number, second: number) => {
179
+ const c = 3 * first;
180
+ const b = 3 * (second - first) - c;
181
+ const a = 1 - c - b;
182
+ return {
183
+ at: (t: number): number => ((a * t + b) * t + c) * t,
184
+ slope: (t: number): number => (3 * a * t + 2 * b) * t + c,
185
+ };
186
+ };
187
+ const curveX = polynomial(x1, x2);
188
+ const curveY = polynomial(y1, y2);
189
+ const precision = 1e-7;
190
+
191
+ const parameterFor = (x: number): number => {
192
+ let t = x;
193
+ for (let iteration = 0; iteration < 8; iteration++) {
194
+ const error = curveX.at(t) - x;
195
+ if (Math.abs(error) < precision) return t;
196
+ const slope = curveX.slope(t);
197
+ if (Math.abs(slope) < 1e-6) break;
198
+ t -= error / slope;
199
+ if (t < 0 || t > 1) break;
200
+ }
201
+ // Newton left the curve or stalled on a flat stretch: bisection always converges.
202
+ let low = 0;
203
+ let high = 1;
204
+ t = x;
205
+ for (let iteration = 0; iteration < 60; iteration++) {
206
+ const value = curveX.at(t);
207
+ if (Math.abs(value - x) < precision) break;
208
+ if (value < x) low = t;
209
+ else high = t;
210
+ t = (low + high) / 2;
211
+ }
212
+ return t;
213
+ };
214
+
215
+ return (progress) => {
216
+ if (!(progress > 0)) return 0;
217
+ if (progress >= 1) return 1;
218
+ return curveY.at(parameterFor(progress));
219
+ };
220
+ }