@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
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell dimensions of the design system (guide §5.1, `shell` group of
|
|
3
|
+
* `cd-tokens.generated.json`), keyed by a readable name. Scripts use `SHELL`; CSS uses the
|
|
4
|
+
* `var(--cd-shell-*)` variables the consumer vendors. A drift test pins `SHELL` to the token JSON.
|
|
5
|
+
*/
|
|
6
|
+
export const SHELL_TOKENS = {
|
|
7
|
+
edge: "--cd-shell-rand",
|
|
8
|
+
gap: "--cd-shell-luecke",
|
|
9
|
+
row: "--cd-shell-zeile",
|
|
10
|
+
target: "--cd-shell-ziel",
|
|
11
|
+
entry: "--cd-shell-eintrag",
|
|
12
|
+
free: "--cd-shell-frei",
|
|
13
|
+
menuMinWidth: "--cd-shell-menue-breite",
|
|
14
|
+
inspectorWidth: "--cd-shell-inspector-breite",
|
|
15
|
+
fitInsetTop: "--cd-shell-einpassen-oben",
|
|
16
|
+
fitInsetTopSecondRow: "--cd-shell-einpassen-oben-schmal",
|
|
17
|
+
fitInsetSide: "--cd-shell-einpassen-seite",
|
|
18
|
+
fitInsetBottom: "--cd-shell-einpassen-unten",
|
|
19
|
+
fitInsetBottomWeb: "--cd-shell-einpassen-unten-web",
|
|
20
|
+
narrow: "--cd-shell-schmal",
|
|
21
|
+
veryNarrow: "--cd-shell-sehr-schmal",
|
|
22
|
+
low: "--cd-shell-niedrig",
|
|
23
|
+
layerIslands: "--cd-shell-ebene-inseln",
|
|
24
|
+
layerInspector: "--cd-shell-ebene-inspector",
|
|
25
|
+
layerTopRow: "--cd-shell-ebene-reihe",
|
|
26
|
+
layerPopups: "--cd-shell-ebene-aufklapper",
|
|
27
|
+
layerDialog: "--cd-shell-ebene-dialog",
|
|
28
|
+
layerToast: "--cd-shell-ebene-toast",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export type ShellSizeName = keyof typeof SHELL_TOKENS;
|
|
32
|
+
|
|
33
|
+
export type ShellSizes = Readonly<Record<ShellSizeName, number>>;
|
|
34
|
+
|
|
35
|
+
/** Pixels (layers: plain z-index numbers), equal to `parseFloat` of the token JSON values. */
|
|
36
|
+
export const SHELL: ShellSizes = {
|
|
37
|
+
edge: 12,
|
|
38
|
+
gap: 8,
|
|
39
|
+
row: 40,
|
|
40
|
+
target: 32,
|
|
41
|
+
entry: 36,
|
|
42
|
+
free: 64,
|
|
43
|
+
menuMinWidth: 248,
|
|
44
|
+
inspectorWidth: 288,
|
|
45
|
+
fitInsetTop: 88,
|
|
46
|
+
fitInsetTopSecondRow: 140,
|
|
47
|
+
fitInsetSide: 24,
|
|
48
|
+
fitInsetBottom: 64,
|
|
49
|
+
fitInsetBottomWeb: 92,
|
|
50
|
+
narrow: 760,
|
|
51
|
+
veryNarrow: 440,
|
|
52
|
+
low: 820,
|
|
53
|
+
layerIslands: 20,
|
|
54
|
+
layerInspector: 25,
|
|
55
|
+
layerTopRow: 30,
|
|
56
|
+
layerPopups: 40,
|
|
57
|
+
layerDialog: 50,
|
|
58
|
+
layerToast: 60,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Shell sizes are pixels ("12px") or plain z-index numbers ("20"); any other unit would make
|
|
62
|
+
// parseFloat silently return a number with the wrong meaning.
|
|
63
|
+
const PIXEL_OR_PLAIN_NUMBER = /^\s*-?(?:\d+\.?\d*|\.\d+)(?:px)?\s*$/;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads the `shell` group of `cd-tokens.generated.json` (`{ "--cd-shell-rand": "12px", … }`) into
|
|
67
|
+
* numbers. Throws when a size of `SHELL_TOKENS` is missing or not a number.
|
|
68
|
+
*/
|
|
69
|
+
export function shellSizesFromTokens(shellGroup: Readonly<Record<string, string>>): ShellSizes {
|
|
70
|
+
const sizes = {} as Record<ShellSizeName, number>;
|
|
71
|
+
for (const name of Object.keys(SHELL_TOKENS) as ShellSizeName[]) {
|
|
72
|
+
const token = SHELL_TOKENS[name];
|
|
73
|
+
const raw: unknown = Object.hasOwn(shellGroup, token) ? shellGroup[token] : undefined;
|
|
74
|
+
if (raw === undefined) {
|
|
75
|
+
throw new Error(`@miragon/modeler-shell: the shell token ${token} is missing`);
|
|
76
|
+
}
|
|
77
|
+
if (typeof raw !== "string" || !PIXEL_OR_PLAIN_NUMBER.test(raw)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`@miragon/modeler-shell: the shell token ${token} is not a number: ${JSON.stringify(raw)}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
sizes[name] = Number.parseFloat(raw);
|
|
83
|
+
}
|
|
84
|
+
return sizes;
|
|
85
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** A point in pixels or diagram coordinates, as the context says. */
|
|
2
|
+
export interface Point {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Size {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A rectangle; content bounds and viewboxes are in diagram coordinates. */
|
|
13
|
+
export interface Bounds {
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A viewbox together with the scale diagram-js shows it at (`canvas.viewbox().scale`). */
|
|
21
|
+
export interface ScaledViewbox extends Bounds {
|
|
22
|
+
scale: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Distances in pixels from the edges of the modeler's container. */
|
|
26
|
+
export interface Insets {
|
|
27
|
+
top: number;
|
|
28
|
+
right: number;
|
|
29
|
+
bottom: number;
|
|
30
|
+
left: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The host column of guide §5.2: `ide` is a VS Code webview (later IntelliJ), `web` a Miragon web
|
|
35
|
+
* app that owns its page, `embedded` a modeler inside a foreign page (designIQ, modeler-api hosts).
|
|
36
|
+
*/
|
|
37
|
+
export type ShellHost = "ide" | "web" | "embedded";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `data-chrome` names of the chrome (guide §5.2, §13.4). The web column adds `primary`, `legal`,
|
|
41
|
+
* `toasts`, `empty-state`, `drop-zone` and `problems-popover` (0.2); IDE and embedded hosts never
|
|
42
|
+
* show `primary`, `legal`, `empty-state` and `drop-zone`, the IDE host never `toasts` and
|
|
43
|
+
* `problems-popover`.
|
|
44
|
+
*/
|
|
45
|
+
export type ChromeIsland =
|
|
46
|
+
| "menu"
|
|
47
|
+
| "palette"
|
|
48
|
+
| "primary"
|
|
49
|
+
| "zoom"
|
|
50
|
+
| "problems"
|
|
51
|
+
| "legend"
|
|
52
|
+
| "inspector"
|
|
53
|
+
| "status"
|
|
54
|
+
| "legal"
|
|
55
|
+
| "toasts"
|
|
56
|
+
| "empty-state"
|
|
57
|
+
| "drop-zone"
|
|
58
|
+
| "problems-popover";
|
|
59
|
+
|
|
60
|
+
export const CHROME_ISLANDS: readonly ChromeIsland[] = [
|
|
61
|
+
"menu",
|
|
62
|
+
"palette",
|
|
63
|
+
"primary",
|
|
64
|
+
"zoom",
|
|
65
|
+
"problems",
|
|
66
|
+
"legend",
|
|
67
|
+
"inspector",
|
|
68
|
+
"status",
|
|
69
|
+
"legal",
|
|
70
|
+
"toasts",
|
|
71
|
+
"empty-state",
|
|
72
|
+
"drop-zone",
|
|
73
|
+
"problems-popover",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** Where the palette sits: in the top row, or in the second row below it (guide §5.15). */
|
|
77
|
+
export type PaletteRow = "top" | "second-row";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What the zoom panel and the chrome keys act on. The diagram-js viewport implements it; the DOM
|
|
81
|
+
* shell only talks to this interface.
|
|
82
|
+
*/
|
|
83
|
+
export interface ZoomTarget {
|
|
84
|
+
/** The current zoom, 1 = 100 % (diagram-js `canvas.zoom()`, rounded to three decimals). */
|
|
85
|
+
zoom(): number;
|
|
86
|
+
/** One step on the zoom raster around the container centre (diagram-js `zoomScroll.stepZoom`). */
|
|
87
|
+
stepZoom(direction: 1 | -1): void;
|
|
88
|
+
/** 100 % around the container centre. Counts as a user move. */
|
|
89
|
+
zoomToActual(): void;
|
|
90
|
+
/** Explicit fit (button, Shift+1, command): animated unless reduced motion, makes the view automatic. */
|
|
91
|
+
fit(): void;
|
|
92
|
+
/** False while there is nothing to fit (the content bounds are empty). */
|
|
93
|
+
canFit(): boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Calls `listener` after every viewbox change and whenever `canFit()` may have changed, possibly
|
|
96
|
+
* several times per frame. Returns the unsubscribe function.
|
|
97
|
+
*/
|
|
98
|
+
onChange(listener: () => void): () => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The persisted view (VS Code `setState`): JSON-serializable. */
|
|
102
|
+
export interface ViewportState {
|
|
103
|
+
viewbox: Bounds;
|
|
104
|
+
/** The user zoomed or panned since the last fit; a resize no longer refits. */
|
|
105
|
+
userMoved: boolean;
|
|
106
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { SHELL } from "../sizes.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Height of one line of the web app's legal line: 13px text on a 20px line (guide §5.10). Where a
|
|
5
|
+
* narrow container cannot hold it in one line, it wraps (40 for two lines) and everything stacked
|
|
6
|
+
* above it rises by as much: the pill, the legend, the raised zoom panel and the fit's bottom.
|
|
7
|
+
*/
|
|
8
|
+
export const LEGAL_LINE_HEIGHT = 20;
|
|
9
|
+
|
|
10
|
+
/** A measured legal line's height in whole lines, at least one: what the stack above it stands on. */
|
|
11
|
+
export function legalLineHeight(measured: number | undefined): number {
|
|
12
|
+
if (measured === undefined || !Number.isFinite(measured) || measured <= 0) return LEGAL_LINE_HEIGHT;
|
|
13
|
+
return Math.max(1, Math.round(measured / LEGAL_LINE_HEIGHT)) * LEGAL_LINE_HEIGHT;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BottomRowInput {
|
|
17
|
+
containerWidth: number;
|
|
18
|
+
/** Measured right edge of the legal line relative to the shell root; absent while it is not shown. */
|
|
19
|
+
legalRight?: number | undefined;
|
|
20
|
+
/** Measured width of the zoom panel (173 with a 56px readout); absent while it is not shown. */
|
|
21
|
+
zoomWidth?: number | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Web app, bottom row: true when the zoom panel at `right: 12px` would keep less than
|
|
26
|
+
* `SHELL.gap` (8px) to the legal line at `left: 12px`. The zoom panel then stands above the legal
|
|
27
|
+
* line at `bottom: 40px` (12 + 20 + 8; a wrapped line lifts it further), like the pill
|
|
28
|
+
* (`data-zoom-row="raised"` on the root), and the fit keeps 92px at the bottom (`FitLayout.zoomRaised`). Guide §5.10 and §5.15 do not settle
|
|
29
|
+
* this collision of a narrow container; it is the shell's rule until they do.
|
|
30
|
+
* False when either of the two is not shown.
|
|
31
|
+
*/
|
|
32
|
+
export function zoomRowRaised(input: BottomRowInput): boolean {
|
|
33
|
+
const { containerWidth, legalRight, zoomWidth } = input;
|
|
34
|
+
if (legalRight === undefined || zoomWidth === undefined) return false;
|
|
35
|
+
const zoomLeft = containerWidth - SHELL.edge - zoomWidth;
|
|
36
|
+
return legalRight + SHELL.gap > zoomLeft;
|
|
37
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Why a drag cannot be dropped (guide §9 Drop forbidden); `messages.dropForbidden` words each one. */
|
|
2
|
+
export type DropForbiddenReason = "multiple" | "type" | "dialog";
|
|
3
|
+
|
|
4
|
+
/** One entry of `DataTransfer.items` as a drag shows it (names are not readable before the drop). */
|
|
5
|
+
export interface DragItemLike {
|
|
6
|
+
kind: string;
|
|
7
|
+
type: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DragContext {
|
|
11
|
+
/** `DropZoneOptions.accept`: extensions (".storm") and MIME types ("application/json", "text/*"). */
|
|
12
|
+
accept: readonly string[];
|
|
13
|
+
/** `DropZoneOptions.multiple`. */
|
|
14
|
+
multiple: boolean;
|
|
15
|
+
/** A modal dialog is open (the shell's dialog test, as for the keys). */
|
|
16
|
+
dialogOpen: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What the drop zone shows for the drag over the page right now (guide §9). */
|
|
20
|
+
export type DragVerdict =
|
|
21
|
+
{ state: "none" } | { state: "over" } | { state: "forbidden"; reason: DropForbiddenReason };
|
|
22
|
+
|
|
23
|
+
interface AcceptEntries {
|
|
24
|
+
extensions: readonly string[];
|
|
25
|
+
mimeTypes: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `<input accept>` tokens: trimmed, lower case, blanks dropped; "." starts an extension, "/" marks a MIME type. */
|
|
29
|
+
function acceptEntries(accept: readonly string[]): AcceptEntries {
|
|
30
|
+
const entries = accept.map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
|
|
31
|
+
return {
|
|
32
|
+
extensions: entries.filter((entry) => entry.startsWith(".")),
|
|
33
|
+
mimeTypes: entries.filter((entry) => !entry.startsWith(".") && entry.includes("/")),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function matchesMimeType(type: string, mimeTypes: readonly string[]): boolean {
|
|
38
|
+
const normalized = type.trim().toLowerCase();
|
|
39
|
+
if (normalized === "") return false;
|
|
40
|
+
return mimeTypes.some((entry) =>
|
|
41
|
+
entry.endsWith("/*") ? normalized.startsWith(entry.slice(0, -1)) : normalized === entry,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The drop zone's state for a drag (guide §9, Drag-over and Drop forbidden):
|
|
47
|
+
*
|
|
48
|
+
* - `none`: no item of kind `file` (a palette drag, selected text): the drop zone stays out of it.
|
|
49
|
+
* - `forbidden` / `dialog`: a modal dialog is open.
|
|
50
|
+
* - `forbidden` / `multiple`: more than one file and `multiple` is false.
|
|
51
|
+
* - `forbidden` / `type`: `accept` lists at least one MIME type and every file item carries a
|
|
52
|
+
* non-empty type that none of them matches (`type/*` wildcards included). Extensions cannot be
|
|
53
|
+
* checked before the drop, and an empty type (".storm" has none) never forbids.
|
|
54
|
+
* - `over` otherwise.
|
|
55
|
+
*/
|
|
56
|
+
export function dragVerdict(items: readonly DragItemLike[], context: DragContext): DragVerdict {
|
|
57
|
+
const files = items.filter((item) => item.kind === "file");
|
|
58
|
+
if (files.length === 0) return { state: "none" };
|
|
59
|
+
if (context.dialogOpen) return { state: "forbidden", reason: "dialog" };
|
|
60
|
+
if (files.length > 1 && !context.multiple) return { state: "forbidden", reason: "multiple" };
|
|
61
|
+
const { mimeTypes } = acceptEntries(context.accept);
|
|
62
|
+
const foreignType = (item: DragItemLike) =>
|
|
63
|
+
item.type.trim() !== "" && !matchesMimeType(item.type, mimeTypes);
|
|
64
|
+
if (mimeTypes.length > 0 && files.every(foreignType)) return { state: "forbidden", reason: "type" };
|
|
65
|
+
return { state: "over" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A dropped file matches `accept` like `<input accept>`: an entry starting with "." matches the
|
|
70
|
+
* end of the name (case-insensitive), an entry with "/" the MIME type exactly or as `type/*`; an
|
|
71
|
+
* empty `accept` matches every file.
|
|
72
|
+
*/
|
|
73
|
+
export function acceptsFile(file: { name: string; type: string }, accept: readonly string[]): boolean {
|
|
74
|
+
const { extensions, mimeTypes } = acceptEntries(accept);
|
|
75
|
+
if (extensions.length === 0 && mimeTypes.length === 0) return true;
|
|
76
|
+
const name = file.name.toLowerCase();
|
|
77
|
+
return extensions.some((extension) => name.endsWith(extension)) || matchesMimeType(file.type, mimeTypes);
|
|
78
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { PALETTE_CLEARANCE, decidePaletteRow } from "../layout.js";
|
|
2
|
+
import { SHELL } from "../sizes.js";
|
|
3
|
+
import type { PaletteRow } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/** Border-box widths of the web app's primary action, measured with and without its label. */
|
|
6
|
+
export interface PrimaryWidths {
|
|
7
|
+
/** Icon and label: padding `0 16px`, icon 16px, gap 8px, label 13px/600 (guide §5.7). */
|
|
8
|
+
withLabel: number;
|
|
9
|
+
/** The icon alone: `SHELL.row` (40) square. */
|
|
10
|
+
iconOnly: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TopRowInput {
|
|
14
|
+
containerWidth: number;
|
|
15
|
+
/** The palette's intrinsic border-box width, as for `decidePaletteRow`. */
|
|
16
|
+
paletteWidth: number;
|
|
17
|
+
/** Right edge of the menu trigger, relative to the shell root. */
|
|
18
|
+
menuRight: number;
|
|
19
|
+
/** The primary action at `right: 12px`; absent in IDE and embedded hosts and without one. */
|
|
20
|
+
primary?: PrimaryWidths | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TopRow {
|
|
24
|
+
paletteRow: PaletteRow;
|
|
25
|
+
/** `collapsed`: the primary action shows its icon only; its `aria-label` keeps the name. */
|
|
26
|
+
primaryLabel: "shown" | "collapsed";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The top row of guide §5.15 with a primary action: when it gets tight, the primary action loses
|
|
31
|
+
* its label first, and only then the palette moves to the second row.
|
|
32
|
+
*
|
|
33
|
+
* 1. Without `primary`: `decidePaletteRow` alone, label `shown`.
|
|
34
|
+
* 2. With the label (`primaryLeft = containerWidth − SHELL.edge − withLabel`): palette `top` →
|
|
35
|
+
* `{ top, shown }`.
|
|
36
|
+
* 3. Icon only (`primaryLeft = containerWidth − SHELL.edge − iconOnly`): palette `top` →
|
|
37
|
+
* `{ top, collapsed }`.
|
|
38
|
+
* 4. Otherwise the palette is in the second row and the top row holds only menu trigger and
|
|
39
|
+
* primary action: the label shows again when `menuRight + PALETTE_CLEARANCE` stays left of the
|
|
40
|
+
* labelled button, else it stays collapsed.
|
|
41
|
+
*
|
|
42
|
+
* Up to `SHELL.narrow` the palette is always in the second row (step 4).
|
|
43
|
+
*/
|
|
44
|
+
export function decideTopRow(input: TopRowInput): TopRow {
|
|
45
|
+
const { containerWidth, paletteWidth, menuRight, primary } = input;
|
|
46
|
+
if (!primary) {
|
|
47
|
+
return {
|
|
48
|
+
paletteRow: decidePaletteRow({ containerWidth, paletteWidth, menuRight }),
|
|
49
|
+
primaryLabel: "shown",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const paletteRowBeside = (primaryWidth: number): PaletteRow =>
|
|
53
|
+
decidePaletteRow({
|
|
54
|
+
containerWidth,
|
|
55
|
+
paletteWidth,
|
|
56
|
+
menuRight,
|
|
57
|
+
primaryLeft: containerWidth - SHELL.edge - primaryWidth,
|
|
58
|
+
});
|
|
59
|
+
if (paletteRowBeside(primary.withLabel) === "top") return { paletteRow: "top", primaryLabel: "shown" };
|
|
60
|
+
if (paletteRowBeside(primary.iconOnly) === "top") return { paletteRow: "top", primaryLabel: "collapsed" };
|
|
61
|
+
const labelledLeft = containerWidth - SHELL.edge - primary.withLabel;
|
|
62
|
+
return {
|
|
63
|
+
paletteRow: "second-row",
|
|
64
|
+
primaryLabel: menuRight + PALETTE_CLEARANCE <= labelledLeft ? "shown" : "collapsed",
|
|
65
|
+
};
|
|
66
|
+
}
|
package/src/core/zoom.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ShellMessages } from "./messages.js";
|
|
2
|
+
|
|
3
|
+
/** diagram-js `ZoomScroll` RANGE (not configurable): 20 % … 400 %. */
|
|
4
|
+
export const ZOOM_MIN = 0.2;
|
|
5
|
+
export const ZOOM_MAX = 4;
|
|
6
|
+
|
|
7
|
+
/** Zoom out is disabled at 20 % and below (`zoom <= 0.2 · 1.001`), so it never zooms in. */
|
|
8
|
+
export function canZoomOut(zoom: number): boolean {
|
|
9
|
+
return zoom > ZOOM_MIN * 1.001;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Zoom in is disabled at 400 % (`zoom >= 4 · 0.999`). */
|
|
13
|
+
export function canZoomIn(zoom: number): boolean {
|
|
14
|
+
return zoom < ZOOM_MAX * 0.999;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A zoom as a whole percentage in the UI language: `Intl.NumberFormat(lang, { style: "percent",
|
|
19
|
+
* maximumFractionDigits: 0 })`, so English "86%", German "86 %" (with U+00A0).
|
|
20
|
+
*/
|
|
21
|
+
export function formatPercent(zoom: number, lang: string): string {
|
|
22
|
+
return numberFormat(lang, "percent").format(zoom);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Any other number in the chrome ("2 problems"), via `Intl.NumberFormat(lang)`. */
|
|
26
|
+
export function formatNumber(value: number, lang: string): string {
|
|
27
|
+
return numberFormat(lang, "decimal").format(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const FALLBACK_LANG = "en";
|
|
31
|
+
const formats = new Map<string, Intl.NumberFormat>();
|
|
32
|
+
|
|
33
|
+
// The readout formats on every animation frame of a zoom, so each formatter is built once. A
|
|
34
|
+
// language tag Intl rejects (an empty `lang=""`, a typo) falls back to English, the shell's default
|
|
35
|
+
// language, instead of breaking the chrome.
|
|
36
|
+
function numberFormat(lang: string, style: "percent" | "decimal"): Intl.NumberFormat {
|
|
37
|
+
const key = `${style}|${lang}`;
|
|
38
|
+
let format = formats.get(key);
|
|
39
|
+
if (!format) {
|
|
40
|
+
const options: Intl.NumberFormatOptions =
|
|
41
|
+
style === "percent" ? { style, maximumFractionDigits: 0 } : { style };
|
|
42
|
+
try {
|
|
43
|
+
format = new Intl.NumberFormat(lang, options);
|
|
44
|
+
} catch {
|
|
45
|
+
format = new Intl.NumberFormat(FALLBACK_LANG, options);
|
|
46
|
+
}
|
|
47
|
+
formats.set(key, format);
|
|
48
|
+
}
|
|
49
|
+
return format;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ZoomReadout {
|
|
53
|
+
/** "86%" */
|
|
54
|
+
text: string;
|
|
55
|
+
/** "Zoom to 100% (now 86%)" */
|
|
56
|
+
ariaLabel: string;
|
|
57
|
+
canZoomOut: boolean;
|
|
58
|
+
canZoomIn: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Everything the zoom panel shows for a zoom: readout text, its accessible name and the limits. */
|
|
62
|
+
export function zoomReadout(
|
|
63
|
+
zoom: number,
|
|
64
|
+
lang: string,
|
|
65
|
+
messages: Pick<ShellMessages, "zoomToActualNow">,
|
|
66
|
+
): ZoomReadout {
|
|
67
|
+
const text = formatPercent(zoom, lang);
|
|
68
|
+
return {
|
|
69
|
+
text,
|
|
70
|
+
ariaLabel: messages.zoomToActualNow(formatPercent(1, lang), text),
|
|
71
|
+
canZoomOut: canZoomOut(zoom),
|
|
72
|
+
canZoomIn: canZoomIn(zoom),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Bounds } from "../core/index.js";
|
|
2
|
+
import type { ShellHandle } from "../dom/index.js";
|
|
3
|
+
import { shellServiceOf } from "./service.js";
|
|
4
|
+
import type { DiagramEventListener, EventBusLike, InjectorLike } from "./types.js";
|
|
5
|
+
import { DiagramViewport, type Viewport } from "./viewport.js";
|
|
6
|
+
|
|
7
|
+
export interface ConnectOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Content bounds in diagram coordinates, tight around the content: the fit insets are the only
|
|
10
|
+
* margin (guide §5.9). `undefined` = nothing to fit (empty file).
|
|
11
|
+
*/
|
|
12
|
+
contentBounds(): Bounds | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* An empty file (guide §13.5: no element, only whitespace, comments or a title), which shows the
|
|
15
|
+
* empty-file hint. Default: `contentBounds()` is empty. A notation whose bounds are never empty
|
|
16
|
+
* (Wardley's plot) passes its own test.
|
|
17
|
+
*/
|
|
18
|
+
isEmpty?: (() => boolean) | undefined;
|
|
19
|
+
prefersReducedMotion?: (() => boolean) | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DiagramJsConnection {
|
|
23
|
+
readonly viewport: Viewport;
|
|
24
|
+
/**
|
|
25
|
+
* Unbinds viewport, palette, keys and the hint sync from the shell; destroys neither the diagram
|
|
26
|
+
* nor the shell. Runs by itself on `diagram.destroy`.
|
|
27
|
+
*/
|
|
28
|
+
destroy(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** After the viewport's own listeners, so its content state is fresh when the hint reads it. */
|
|
32
|
+
const HINT_SYNC_PRIORITY = 500;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Connects a diagram built with `shellModule` to a mounted shell: creates the viewport with
|
|
36
|
+
* `shell.fitInsets`, binds it with `shell.setZoomTarget`, registers the palette
|
|
37
|
+
* (`shell.registerPalette`), keeps the empty-file hint in sync with the diagram (after every
|
|
38
|
+
* `import.done` and `elements.changed`, so the first element created on the canvas hides it) and
|
|
39
|
+
* calls `canvas.resized()`. Throws when `shellModule` is missing.
|
|
40
|
+
*/
|
|
41
|
+
export function connectDiagramJs(
|
|
42
|
+
shell: ShellHandle,
|
|
43
|
+
diagram: InjectorLike,
|
|
44
|
+
options: ConnectOptions,
|
|
45
|
+
): DiagramJsConnection {
|
|
46
|
+
const service = shellServiceOf(diagram);
|
|
47
|
+
const eventBus = diagram.get<EventBusLike>("eventBus");
|
|
48
|
+
const prefersReducedMotion = options.prefersReducedMotion?.bind(options);
|
|
49
|
+
const viewport = new DiagramViewport(diagram, {
|
|
50
|
+
contentBounds: () => options.contentBounds(),
|
|
51
|
+
insets: () => shell.fitInsets(),
|
|
52
|
+
...(prefersReducedMotion ? { prefersReducedMotion } : {}),
|
|
53
|
+
});
|
|
54
|
+
shell.setZoomTarget(viewport);
|
|
55
|
+
const detachPalette = service.attachShell(shell);
|
|
56
|
+
// The shell has moved the canvas element into its slot; diagram-js caches the old size.
|
|
57
|
+
viewport.resized();
|
|
58
|
+
|
|
59
|
+
const isEmpty = options.isEmpty ? () => options.isEmpty?.() === true : () => !viewport.canFit();
|
|
60
|
+
const syncHint: DiagramEventListener = () => shell.setEmptyHint(isEmpty());
|
|
61
|
+
eventBus.on(["import.done", "elements.changed"], HINT_SYNC_PRIORITY, syncHint);
|
|
62
|
+
|
|
63
|
+
let destroyed = false;
|
|
64
|
+
const destroy = (): void => {
|
|
65
|
+
if (destroyed) return;
|
|
66
|
+
destroyed = true;
|
|
67
|
+
eventBus.off(["import.done", "elements.changed"], syncHint);
|
|
68
|
+
eventBus.off("diagram.destroy", destroy);
|
|
69
|
+
detachPalette();
|
|
70
|
+
shell.setZoomTarget(null);
|
|
71
|
+
viewport.destroy();
|
|
72
|
+
};
|
|
73
|
+
eventBus.on("diagram.destroy", destroy);
|
|
74
|
+
return { viewport, destroy };
|
|
75
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
CanvasLike,
|
|
3
|
+
DiagramEventListener,
|
|
4
|
+
DiagramViewbox,
|
|
5
|
+
EventBusLike,
|
|
6
|
+
InjectorLike,
|
|
7
|
+
KeyboardLike,
|
|
8
|
+
ModuleDeclarationLike,
|
|
9
|
+
PaletteLike,
|
|
10
|
+
ZoomScrollLike,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
export { shellModule } from "./module.js";
|
|
13
|
+
export type { Viewport, ViewportOptions } from "./viewport.js";
|
|
14
|
+
export { createViewport } from "./viewport.js";
|
|
15
|
+
export type { ConnectOptions, DiagramJsConnection } from "./connect.js";
|
|
16
|
+
export { connectDiagramJs } from "./connect.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { SHELL_SERVICE, ShellDiagramService } from "./service.js";
|
|
2
|
+
import type { ModuleDeclarationLike } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Add to the modeler's modules (`additionalModules`, after the renderer's own modules):
|
|
6
|
+
* - `paletteTooltip: ["value", null]` removes diagram-js's (or the renderer's) palette tooltip; the
|
|
7
|
+
* chrome tooltip takes over;
|
|
8
|
+
* - the palette skin: on `palette.create` it sets `data-chrome="palette"` and moves the palette
|
|
9
|
+
* before the canvas `<svg>`; after every `palette.changed` it re-applies `role="toolbar"`,
|
|
10
|
+
* `aria-label`, roving `tabindex` and `aria-pressed` (from `tool-manager.update`);
|
|
11
|
+
* - `keyboard.addListener(2000, …)` returning `true` for every chrome key, so `KeyboardBindings` and
|
|
12
|
+
* the modeler's keyboard modules (priority 1000) never act on them.
|
|
13
|
+
* It buffers the palette until `connectDiagramJs` hands it to a shell, so the diagram may be
|
|
14
|
+
* constructed before `mountShell`.
|
|
15
|
+
*/
|
|
16
|
+
export const shellModule: ModuleDeclarationLike = {
|
|
17
|
+
__init__: [SHELL_SERVICE],
|
|
18
|
+
[SHELL_SERVICE]: ["type", ShellDiagramService],
|
|
19
|
+
paletteTooltip: ["value", null],
|
|
20
|
+
};
|