@miragon/modeler-shell 0.1.0 → 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 +1051 -84
- package/README.md +162 -31
- package/dist/appearance-BBaM_4bD.d.ts +323 -0
- package/dist/appearance-XX3JC3pI.d.cts +323 -0
- package/dist/{chunk-6LQOFACK.js → chunk-66HCSJNY.js} +1247 -31
- package/dist/chunk-66HCSJNY.js.map +1 -0
- package/dist/{chunk-RJRFR6N3.js → chunk-KYDG5DMX.js} +2 -2
- package/dist/{chunk-5JSZ4VGA.js → chunk-OADW3GPA.js} +240 -6
- package/dist/chunk-OADW3GPA.js.map +1 -0
- package/dist/core.cjs +260 -6
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +81 -6
- package/dist/core.d.ts +81 -6
- package/dist/core.js +43 -3
- package/dist/diagram-js.cjs +34 -2
- package/dist/diagram-js.cjs.map +1 -1
- package/dist/diagram-js.d.cts +10 -3
- package/dist/diagram-js.d.ts +10 -3
- package/dist/diagram-js.js +14 -2
- package/dist/diagram-js.js.map +1 -1
- package/dist/index.cjs +1654 -197
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -9
- package/dist/index.d.ts +18 -9
- package/dist/index.js +44 -4
- package/dist/shell.css +692 -3
- package/dist/testing-vscode-stub.js +2 -2
- package/dist/testing.cjs +2849 -550
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +75 -9
- package/dist/testing.d.ts +75 -9
- package/dist/testing.js +2744 -558
- package/dist/testing.js.map +1 -1
- package/dist/toasts-Bp0REqdq.d.cts +142 -0
- package/dist/toasts-Bp0REqdq.d.ts +142 -0
- package/dist/{types-OuFlqZp0.d.cts → types-CnRrINRs.d.cts} +170 -9
- package/dist/{types-DnZn9CPw.d.ts → types-PuDZczzy.d.ts} +170 -9
- package/dist/vscode-extension.js +2 -2
- package/dist/vscode-webview.cjs +1541 -178
- package/dist/vscode-webview.cjs.map +1 -1
- package/dist/vscode-webview.d.cts +6 -4
- package/dist/vscode-webview.d.ts +6 -4
- package/dist/vscode-webview.js +11 -2
- package/dist/vscode-webview.js.map +1 -1
- package/package.json +2 -2
- 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 +18 -3
- package/src/core/index.ts +43 -0
- package/src/core/keymap.ts +20 -0
- package/src/core/messages.ts +71 -0
- package/src/core/types.ts +24 -2
- 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/diagram-js/service.ts +4 -1
- package/src/diagram-js/viewport.ts +19 -0
- package/src/dom/buttons.css +124 -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/index.ts +13 -0
- package/src/dom/keys.ts +8 -0
- package/src/dom/menu.ts +28 -17
- package/src/dom/mount.ts +290 -20
- package/src/dom/problems.ts +5 -4
- package/src/dom/shell-dom.css +7 -4
- package/src/dom/types.ts +188 -6
- 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/shell.css +3 -0
- package/src/testing/case-tools.ts +164 -0
- package/src/testing/cases.ts +209 -144
- package/src/testing/checks.ts +51 -15
- package/src/testing/facts.ts +56 -2
- package/src/testing/geometry.ts +206 -26
- package/src/testing/harness.ts +56 -7
- package/src/testing/index.ts +4 -0
- package/src/testing/page-tools.ts +24 -1
- package/src/testing/page.ts +10 -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/vscode/webview/mount.ts +13 -1
- package/dist/chunk-5JSZ4VGA.js.map +0 -1
- package/dist/chunk-6LQOFACK.js.map +0 -1
- package/dist/keymap-CObQE6q6.d.cts +0 -151
- package/dist/keymap-DORzHnK-.d.ts +0 -151
- package/dist/types-B6NfkiUG.d.cts +0 -66
- package/dist/types-B6NfkiUG.d.ts +0 -66
- /package/dist/{chunk-RJRFR6N3.js.map → chunk-KYDG5DMX.js.map} +0 -0
package/src/dom/types.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AppearanceChoice,
|
|
2
3
|
Insets,
|
|
3
4
|
Platform,
|
|
5
|
+
ProblemItem,
|
|
4
6
|
ShellHost,
|
|
5
7
|
ShellMessageOverrides,
|
|
6
8
|
ShellMessages,
|
|
7
9
|
Theme,
|
|
10
|
+
ThemeColors,
|
|
11
|
+
ToastRequest,
|
|
8
12
|
ZoomTarget,
|
|
9
13
|
} from "../core/index.js";
|
|
10
14
|
import type { ShellIconName } from "./icons.js";
|
|
@@ -32,10 +36,11 @@ export interface ShellOptions {
|
|
|
32
36
|
keyScope?: "document" | "root" | undefined;
|
|
33
37
|
/**
|
|
34
38
|
* The modes the modeler can show (a static value of the modeler package), default `["light"]`.
|
|
35
|
-
* Read in the `embedded`
|
|
36
|
-
* (until the contract's `appearance` surface an embedded modeler stays in its first mode,
|
|
37
|
-
* whatever the page shows) and mirrors the host's `data-cd-kontrast` (guide §13.3).
|
|
38
|
-
*
|
|
39
|
+
* Read in the `embedded` and `web` hosts. Embedded: the root carries `data-cd-theme="<themes[0]>"`
|
|
40
|
+
* itself (until the contract's `appearance` surface an embedded modeler stays in its first mode,
|
|
41
|
+
* whatever the page shows) and mirrors the host's `data-cd-kontrast` (guide §13.3). Web: with
|
|
42
|
+
* `"dark"` the shell renders the Appearance group and applies the choice on `<html>` (needs
|
|
43
|
+
* `appearance`); light-only it renders no group. In the `ide` host the host writes the mode.
|
|
39
44
|
*/
|
|
40
45
|
themes?: readonly Theme[] | undefined;
|
|
41
46
|
/** The fixed menu groups this modeler fills (guide §5.4); omitted groups and empty ones collapse. */
|
|
@@ -48,6 +53,24 @@ export interface ShellOptions {
|
|
|
48
53
|
inspector?: InspectorOptions | undefined;
|
|
49
54
|
/** The empty-file hint of IDE and embedded hosts (guide §13.5); omitted = no hint. */
|
|
50
55
|
emptyHint?: EmptyHintOptions | undefined;
|
|
56
|
+
|
|
57
|
+
// ---- Web host (0.2): read in `host: "web"` only, ignored elsewhere (guide §5.2) ----
|
|
58
|
+
|
|
59
|
+
/** The primary action at the top right (guide §5.7); omitted = the top right stays empty. */
|
|
60
|
+
primary?: PrimaryActionOptions | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* The legal line at the bottom left (guide §5.10), always rendered in the web host: copyright,
|
|
63
|
+
* Impressum and Datenschutz (`lang="de"`), then `links`.
|
|
64
|
+
*/
|
|
65
|
+
legal?: LegalLineOptions | undefined;
|
|
66
|
+
/** The File group, first in the menu (guide §5.4); omitted = no File group. */
|
|
67
|
+
file?: FileMenuOptions | undefined;
|
|
68
|
+
/** The mode choice of a dark-capable modeler (guide §13.3); required when `themes` includes `"dark"`. */
|
|
69
|
+
appearance?: WebAppearanceOptions | undefined;
|
|
70
|
+
/** The start screen (guide §8 Empty State); omitted = none. Shown by `setEmptyState(true)`. */
|
|
71
|
+
emptyState?: EmptyStateOptions | undefined;
|
|
72
|
+
/** Files dropped anywhere on the page (guide §9 Drag-over, Drop forbidden); omitted = none. */
|
|
73
|
+
dropZone?: DropZoneOptions | undefined;
|
|
51
74
|
}
|
|
52
75
|
|
|
53
76
|
/** Rendered in this order, separated by separators: Model, Export, Help (guide §5.4). */
|
|
@@ -96,6 +119,10 @@ export interface MenuActionEntry extends MenuItemBase {
|
|
|
96
119
|
*/
|
|
97
120
|
export interface MenuDialogEntry extends MenuItemBase {
|
|
98
121
|
kind: "dialog";
|
|
122
|
+
/** Shortcut chip text shown on the right, verbatim ("?" for "Keyboard shortcuts…"). */
|
|
123
|
+
shortcut?: string | undefined;
|
|
124
|
+
/** As for `MenuActionEntry`: default `ariaKeyShortcutsFromLabel(shortcut)`, `""` sets none. */
|
|
125
|
+
ariaKeyShortcuts?: string | undefined;
|
|
99
126
|
onSelect(): void;
|
|
100
127
|
}
|
|
101
128
|
|
|
@@ -126,8 +153,17 @@ export interface MenuRadioOption {
|
|
|
126
153
|
}
|
|
127
154
|
|
|
128
155
|
export interface ProblemsOptions {
|
|
129
|
-
/**
|
|
130
|
-
|
|
156
|
+
/**
|
|
157
|
+
* A click on the pill where the host has a problems view of its own: the IDE host opens its
|
|
158
|
+
* Problems view (give it there), an embedding host its own list. Ignored in the web host, where
|
|
159
|
+
* the pill opens the problems popover; an embedded shell without it opens the popover as well.
|
|
160
|
+
*/
|
|
161
|
+
onActivate?: (() => void) | undefined;
|
|
162
|
+
/**
|
|
163
|
+
* A click on a row of the problems popover, after the popover closed (guide §5.10): select the
|
|
164
|
+
* element the item names, where there is one.
|
|
165
|
+
*/
|
|
166
|
+
onSelectItem?: ((item: ProblemItem) => void) | undefined;
|
|
131
167
|
}
|
|
132
168
|
|
|
133
169
|
export interface LegendOptions {
|
|
@@ -168,6 +204,10 @@ export interface ShellHandle {
|
|
|
168
204
|
readonly tooltip: TooltipHandle;
|
|
169
205
|
readonly legend: LegendHandle | undefined;
|
|
170
206
|
readonly inspector: InspectorHandle | undefined;
|
|
207
|
+
/** The web app's primary action; `undefined` without `primary` and outside the web host. */
|
|
208
|
+
readonly primary: PrimaryActionHandle | undefined;
|
|
209
|
+
/** The web app's mode choice; `undefined` outside the web host and for a light-only modeler. */
|
|
210
|
+
readonly appearance: AppearanceHandle | undefined;
|
|
171
211
|
/** Binds zoom panel, readout and chrome keys; `null` unbinds (buttons disabled). */
|
|
172
212
|
setZoomTarget(target: ZoomTarget | null): void;
|
|
173
213
|
/** The fit insets for what is open right now (palette row, legend, inspector, pill). */
|
|
@@ -194,6 +234,21 @@ export interface ShellHandle {
|
|
|
194
234
|
setReadOnly(readOnly: boolean, options?: { delayMs?: number | undefined }): void;
|
|
195
235
|
/** The lock, true from the first `setReadOnly(true)` on, also while its marks are still delayed. */
|
|
196
236
|
isReadOnly(): boolean;
|
|
237
|
+
/**
|
|
238
|
+
* Shows or hides the web app's start screen (guide §8): while it shows, menu trigger, primary
|
|
239
|
+
* action, palette, zoom panel, pill, legend, status box, hint and inspector are hidden, the canvas
|
|
240
|
+
* slot is `inert` and the chrome keys do nothing; the legal line, toasts and the drop zone stay.
|
|
241
|
+
* A no-op without `emptyState`. Hiding it while it holds the focus moves the focus to the canvas.
|
|
242
|
+
*/
|
|
243
|
+
setEmptyState(shown: boolean): void;
|
|
244
|
+
isEmptyStateShown(): boolean;
|
|
245
|
+
/**
|
|
246
|
+
* Shows a toast (guide §8, §9) in the web and embedded hosts: bottom centre, at most three, the
|
|
247
|
+
* newest at the bottom, closing by itself after `toastLifetime` unless hovered or focused. In the
|
|
248
|
+
* `ide` host it shows nothing and returns an inert handle: there the extension shows the host's
|
|
249
|
+
* notification (§13.5).
|
|
250
|
+
*/
|
|
251
|
+
toast(request: ToastRequest): ToastHandle;
|
|
197
252
|
/** Removes every element, attribute, listener, observer and timer the shell added. Idempotent. */
|
|
198
253
|
destroy(): void;
|
|
199
254
|
}
|
|
@@ -267,6 +322,11 @@ export interface ProblemSummary {
|
|
|
267
322
|
count: number;
|
|
268
323
|
/** How many of them are errors; decides icon and wording. */
|
|
269
324
|
errors: number;
|
|
325
|
+
/**
|
|
326
|
+
* The rows of the problems popover (web, embedded without `onActivate`), in this order; the IDE
|
|
327
|
+
* host ignores them. `problemSummary(items)` builds the whole summary from them.
|
|
328
|
+
*/
|
|
329
|
+
items?: readonly ProblemItem[] | undefined;
|
|
270
330
|
}
|
|
271
331
|
|
|
272
332
|
export type StatusSeverity = "danger" | "warning";
|
|
@@ -275,3 +335,125 @@ export interface StatusMessage {
|
|
|
275
335
|
severity: StatusSeverity;
|
|
276
336
|
text: string;
|
|
277
337
|
}
|
|
338
|
+
|
|
339
|
+
// ---- Web host (0.2) ----
|
|
340
|
+
|
|
341
|
+
/** The web app's primary action (guide §5.7): a blue primary button, icon and label, at the top right. */
|
|
342
|
+
export interface PrimaryActionOptions {
|
|
343
|
+
/** Label and `aria-label`; default `messages.share` ("Share"). */
|
|
344
|
+
label?: string | undefined;
|
|
345
|
+
/** Default `"share-2"`. */
|
|
346
|
+
icon?: ShellIconName | undefined;
|
|
347
|
+
onSelect(): void;
|
|
348
|
+
/** Read on mount and on `primary.refresh()`; disabled stays focusable (`aria-disabled`). */
|
|
349
|
+
disabled?: (() => boolean) | undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export interface PrimaryActionHandle {
|
|
353
|
+
/** The `<button data-chrome="primary">`. */
|
|
354
|
+
readonly element: HTMLButtonElement;
|
|
355
|
+
/** True while the label is collapsed to the icon (guide §5.15); the root then carries `data-primary="collapsed"`. */
|
|
356
|
+
isCollapsed(): boolean;
|
|
357
|
+
/** Re-reads `disabled()`. */
|
|
358
|
+
refresh(): void;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A link of the legal line after Impressum and Datenschutz. */
|
|
362
|
+
export interface LegalLink {
|
|
363
|
+
label: string;
|
|
364
|
+
href: string;
|
|
365
|
+
/** For a label in another language than the tool's. */
|
|
366
|
+
lang?: string | undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export interface LegalLineOptions {
|
|
370
|
+
/** After Impressum and Datenschutz: `{ label: "GitHub", href: "https://github.com/Miragon/…" }`. */
|
|
371
|
+
links?: readonly LegalLink[] | undefined;
|
|
372
|
+
/** The copyright year; default the current year at mount. */
|
|
373
|
+
year?: number | undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** One entry of the File group; label and icon come from the shell (guide §2, §5.4). */
|
|
377
|
+
export interface FileMenuAction {
|
|
378
|
+
onSelect(): void;
|
|
379
|
+
/** Shortcut chip, e.g. "⌘O"; the web app binds the key itself. */
|
|
380
|
+
shortcut?: string | undefined;
|
|
381
|
+
/** `kind: "dialog"` (`aria-haspopup="dialog"`) when it opens an in-page dialog, not the browser's file picker. */
|
|
382
|
+
dialog?: boolean | undefined;
|
|
383
|
+
disabled?: (() => boolean) | undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The File group of the web app (guide §5.4), in this order, as far as the modeler has them:
|
|
388
|
+
* "New diagram" (`file-plus`), "Open…" (`folder-open`), "Show example", "Import…" (`import`).
|
|
389
|
+
* Menu ids `new-diagram`, `open`, `show-example`, `import`, `data-menu-group="file"`.
|
|
390
|
+
*/
|
|
391
|
+
export interface FileMenuOptions {
|
|
392
|
+
newDiagram?: FileMenuAction | undefined;
|
|
393
|
+
open?: FileMenuAction | undefined;
|
|
394
|
+
showExample?: FileMenuAction | undefined;
|
|
395
|
+
import?: FileMenuAction | undefined;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** What `localStorage` offers; a blocked storage throws, the shell catches it. */
|
|
399
|
+
export interface StorageLike {
|
|
400
|
+
getItem(key: string): string | null;
|
|
401
|
+
setItem(key: string, value: string): void;
|
|
402
|
+
removeItem(key: string): void;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export interface WebAppearanceOptions {
|
|
406
|
+
/** The two `theme-color` values from the vendored token JSON (see `ThemeColors`). */
|
|
407
|
+
themeColors: ThemeColors;
|
|
408
|
+
/** Default `globalThis.localStorage`. */
|
|
409
|
+
storage?: StorageLike | undefined;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The mode choice of a dark-capable web app (guide §13.3), as the Appearance group drives it. */
|
|
413
|
+
export interface AppearanceHandle {
|
|
414
|
+
/** The stored choice, `"system"` without one. */
|
|
415
|
+
choice(): AppearanceChoice;
|
|
416
|
+
/**
|
|
417
|
+
* Stores the choice (`"system"` removes `APPEARANCE_STORAGE_KEY`) and applies `documentMode` on
|
|
418
|
+
* `<html>` and the metas in one step, with `cd-shell-no-transition` for one frame (guide §7).
|
|
419
|
+
*/
|
|
420
|
+
setChoice(choice: AppearanceChoice): void;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The web app's start screen (guide §8): a centred glass card with the Komet and two actions. */
|
|
424
|
+
export interface EmptyStateOptions {
|
|
425
|
+
/** The card's title (h1, 21px/600), the modeler's name: "Event Storming Modeler". */
|
|
426
|
+
title: string;
|
|
427
|
+
/** One line of help (16px/1.6, `--cd-ui-text-leise`): "Start a new diagram, or explore the example." */
|
|
428
|
+
text: string;
|
|
429
|
+
/**
|
|
430
|
+
* URL of the official Miragon icon `miragon-komet-blau.svg`, vendored unchanged from
|
|
431
|
+
* `Miragon/corporate-identity` (guide §2, §11), shown at 64px with `alt=""`.
|
|
432
|
+
*/
|
|
433
|
+
komet: string;
|
|
434
|
+
/** "New diagram", the blue primary button. The shell hides the card first. */
|
|
435
|
+
onNewDiagram(): void;
|
|
436
|
+
/** "Show example", the ghost button; omitted = no example button. The shell hides the card first. */
|
|
437
|
+
onShowExample?: (() => void) | undefined;
|
|
438
|
+
/** The drag-and-drop hint (13px, `--cd-ui-text-leise`), shown with a `dropZone` only; default `messages.dropHint`. */
|
|
439
|
+
dropHint?: string | undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export interface DropZoneOptions {
|
|
443
|
+
/**
|
|
444
|
+
* Like `<input accept>`: extensions (".storm") and MIME types ("application/json", "text/*").
|
|
445
|
+
* List the MIME types too, so a drag can already show a foreign type as forbidden. Empty = any file.
|
|
446
|
+
*/
|
|
447
|
+
accept: readonly string[];
|
|
448
|
+
/** Default `false`: a drag with more than one file is forbidden. */
|
|
449
|
+
multiple?: boolean | undefined;
|
|
450
|
+
/** The dropped files that match `accept` (one unless `multiple`). */
|
|
451
|
+
onDrop(files: readonly File[]): void;
|
|
452
|
+
/** The dropped files outside `accept`; default a danger toast `messages.cannotOpenFile`. */
|
|
453
|
+
onReject?: ((files: readonly File[]) => void) | undefined;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export interface ToastHandle {
|
|
457
|
+
/** Closes the toast (slide and fade out); a second call does nothing. */
|
|
458
|
+
dismiss(): void;
|
|
459
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
acceptsFile,
|
|
3
|
+
dragVerdict,
|
|
4
|
+
type DragItemLike,
|
|
5
|
+
type DragVerdict,
|
|
6
|
+
type ShellMessages,
|
|
7
|
+
type ToastRequest,
|
|
8
|
+
} from "../../core/index.js";
|
|
9
|
+
import { createElement } from "../element.js";
|
|
10
|
+
import type { DropZoneOptions, ToastHandle } from "../types.js";
|
|
11
|
+
|
|
12
|
+
/** The drop zone as the root shows it: `data-drop="over"` or `"forbidden"` while a file drag is on the page. */
|
|
13
|
+
export type DropZoneState = "over" | "forbidden" | null;
|
|
14
|
+
|
|
15
|
+
export interface DropZoneContext {
|
|
16
|
+
ownerDocument: Document;
|
|
17
|
+
/** Where the overlay goes: the shell's layer (40, guide §5.13). */
|
|
18
|
+
layer: HTMLElement;
|
|
19
|
+
messages: ShellMessages;
|
|
20
|
+
lang: string;
|
|
21
|
+
options: DropZoneOptions;
|
|
22
|
+
/** A modal dialog is open in the document (the shell's dialog test). */
|
|
23
|
+
modalOpen(): boolean;
|
|
24
|
+
/** For the default `onReject`. */
|
|
25
|
+
toast(request: ToastRequest): ToastHandle;
|
|
26
|
+
/** Writes the root's `data-drop`. */
|
|
27
|
+
setState(state: DropZoneState): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DropZoneController {
|
|
31
|
+
/** The overlay `<div data-chrome="drop-zone" aria-hidden="true">`. */
|
|
32
|
+
readonly element: HTMLElement;
|
|
33
|
+
state(): DropZoneState;
|
|
34
|
+
destroy(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DRAG_EVENTS = ["dragenter", "dragover", "dragleave", "drop"] as const;
|
|
38
|
+
|
|
39
|
+
function carriesFiles(transfer: DataTransfer | null): transfer is DataTransfer {
|
|
40
|
+
if (!transfer) return false;
|
|
41
|
+
if (Array.from(transfer.types ?? []).includes("Files")) return true;
|
|
42
|
+
return Array.from(transfer.items ?? []).some((item) => item.kind === "file");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What the drag shows of its items; a browser without `items` still names "Files" in `types`. */
|
|
46
|
+
function dragItems(transfer: DataTransfer): DragItemLike[] {
|
|
47
|
+
const items = Array.from(transfer.items ?? [], (item) => ({ kind: item.kind, type: item.type }));
|
|
48
|
+
return items.length > 0 ? items : [{ kind: "file", type: "" }];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The extensions of `accept` for the reject toast ("a .storm, .json, or .txt file"), else every entry. */
|
|
52
|
+
function acceptList(accept: readonly string[], lang: string): string {
|
|
53
|
+
const entries = accept.map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
54
|
+
const extensions = entries.filter((entry) => entry.startsWith("."));
|
|
55
|
+
const listed = extensions.length > 0 ? extensions : entries;
|
|
56
|
+
let format: Intl.ListFormat;
|
|
57
|
+
try {
|
|
58
|
+
format = new Intl.ListFormat(lang, { type: "disjunction" });
|
|
59
|
+
} catch {
|
|
60
|
+
format = new Intl.ListFormat("en", { type: "disjunction" });
|
|
61
|
+
}
|
|
62
|
+
return format.format(listed);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The page-wide drop zone of the web app (guide §9). Listens on the window (`dragenter`,
|
|
67
|
+
* `dragover`, `dragleave`, `drop`; outermost pair counted) for drags carrying files only, so
|
|
68
|
+
* palette drags and text drags into the label editor stay alone; `dragVerdict` decides the state.
|
|
69
|
+
* Drag-over: overlay `inset: 12px`, fill `--cd-ui-hover`, 2px dashed `--cd-ui-interaktion`, radius
|
|
70
|
+
* `lg`, chip `messages.dropFile` centred on `--cd-ui-flaeche-hoch` (13px/600, radius `md`,
|
|
71
|
+
* `shadow-2`), `dropEffect = "copy"`. Forbidden: fill `--cd-ui-danger-flaeche`, dashed
|
|
72
|
+
* `--cd-ui-danger`, chip `messages.dropForbidden(reason)`, `dropEffect = "none"` (cursor
|
|
73
|
+
* `not-allowed`). Every file drag is `preventDefault`-ed, so the browser never navigates to the
|
|
74
|
+
* file. A drop: files through `acceptsFile` → `onDrop(accepted)`, the rest → `onReject` (default
|
|
75
|
+
* a danger toast `cannotOpenFile(name, accept joined by Intl.ListFormat disjunction)`); a forbidden
|
|
76
|
+
* drop opens nothing. Hidden while no file drag is on the page.
|
|
77
|
+
*/
|
|
78
|
+
export function createDropZone(context: DropZoneContext): DropZoneController {
|
|
79
|
+
const { ownerDocument, layer, messages, lang, options, modalOpen, toast, setState } = context;
|
|
80
|
+
const chip = createElement(ownerDocument, "span", { class: "cd-shell-drop-zone-chip" });
|
|
81
|
+
const element = createElement(
|
|
82
|
+
ownerDocument,
|
|
83
|
+
"div",
|
|
84
|
+
{ class: "cd-shell-drop-zone", "data-chrome": "drop-zone", "aria-hidden": "true" },
|
|
85
|
+
chip,
|
|
86
|
+
);
|
|
87
|
+
element.hidden = true;
|
|
88
|
+
layer.append(element);
|
|
89
|
+
|
|
90
|
+
const view = ownerDocument.defaultView;
|
|
91
|
+
// dragenter and dragleave fire for every element the pointer crosses: only the outermost pair counts.
|
|
92
|
+
let depth = 0;
|
|
93
|
+
let current: DropZoneState = null;
|
|
94
|
+
|
|
95
|
+
function verdictFor(items: readonly DragItemLike[]): DragVerdict {
|
|
96
|
+
return dragVerdict(items, {
|
|
97
|
+
accept: options.accept,
|
|
98
|
+
multiple: options.multiple ?? false,
|
|
99
|
+
dialogOpen: modalOpen(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function show(verdict: DragVerdict): void {
|
|
104
|
+
const next: DropZoneState = verdict.state === "none" ? null : verdict.state;
|
|
105
|
+
if (verdict.state === "over") chip.textContent = messages.dropFile;
|
|
106
|
+
if (verdict.state === "forbidden") chip.textContent = messages.dropForbidden(verdict.reason);
|
|
107
|
+
if (next === current) return;
|
|
108
|
+
current = next;
|
|
109
|
+
element.hidden = next === null;
|
|
110
|
+
if (next === null) element.removeAttribute("data-state");
|
|
111
|
+
else element.setAttribute("data-state", next);
|
|
112
|
+
setState(next);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function track(transfer: DataTransfer): void {
|
|
116
|
+
const verdict = verdictFor(dragItems(transfer));
|
|
117
|
+
show(verdict);
|
|
118
|
+
transfer.dropEffect = verdict.state === "forbidden" ? "none" : "copy";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function rejectByDefault(files: readonly File[]): void {
|
|
122
|
+
const accepted = acceptList(options.accept, lang);
|
|
123
|
+
for (const file of files) toast({ status: "danger", text: messages.cannotOpenFile(file.name, accepted) });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function receive(files: readonly File[], verdict: DragVerdict): void {
|
|
127
|
+
// The type verdict only guesses while names are unreadable; at the drop acceptsFile knows.
|
|
128
|
+
if (verdict.state === "forbidden" && verdict.reason !== "type") return;
|
|
129
|
+
const accepted = files.filter((file) => acceptsFile(file, options.accept));
|
|
130
|
+
const rejected = files.filter((file) => !accepted.includes(file));
|
|
131
|
+
if (accepted.length > 0) options.onDrop(accepted);
|
|
132
|
+
if (rejected.length > 0) (options.onReject ?? rejectByDefault)(rejected);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const onDragEvent = (event: Event): void => {
|
|
136
|
+
const transfer = (event as DragEvent).dataTransfer;
|
|
137
|
+
if (!carriesFiles(transfer)) return;
|
|
138
|
+
// Unprevented, the browser opens the file in place of the app.
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
switch (event.type) {
|
|
141
|
+
case "dragenter":
|
|
142
|
+
depth += 1;
|
|
143
|
+
track(transfer);
|
|
144
|
+
break;
|
|
145
|
+
case "dragover":
|
|
146
|
+
// A drag that entered before the listener existed is on the page all the same.
|
|
147
|
+
depth = Math.max(depth, 1);
|
|
148
|
+
track(transfer);
|
|
149
|
+
break;
|
|
150
|
+
case "dragleave":
|
|
151
|
+
depth = Math.max(0, depth - 1);
|
|
152
|
+
if (depth === 0) show({ state: "none" });
|
|
153
|
+
break;
|
|
154
|
+
case "drop": {
|
|
155
|
+
depth = 0;
|
|
156
|
+
show({ state: "none" });
|
|
157
|
+
const files = Array.from(transfer.files ?? []);
|
|
158
|
+
const items =
|
|
159
|
+
files.length > 0 ? files.map((file) => ({ kind: "file", type: file.type })) : dragItems(transfer);
|
|
160
|
+
receive(files, verdictFor(items));
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
for (const type of DRAG_EVENTS) view?.addEventListener(type, onDragEvent);
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
element,
|
|
170
|
+
state: () => current,
|
|
171
|
+
destroy() {
|
|
172
|
+
for (const type of DRAG_EVENTS) view?.removeEventListener(type, onDragEvent);
|
|
173
|
+
depth = 0;
|
|
174
|
+
if (current !== null) {
|
|
175
|
+
current = null;
|
|
176
|
+
setState(null);
|
|
177
|
+
}
|
|
178
|
+
element.remove();
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { ariaKeyShortcutsFromLabel, type ShellMessages } from "../../core/index.js";
|
|
2
|
+
import { createElement } from "../element.js";
|
|
3
|
+
import type { EmptyStateOptions, FileMenuAction } from "../types.js";
|
|
4
|
+
|
|
5
|
+
export interface EmptyStateContext {
|
|
6
|
+
ownerDocument: Document;
|
|
7
|
+
messages: ShellMessages;
|
|
8
|
+
options: EmptyStateOptions;
|
|
9
|
+
/** The drop hint under the buttons; `undefined` without a drop zone. */
|
|
10
|
+
dropHint: string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* The File group's "Open…": the hint offers it as a text link, the keyboard's way to a file
|
|
13
|
+
* while the start screen hides the menu and a drop needs a pointer. `undefined` without one.
|
|
14
|
+
*/
|
|
15
|
+
open?: FileMenuAction | undefined;
|
|
16
|
+
/** `id` of the title, the section's `aria-labelledby`. */
|
|
17
|
+
titleId: string;
|
|
18
|
+
/** Runs before `onNewDiagram` / `onShowExample`: the shell hides the card and moves the focus. */
|
|
19
|
+
onLeave(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EmptyStateController {
|
|
23
|
+
/** The `<section data-chrome="empty-state">`. */
|
|
24
|
+
readonly element: HTMLElement;
|
|
25
|
+
isShown(): boolean;
|
|
26
|
+
setShown(shown: boolean): void;
|
|
27
|
+
/** Focuses "New diagram", the card's first button. */
|
|
28
|
+
focus(): void;
|
|
29
|
+
destroy(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The Komet's size on the card (guide §8). */
|
|
33
|
+
const KOMET_SIZE = "64";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `<section class="cd-shell-empty-state" data-chrome="empty-state" aria-labelledby>` (guide §8):
|
|
37
|
+
* centred in the container, `min(400px, 100cqw − 24px)` wide, glass, `shadow-2`, radius `lg`,
|
|
38
|
+
* padding `32px 40px`, content centred with 16px gaps: `<img src=komet width=64 height=64
|
|
39
|
+
* alt="">`, `<h1>` title 21px/600, help 16px/1.6 `--cd-ui-text-leise`, then the buttons side by
|
|
40
|
+
* side with 8px gap, 36px high (`cd-shell-button`): primary "New diagram" first, ghost "Show
|
|
41
|
+
* example", then the drop hint 13px `--cd-ui-text-leise`. With the File group's "Open…" the hint
|
|
42
|
+
* ends in a text link `messages.openFileLink` in `--cd-ui-link` (and its shortcut chip in Geist
|
|
43
|
+
* Mono): not a third action, the keyboard's way to open a file. Opening keeps the card until the
|
|
44
|
+
* modeler leaves it (`setEmptyState(false)` after the file loaded). Layer 20, `hidden` until
|
|
45
|
+
* `setShown(true)`.
|
|
46
|
+
*/
|
|
47
|
+
export function createEmptyState(context: EmptyStateContext): EmptyStateController {
|
|
48
|
+
const { ownerDocument, messages, options, dropHint, open, titleId, onLeave } = context;
|
|
49
|
+
|
|
50
|
+
const button = (variant: "primary" | "ghost", label: string, run: () => void): HTMLButtonElement => {
|
|
51
|
+
const element = createElement(
|
|
52
|
+
ownerDocument,
|
|
53
|
+
"button",
|
|
54
|
+
{ type: "button", class: "cd-shell-button", "data-variant": variant },
|
|
55
|
+
label,
|
|
56
|
+
);
|
|
57
|
+
element.addEventListener("click", () => {
|
|
58
|
+
onLeave();
|
|
59
|
+
run();
|
|
60
|
+
});
|
|
61
|
+
return element;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const onShowExample = options.onShowExample;
|
|
65
|
+
const newDiagram = button("primary", messages.newDiagram, () => options.onNewDiagram());
|
|
66
|
+
const element = createElement(
|
|
67
|
+
ownerDocument,
|
|
68
|
+
"section",
|
|
69
|
+
{ class: "cd-shell-empty-state", "data-chrome": "empty-state", "aria-labelledby": titleId },
|
|
70
|
+
// Dragging the mark would start an image drag that some browsers carry as a file.
|
|
71
|
+
createElement(ownerDocument, "img", {
|
|
72
|
+
class: "cd-shell-empty-state-mark",
|
|
73
|
+
src: options.komet,
|
|
74
|
+
width: KOMET_SIZE,
|
|
75
|
+
height: KOMET_SIZE,
|
|
76
|
+
alt: "",
|
|
77
|
+
draggable: "false",
|
|
78
|
+
}),
|
|
79
|
+
createElement(ownerDocument, "h1", { class: "cd-shell-empty-state-title", id: titleId }, options.title),
|
|
80
|
+
createElement(ownerDocument, "p", { class: "cd-shell-empty-state-text" }, options.text),
|
|
81
|
+
createElement(
|
|
82
|
+
ownerDocument,
|
|
83
|
+
"div",
|
|
84
|
+
{ class: "cd-shell-empty-state-actions" },
|
|
85
|
+
newDiagram,
|
|
86
|
+
onShowExample && button("ghost", messages.showExample, () => onShowExample()),
|
|
87
|
+
),
|
|
88
|
+
hintParagraph(),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
function hintParagraph(): HTMLElement | undefined {
|
|
92
|
+
const hasDropHint = dropHint !== undefined && dropHint !== "";
|
|
93
|
+
if (!hasDropHint && !open) return undefined;
|
|
94
|
+
const paragraph = createElement(ownerDocument, "p", { class: "cd-shell-empty-state-hint" });
|
|
95
|
+
if (hasDropHint) paragraph.append(dropHint);
|
|
96
|
+
if (!open) return paragraph;
|
|
97
|
+
const shortcut = open.shortcut?.trim();
|
|
98
|
+
const link = createElement(
|
|
99
|
+
ownerDocument,
|
|
100
|
+
"button",
|
|
101
|
+
{
|
|
102
|
+
type: "button",
|
|
103
|
+
class: "cd-shell-empty-state-open",
|
|
104
|
+
"aria-keyshortcuts": shortcut ? ariaKeyShortcutsFromLabel(shortcut) : undefined,
|
|
105
|
+
"aria-haspopup": open.dialog ? "dialog" : undefined,
|
|
106
|
+
},
|
|
107
|
+
messages.openFileLink,
|
|
108
|
+
);
|
|
109
|
+
link.addEventListener("click", () => open.onSelect());
|
|
110
|
+
if (hasDropHint) {
|
|
111
|
+
paragraph.append(" ", createElement(ownerDocument, "span", { "aria-hidden": "true" }, "·"), " ");
|
|
112
|
+
}
|
|
113
|
+
// The link and its chip wrap as one: a chip alone on a line names no action.
|
|
114
|
+
const openItem = createElement(ownerDocument, "span", { class: "cd-shell-empty-state-open-item" }, link);
|
|
115
|
+
if (shortcut) {
|
|
116
|
+
openItem.append(
|
|
117
|
+
" ",
|
|
118
|
+
createElement(
|
|
119
|
+
ownerDocument,
|
|
120
|
+
"kbd",
|
|
121
|
+
{ class: "cd-shell-empty-state-key", "aria-hidden": "true" },
|
|
122
|
+
shortcut,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
paragraph.append(openItem);
|
|
127
|
+
return paragraph;
|
|
128
|
+
}
|
|
129
|
+
element.hidden = true;
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
element,
|
|
133
|
+
isShown: () => !element.hidden,
|
|
134
|
+
setShown(shown) {
|
|
135
|
+
element.hidden = !shown;
|
|
136
|
+
},
|
|
137
|
+
focus() {
|
|
138
|
+
newDiagram.focus({ preventScroll: true });
|
|
139
|
+
},
|
|
140
|
+
destroy() {
|
|
141
|
+
element.remove();
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ShellMessages } from "../../core/index.js";
|
|
2
|
+
import { createElement } from "../element.js";
|
|
3
|
+
import type { LegalLineOptions } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/** The Miragon legal pages (corporate-identity, skill docs-site, `miragon/legal.ts`). */
|
|
6
|
+
export const MIRAGON_HOME = "https://www.miragon.io/";
|
|
7
|
+
export const MIRAGON_IMPRESSUM = "https://www.miragon.io/impressum/";
|
|
8
|
+
export const MIRAGON_DATENSCHUTZ = "https://www.miragon.io/datenschutz/";
|
|
9
|
+
|
|
10
|
+
export interface LegalLineContext {
|
|
11
|
+
ownerDocument: Document;
|
|
12
|
+
messages: ShellMessages;
|
|
13
|
+
options: LegalLineOptions;
|
|
14
|
+
/** `cd-shell-legal-N`: the prefix of the id the links' `aria-describedby` names. */
|
|
15
|
+
idPrefix: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LegalLineController {
|
|
19
|
+
/** The `<nav data-chrome="legal">`. */
|
|
20
|
+
readonly element: HTMLElement;
|
|
21
|
+
destroy(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `<nav class="cd-shell-legal" data-chrome="legal" aria-label="Legal">` (guide §5.10, §2): one
|
|
26
|
+
* line, 13px, line height 20px, at `left: 12px; bottom: 12px`, glass without border and shadow,
|
|
27
|
+
* padding `0 8px`, radius `sm`, layer 20; in a container too narrow for it, it wraps between its
|
|
28
|
+
* entries and grows upwards. Links in `--cd-ui-link`, `target="_blank" rel="noopener noreferrer"`
|
|
29
|
+
* and `aria-describedby` a hidden `messages.opensInNewTab`, each after the first in a
|
|
30
|
+
* `<span class="cd-shell-legal-item">` with its separator `<span aria-hidden="true">·</span>`:
|
|
31
|
+
* `messages.copyright(year)` → `MIRAGON_HOME`, "Impressum" → `MIRAGON_IMPRESSUM` and "Datenschutz"
|
|
32
|
+
* → `MIRAGON_DATENSCHUTZ` (both `lang="de"`, never translated), then `options.links`.
|
|
33
|
+
*/
|
|
34
|
+
export function createLegalLine(context: LegalLineContext): LegalLineController {
|
|
35
|
+
const { ownerDocument, messages, options } = context;
|
|
36
|
+
const year = options.year ?? new Date().getFullYear();
|
|
37
|
+
|
|
38
|
+
// Every link opens a new tab; screen readers hear that from one shared description.
|
|
39
|
+
const newTabId = `${context.idPrefix}-new-tab`;
|
|
40
|
+
const link = (label: string, href: string, lang?: string): HTMLAnchorElement =>
|
|
41
|
+
createElement(
|
|
42
|
+
ownerDocument,
|
|
43
|
+
"a",
|
|
44
|
+
{ href, lang, target: "_blank", rel: "noopener noreferrer", "aria-describedby": newTabId },
|
|
45
|
+
label,
|
|
46
|
+
);
|
|
47
|
+
const separator = (): HTMLElement => createElement(ownerDocument, "span", { "aria-hidden": "true" }, "·");
|
|
48
|
+
const item = (anchor: HTMLAnchorElement): HTMLElement =>
|
|
49
|
+
createElement(ownerDocument, "span", { class: "cd-shell-legal-item" }, separator(), anchor);
|
|
50
|
+
|
|
51
|
+
// The legal pages are German whatever the tool's language (guide §2), so their names are too.
|
|
52
|
+
const links = [
|
|
53
|
+
link(messages.copyright(year), MIRAGON_HOME),
|
|
54
|
+
link("Impressum", MIRAGON_IMPRESSUM, "de"),
|
|
55
|
+
link("Datenschutz", MIRAGON_DATENSCHUTZ, "de"),
|
|
56
|
+
...(options.links ?? []).map((extra) => link(extra.label, extra.href, extra.lang)),
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const element = createElement(ownerDocument, "nav", {
|
|
60
|
+
class: "cd-shell-legal",
|
|
61
|
+
"data-chrome": "legal",
|
|
62
|
+
"aria-label": messages.legal,
|
|
63
|
+
});
|
|
64
|
+
element.append(
|
|
65
|
+
...links.map((anchor, index) => (index === 0 ? anchor : item(anchor))),
|
|
66
|
+
createElement(ownerDocument, "span", { id: newTabId, hidden: "" }, messages.opensInNewTab),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
element,
|
|
71
|
+
destroy() {
|
|
72
|
+
element.remove();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|