@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.
- package/API.md +2869 -0
- package/LICENSE +21 -0
- package/README.md +566 -2
- package/THIRD_PARTY_NOTICES.md +52 -0
- package/dist/appearance-BBaM_4bD.d.ts +323 -0
- package/dist/appearance-D3FUcbu6.d.cts +53 -0
- package/dist/appearance-D3FUcbu6.d.ts +53 -0
- package/dist/appearance-XX3JC3pI.d.cts +323 -0
- package/dist/chunk-66HCSJNY.js +2792 -0
- package/dist/chunk-66HCSJNY.js.map +1 -0
- package/dist/chunk-KYDG5DMX.js +136 -0
- package/dist/chunk-KYDG5DMX.js.map +1 -0
- package/dist/chunk-N4QXFF3A.js +23 -0
- package/dist/chunk-N4QXFF3A.js.map +1 -0
- package/dist/chunk-OADW3GPA.js +785 -0
- package/dist/chunk-OADW3GPA.js.map +1 -0
- package/dist/core.cjs +873 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +246 -0
- package/dist/core.d.ts +246 -0
- package/dist/core.js +131 -0
- package/dist/core.js.map +1 -0
- package/dist/diagram-js.cjs +1147 -0
- package/dist/diagram-js.cjs.map +1 -0
- package/dist/diagram-js.d.cts +170 -0
- package/dist/diagram-js.d.ts +170 -0
- package/dist/diagram-js.js +879 -0
- package/dist/diagram-js.js.map +1 -0
- package/dist/index.cjs +3632 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +29 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +143 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol-DhYBakWL.d.cts +15 -0
- package/dist/protocol-IeW8bf4f.d.ts +15 -0
- package/dist/shell.css +1603 -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 +4663 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +231 -0
- package/dist/testing.d.ts +231 -0
- package/dist/testing.js +4483 -0
- package/dist/testing.js.map +1 -0
- package/dist/toasts-Bp0REqdq.d.cts +142 -0
- package/dist/toasts-Bp0REqdq.d.ts +142 -0
- package/dist/types-CnRrINRs.d.cts +445 -0
- package/dist/types-PuDZczzy.d.ts +445 -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 +3561 -0
- package/dist/vscode-webview.cjs.map +1 -0
- package/dist/vscode-webview.d.cts +84 -0
- package/dist/vscode-webview.d.ts +84 -0
- package/dist/vscode-webview.js +259 -0
- package/dist/vscode-webview.js.map +1 -0
- package/package.json +145 -4
- package/src/core/appearance.ts +104 -0
- package/src/core/feedback/appearance.ts +141 -0
- package/src/core/feedback/problems.ts +23 -0
- package/src/core/feedback/toasts.ts +118 -0
- package/src/core/fit.ts +220 -0
- package/src/core/index.ts +127 -0
- package/src/core/keymap.ts +295 -0
- package/src/core/layout.ts +69 -0
- package/src/core/messages.ts +159 -0
- package/src/core/sizes.ts +85 -0
- package/src/core/types.ts +106 -0
- package/src/core/web-chrome/bottom-row.ts +37 -0
- package/src/core/web-chrome/drop.ts +78 -0
- package/src/core/web-chrome/top-row.ts +66 -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 +243 -0
- package/src/diagram-js/types.ts +67 -0
- package/src/diagram-js/viewport.ts +583 -0
- package/src/diagram-js.ts +2 -0
- package/src/dom/buttons.css +124 -0
- package/src/dom/element.ts +51 -0
- package/src/dom/feedback/appearance.ts +184 -0
- package/src/dom/feedback/feedback.css +435 -0
- package/src/dom/feedback/problems-popover.ts +287 -0
- package/src/dom/feedback/toasts.ts +431 -0
- package/src/dom/hint.ts +51 -0
- package/src/dom/icons.ts +309 -0
- package/src/dom/index.ts +42 -0
- package/src/dom/inspector.ts +35 -0
- package/src/dom/keys.ts +67 -0
- package/src/dom/ledger.ts +87 -0
- package/src/dom/legend.ts +85 -0
- package/src/dom/menu.ts +404 -0
- package/src/dom/mount.ts +731 -0
- package/src/dom/problems.ts +73 -0
- package/src/dom/shell-dom.css +969 -0
- package/src/dom/status.ts +72 -0
- package/src/dom/tooltip.ts +237 -0
- package/src/dom/types.ts +459 -0
- package/src/dom/web/drop-zone.ts +181 -0
- package/src/dom/web/empty-state.ts +144 -0
- package/src/dom/web/legal.ts +75 -0
- package/src/dom/web/menu-groups.ts +69 -0
- package/src/dom/web/primary.ts +124 -0
- package/src/dom/web/web.css +386 -0
- package/src/dom/zoom-panel.ts +154 -0
- package/src/index.ts +3 -0
- package/src/shell.css +9 -0
- package/src/testing/case-tools.ts +164 -0
- package/src/testing/cases.ts +1077 -0
- package/src/testing/checks.ts +868 -0
- package/src/testing/facts.ts +641 -0
- package/src/testing/geometry.ts +652 -0
- package/src/testing/harness.ts +187 -0
- package/src/testing/index.ts +19 -0
- package/src/testing/page-tools.ts +457 -0
- package/src/testing/page.ts +27 -0
- package/src/testing/vscode-stub.ts +237 -0
- package/src/testing/web-cases.ts +913 -0
- package/src/testing/web-checks.ts +1049 -0
- package/src/testing/web-facts.ts +713 -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 +88 -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/testing.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { appearanceAttributes, type Appearance } from "../../core/index.js";
|
|
2
|
+
|
|
3
|
+
export interface WebviewHtmlOptions {
|
|
4
|
+
/** `webview.cspSource` */
|
|
5
|
+
cspSource: string;
|
|
6
|
+
/** The bundle, `webview.asWebviewUri(…).toString()`. */
|
|
7
|
+
scriptUri: string;
|
|
8
|
+
/** Stylesheets in load order (token CSS first, see API.md). */
|
|
9
|
+
styleUris?: readonly string[] | undefined;
|
|
10
|
+
/** `<title>` */
|
|
11
|
+
title: string;
|
|
12
|
+
/** The tool's UI language for `<html lang>`; English modelers pass `"en"`. */
|
|
13
|
+
lang: string;
|
|
14
|
+
/** Resolved appearance (`currentAppearance`), written before the first paint. */
|
|
15
|
+
appearance: Appearance;
|
|
16
|
+
/** Markup inside `<body>` before the script; default `<div id="app"></div>`. */
|
|
17
|
+
body?: string | undefined;
|
|
18
|
+
/** Script nonce; default `createNonce()`. */
|
|
19
|
+
nonce?: string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Allow inline styles (`style-src … 'unsafe-inline'`), default `true`: renderers write `style`
|
|
22
|
+
* attributes into markup (palette entries, SVG strings). The shell and diagram-js themselves
|
|
23
|
+
* only style through the CSSOM and run without it; pass `false` once the renderer does too.
|
|
24
|
+
*/
|
|
25
|
+
inlineStyles?: boolean | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const NONCE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
29
|
+
const NONCE_LENGTH = 32;
|
|
30
|
+
/** Bytes at or above this limit are rejected, so every character is equally likely. */
|
|
31
|
+
const UNBIASED_BYTE_LIMIT = 256 - (256 % NONCE_ALPHABET.length);
|
|
32
|
+
/** The `base64-value` of a CSP `nonce-source`. */
|
|
33
|
+
const CSP_NONCE = /^[A-Za-z0-9+/_-]+={0,2}$/;
|
|
34
|
+
|
|
35
|
+
const DEFAULT_BODY = '<div id="app"></div>';
|
|
36
|
+
|
|
37
|
+
/** 32 characters `[A-Za-z0-9]` from `crypto.getRandomValues`. */
|
|
38
|
+
export function createNonce(): string {
|
|
39
|
+
let nonce = "";
|
|
40
|
+
while (nonce.length < NONCE_LENGTH) {
|
|
41
|
+
for (const byte of crypto.getRandomValues(new Uint8Array(NONCE_LENGTH))) {
|
|
42
|
+
if (byte < UNBIASED_BYTE_LIMIT && nonce.length < NONCE_LENGTH) {
|
|
43
|
+
nonce += NONCE_ALPHABET.charAt(byte % NONCE_ALPHABET.length);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return nonce;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function escapeHtml(value: string): string {
|
|
51
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function attribute(name: string, value: string): string {
|
|
55
|
+
return `${name}="${escapeHtml(value)}"`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function contentSecurityPolicy(cspSource: string, nonce: string, inlineStyles: boolean): string {
|
|
59
|
+
return [
|
|
60
|
+
"default-src 'none'",
|
|
61
|
+
`img-src ${cspSource} data: blob:`,
|
|
62
|
+
inlineStyles ? `style-src ${cspSource} 'unsafe-inline'` : `style-src ${cspSource}`,
|
|
63
|
+
`font-src ${cspSource} data:`,
|
|
64
|
+
`script-src 'nonce-${nonce}'`,
|
|
65
|
+
].join("; ");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The webview document: `<html lang class="cd-shell-document" data-cd-theme [data-cd-kontrast]>`,
|
|
70
|
+
* the CSP (`default-src 'none'`, `img-src <cspSource> data: blob:`, `style-src <cspSource>` plus
|
|
71
|
+
* `'unsafe-inline'` unless `inlineStyles: false`, `font-src <cspSource> data:`,
|
|
72
|
+
* `script-src 'nonce-…'`), the stylesheets, `body` and the nonce'd script. Attribute values and the
|
|
73
|
+
* title are escaped; `body` is markup and is not. Throws for a `nonce` that is not a CSP base64
|
|
74
|
+
* value.
|
|
75
|
+
*/
|
|
76
|
+
export function renderWebviewHtml(options: WebviewHtmlOptions): string {
|
|
77
|
+
const nonce = options.nonce ?? createNonce();
|
|
78
|
+
if (!CSP_NONCE.test(nonce)) {
|
|
79
|
+
throw new Error(`@miragon/modeler-shell: renderWebviewHtml got an invalid nonce "${nonce}"`);
|
|
80
|
+
}
|
|
81
|
+
const appearance = appearanceAttributes(options.appearance);
|
|
82
|
+
const kontrast = appearance["data-cd-kontrast"];
|
|
83
|
+
const htmlAttributes = [
|
|
84
|
+
attribute("lang", options.lang),
|
|
85
|
+
attribute("class", "cd-shell-document"),
|
|
86
|
+
attribute("data-cd-theme", appearance["data-cd-theme"]),
|
|
87
|
+
...(kontrast ? [attribute("data-cd-kontrast", kontrast)] : []),
|
|
88
|
+
].join(" ");
|
|
89
|
+
const stylesheets = (options.styleUris ?? []).map(
|
|
90
|
+
(uri) => `\n <link ${attribute("rel", "stylesheet")} ${attribute("href", uri)} />`,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return `<!doctype html>
|
|
94
|
+
<html ${htmlAttributes}>
|
|
95
|
+
<head>
|
|
96
|
+
<meta charset="UTF-8" />
|
|
97
|
+
<meta http-equiv="Content-Security-Policy" ${attribute("content", contentSecurityPolicy(options.cspSource, nonce, options.inlineStyles !== false))} />
|
|
98
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />${stylesheets.join("")}
|
|
99
|
+
<title>${escapeHtml(options.title)}</title>
|
|
100
|
+
</head>
|
|
101
|
+
<body>
|
|
102
|
+
${options.body ?? DEFAULT_BODY}
|
|
103
|
+
<script ${attribute("nonce", nonce)} ${attribute("src", options.scriptUri)}></script>
|
|
104
|
+
</body>
|
|
105
|
+
</html>
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { ColorThemeLike, DisposableLike, VsCodeLike, WebviewPanelLike } from "./types.js";
|
|
2
|
+
export type { WebviewHtmlOptions } from "./html.js";
|
|
3
|
+
export { createNonce, renderWebviewHtml } from "./html.js";
|
|
4
|
+
export type { ThemeBridgeOptions } from "./theme-bridge.js";
|
|
5
|
+
export { connectThemeBridge, currentAppearance } from "./theme-bridge.js";
|
|
6
|
+
export { revealFileLabel } from "./reveal.js";
|
|
7
|
+
export type { AppearanceMessage } from "../protocol.js";
|
|
8
|
+
export { APPEARANCE_MESSAGE_TYPE, appearanceMessage, isAppearanceMessage } from "../protocol.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The label VS Code itself uses for revealing a file, for the action on the export notification:
|
|
3
|
+
* `darwin` "Reveal in Finder", `win32` "Reveal in File Explorer", otherwise "Open Containing Folder".
|
|
4
|
+
*/
|
|
5
|
+
export function revealFileLabel(platform: string): string {
|
|
6
|
+
if (platform === "darwin") return "Reveal in Finder";
|
|
7
|
+
if (platform === "win32") return "Reveal in File Explorer";
|
|
8
|
+
return "Open Containing Folder";
|
|
9
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requestedAppearance,
|
|
3
|
+
resolveAppearance,
|
|
4
|
+
type Appearance,
|
|
5
|
+
type ColorThemeSetting,
|
|
6
|
+
type Theme,
|
|
7
|
+
} from "../../core/index.js";
|
|
8
|
+
import { appearanceMessage } from "../protocol.js";
|
|
9
|
+
import type { DisposableLike, VsCodeLike, WebviewPanelLike } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export interface ThemeBridgeOptions {
|
|
12
|
+
/** The modes the modeler can show, a static value of the modeler package (`["light"]` today). */
|
|
13
|
+
themes: readonly Theme[];
|
|
14
|
+
/** Full key of `miragon.<modeler>.colorTheme`; omit until the modeler can show dark. */
|
|
15
|
+
settingKey?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isColorThemeSetting(value: unknown): value is ColorThemeSetting {
|
|
19
|
+
return value === "automatic" || value === "light" || value === "dark";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readColorThemeSetting(vscode: VsCodeLike, settingKey: string): ColorThemeSetting | undefined {
|
|
23
|
+
const separator = settingKey.lastIndexOf(".");
|
|
24
|
+
const value =
|
|
25
|
+
separator < 0
|
|
26
|
+
? vscode.workspace.getConfiguration().get<unknown>(settingKey)
|
|
27
|
+
: vscode.workspace
|
|
28
|
+
.getConfiguration(settingKey.slice(0, separator))
|
|
29
|
+
.get<unknown>(settingKey.slice(separator + 1));
|
|
30
|
+
return isColorThemeSetting(value) ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The resolved appearance right now: color theme kind (and setting) through `resolveAppearance`.
|
|
35
|
+
* A setting value outside `automatic | light | dark` counts as `automatic`.
|
|
36
|
+
*/
|
|
37
|
+
export function currentAppearance(vscode: VsCodeLike, options: ThemeBridgeOptions): Appearance {
|
|
38
|
+
const setting = options.settingKey ? readColorThemeSetting(vscode, options.settingKey) : undefined;
|
|
39
|
+
return resolveAppearance(requestedAppearance(vscode.window.activeColorTheme.kind, setting), options.themes);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Posts an `AppearanceMessage` to the panel's webview on `onDidChangeActiveColorTheme`, on a change
|
|
44
|
+
* of `settingKey` and whenever the panel becomes visible again (hidden retained webviews may miss
|
|
45
|
+
* messages). Disposes itself with the panel. Throws when `themes` is empty.
|
|
46
|
+
*/
|
|
47
|
+
export function connectThemeBridge(
|
|
48
|
+
vscode: VsCodeLike,
|
|
49
|
+
panel: WebviewPanelLike,
|
|
50
|
+
options: ThemeBridgeOptions,
|
|
51
|
+
): DisposableLike {
|
|
52
|
+
if (options.themes.length === 0) {
|
|
53
|
+
throw new Error("@miragon/modeler-shell: connectThemeBridge needs at least one theme");
|
|
54
|
+
}
|
|
55
|
+
const { settingKey } = options;
|
|
56
|
+
const post = (): void => {
|
|
57
|
+
void panel.webview.postMessage(appearanceMessage(currentAppearance(vscode, options)));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let wasVisible = panel.visible;
|
|
61
|
+
const subscriptions: DisposableLike[] = [
|
|
62
|
+
vscode.window.onDidChangeActiveColorTheme(post),
|
|
63
|
+
panel.onDidChangeViewState(({ webviewPanel }) => {
|
|
64
|
+
const becameVisible = webviewPanel.visible && !wasVisible;
|
|
65
|
+
wasVisible = webviewPanel.visible;
|
|
66
|
+
if (becameVisible) post();
|
|
67
|
+
}),
|
|
68
|
+
];
|
|
69
|
+
if (settingKey) {
|
|
70
|
+
subscriptions.push(
|
|
71
|
+
vscode.workspace.onDidChangeConfiguration((event) => {
|
|
72
|
+
if (event.affectsConfiguration(settingKey)) post();
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let disposed = false;
|
|
78
|
+
const dispose = (): void => {
|
|
79
|
+
if (disposed) return;
|
|
80
|
+
disposed = true;
|
|
81
|
+
for (const subscription of subscriptions.splice(0)) subscription.dispose();
|
|
82
|
+
};
|
|
83
|
+
subscriptions.push(panel.onDidDispose(dispose));
|
|
84
|
+
return { dispose };
|
|
85
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of the `vscode` namespace the extension helpers use, typed structurally: the shell
|
|
3
|
+
* never imports `vscode`; the extension passes its own `import * as vscode from "vscode"`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface DisposableLike {
|
|
7
|
+
dispose(): unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ColorThemeLike {
|
|
11
|
+
/** `vscode.ColorThemeKind`: 1 Light, 2 Dark, 3 HighContrast, 4 HighContrastLight. */
|
|
12
|
+
readonly kind: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface VsCodeLike {
|
|
16
|
+
readonly window: {
|
|
17
|
+
readonly activeColorTheme: ColorThemeLike;
|
|
18
|
+
onDidChangeActiveColorTheme(listener: (theme: ColorThemeLike) => unknown): DisposableLike;
|
|
19
|
+
};
|
|
20
|
+
readonly workspace: {
|
|
21
|
+
getConfiguration(section?: string): { get<T>(key: string): T | undefined };
|
|
22
|
+
onDidChangeConfiguration(
|
|
23
|
+
listener: (event: { affectsConfiguration(section: string): boolean }) => unknown,
|
|
24
|
+
): DisposableLike;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `vscode.WebviewPanel` as far as the theme bridge needs it. */
|
|
29
|
+
export interface WebviewPanelLike {
|
|
30
|
+
readonly webview: { postMessage(message: unknown): unknown };
|
|
31
|
+
readonly visible: boolean;
|
|
32
|
+
onDidChangeViewState(
|
|
33
|
+
listener: (event: { readonly webviewPanel: { readonly visible: boolean } }) => unknown,
|
|
34
|
+
): DisposableLike;
|
|
35
|
+
onDidDispose(listener: () => unknown): DisposableLike;
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Appearance } from "../core/index.js";
|
|
2
|
+
|
|
3
|
+
/** The one message the theme bridge posts to the webview. */
|
|
4
|
+
export const APPEARANCE_MESSAGE_TYPE = "modelerShell.appearance";
|
|
5
|
+
|
|
6
|
+
export interface AppearanceMessage {
|
|
7
|
+
type: typeof APPEARANCE_MESSAGE_TYPE;
|
|
8
|
+
/** Already resolved against the modeler's themes. */
|
|
9
|
+
appearance: Appearance;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A plain, JSON-safe message (no `kontrast: undefined` key). */
|
|
13
|
+
export function appearanceMessage(appearance: Appearance): AppearanceMessage {
|
|
14
|
+
return {
|
|
15
|
+
type: APPEARANCE_MESSAGE_TYPE,
|
|
16
|
+
appearance: appearance.kontrast
|
|
17
|
+
? { theme: appearance.theme, kontrast: appearance.kontrast }
|
|
18
|
+
: { theme: appearance.theme },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Type guard for `MessageEvent.data` in the webview. */
|
|
27
|
+
export function isAppearanceMessage(value: unknown): value is AppearanceMessage {
|
|
28
|
+
if (!isRecord(value) || value.type !== APPEARANCE_MESSAGE_TYPE || !isRecord(value.appearance)) return false;
|
|
29
|
+
const { theme, kontrast } = value.appearance;
|
|
30
|
+
return (theme === "light" || theme === "dark") && (kontrast === undefined || kontrast === "hoch");
|
|
31
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
REDUCED_MOTION_CLASS,
|
|
3
|
+
appearanceAttributes,
|
|
4
|
+
appearanceFromBodyClasses,
|
|
5
|
+
reducedMotionFromBodyClasses,
|
|
6
|
+
resolveAppearance,
|
|
7
|
+
type Appearance,
|
|
8
|
+
type Theme,
|
|
9
|
+
} from "../../core/index.js";
|
|
10
|
+
import { isAppearanceMessage } from "../protocol.js";
|
|
11
|
+
|
|
12
|
+
const NO_TRANSITION_CLASS = "cd-shell-no-transition";
|
|
13
|
+
|
|
14
|
+
/** The latest mode switch per `<html>`: only its second frame may lift the transition stop. */
|
|
15
|
+
const latestSwitch = new WeakMap<Element, number>();
|
|
16
|
+
|
|
17
|
+
function showsAppearance(element: Element, appearance: Appearance): boolean {
|
|
18
|
+
const attributes = appearanceAttributes(appearance);
|
|
19
|
+
return (
|
|
20
|
+
element.getAttribute("data-cd-theme") === attributes["data-cd-theme"] &&
|
|
21
|
+
element.getAttribute("data-cd-kontrast") === (attributes["data-cd-kontrast"] ?? null)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Writes `data-cd-theme` and `data-cd-kontrast` on `element` (default `<html>`) in one task, with
|
|
27
|
+
* transitions suppressed for one frame: adds `cd-shell-no-transition` to `<html>`, sets both
|
|
28
|
+
* attributes and removes the class in a nested `requestAnimationFrame` (after the last of several
|
|
29
|
+
* quick switches).
|
|
30
|
+
*/
|
|
31
|
+
export function applyAppearance(appearance: Appearance, element?: HTMLElement): void {
|
|
32
|
+
const target = element ?? document.documentElement;
|
|
33
|
+
const html = target.ownerDocument.documentElement;
|
|
34
|
+
const attributes = appearanceAttributes(appearance);
|
|
35
|
+
const kontrast = attributes["data-cd-kontrast"];
|
|
36
|
+
|
|
37
|
+
const switchId = (latestSwitch.get(html) ?? 0) + 1;
|
|
38
|
+
latestSwitch.set(html, switchId);
|
|
39
|
+
html.classList.add(NO_TRANSITION_CLASS);
|
|
40
|
+
target.setAttribute("data-cd-theme", attributes["data-cd-theme"]);
|
|
41
|
+
if (kontrast) target.setAttribute("data-cd-kontrast", kontrast);
|
|
42
|
+
else target.removeAttribute("data-cd-kontrast");
|
|
43
|
+
|
|
44
|
+
requestAnimationFrame(() =>
|
|
45
|
+
requestAnimationFrame(() => {
|
|
46
|
+
if (latestSwitch.get(html) !== switchId) return;
|
|
47
|
+
latestSwitch.delete(html);
|
|
48
|
+
html.classList.remove(NO_TRANSITION_CLASS);
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AppearanceListenerOptions {
|
|
54
|
+
/** The modes the modeler can show; the body-class fallback resolves against them. */
|
|
55
|
+
themes: readonly Theme[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Listens for `AppearanceMessage`s on `window` and applies them to `<html>`, resolved against
|
|
60
|
+
* `themes` once more so a webview never shows a mode its bundle cannot. A page whose `<html>` has
|
|
61
|
+
* no `data-cd-theme` at connect (no `renderWebviewHtml`) falls back to the VS Code body classes,
|
|
62
|
+
* through a `MutationObserver` on `<body>` until the first message (resolved against `themes`).
|
|
63
|
+
* With the attributes of `renderWebviewHtml` there is no fallback: the extension's bridge knows the
|
|
64
|
+
* `colorTheme` setting, the body classes do not, and VS Code changes them before the bridge's
|
|
65
|
+
* message arrives, which would flash the mode without the setting. Returns the disconnect
|
|
66
|
+
* function. Throws when `themes` is empty.
|
|
67
|
+
*/
|
|
68
|
+
export function connectAppearance(options: AppearanceListenerOptions): () => void {
|
|
69
|
+
const { themes } = options;
|
|
70
|
+
if (themes.length === 0) {
|
|
71
|
+
throw new Error("@miragon/modeler-shell: connectAppearance needs at least one theme");
|
|
72
|
+
}
|
|
73
|
+
const html = document.documentElement;
|
|
74
|
+
const show = (requested: Appearance): void => {
|
|
75
|
+
const resolved = resolveAppearance(requested, themes);
|
|
76
|
+
if (!showsAppearance(html, resolved)) applyAppearance(resolved, html);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
let fallbackActive = !html.hasAttribute("data-cd-theme");
|
|
80
|
+
const applyBodyClasses = (): void => {
|
|
81
|
+
if (!fallbackActive || !document.body) return;
|
|
82
|
+
const requested = appearanceFromBodyClasses(document.body.classList);
|
|
83
|
+
if (requested) show(requested);
|
|
84
|
+
};
|
|
85
|
+
const observer = new MutationObserver(applyBodyClasses);
|
|
86
|
+
const startFallback = (): void => {
|
|
87
|
+
if (!fallbackActive || !document.body) return;
|
|
88
|
+
observer.observe(document.body, { attributes: true, attributeFilter: ["class"] });
|
|
89
|
+
applyBodyClasses();
|
|
90
|
+
};
|
|
91
|
+
const stopFallback = (): void => {
|
|
92
|
+
if (!fallbackActive) return;
|
|
93
|
+
fallbackActive = false;
|
|
94
|
+
observer.disconnect();
|
|
95
|
+
document.removeEventListener("DOMContentLoaded", startFallback);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const onMessage = (event: MessageEvent): void => {
|
|
99
|
+
if (!isAppearanceMessage(event.data)) return;
|
|
100
|
+
stopFallback();
|
|
101
|
+
show(event.data.appearance);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
window.addEventListener("message", onMessage);
|
|
105
|
+
if (document.body) startFallback();
|
|
106
|
+
else if (fallbackActive) document.addEventListener("DOMContentLoaded", startFallback, { once: true });
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
window.removeEventListener("message", onMessage);
|
|
110
|
+
fallbackActive = false;
|
|
111
|
+
observer.disconnect();
|
|
112
|
+
document.removeEventListener("DOMContentLoaded", startFallback);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mirrors VS Code's `workbench.reduceMotion` (the body class `vscode-reduce-motion`, which VS Code
|
|
118
|
+
* keeps up to date) as `REDUCED_MOTION_CLASS` on `<html>` for the whole session: the shell's
|
|
119
|
+
* animations stop and an explicit fit jumps, as under `prefers-reduced-motion` (guide §5.14, §7).
|
|
120
|
+
* Returns the disconnect function, which removes the class again.
|
|
121
|
+
*/
|
|
122
|
+
export function connectReducedMotion(): () => void {
|
|
123
|
+
const html = document.documentElement;
|
|
124
|
+
const apply = (): void => {
|
|
125
|
+
if (document.body)
|
|
126
|
+
html.classList.toggle(REDUCED_MOTION_CLASS, reducedMotionFromBodyClasses(document.body.classList));
|
|
127
|
+
};
|
|
128
|
+
const observer = new MutationObserver(apply);
|
|
129
|
+
const start = (): void => {
|
|
130
|
+
if (!document.body) return;
|
|
131
|
+
observer.observe(document.body, { attributes: true, attributeFilter: ["class"] });
|
|
132
|
+
apply();
|
|
133
|
+
};
|
|
134
|
+
if (document.body) start();
|
|
135
|
+
else document.addEventListener("DOMContentLoaded", start, { once: true });
|
|
136
|
+
return () => {
|
|
137
|
+
observer.disconnect();
|
|
138
|
+
document.removeEventListener("DOMContentLoaded", start);
|
|
139
|
+
html.classList.remove(REDUCED_MOTION_CLASS);
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { AppearanceListenerOptions } from "./appearance.js";
|
|
2
|
+
export { applyAppearance, connectAppearance, connectReducedMotion } from "./appearance.js";
|
|
3
|
+
export type { VsCodeShellOptions } from "./mount.js";
|
|
4
|
+
export { mountVsCodeShell } from "./mount.js";
|
|
5
|
+
export type { PersistViewOptions, PersistableView, ShellWebviewState, WebviewStateApi } from "./state.js";
|
|
6
|
+
export { SHELL_STATE_KEY, persistView, readShellState, writeShellState } from "./state.js";
|
|
7
|
+
export type { AppearanceMessage } from "../protocol.js";
|
|
8
|
+
export { APPEARANCE_MESSAGE_TYPE, appearanceMessage, isAppearanceMessage } from "../protocol.js";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mountShell, type ShellHandle, type ShellOptions } from "../../dom/index.js";
|
|
2
|
+
import { connectAppearance, connectReducedMotion, type AppearanceListenerOptions } from "./appearance.js";
|
|
3
|
+
|
|
4
|
+
/** The web column's options (0.2): a VS Code webview has none of them (guide §5.2, §13.5). */
|
|
5
|
+
type WebHostOptionKey = "primary" | "legal" | "file" | "appearance" | "emptyState" | "dropZone";
|
|
6
|
+
|
|
7
|
+
export interface VsCodeShellOptions
|
|
8
|
+
extends Omit<ShellOptions, "host" | "keyScope" | "themes" | WebHostOptionKey>, AppearanceListenerOptions {}
|
|
9
|
+
|
|
10
|
+
/** The shell's handle with a `destroy` that runs `beforeDestroy` first; every other member delegates. */
|
|
11
|
+
function withDestroyHook(shell: ShellHandle, beforeDestroy: () => void): ShellHandle {
|
|
12
|
+
return {
|
|
13
|
+
get root() {
|
|
14
|
+
return shell.root;
|
|
15
|
+
},
|
|
16
|
+
get canvas() {
|
|
17
|
+
return shell.canvas;
|
|
18
|
+
},
|
|
19
|
+
get host() {
|
|
20
|
+
return shell.host;
|
|
21
|
+
},
|
|
22
|
+
get lang() {
|
|
23
|
+
return shell.lang;
|
|
24
|
+
},
|
|
25
|
+
get platform() {
|
|
26
|
+
return shell.platform;
|
|
27
|
+
},
|
|
28
|
+
get messages() {
|
|
29
|
+
return shell.messages;
|
|
30
|
+
},
|
|
31
|
+
get menu() {
|
|
32
|
+
return shell.menu;
|
|
33
|
+
},
|
|
34
|
+
get tooltip() {
|
|
35
|
+
return shell.tooltip;
|
|
36
|
+
},
|
|
37
|
+
get legend() {
|
|
38
|
+
return shell.legend;
|
|
39
|
+
},
|
|
40
|
+
get inspector() {
|
|
41
|
+
return shell.inspector;
|
|
42
|
+
},
|
|
43
|
+
get primary() {
|
|
44
|
+
return shell.primary;
|
|
45
|
+
},
|
|
46
|
+
get appearance() {
|
|
47
|
+
return shell.appearance;
|
|
48
|
+
},
|
|
49
|
+
setZoomTarget: (target) => shell.setZoomTarget(target),
|
|
50
|
+
fitInsets: () => shell.fitInsets(),
|
|
51
|
+
registerPalette: (palette) => shell.registerPalette(palette),
|
|
52
|
+
setProblems: (summary) => shell.setProblems(summary),
|
|
53
|
+
setStatus: (status, options) => shell.setStatus(status, options),
|
|
54
|
+
setEmptyHint: (visible) => shell.setEmptyHint(visible),
|
|
55
|
+
setReadOnly: (readOnly, options) => shell.setReadOnly(readOnly, options),
|
|
56
|
+
isReadOnly: () => shell.isReadOnly(),
|
|
57
|
+
setEmptyState: (shown) => shell.setEmptyState(shown),
|
|
58
|
+
isEmptyStateShown: () => shell.isEmptyStateShown(),
|
|
59
|
+
toast: (request) => shell.toast(request),
|
|
60
|
+
destroy() {
|
|
61
|
+
beforeDestroy();
|
|
62
|
+
shell.destroy();
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `mountShell` for a VS Code webview: `host: "ide"`, the key listener on `document` (VS Code's own
|
|
69
|
+
* listener sits on `window`, so handled keys are stopped before it), `cd-shell-document` on
|
|
70
|
+
* `<html>`, plus `connectAppearance` and `connectReducedMotion`. `destroy()` also disconnects both.
|
|
71
|
+
*/
|
|
72
|
+
export function mountVsCodeShell(root: HTMLElement, options: VsCodeShellOptions): ShellHandle {
|
|
73
|
+
const { themes } = options;
|
|
74
|
+
const disconnectAppearance = connectAppearance({ themes });
|
|
75
|
+
const disconnectReducedMotion = connectReducedMotion();
|
|
76
|
+
const disconnect = (): void => {
|
|
77
|
+
disconnectAppearance();
|
|
78
|
+
disconnectReducedMotion();
|
|
79
|
+
};
|
|
80
|
+
let shell: ShellHandle;
|
|
81
|
+
try {
|
|
82
|
+
shell = mountShell(root, { ...options, host: "ide", keyScope: "document" });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
disconnect();
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
return withDestroyHook(shell, disconnect);
|
|
88
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Bounds, ViewportState } from "../../core/index.js";
|
|
2
|
+
|
|
3
|
+
/** `acquireVsCodeApi()` as far as the state helpers need it. */
|
|
4
|
+
export interface WebviewStateApi {
|
|
5
|
+
getState(): unknown;
|
|
6
|
+
setState(state: unknown): void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** What the shell keeps across a webview rebuild (guide §13.5: legend state and view). */
|
|
10
|
+
export interface ShellWebviewState {
|
|
11
|
+
legendOpen?: boolean | undefined;
|
|
12
|
+
view?: ViewportState | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The key under which the shell's part lives in the webview state; other keys stay untouched. */
|
|
16
|
+
export const SHELL_STATE_KEY = "modelerShell";
|
|
17
|
+
|
|
18
|
+
type StateRecord = Record<string, unknown>;
|
|
19
|
+
|
|
20
|
+
function isRecord(value: unknown): value is StateRecord {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function storedState(api: WebviewStateApi): StateRecord {
|
|
25
|
+
const state = api.getState();
|
|
26
|
+
return isRecord(state) ? state : {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function viewboxFrom(value: unknown): Bounds | undefined {
|
|
30
|
+
if (!isRecord(value)) return undefined;
|
|
31
|
+
const { x, y, width, height } = value;
|
|
32
|
+
if (typeof x !== "number" || typeof y !== "number") return undefined;
|
|
33
|
+
if (typeof width !== "number" || typeof height !== "number") return undefined;
|
|
34
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
return width > 0 && height > 0 ? { x, y, width, height } : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function viewFrom(value: unknown): ViewportState | undefined {
|
|
41
|
+
if (!isRecord(value) || typeof value.userMoved !== "boolean") return undefined;
|
|
42
|
+
const viewbox = viewboxFrom(value.viewbox);
|
|
43
|
+
return viewbox ? { viewbox, userMoved: value.userMoved } : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The shell's part of `api.getState()`; `{}` when there is none or it is malformed. */
|
|
47
|
+
export function readShellState(api: WebviewStateApi): ShellWebviewState {
|
|
48
|
+
const part = storedState(api)[SHELL_STATE_KEY];
|
|
49
|
+
if (!isRecord(part)) return {};
|
|
50
|
+
const state: ShellWebviewState = {};
|
|
51
|
+
if (typeof part.legendOpen === "boolean") state.legendOpen = part.legendOpen;
|
|
52
|
+
const view = viewFrom(part.view);
|
|
53
|
+
if (view) state.view = view;
|
|
54
|
+
return state;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Merges `patch` into the shell's part and writes the whole state back, keeping every other key.
|
|
59
|
+
* A value that is `undefined` (for example `viewport.getState()` before the first import) or
|
|
60
|
+
* malformed leaves the stored value alone; nothing is written when nothing changed.
|
|
61
|
+
*/
|
|
62
|
+
export function writeShellState(api: WebviewStateApi, patch: ShellWebviewState): void {
|
|
63
|
+
const state = storedState(api);
|
|
64
|
+
const part = isRecord(state[SHELL_STATE_KEY]) ? (state[SHELL_STATE_KEY] as StateRecord) : {};
|
|
65
|
+
const next: StateRecord = { ...part };
|
|
66
|
+
if (typeof patch.legendOpen === "boolean") next.legendOpen = patch.legendOpen;
|
|
67
|
+
const view = viewFrom(patch.view);
|
|
68
|
+
if (view) next.view = view;
|
|
69
|
+
if (JSON.stringify(next) === JSON.stringify(state[SHELL_STATE_KEY])) return;
|
|
70
|
+
api.setState({ ...state, [SHELL_STATE_KEY]: next });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What `persistView` reads of a viewport (`connectDiagramJs(…).viewport`). */
|
|
74
|
+
export interface PersistableView {
|
|
75
|
+
onChange(listener: () => void): () => void;
|
|
76
|
+
getState(): ViewportState | undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface PersistViewOptions {
|
|
80
|
+
/** Quiet time before the write, default 250ms. */
|
|
81
|
+
delayMs?: number | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A pan, a wheel zoom or a fit animation changes the view on every frame. */
|
|
85
|
+
const PERSIST_VIEW_DELAY_MS = 250;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Keeps the stored view (`writeShellState(api, { view })`) up to date once the view has rested for
|
|
89
|
+
* `delayMs`, instead of one `vscode.setState` IPC message per animation frame. Returns the stop
|
|
90
|
+
* function; it writes a pending change at once.
|
|
91
|
+
*/
|
|
92
|
+
export function persistView(
|
|
93
|
+
api: WebviewStateApi,
|
|
94
|
+
viewport: PersistableView,
|
|
95
|
+
options: PersistViewOptions = {},
|
|
96
|
+
): () => void {
|
|
97
|
+
const delayMs = options.delayMs ?? PERSIST_VIEW_DELAY_MS;
|
|
98
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
99
|
+
const write = (): void => {
|
|
100
|
+
timer = undefined;
|
|
101
|
+
writeShellState(api, { view: viewport.getState() });
|
|
102
|
+
};
|
|
103
|
+
const unsubscribe = viewport.onChange(() => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
timer = setTimeout(write, delayMs);
|
|
106
|
+
});
|
|
107
|
+
let stopped = false;
|
|
108
|
+
return () => {
|
|
109
|
+
if (stopped) return;
|
|
110
|
+
stopped = true;
|
|
111
|
+
unsubscribe();
|
|
112
|
+
if (timer === undefined) return;
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
write();
|
|
115
|
+
};
|
|
116
|
+
}
|