@stocksharp/diagram 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +42 -0
  2. package/dist/esm/canvas-renderer.js +91 -16
  3. package/dist/esm/canvas-renderer.js.map +1 -1
  4. package/dist/esm/diagram/context-menu.js +272 -0
  5. package/dist/esm/diagram/context-menu.js.map +1 -0
  6. package/dist/esm/diagram/stocksharp-diagram.js +83 -5
  7. package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
  8. package/dist/esm/draw-surface.js +2 -0
  9. package/dist/esm/draw-surface.js.map +1 -0
  10. package/dist/esm/i18n.js.map +1 -1
  11. package/dist/esm/index.js.map +1 -1
  12. package/dist/esm/svg-surface.js +341 -0
  13. package/dist/esm/svg-surface.js.map +1 -0
  14. package/dist/ssdiagram.js +879 -164
  15. package/dist/ssdiagram.js.map +4 -4
  16. package/dist/types/canvas-renderer.d.ts +12 -0
  17. package/dist/types/canvas-renderer.d.ts.map +1 -1
  18. package/dist/types/diagram/api.d.ts +36 -2
  19. package/dist/types/diagram/api.d.ts.map +1 -1
  20. package/dist/types/diagram/context-menu.d.ts +31 -0
  21. package/dist/types/diagram/context-menu.d.ts.map +1 -0
  22. package/dist/types/diagram/stocksharp-diagram.d.ts +11 -5
  23. package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
  24. package/dist/types/draw-surface.d.ts +52 -0
  25. package/dist/types/draw-surface.d.ts.map +1 -0
  26. package/dist/types/i18n.d.ts +4 -0
  27. package/dist/types/i18n.d.ts.map +1 -1
  28. package/dist/types/index.d.ts +2 -2
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/dist/types/svg-surface.d.ts +74 -0
  31. package/dist/types/svg-surface.d.ts.map +1 -0
  32. package/package.json +1 -1
  33. package/src/canvas-renderer.ts +103 -16
  34. package/src/diagram/api.ts +46 -2
  35. package/src/diagram/context-menu.ts +298 -0
  36. package/src/diagram/stocksharp-diagram.ts +93 -6
  37. package/src/draw-surface.ts +61 -0
  38. package/src/i18n.ts +7 -0
  39. package/src/index.ts +8 -0
  40. package/src/svg-surface.ts +430 -0
@@ -1,3 +1,5 @@
1
+ import type { DrawSurface } from './draw-surface.js';
2
+ import { SvgSurface } from './svg-surface.js';
1
3
  // Internal dependency-free canvas renderer used by StockSharpDiagram.
2
4
  //
3
5
  // Dependency-free, pure 2D canvas. Demonstrates the hard parts: typed
