@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.
- package/README.md +42 -0
- package/dist/esm/canvas-renderer.js +91 -16
- package/dist/esm/canvas-renderer.js.map +1 -1
- package/dist/esm/diagram/context-menu.js +272 -0
- package/dist/esm/diagram/context-menu.js.map +1 -0
- package/dist/esm/diagram/stocksharp-diagram.js +83 -5
- package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
- package/dist/esm/draw-surface.js +2 -0
- package/dist/esm/draw-surface.js.map +1 -0
- package/dist/esm/i18n.js.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/svg-surface.js +341 -0
- package/dist/esm/svg-surface.js.map +1 -0
- package/dist/ssdiagram.js +879 -164
- package/dist/ssdiagram.js.map +4 -4
- package/dist/types/canvas-renderer.d.ts +12 -0
- package/dist/types/canvas-renderer.d.ts.map +1 -1
- package/dist/types/diagram/api.d.ts +36 -2
- package/dist/types/diagram/api.d.ts.map +1 -1
- package/dist/types/diagram/context-menu.d.ts +31 -0
- package/dist/types/diagram/context-menu.d.ts.map +1 -0
- package/dist/types/diagram/stocksharp-diagram.d.ts +11 -5
- package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
- package/dist/types/draw-surface.d.ts +52 -0
- package/dist/types/draw-surface.d.ts.map +1 -0
- package/dist/types/i18n.d.ts +4 -0
- package/dist/types/i18n.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/svg-surface.d.ts +74 -0
- package/dist/types/svg-surface.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/canvas-renderer.ts +103 -16
- package/src/diagram/api.ts +46 -2
- package/src/diagram/context-menu.ts +298 -0
- package/src/diagram/stocksharp-diagram.ts +93 -6
- package/src/draw-surface.ts +61 -0
- package/src/i18n.ts +7 -0
- package/src/index.ts +8 -0
- package/src/svg-surface.ts +430 -0
package/src/canvas-renderer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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 =
|
|
1462
|
+
this.ctx = surface;
|
|
1376
1463
|
this.width = width;
|
|
1377
1464
|
this.height = height;
|
|
1378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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);
|
package/src/diagram/api.ts
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
+
}
|