@stocksharp/diagram 0.4.1 → 0.6.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 +105 -2
- package/dist/esm/canvas-renderer.js +116 -36
- package/dist/esm/canvas-renderer.js.map +1 -1
- package/dist/esm/diagram/context-menu.js +319 -0
- package/dist/esm/diagram/context-menu.js.map +1 -0
- package/dist/esm/diagram/stocksharp-diagram.js +121 -9
- 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/embed.js +7 -4
- package/dist/esm/embed.js.map +1 -1
- package/dist/esm/i18n.js +15 -4
- 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 +978 -188
- package/dist/ssdiagram.js.map +4 -4
- package/dist/types/canvas-renderer.d.ts +13 -0
- package/dist/types/canvas-renderer.d.ts.map +1 -1
- package/dist/types/diagram/api.d.ts +42 -2
- package/dist/types/diagram/api.d.ts.map +1 -1
- package/dist/types/diagram/context-menu.d.ts +33 -0
- package/dist/types/diagram/context-menu.d.ts.map +1 -0
- package/dist/types/diagram/stocksharp-diagram.d.ts +18 -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/embed.d.ts.map +1 -1
- package/dist/types/i18n.d.ts +12 -0
- package/dist/types/i18n.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -1
- 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 +120 -27
- package/src/diagram/api.ts +53 -2
- package/src/diagram/context-menu.ts +363 -0
- package/src/diagram/stocksharp-diagram.ts +134 -10
- package/src/draw-surface.ts +61 -0
- package/src/embed.ts +7 -4
- package/src/i18n.ts +41 -6
- package/src/index.ts +10 -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 {
|
|
@@ -1999,6 +2086,18 @@ export class Diagram {
|
|
|
1999
2086
|
}
|
|
2000
2087
|
|
|
2001
2088
|
// ---- input ------------------------------------------------------
|
|
2089
|
+
// Ends hover and everything hanging off it: highlight, cursor and the delayed tooltip.
|
|
2090
|
+
private clearHover(): void {
|
|
2091
|
+
if (this.hoverPort !== null) this.emit('portHover', { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
|
|
2092
|
+
if (this.hoverNode !== null) this.emit('nodeHover', { node: this.hoverNode, hovering: false });
|
|
2093
|
+
if (this.hoveredLink !== null) this.emit('linkHover', { link: this.hoveredLink, hovering: false });
|
|
2094
|
+
this.hoverPort = null; this.hoverNode = null; this.hoveredLink = null;
|
|
2095
|
+
this.tipTarget = null; this.tipShow = false;
|
|
2096
|
+
if (this.tipTimer !== null) { clearTimeout(this.tipTimer); this.tipTimer = null; }
|
|
2097
|
+
this.canvas.style.cursor = 'default';
|
|
2098
|
+
this.scheduleDraw();
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2002
2101
|
private cancelLongPress(): void {
|
|
2003
2102
|
if (this.lpTimer !== null) { clearTimeout(this.lpTimer); this.lpTimer = null; }
|
|
2004
2103
|
this.lpStart = null;
|
|
@@ -2024,7 +2123,10 @@ export class Diagram {
|
|
|
2024
2123
|
}
|
|
2025
2124
|
this.dragNode = null; this.dragStart = []; this.rubber = null;
|
|
2026
2125
|
this.panning = false; this.linking = null; this.relinking = null; this.relinkCandidate = null; this.linkSnap = null;
|
|
2027
|
-
this
|
|
2126
|
+
// A menu is about to cover this spot, and it opens under a cursor that has not moved --
|
|
2127
|
+
// which fires no boundary event, so nothing else would end the hover. Without this the
|
|
2128
|
+
// tooltip armed by the last pointermove appears 400ms later, through the open menu.
|
|
2129
|
+
this.clearHover();
|
|
2028
2130
|
this.emit('contextMenu', { x: pageX, y: pageY, link, node, port });
|
|
2029
2131
|
}
|
|
2030
2132
|
|
|
@@ -2381,16 +2483,7 @@ export class Diagram {
|
|
|
2381
2483
|
this.emitViewChanged(true);
|
|
2382
2484
|
this.scheduleDraw();
|
|
2383
2485
|
}, { passive: false });
|
|
2384
|
-
this.listen(this.canvas, 'pointerleave', () =>
|
|
2385
|
-
if (this.hoverPort !== null) this.emit('portHover', { node: this.hoverPort.node, port: this.hoverPort.port, hovering: false });
|
|
2386
|
-
if (this.hoverNode !== null) this.emit('nodeHover', { node: this.hoverNode, hovering: false });
|
|
2387
|
-
if (this.hoveredLink !== null) this.emit('linkHover', { link: this.hoveredLink, hovering: false });
|
|
2388
|
-
this.hoverPort = null; this.hoverNode = null; this.hoveredLink = null;
|
|
2389
|
-
this.tipTarget = null; this.tipShow = false;
|
|
2390
|
-
if (this.tipTimer !== null) { clearTimeout(this.tipTimer); this.tipTimer = null; }
|
|
2391
|
-
this.canvas.style.cursor = 'default';
|
|
2392
|
-
this.scheduleDraw();
|
|
2393
|
-
});
|
|
2486
|
+
this.listen(this.canvas, 'pointerleave', () => this.clearHover());
|
|
2394
2487
|
this.listen(this.canvas, 'dblclick', (e) => {
|
|
2395
2488
|
const [sx, sy] = localXY(e);
|
|
2396
2489
|
const [wx, wy] = this.toWorld(sx, sy);
|
|
@@ -3072,7 +3165,7 @@ export class Diagram {
|
|
|
3072
3165
|
}
|
|
3073
3166
|
}
|
|
3074
3167
|
|
|
3075
|
-
function roundRect(ctx:
|
|
3168
|
+
function roundRect(ctx: DrawSurface, x: number, y: number, w: number, h: number, r: number): void {
|
|
3076
3169
|
const rr = Math.min(r, w / 2, h / 2);
|
|
3077
3170
|
ctx.beginPath();
|
|
3078
3171
|
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,19 @@ 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
|
+
| 'overview'
|
|
127
|
+
| 'properties' | 'help';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Names a submenu rather than something to run. Deliberately outside ContextCommand:
|
|
131
|
+
* `executeContextCommand` takes commands, and a submenu label has nothing to execute.
|
|
132
|
+
*/
|
|
133
|
+
export type ContextCommandGroup = 'export';
|
|
116
134
|
|
|
117
135
|
export interface ContextCommandPayload {
|
|
118
136
|
command: ContextCommand;
|
|
@@ -123,6 +141,38 @@ export interface ContextCommandPayload {
|
|
|
123
141
|
export interface ContextCommandState {
|
|
124
142
|
command: ContextCommand;
|
|
125
143
|
enabled: boolean;
|
|
144
|
+
/**
|
|
145
|
+
* Present only on commands that toggle something, and then it is the current state --
|
|
146
|
+
* `overview` is the one today. A menu draws these with a tick rather than as two separate
|
|
147
|
+
* show/hide entries, which would leave one of them dead at all times.
|
|
148
|
+
*/
|
|
149
|
+
checked?: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A submenu: its own enabled state, plus the items to show when it opens. */
|
|
153
|
+
export interface ContextCommandGroupState {
|
|
154
|
+
group: ContextCommandGroup;
|
|
155
|
+
enabled: boolean;
|
|
156
|
+
commands: ContextCommandState[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One entry of the menu, in the order it should be drawn. Discriminate on `group`:
|
|
161
|
+
* an entry that has it is a submenu, an entry that does not is a command.
|
|
162
|
+
*/
|
|
163
|
+
export type ContextMenuItemState = ContextCommandState | ContextCommandGroupState;
|
|
164
|
+
|
|
165
|
+
/** What an export request asks the host to produce. */
|
|
166
|
+
export type ExportFormat = 'document' | 'png' | 'svg';
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The user picked something from the export submenu. Like `nodeProperties`, this is a
|
|
170
|
+
* request and nothing more: the control does not write files or open dialogs, so the
|
|
171
|
+
* host fulfils it with `saveDocument()`, `takeScreenshot()` or `takeSvg()` and its own
|
|
172
|
+
* options for scope, padding and background.
|
|
173
|
+
*/
|
|
174
|
+
export interface ExportRequestedPayload {
|
|
175
|
+
format: ExportFormat;
|
|
126
176
|
}
|
|
127
177
|
|
|
128
178
|
export interface ContextMenuRequestedPayload {
|
|
@@ -132,7 +182,7 @@ export interface ContextMenuRequestedPayload {
|
|
|
132
182
|
link: Link | null;
|
|
133
183
|
port: Port | null;
|
|
134
184
|
portDirection: PortDirection | null;
|
|
135
|
-
commands:
|
|
185
|
+
commands: ContextMenuItemState[];
|
|
136
186
|
}
|
|
137
187
|
|
|
138
188
|
export interface NodeSelectedPayload {
|
|
@@ -251,6 +301,7 @@ export interface DiagramEvents {
|
|
|
251
301
|
loadFinished: LoadFinishedPayload;
|
|
252
302
|
contextCommand: ContextCommandPayload;
|
|
253
303
|
contextMenuRequested: ContextMenuRequestedPayload;
|
|
304
|
+
exportRequested: ExportRequestedPayload;
|
|
254
305
|
undoRequested: NodeChangePayload & LinkChangePayload;
|
|
255
306
|
redoRequested: NodeChangePayload & LinkChangePayload;
|
|
256
307
|
nodeEdit: NodeChangePayload;
|
|
@@ -0,0 +1,363 @@
|
|
|
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
|
+
const CHECK_CLASS = 'ssdiagram-context-menu-check';
|
|
24
|
+
|
|
25
|
+
/** A rule below these entries, so related commands read as blocks rather than one long list. */
|
|
26
|
+
const SEPARATE_AFTER: ReadonlySet<string> = new Set(['redo', 'paste', 'delete', 'overview']);
|
|
27
|
+
|
|
28
|
+
function label(id: ContextCommand | ContextCommandGroup): string {
|
|
29
|
+
switch (id) {
|
|
30
|
+
case 'undo': return t('undo', 'Undo');
|
|
31
|
+
case 'redo': return t('redo', 'Redo');
|
|
32
|
+
case 'cut': return t('cut', 'Cut');
|
|
33
|
+
case 'copy': return t('copy', 'Copy');
|
|
34
|
+
case 'paste': return t('paste', 'Paste');
|
|
35
|
+
case 'open': return t('ctxOpen', 'Open');
|
|
36
|
+
case 'delete': return t('ctxDelete', 'Delete');
|
|
37
|
+
case 'export': return t('ctxExportAs', 'Export as');
|
|
38
|
+
case 'exportDocument': return t('ctxExportDocument', 'Scheme');
|
|
39
|
+
case 'exportPng': return t('ctxExportPng', 'PNG image');
|
|
40
|
+
case 'exportSvg': return t('ctxExportSvg', 'SVG image');
|
|
41
|
+
case 'overview': return t('ctxOverview', 'Overview');
|
|
42
|
+
case 'properties': return t('properties', 'Properties');
|
|
43
|
+
case 'help': return t('ctxHelp', 'Help');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PANEL_STYLE: Partial<CSSStyleDeclaration> = {
|
|
48
|
+
position: 'fixed',
|
|
49
|
+
zIndex: '2147483000',
|
|
50
|
+
minWidth: '176px',
|
|
51
|
+
// Translations run longer than English -- German and Russian routinely half again. Without
|
|
52
|
+
// a cap the panel just grows, and on a narrow viewport it leaves the screen entirely, which
|
|
53
|
+
// no amount of repositioning can recover.
|
|
54
|
+
maxWidth: 'min(320px, calc(100vw - 16px))',
|
|
55
|
+
padding: '4px',
|
|
56
|
+
border: '1px solid var(--ssdiagram-menu-border, var(--ssdiagram-control-border, var(--t-border, #3a4250)))',
|
|
57
|
+
borderRadius: '7px',
|
|
58
|
+
background: 'var(--ssdiagram-menu-background, var(--ssdiagram-control-background, var(--t-panel, #181a20)))',
|
|
59
|
+
color: 'var(--ssdiagram-menu-color, var(--ssdiagram-control-color, var(--t-text, #eaecef)))',
|
|
60
|
+
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.45)',
|
|
61
|
+
font: 'var(--ssdiagram-menu-font, 12.5px/1.35 system-ui, -apple-system, "Segoe UI", sans-serif)',
|
|
62
|
+
boxSizing: 'border-box',
|
|
63
|
+
userSelect: 'none',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const ITEM_STYLE: Partial<CSSStyleDeclaration> = {
|
|
67
|
+
display: 'flex',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
gap: '10px',
|
|
70
|
+
width: '100%',
|
|
71
|
+
padding: '6px 10px',
|
|
72
|
+
border: '0',
|
|
73
|
+
borderRadius: '4px',
|
|
74
|
+
background: 'transparent',
|
|
75
|
+
color: 'inherit',
|
|
76
|
+
font: 'inherit',
|
|
77
|
+
// Logical, not physical: on an RTL page these follow the writing direction instead of
|
|
78
|
+
// pinning the text left and truncating from the wrong end.
|
|
79
|
+
textAlign: 'start',
|
|
80
|
+
overflowWrap: 'anywhere',
|
|
81
|
+
cursor: 'pointer',
|
|
82
|
+
boxSizing: 'border-box',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const HOVER_BACKGROUND = 'var(--ssdiagram-menu-hover, var(--t-hover, rgba(255, 255, 255, 0.08)))';
|
|
86
|
+
const DISABLED_COLOR = 'var(--ssdiagram-menu-disabled-color, var(--t-text-dim, #848e9c))';
|
|
87
|
+
|
|
88
|
+
export interface ContextMenuViewOptions {
|
|
89
|
+
/** Where the menu is appended. The diagram mount point, so it survives a fullscreened panel. */
|
|
90
|
+
container: HTMLElement;
|
|
91
|
+
/** Runs the picked command. The same entry point a host menu would use. */
|
|
92
|
+
execute: (command: ContextCommand) => void;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class ContextMenuView {
|
|
96
|
+
private readonly container: HTMLElement;
|
|
97
|
+
private readonly execute: (command: ContextCommand) => void;
|
|
98
|
+
private root: HTMLElement | null = null;
|
|
99
|
+
private submenu: HTMLElement | null = null;
|
|
100
|
+
private readonly submenuTriggers = new Map<HTMLElement, HTMLElement>();
|
|
101
|
+
private readonly detach: Array<() => void> = [];
|
|
102
|
+
|
|
103
|
+
constructor(options: ContextMenuViewOptions) {
|
|
104
|
+
this.container = options.container;
|
|
105
|
+
this.execute = options.execute;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get isOpen(): boolean {
|
|
109
|
+
return this.root !== null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
show(x: number, y: number, items: readonly ContextMenuItemState[]): void {
|
|
113
|
+
this.hide();
|
|
114
|
+
const owner = this.container.ownerDocument ?? document;
|
|
115
|
+
const root = this.panel(owner);
|
|
116
|
+
root.className = ROOT_CLASS;
|
|
117
|
+
root.setAttribute('role', 'menu');
|
|
118
|
+
|
|
119
|
+
const gutter = items.some((item) => !('group' in item) && item.checked !== undefined);
|
|
120
|
+
items.forEach((item, index) => {
|
|
121
|
+
if ('group' in item) root.appendChild(this.groupEntry(owner, item, gutter));
|
|
122
|
+
else root.appendChild(this.commandEntry(owner, item.command, item.enabled, true, item.checked, gutter));
|
|
123
|
+
const id = 'group' in item ? item.group : item.command;
|
|
124
|
+
if (SEPARATE_AFTER.has(id) && index < items.length - 1) root.appendChild(this.separator(owner));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this.container.appendChild(root);
|
|
128
|
+
this.root = root;
|
|
129
|
+
this.place(root, x, y);
|
|
130
|
+
this.watchForDismissal(owner);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
hide(): void {
|
|
134
|
+
for (const dispose of this.detach.splice(0)) dispose();
|
|
135
|
+
this.submenu = null;
|
|
136
|
+
this.submenuTriggers.clear();
|
|
137
|
+
this.root?.remove();
|
|
138
|
+
this.root = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
destroy(): void {
|
|
142
|
+
this.hide();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private panel(owner: Document): HTMLElement {
|
|
146
|
+
const panel = owner.createElement('div');
|
|
147
|
+
Object.assign(panel.style, PANEL_STYLE);
|
|
148
|
+
return panel;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private separator(owner: Document): HTMLElement {
|
|
152
|
+
const rule = owner.createElement('div');
|
|
153
|
+
rule.className = SEPARATOR_CLASS;
|
|
154
|
+
Object.assign(rule.style, {
|
|
155
|
+
height: '1px',
|
|
156
|
+
margin: '4px 6px',
|
|
157
|
+
background: 'var(--ssdiagram-menu-border, var(--t-border, #3a4250))',
|
|
158
|
+
});
|
|
159
|
+
return rule;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private item(
|
|
163
|
+
owner: Document,
|
|
164
|
+
text: string,
|
|
165
|
+
enabled: boolean,
|
|
166
|
+
arrow: boolean,
|
|
167
|
+
checked: boolean | undefined,
|
|
168
|
+
gutter: boolean,
|
|
169
|
+
): HTMLButtonElement {
|
|
170
|
+
const button = owner.createElement('button');
|
|
171
|
+
button.type = 'button';
|
|
172
|
+
button.className = ITEM_CLASS;
|
|
173
|
+
button.setAttribute('role', checked === undefined ? 'menuitem' : 'menuitemcheckbox');
|
|
174
|
+
if (checked !== undefined) button.setAttribute('aria-checked', String(checked));
|
|
175
|
+
Object.assign(button.style, ITEM_STYLE);
|
|
176
|
+
if (!enabled) {
|
|
177
|
+
button.disabled = true;
|
|
178
|
+
button.style.color = DISABLED_COLOR;
|
|
179
|
+
button.style.cursor = 'default';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Every item in a panel that has a checkable entry gets the same gutter, so the
|
|
183
|
+
// captions stay on one line rather than one row sitting further in than the rest.
|
|
184
|
+
if (gutter) {
|
|
185
|
+
const tick = owner.createElement('span');
|
|
186
|
+
tick.className = CHECK_CLASS;
|
|
187
|
+
tick.textContent = checked === true ? '✓' : '';
|
|
188
|
+
tick.setAttribute('aria-hidden', 'true');
|
|
189
|
+
Object.assign(tick.style, { flex: 'none', width: '12px', textAlign: 'center' });
|
|
190
|
+
button.appendChild(tick);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const caption = owner.createElement('span');
|
|
194
|
+
caption.className = LABEL_CLASS;
|
|
195
|
+
caption.textContent = text;
|
|
196
|
+
caption.style.flex = '1';
|
|
197
|
+
button.appendChild(caption);
|
|
198
|
+
|
|
199
|
+
if (arrow) {
|
|
200
|
+
const chevron = owner.createElement('span');
|
|
201
|
+
chevron.className = ARROW_CLASS;
|
|
202
|
+
// Points the way the submenu actually opens, which on an RTL page is the other way.
|
|
203
|
+
chevron.textContent = this.rtl() ? '‹' : '›';
|
|
204
|
+
chevron.setAttribute('aria-hidden', 'true');
|
|
205
|
+
Object.assign(chevron.style, { flex: 'none', opacity: '0.7', fontSize: '14px' });
|
|
206
|
+
button.appendChild(chevron);
|
|
207
|
+
}
|
|
208
|
+
return button;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// `closesSubmenu` separates the two places an item can live. A top-level item shuts any
|
|
212
|
+
// open submenu when the pointer arrives, the way menus everywhere behave. An item inside
|
|
213
|
+
// the submenu must not: reaching it means the pointer has entered that very panel, and
|
|
214
|
+
// closing it there would pull it out from under the pointer and drop the click on the
|
|
215
|
+
// canvas behind.
|
|
216
|
+
private commandEntry(
|
|
217
|
+
owner: Document,
|
|
218
|
+
command: ContextCommand,
|
|
219
|
+
enabled: boolean,
|
|
220
|
+
closesSubmenu: boolean,
|
|
221
|
+
checked: boolean | undefined,
|
|
222
|
+
gutter: boolean,
|
|
223
|
+
): HTMLElement {
|
|
224
|
+
const button = this.item(owner, label(command), enabled, false, checked, gutter);
|
|
225
|
+
this.on(button, 'pointerenter', () => {
|
|
226
|
+
if (closesSubmenu) this.closeSubmenu();
|
|
227
|
+
if (enabled) button.style.background = HOVER_BACKGROUND;
|
|
228
|
+
});
|
|
229
|
+
this.on(button, 'pointerleave', () => { button.style.background = 'transparent'; });
|
|
230
|
+
this.on(button, 'click', () => {
|
|
231
|
+
if (!enabled) return;
|
|
232
|
+
// Hide first: a command may open a dialog, and a menu left on top of it looks stuck.
|
|
233
|
+
this.hide();
|
|
234
|
+
this.execute(command);
|
|
235
|
+
});
|
|
236
|
+
return button;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private groupEntry(
|
|
240
|
+
owner: Document,
|
|
241
|
+
group: { group: ContextCommandGroup; enabled: boolean; commands: readonly { command: ContextCommand; enabled: boolean; checked?: boolean }[] },
|
|
242
|
+
gutter: boolean,
|
|
243
|
+
): HTMLElement {
|
|
244
|
+
const host = owner.createElement('div');
|
|
245
|
+
host.style.position = 'relative';
|
|
246
|
+
|
|
247
|
+
const button = this.item(owner, label(group.group), group.enabled, true, undefined, gutter);
|
|
248
|
+
button.setAttribute('aria-haspopup', 'menu');
|
|
249
|
+
button.setAttribute('aria-expanded', 'false');
|
|
250
|
+
host.appendChild(button);
|
|
251
|
+
|
|
252
|
+
const panel = this.panel(owner);
|
|
253
|
+
panel.className = SUBMENU_CLASS;
|
|
254
|
+
panel.setAttribute('role', 'menu');
|
|
255
|
+
panel.setAttribute('aria-label', label(group.group));
|
|
256
|
+
// insetInlineStart, not left: the submenu opens towards the end of the line, which
|
|
257
|
+
// is the right on an LTR page and the left on an RTL one.
|
|
258
|
+
Object.assign(panel.style, { position: 'absolute', insetInlineStart: '100%', top: '-5px', display: 'none' });
|
|
259
|
+
const childGutter = group.commands.some((child) => child.checked !== undefined);
|
|
260
|
+
for (const child of group.commands) {
|
|
261
|
+
panel.appendChild(this.commandEntry(owner, child.command, child.enabled, false, child.checked, childGutter));
|
|
262
|
+
}
|
|
263
|
+
host.appendChild(panel);
|
|
264
|
+
|
|
265
|
+
const open = (): void => {
|
|
266
|
+
if (!group.enabled) return;
|
|
267
|
+
button.style.background = HOVER_BACKGROUND;
|
|
268
|
+
button.setAttribute('aria-expanded', 'true');
|
|
269
|
+
panel.style.display = 'block';
|
|
270
|
+
this.submenu = panel;
|
|
271
|
+
this.keepOnScreen(panel);
|
|
272
|
+
};
|
|
273
|
+
this.on(button, 'pointerenter', open);
|
|
274
|
+
// Tap opens it too: a touch device never sends pointerenter on its own.
|
|
275
|
+
this.on(button, 'click', open);
|
|
276
|
+
this.on(host, 'pointerleave', () => {
|
|
277
|
+
if (this.submenu !== panel) return;
|
|
278
|
+
button.style.background = 'transparent';
|
|
279
|
+
button.setAttribute('aria-expanded', 'false');
|
|
280
|
+
panel.style.display = 'none';
|
|
281
|
+
this.submenu = null;
|
|
282
|
+
});
|
|
283
|
+
this.submenuTriggers.set(panel, button);
|
|
284
|
+
return host;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private closeSubmenu(): void {
|
|
288
|
+
if (this.submenu === null) return;
|
|
289
|
+
this.submenu.style.display = 'none';
|
|
290
|
+
this.submenuTriggers.get(this.submenu)?.setAttribute('aria-expanded', 'false');
|
|
291
|
+
this.submenu = null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Reads the resolved writing direction so the submenu's side and its arrow agree with it.
|
|
295
|
+
private rtl(): boolean {
|
|
296
|
+
if (typeof getComputedStyle !== 'function') return false;
|
|
297
|
+
try {
|
|
298
|
+
return getComputedStyle(this.container).direction === 'rtl';
|
|
299
|
+
} catch {
|
|
300
|
+
// A detached or fake container has no computed style; left-to-right is the safe read.
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Keeps the panel inside the viewport, flipping it left when it would run off the edge. */
|
|
306
|
+
private keepOnScreen(panel: HTMLElement): void {
|
|
307
|
+
if (typeof panel.getBoundingClientRect !== 'function' || typeof window === 'undefined') return;
|
|
308
|
+
const box = panel.getBoundingClientRect();
|
|
309
|
+
if (box.right > window.innerWidth - 4 || box.left < 4) {
|
|
310
|
+
panel.style.insetInlineStart = 'auto';
|
|
311
|
+
panel.style.insetInlineEnd = '100%';
|
|
312
|
+
}
|
|
313
|
+
if (box.bottom > window.innerHeight - 4) {
|
|
314
|
+
panel.style.top = 'auto';
|
|
315
|
+
panel.style.bottom = '-5px';
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private place(root: HTMLElement, x: number, y: number): void {
|
|
320
|
+
root.style.left = `${x}px`;
|
|
321
|
+
root.style.top = `${y}px`;
|
|
322
|
+
if (typeof root.getBoundingClientRect !== 'function' || typeof window === 'undefined') return;
|
|
323
|
+
const box = root.getBoundingClientRect();
|
|
324
|
+
// Flip rather than clamp: a menu shoved back onto the screen would cover the very
|
|
325
|
+
// thing that was right-clicked.
|
|
326
|
+
if (box.right > window.innerWidth - 4) root.style.left = `${Math.max(4, x - box.width)}px`;
|
|
327
|
+
if (box.bottom > window.innerHeight - 4) root.style.top = `${Math.max(4, y - box.height)}px`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private watchForDismissal(owner: Document): void {
|
|
331
|
+
const dismiss = (): void => this.hide();
|
|
332
|
+
const outside = (event: Event): void => {
|
|
333
|
+
// Duck-typed rather than `target instanceof Node`: this runs in whatever document
|
|
334
|
+
// the host mounted us in, and a cross-document Node fails that check.
|
|
335
|
+
const root = this.root;
|
|
336
|
+
const target = event.target as Node | null;
|
|
337
|
+
if (root !== null && target !== null && typeof root.contains === 'function' && root.contains(target)) return;
|
|
338
|
+
this.hide();
|
|
339
|
+
};
|
|
340
|
+
this.on(owner, 'pointerdown', outside, true);
|
|
341
|
+
this.on(owner, 'keydown', (event: Event) => {
|
|
342
|
+
if ((event as KeyboardEvent).key === 'Escape') this.hide();
|
|
343
|
+
});
|
|
344
|
+
if (typeof window !== 'undefined') {
|
|
345
|
+
// Scroll and resize move the diagram out from under the menu, which would otherwise
|
|
346
|
+
// hang in place pointing at nothing.
|
|
347
|
+
this.on(window, 'blur', dismiss);
|
|
348
|
+
this.on(window, 'resize', dismiss);
|
|
349
|
+
this.on(window, 'scroll', dismiss, true);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private on(
|
|
354
|
+
target: { addEventListener?: (type: string, listener: EventListener, options?: boolean) => void; removeEventListener?: (type: string, listener: EventListener, options?: boolean) => void },
|
|
355
|
+
type: string,
|
|
356
|
+
listener: EventListener,
|
|
357
|
+
capture = false,
|
|
358
|
+
): void {
|
|
359
|
+
if (typeof target.addEventListener !== 'function') return;
|
|
360
|
+
target.addEventListener(type, listener, capture);
|
|
361
|
+
this.detach.push(() => target.removeEventListener?.(type, listener, capture));
|
|
362
|
+
}
|
|
363
|
+
}
|