@@ -507,7 +509,9 @@ function colorLuminance(color: string | undefined): number {
507
509
  export class Diagram {
508
510
  private readonly host: HTMLElement;
509
511
  private readonly canvas: HTMLCanvasElement;
510
- private ctx: CanvasRenderingContext2D;
512
+ // Typed as the surface, not the context: the on-screen path still passes the browser's own
513
+ // 2D context (it satisfies the interface structurally), while export can hand in an SVG one.
514
+ private ctx: DrawSurface;
511
515
  private readonly opts: DiagramOptions;
512
516
 
513
517
  private nodes: NodeModel[] = [];
@@ -1332,6 +1336,65 @@ export class Diagram {
1332
1336
  return copy;
1333
1337
  }
1334
1338
 
1339
+ const layout = this.exportLayout(scope, options);
1340
+ const { pixelRatio, exportScale, padding, bounds, width, height, pixelWidth, pixelHeight } = layout;
1341
+
1342
+ const output = document.createElement('canvas');
1343
+ output.width = pixelWidth;
1344
+ output.height = pixelHeight;
1345
+ output.style.width = `${width}px`;
1346
+ output.style.height = `${height}px`;
1347
+ const outputContext = output.getContext('2d');
1348
+ if (outputContext === null) throw new Error('ssdiagram: screenshot 2d context unavailable');
1349
+
1350
+ const previous = {
1351
+ ctx: this.ctx,
1352
+ width: this.width,
1353
+ height: this.height,
1354
+ dpr: this.dpr,
1355
+ scale: this.scale,
1356
+ offX: this.offX,
1357
+ offY: this.offY,
1358
+ overviewVisible: this.overviewVisible,
1359
+ background: this.opts.background,
1360
+ };
1361
+ try {
1362
+ this.ctx = outputContext;
1363
+ this.width = width;
1364
+ this.height = height;
1365
+ this.dpr = pixelRatio;
1366
+ this.scale = exportScale;
1367
+ this.offX = scope === 'content' && bounds !== null ? padding - bounds.minX * exportScale : previous.offX;
1368
+ this.offY = scope === 'content' && bounds !== null ? padding - bounds.minY * exportScale : previous.offY;
1369
+ this.overviewVisible = options.includeOverview ?? (scope === 'viewport' && previous.overviewVisible);
1370
+ if (options.background !== undefined) this.opts.background = options.background;
1371
+ outputContext.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
1372
+ this.draw({
1373
+ grid: options.includeGrid ?? true,
1374
+ overview: this.overviewVisible,
1375
+ selection: options.includeSelection ?? scope === 'viewport',
1376
+ runtime: options.includeRuntimeState ?? true,
1377
+ transient: false,
1378
+ });
1379
+ } finally {
1380
+ this.ctx = previous.ctx;
1381
+ this.width = previous.width;
1382
+ this.height = previous.height;
1383
+ this.dpr = previous.dpr;
1384
+ this.scale = previous.scale;
1385
+ this.offX = previous.offX;
1386
+ this.offY = previous.offY;
1387
+ this.overviewVisible = previous.overviewVisible;
1388
+ this.opts.background = previous.background;
1389
+ }
1390
+ return output;
1391
+ }
1392
+
1393
+ // Everything an export needs to know about size and placement, for either output. Shared on
1394
+ // purpose: a raster and a vector export of the same scope must frame the picture identically,
1395
+ // and two copies of this arithmetic would eventually disagree about padding or where the origin
1396
+ // sits. A block comment here would be lifted onto the next public member in the .d.ts.
1397
+ private exportLayout(scope: DiagramScreenshotScope, options: DiagramScreenshotOptions) {
1335
1398
  const pixelRatio = this.positiveScreenshotNumber(options.pixelRatio ?? this.dpr, 'pixelRatio');
1336
1399
  const exportScale = scope === 'content'
1337
1400
  ? this.positiveScreenshotNumber(options.scale ?? 1, 'scale')
@@ -1348,17 +1411,41 @@ export class Diagram {
1348
1411
  : this.height;
1349
1412
  const pixelWidth = Math.ceil(width * pixelRatio);
1350
1413
  const pixelHeight = Math.ceil(height * pixelRatio);
1414
+
1351
1415
  if (pixelWidth > 16384 || pixelHeight > 16384 || pixelWidth * pixelHeight > 268_435_456) {
1352
1416
  throw new RangeError(`ssdiagram: screenshot is too large (${pixelWidth}x${pixelHeight})`);
1353
1417
  }
1354
1418
 
1355
- const output = document.createElement('canvas');
1356
- output.width = pixelWidth;
1357
- output.height = pixelHeight;
1358
- output.style.width = `${width}px`;
1359
- output.style.height = `${height}px`;
1360
- const outputContext = output.getContext('2d');
1361
- if (outputContext === null) throw new Error('ssdiagram: screenshot 2d context unavailable');
1419
+ return { pixelRatio, exportScale, padding, bounds, width, height, pixelWidth, pixelHeight };
1420
+ }
1421
+
1422
+ /**
1423
+ * The same picture takeScreenshot produces, as SVG.
1424
+ *
1425
+ * It is the renderer that draws it -- the surface underneath simply records instead of paints --
1426
+ * so the vector output cannot drift from what the screen and the raster export show. Text keeps
1427
+ * the on-screen metrics by measuring through a real 2D context.
1428
+ *
1429
+ * Scale and pixelRatio still frame the picture (they decide the viewBox), but nothing is
1430
+ * resampled: the result is resolution-independent, which is the point of asking for it.
1431
+ */
1432
+ takeSvg(options: DiagramScreenshotOptions = {}): string {
1433
+ if (this.destroyed) throw new Error('ssdiagram: cannot export a destroyed diagram');
1434
+
1435
+ const scope = options.scope ?? 'viewport';
1436
+ if (scope !== 'viewport' && scope !== 'content') {
1437
+ throw new RangeError(`ssdiagram: unsupported screenshot scope "${String(scope)}"`);
1438
+ }
1439
+
1440
+ const { exportScale, padding, bounds, width, height } = this.exportLayout(scope, options);
1441
+
1442
+ const metrics = this.canvas.getContext('2d');
1443
+ const surface = new SvgSurface({
1444
+ width,
1445
+ height,
1446
+ background: options.background ?? this.opts.background ?? '#1b1b1f',
1447
+ metrics: metrics ?? undefined,
1448
+ });
1362
1449
 
1363
1450
  const previous = {
1364
1451
  ctx: this.ctx,
@@ -1369,19 +1456,19 @@ export class Diagram {
1369
1456
  offX: this.offX,
1370
1457
  offY: this.offY,
1371
1458
  overviewVisible: this.overviewVisible,
1372
- background: this.opts.background,
1373
1459
  };
1460
+
1374
1461
  try {
1375
- this.ctx = outputContext;
1462
+ this.ctx = surface;
1376
1463
  this.width = width;
1377
1464
  this.height = height;
1378
- this.dpr = pixelRatio;
1465
+ // 1: an SVG has no device pixels to multiply by, and the viewBox carries the size.
1466
+ this.dpr = 1;
1379
1467
  this.scale = exportScale;
1380
1468
  this.offX = scope === 'content' && bounds !== null ? padding - bounds.minX * exportScale : previous.offX;
1381
1469
  this.offY = scope === 'content' && bounds !== null ? padding - bounds.minY * exportScale : previous.offY;
1382
1470
  this.overviewVisible = options.includeOverview ?? (scope === 'viewport' && previous.overviewVisible);
1383
- if (options.background !== undefined) this.opts.background = options.background;
1384
- outputContext.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
1471
+
1385
1472
  this.draw({
1386
1473
  grid: options.includeGrid ?? true,
1387
1474
  overview: this.overviewVisible,
@@ -1398,9 +1485,9 @@ export class Diagram {
1398
1485
  this.offX = previous.offX;
1399
1486
  this.offY = previous.offY;
1400
1487
  this.overviewVisible = previous.overviewVisible;
1401
- this.opts.background = previous.background;
1402
1488
  }
1403
- return output;
1489
+
1490
+ return surface.toSvg();
1404
1491
  }
1405
1492
 
1406
1493
  private positiveScreenshotNumber(value: number, name: string): number {
@@ -3072,7 +3159,7 @@ export class Diagram {
3072
3159
  }
3073
3160
  }
3074
3161
 
3075
- function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
3162
+ function roundRect(ctx: DrawSurface, x: number, y: number, w: number, h: number, r: number): void {
3076
3163
  const rr = Math.min(r, w / 2, h / 2);
3077
3164
  ctx.beginPath();
3078
3165
  ctx.moveTo(x + rr, y);
@@ -18,6 +18,12 @@ export interface DiagramOptions {
18
18
  catalog: import('./catalog.js').StockSharpCatalog;
19
19
  /** Show the built-in top-right fullscreen request button. Defaults to true. */
20
20
  showFullscreenButton?: boolean;
21
+ /**
22
+ * Show the built-in context menu on right-click. Defaults to true, because the control
23
+ * suppresses the browser's own menu regardless. Turn it off to draw your own from
24
+ * `contextMenuRequested`, which is emitted either way.
25
+ */
26
+ showContextMenu?: boolean;
21
27
  /** Tooltip/aria text for that button. Defaults to English. */
22
28
  fullscreenLabels?: DiagramFullscreenLabels;
23
29
  overviewContainer?: HTMLElement | null;
@@ -112,7 +118,18 @@ export interface FullscreenRequestedPayload {
112
118
  fullscreen: boolean;
113
119
  }
114
120
 
115
- export type ContextCommand = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'open' | 'delete' | 'properties' | 'help';
121
+ export type ContextCommand =
122
+ | 'undo' | 'redo'
123
+ | 'cut' | 'copy' | 'paste'
124
+ | 'open' | 'delete'
125
+ | 'exportDocument' | 'exportPng' | 'exportSvg'
126
+ | 'properties' | 'help';
127
+
128
+ /**
129
+ * Names a submenu rather than something to run. Deliberately outside ContextCommand:
130
+ * `executeContextCommand` takes commands, and a submenu label has nothing to execute.
131
+ */
132
+ export type ContextCommandGroup = 'export';
116
133
 
117
134
  export interface ContextCommandPayload {
118
135
  command: ContextCommand;
@@ -125,6 +142,32 @@ export interface ContextCommandState {
125
142
  enabled: boolean;
126
143
  }
127
144
 
145
+ /** A submenu: its own enabled state, plus the items to show when it opens. */
146
+ export interface ContextCommandGroupState {
147
+ group: ContextCommandGroup;
148
+ enabled: boolean;
149
+ commands: ContextCommandState[];
150
+ }
151
+
152
+ /**
153
+ * One entry of the menu, in the order it should be drawn. Discriminate on `group`:
154
+ * an entry that has it is a submenu, an entry that does not is a command.
155
+ */
156
+ export type ContextMenuItemState = ContextCommandState | ContextCommandGroupState;
157
+
158
+ /** What an export request asks the host to produce. */
159
+ export type ExportFormat = 'document' | 'png' | 'svg';
160
+
161
+ /**
162
+ * The user picked something from the export submenu. Like `nodeProperties`, this is a
163
+ * request and nothing more: the control does not write files or open dialogs, so the
164
+ * host fulfils it with `saveDocument()`, `takeScreenshot()` or `takeSvg()` and its own
165
+ * options for scope, padding and background.
166
+ */
167
+ export interface ExportRequestedPayload {
168
+ format: ExportFormat;
169
+ }
170
+
128
171
  export interface ContextMenuRequestedPayload {
129
172
  x: number;
130
173
  y: number;
@@ -132,7 +175,7 @@ export interface ContextMenuRequestedPayload {
132
175
  link: Link | null;
133
176
  port: Port | null;
134
177
  portDirection: PortDirection | null;
135
- commands: ContextCommandState[];
178
+ commands: ContextMenuItemState[];
136
179
  }
137
180
 
138
181
  export interface NodeSelectedPayload {
@@ -251,6 +294,7 @@ export interface DiagramEvents {
251
294
  loadFinished: LoadFinishedPayload;
252
295
  contextCommand: ContextCommandPayload;
253
296
  contextMenuRequested: ContextMenuRequestedPayload;
297
+ exportRequested: ExportRequestedPayload;
254
298
  undoRequested: NodeChangePayload & LinkChangePayload;
255
299
  redoRequested: NodeChangePayload & LinkChangePayload;
256
300
  nodeEdit: NodeChangePayload;
@@ -0,0 +1,298 @@
1
+ import { t } from '../i18n.js';
2
+ import type { ContextCommand, ContextCommandGroup, ContextMenuItemState } from './api.js';
3
+
4
+ /**
5
+ * The control's own context menu.
6
+ *
7
+ * It exists because right-click already suppresses the browser's menu whether or not anyone
8
+ * is listening. A control that takes the native menu away owes one back, so this is on by
9
+ * default; a host with its own menu turns it off (`showContextMenu: false`) and keeps using
10
+ * `contextMenuRequested`, which is emitted either way.
11
+ *
12
+ * Styling follows the fullscreen button: inline styles driven by `--ssdiagram-*` custom
13
+ * properties with sensible fallbacks, so the menu is usable with no stylesheet at all and
14
+ * retintable without one. Class names are stable for hosts that would rather use CSS.
15
+ */
16
+
17
+ const ROOT_CLASS = 'ssdiagram-context-menu';
18
+ const ITEM_CLASS = 'ssdiagram-context-menu-item';
19
+ const SUBMENU_CLASS = 'ssdiagram-context-menu-submenu';
20
+ const SEPARATOR_CLASS = 'ssdiagram-context-menu-separator';
21
+ const LABEL_CLASS = 'ssdiagram-context-menu-label';
22
+ const ARROW_CLASS = 'ssdiagram-context-menu-arrow';
23
+
24
+ /** A rule below these entries, so related commands read as blocks rather than one long list. */
25
+ const SEPARATE_AFTER: ReadonlySet<string> = new Set(['redo', 'paste', 'delete']);
26
+
27
+ function label(id: ContextCommand | ContextCommandGroup): string {
28
+ switch (id) {
29
+ case 'undo': return t('undo', 'Undo');
30
+ case 'redo': return t('redo', 'Redo');
31
+ case 'cut': return t('cut', 'Cut');
32
+ case 'copy': return t('copy', 'Copy');
33
+ case 'paste': return t('paste', 'Paste');
34
+ case 'open': return t('ctxOpen', 'Open');
35
+ case 'delete': return t('delete', 'Delete');
36
+ case 'export': return t('ctxExportAs', 'Export as');
37
+ case 'exportDocument': return t('ctxExportDocument', 'Scheme');
38
+ case 'exportPng': return t('ctxExportPng', 'PNG image');
39
+ case 'exportSvg': return t('ctxExportSvg', 'SVG image');
40
+ case 'properties': return t('properties', 'Properties');
41
+ case 'help': return t('ctxHelp', 'Help');
42
+ }
43
+ }
44
+
45
+ const PANEL_STYLE: Partial<CSSStyleDeclaration> = {
46
+ position: 'fixed',
47
+ zIndex: '2147483000',
48
+ minWidth: '176px',
49
+ padding: '4px',
50
+ border: '1px solid var(--ssdiagram-menu-border, var(--ssdiagram-control-border, var(--t-border, #3a4250)))',
51
+ borderRadius: '7px',
52
+ background: 'var(--ssdiagram-menu-background, var(--ssdiagram-control-background, var(--t-panel, #181a20)))',
53
+ color: 'var(--ssdiagram-menu-color, var(--ssdiagram-control-color, var(--t-text, #eaecef)))',
54
+ boxShadow: '0 12px 32px rgba(0, 0, 0, 0.45)',
55
+ font: 'var(--ssdiagram-menu-font, 12.5px/1.35 system-ui, -apple-system, "Segoe UI", sans-serif)',
56
+ boxSizing: 'border-box',
57
+ userSelect: 'none',
58
+ };
59
+
60
+ const ITEM_STYLE: Partial<CSSStyleDeclaration> = {
61
+ display: 'flex',
62
+ alignItems: 'center',
63
+ gap: '10px',
64
+ width: '100%',
65
+ padding: '6px 10px',
66
+ border: '0',
67
+ borderRadius: '4px',
68
+ background: 'transparent',
69
+ color: 'inherit',
70
+ font: 'inherit',
71
+ textAlign: 'left',
72
+ whiteSpace: 'nowrap',
73
+ cursor: 'pointer',
74
+ boxSizing: 'border-box',
75
+ };
76
+
77
+ const HOVER_BACKGROUND = 'var(--ssdiagram-menu-hover, var(--t-hover, rgba(255, 255, 255, 0.08)))';
78
+ const DISABLED_COLOR = 'var(--ssdiagram-menu-disabled-color, var(--t-text-dim, #848e9c))';
79
+
80
+ export interface ContextMenuViewOptions {
81
+ /** Where the menu is appended. The diagram mount point, so it survives a fullscreened panel. */
82
+ container: HTMLElement;
83
+ /** Runs the picked command. The same entry point a host menu would use. */
84
+ execute: (command: ContextCommand) => void;
85
+ }
86
+
87
+ export class ContextMenuView {
88
+ private readonly container: HTMLElement;
89
+ private readonly execute: (command: ContextCommand) => void;
90
+ private root: HTMLElement | null = null;
91
+ private submenu: HTMLElement | null = null;
92
+ private readonly detach: Array<() => void> = [];
93
+
94
+ constructor(options: ContextMenuViewOptions) {
95
+ this.container = options.container;
96
+ this.execute = options.execute;
97
+ }
98
+
99
+ get isOpen(): boolean {
100
+ return this.root !== null;
101
+ }
102
+
103
+ show(x: number, y: number, items: readonly ContextMenuItemState[]): void {
104
+ this.hide();
105
+ const owner = this.container.ownerDocument ?? document;
106
+ const root = this.panel(owner);
107
+ root.className = ROOT_CLASS;
108
+ root.setAttribute('role', 'menu');
109
+
110
+ items.forEach((item, index) => {
111
+ if ('group' in item) root.appendChild(this.groupEntry(owner, item));
112
+ else root.appendChild(this.commandEntry(owner, item.command, item.enabled, true));
113
+ const id = 'group' in item ? item.group : item.command;
114
+ if (SEPARATE_AFTER.has(id) && index < items.length - 1) root.appendChild(this.separator(owner));
115
+ });
116
+
117
+ this.container.appendChild(root);
118
+ this.root = root;
119
+ this.place(root, x, y);
120
+ this.watchForDismissal(owner);
121
+ }
122
+
123
+ hide(): void {
124
+ for (const dispose of this.detach.splice(0)) dispose();
125
+ this.submenu = null;
126
+ this.root?.remove();
127
+ this.root = null;
128
+ }
129
+
130
+ destroy(): void {
131
+ this.hide();
132
+ }
133
+
134
+ private panel(owner: Document): HTMLElement {
135
+ const panel = owner.createElement('div');
136
+ Object.assign(panel.style, PANEL_STYLE);
137
+ return panel;
138
+ }
139
+
140
+ private separator(owner: Document): HTMLElement {
141
+ const rule = owner.createElement('div');
142
+ rule.className = SEPARATOR_CLASS;
143
+ Object.assign(rule.style, {
144
+ height: '1px',
145
+ margin: '4px 6px',
146
+ background: 'var(--ssdiagram-menu-border, var(--t-border, #3a4250))',
147
+ });
148
+ return rule;
149
+ }
150
+
151
+ private item(owner: Document, text: string, enabled: boolean, arrow: boolean): HTMLButtonElement {
152
+ const button = owner.createElement('button');
153
+ button.type = 'button';
154
+ button.className = ITEM_CLASS;
155
+ button.setAttribute('role', 'menuitem');
156
+ Object.assign(button.style, ITEM_STYLE);
157
+ if (!enabled) {
158
+ button.disabled = true;
159
+ button.style.color = DISABLED_COLOR;
160
+ button.style.cursor = 'default';
161
+ }
162
+
163
+ const caption = owner.createElement('span');
164
+ caption.className = LABEL_CLASS;
165
+ caption.textContent = text;
166
+ caption.style.flex = '1';
167
+ button.appendChild(caption);
168
+
169
+ if (arrow) {
170
+ const chevron = owner.createElement('span');
171
+ chevron.className = ARROW_CLASS;
172
+ chevron.textContent = '›';
173
+ chevron.setAttribute('aria-hidden', 'true');
174
+ Object.assign(chevron.style, { flex: 'none', opacity: '0.7', fontSize: '14px' });
175
+ button.appendChild(chevron);
176
+ }
177
+ return button;
178
+ }
179
+
180
+ // `closesSubmenu` separates the two places an item can live. A top-level item shuts any
181
+ // open submenu when the pointer arrives, the way menus everywhere behave. An item inside
182
+ // the submenu must not: reaching it means the pointer has entered that very panel, and
183
+ // closing it there would pull it out from under the pointer and drop the click on the
184
+ // canvas behind.
185
+ private commandEntry(owner: Document, command: ContextCommand, enabled: boolean, closesSubmenu: boolean): HTMLElement {
186
+ const button = this.item(owner, label(command), enabled, false);
187
+ this.on(button, 'pointerenter', () => {
188
+ if (closesSubmenu) this.closeSubmenu();
189
+ if (enabled) button.style.background = HOVER_BACKGROUND;
190
+ });
191
+ this.on(button, 'pointerleave', () => { button.style.background = 'transparent'; });
192
+ this.on(button, 'click', () => {
193
+ if (!enabled) return;
194
+ // Hide first: a command may open a dialog, and a menu left on top of it looks stuck.
195
+ this.hide();
196
+ this.execute(command);
197
+ });
198
+ return button;
199
+ }
200
+
201
+ private groupEntry(owner: Document, group: { group: ContextCommandGroup; enabled: boolean; commands: readonly { command: ContextCommand; enabled: boolean }[] }): HTMLElement {
202
+ const host = owner.createElement('div');
203
+ host.style.position = 'relative';
204
+
205
+ const button = this.item(owner, label(group.group), group.enabled, true);
206
+ host.appendChild(button);
207
+
208
+ const panel = this.panel(owner);
209
+ panel.className = SUBMENU_CLASS;
210
+ panel.setAttribute('role', 'menu');
211
+ Object.assign(panel.style, { position: 'absolute', left: '100%', top: '-5px', display: 'none' });
212
+ for (const child of group.commands) panel.appendChild(this.commandEntry(owner, child.command, child.enabled, false));
213
+ host.appendChild(panel);
214
+
215
+ const open = (): void => {
216
+ if (!group.enabled) return;
217
+ button.style.background = HOVER_BACKGROUND;
218
+ panel.style.display = 'block';
219
+ this.submenu = panel;
220
+ this.keepOnScreen(panel);
221
+ };
222
+ this.on(button, 'pointerenter', open);
223
+ // Tap opens it too: a touch device never sends pointerenter on its own.
224
+ this.on(button, 'click', open);
225
+ this.on(host, 'pointerleave', () => {
226
+ if (this.submenu !== panel) return;
227
+ button.style.background = 'transparent';
228
+ panel.style.display = 'none';
229
+ this.submenu = null;
230
+ });
231
+ return host;
232
+ }
233
+
234
+ private closeSubmenu(): void {
235
+ if (this.submenu === null) return;
236
+ this.submenu.style.display = 'none';
237
+ this.submenu = null;
238
+ }
239
+
240
+ /** Keeps the panel inside the viewport, flipping it left when it would run off the edge. */
241
+ private keepOnScreen(panel: HTMLElement): void {
242
+ if (typeof panel.getBoundingClientRect !== 'function' || typeof window === 'undefined') return;
243
+ const box = panel.getBoundingClientRect();
244
+ if (box.right > window.innerWidth - 4) {
245
+ panel.style.left = 'auto';
246
+ panel.style.right = '100%';
247
+ }
248
+ if (box.bottom > window.innerHeight - 4) {
249
+ panel.style.top = 'auto';
250
+ panel.style.bottom = '-5px';
251
+ }
252
+ }
253
+
254
+ private place(root: HTMLElement, x: number, y: number): void {
255
+ root.style.left = `${x}px`;
256
+ root.style.top = `${y}px`;
257
+ if (typeof root.getBoundingClientRect !== 'function' || typeof window === 'undefined') return;
258
+ const box = root.getBoundingClientRect();
259
+ // Flip rather than clamp: a menu shoved back onto the screen would cover the very
260
+ // thing that was right-clicked.
261
+ if (box.right > window.innerWidth - 4) root.style.left = `${Math.max(4, x - box.width)}px`;
262
+ if (box.bottom > window.innerHeight - 4) root.style.top = `${Math.max(4, y - box.height)}px`;
263
+ }
264
+
265
+ private watchForDismissal(owner: Document): void {
266
+ const dismiss = (): void => this.hide();
267
+ const outside = (event: Event): void => {
268
+ // Duck-typed rather than `target instanceof Node`: this runs in whatever document
269
+ // the host mounted us in, and a cross-document Node fails that check.
270
+ const root = this.root;
271
+ const target = event.target as Node | null;
272
+ if (root !== null && target !== null && typeof root.contains === 'function' && root.contains(target)) return;
273
+ this.hide();
274
+ };
275
+ this.on(owner, 'pointerdown', outside, true);
276
+ this.on(owner, 'keydown', (event: Event) => {
277
+ if ((event as KeyboardEvent).key === 'Escape') this.hide();
278
+ });
279
+ if (typeof window !== 'undefined') {
280
+ // Scroll and resize move the diagram out from under the menu, which would otherwise
281
+ // hang in place pointing at nothing.
282
+ this.on(window, 'blur', dismiss);
283
+ this.on(window, 'resize', dismiss);
284
+ this.on(window, 'scroll', dismiss, true);
285
+ }
286
+ }
287
+
288
+ private on(
289
+ target: { addEventListener?: (type: string, listener: EventListener, options?: boolean) => void; removeEventListener?: (type: string, listener: EventListener, options?: boolean) => void },
290
+ type: string,
291
+ listener: EventListener,
292
+ capture = false,
293
+ ): void {
294
+ if (typeof target.addEventListener !== 'function') return;
295
+ target.addEventListener(type, listener, capture);
296
+ this.detach.push(() => target.removeEventListener?.(type, listener, capture));
297
+ }
298
+ }