@stocksharp/diagram 0.1.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 (87) hide show
  1. package/LICENSE +30 -0
  2. package/NOTICE +21 -0
  3. package/README.md +346 -0
  4. package/dist/esm/canvas-renderer.js +3226 -0
  5. package/dist/esm/canvas-renderer.js.map +1 -0
  6. package/dist/esm/core/action-registry.js +35 -0
  7. package/dist/esm/core/action-registry.js.map +1 -0
  8. package/dist/esm/core/document.js +350 -0
  9. package/dist/esm/core/document.js.map +1 -0
  10. package/dist/esm/core/history.js +136 -0
  11. package/dist/esm/core/history.js.map +1 -0
  12. package/dist/esm/core/model.js +2 -0
  13. package/dist/esm/core/model.js.map +1 -0
  14. package/dist/esm/core/state.js +73 -0
  15. package/dist/esm/core/state.js.map +1 -0
  16. package/dist/esm/core/view-state.js +70 -0
  17. package/dist/esm/core/view-state.js.map +1 -0
  18. package/dist/esm/diagram/api.js +2 -0
  19. package/dist/esm/diagram/api.js.map +1 -0
  20. package/dist/esm/diagram/catalog.js +45 -0
  21. package/dist/esm/diagram/catalog.js.map +1 -0
  22. package/dist/esm/diagram/event-emitter.js +42 -0
  23. package/dist/esm/diagram/event-emitter.js.map +1 -0
  24. package/dist/esm/diagram/palette.js +269 -0
  25. package/dist/esm/diagram/palette.js.map +1 -0
  26. package/dist/esm/diagram/stocksharp-diagram.js +784 -0
  27. package/dist/esm/diagram/stocksharp-diagram.js.map +1 -0
  28. package/dist/esm/diagram/types.js +105 -0
  29. package/dist/esm/diagram/types.js.map +1 -0
  30. package/dist/esm/embed.js +398 -0
  31. package/dist/esm/embed.js.map +1 -0
  32. package/dist/esm/i18n.js +12 -0
  33. package/dist/esm/i18n.js.map +1 -0
  34. package/dist/esm/index.js +12 -0
  35. package/dist/esm/index.js.map +1 -0
  36. package/dist/ssdiagram.js +5141 -0
  37. package/dist/ssdiagram.js.map +7 -0
  38. package/dist/types/canvas-renderer.d.ts +512 -0
  39. package/dist/types/canvas-renderer.d.ts.map +1 -0
  40. package/dist/types/core/action-registry.d.ts +18 -0
  41. package/dist/types/core/action-registry.d.ts.map +1 -0
  42. package/dist/types/core/document.d.ts +12 -0
  43. package/dist/types/core/document.d.ts.map +1 -0
  44. package/dist/types/core/history.d.ts +33 -0
  45. package/dist/types/core/history.d.ts.map +1 -0
  46. package/dist/types/core/model.d.ts +85 -0
  47. package/dist/types/core/model.d.ts.map +1 -0
  48. package/dist/types/core/state.d.ts +68 -0
  49. package/dist/types/core/state.d.ts.map +1 -0
  50. package/dist/types/core/view-state.d.ts +14 -0
  51. package/dist/types/core/view-state.d.ts.map +1 -0
  52. package/dist/types/diagram/api.d.ts +213 -0
  53. package/dist/types/diagram/api.d.ts.map +1 -0
  54. package/dist/types/diagram/catalog.d.ts +19 -0
  55. package/dist/types/diagram/catalog.d.ts.map +1 -0
  56. package/dist/types/diagram/event-emitter.d.ts +9 -0
  57. package/dist/types/diagram/event-emitter.d.ts.map +1 -0
  58. package/dist/types/diagram/palette.d.ts +63 -0
  59. package/dist/types/diagram/palette.d.ts.map +1 -0
  60. package/dist/types/diagram/stocksharp-diagram.d.ts +157 -0
  61. package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -0
  62. package/dist/types/diagram/types.d.ts +171 -0
  63. package/dist/types/diagram/types.d.ts.map +1 -0
  64. package/dist/types/embed.d.ts +35 -0
  65. package/dist/types/embed.d.ts.map +1 -0
  66. package/dist/types/i18n.d.ts +167 -0
  67. package/dist/types/i18n.d.ts.map +1 -0
  68. package/dist/types/index.d.ts +22 -0
  69. package/dist/types/index.d.ts.map +1 -0
  70. package/package.json +126 -0
  71. package/sample.png +0 -0
  72. package/src/canvas-renderer.ts +3249 -0
  73. package/src/core/action-registry.ts +46 -0
  74. package/src/core/document.ts +387 -0
  75. package/src/core/history.ts +154 -0
  76. package/src/core/model.ts +99 -0
  77. package/src/core/state.ts +148 -0
  78. package/src/core/view-state.ts +82 -0
  79. package/src/diagram/api.ts +257 -0
  80. package/src/diagram/catalog.ts +55 -0
  81. package/src/diagram/event-emitter.ts +44 -0
  82. package/src/diagram/palette.ts +312 -0
  83. package/src/diagram/stocksharp-diagram.ts +945 -0
  84. package/src/diagram/types.ts +332 -0
  85. package/src/embed.ts +508 -0
  86. package/src/i18n.ts +210 -0
  87. package/src/index.ts +171 -0
@@ -0,0 +1,945 @@
1
+ import {
2
+ parseDiagramDocument,
3
+ serializeDiagramDocument,
4
+ } from '../core/document.js';
5
+ import {
6
+ parseDiagramViewState,
7
+ serializeDiagramViewState,
8
+ } from '../core/view-state.js';
9
+ import type { DiagramDocument } from '../core/model.js';
10
+ import { DiagramActionRegistry } from '../core/action-registry.js';
11
+ import type {
12
+ DiagramGlobalErrorKind,
13
+ DiagramInteractionPermissions,
14
+ DiagramPortRuntimeState,
15
+ DiagramRuntimeState,
16
+ DiagramSelection,
17
+ DiagramViewState,
18
+ } from '../core/state.js';
19
+ import {
20
+ Diagram as CanvasDiagram,
21
+ type DiagramNodeInit as CanvasNodeInit,
22
+ type LinkInit as CanvasLinkInit,
23
+ type LinkModel,
24
+ type NodeModel,
25
+ type PortInit as CanvasPortInit,
26
+ type PortModel,
27
+ } from '../canvas-renderer.js';
28
+ import { StockSharpCatalog } from './catalog.js';
29
+ import { EventEmitter } from './event-emitter.js';
30
+ import type {
31
+ DiagramEvents,
32
+ DiagramClipboard,
33
+ ContextCommand,
34
+ DiagramLoadOptions,
35
+ DiagramGridSettings,
36
+ DiagramNodeBounds,
37
+ DiagramPoint,
38
+ DiagramOptions,
39
+ DiagramScreenshotOptions,
40
+ DiagramThemeOptions,
41
+ LinkValidationResult,
42
+ LinkValidator,
43
+ NodeErrorKind,
44
+ NodeErrorOptions,
45
+ } from './api.js';
46
+ import {
47
+ DiagramNode,
48
+ Link,
49
+ Node,
50
+ Port,
51
+ type PortDirection,
52
+ type PortUpdate,
53
+ } from './types.js';
54
+
55
+ interface ContextActionContext {
56
+ selection: DiagramSelection;
57
+ nodes: DiagramNode[];
58
+ links: Link[];
59
+ }
60
+
61
+ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
62
+ private readonly div: HTMLElement;
63
+ private readonly catalog: StockSharpCatalog;
64
+ private readonly fullscreenButton: HTMLButtonElement;
65
+ private readonly overviewContainer: HTMLElement | null;
66
+ private readonly zoomLabel: HTMLElement | null;
67
+ private readonly canvas: CanvasDiagram;
68
+ private readonly clipboard: DiagramClipboard | null;
69
+ private readonly disposables: Array<() => void> = [];
70
+ private idCounter = 1;
71
+ private undoEnabled = true;
72
+ private redoEnabled = true;
73
+ private helpEnabled = true;
74
+ private loading = false;
75
+ private destroyed = false;
76
+ private fullscreen = false;
77
+ private fullscreenButtonVisible = true;
78
+ private linkValidator: LinkValidator | null = null;
79
+ private readonly contextActions = new DiagramActionRegistry<ContextCommand, ContextActionContext>();
80
+
81
+ constructor(options: DiagramOptions) {
82
+ super();
83
+ this.div = options.div;
84
+ this.catalog = options.catalog;
85
+ this.overviewContainer = options.overviewContainer ?? null;
86
+ this.zoomLabel = options.zoomLabel ?? null;
87
+ this.clipboard = this.resolveClipboard(options.clipboard);
88
+ this.canvas = new CanvasDiagram({
89
+ host: this.div,
90
+ typeColors: this.portTypeColors(),
91
+ gridSnap: options.gridSnap ?? true,
92
+ gridSize: options.gridSize,
93
+ });
94
+ this.fullscreenButtonVisible = options.showFullscreenButton ?? true;
95
+ this.prepareFullscreenButtonHost();
96
+ this.fullscreenButton = this.createFullscreenButton();
97
+ this.updateFullscreenButton();
98
+ this.registerContextActions();
99
+ this.bindCanvasEvents();
100
+ this.disposables.push(this.catalog.on('portTypesChanged', () => this.applySocketTheme()));
101
+ this.updateZoomLabel();
102
+ }
103
+
104
+ setLinkValidator(validator: LinkValidator | null): void {
105
+ this.linkValidator = validator;
106
+ this.canvas.setLinkValidator(validator === null ? null : ({ fromNode, fromPort, toNode, toPort }) => validator({
107
+ fromNode: this.fromCanvasNode(fromNode),
108
+ fromPort: this.fromCanvasPort(fromPort),
109
+ toNode: this.fromCanvasNode(toNode),
110
+ toPort: this.fromCanvasPort(toPort),
111
+ }));
112
+ }
113
+
114
+ addDiagramNode(node: DiagramNode): string {
115
+ return this.canvas.addDiagramNode(this.toCanvasNode(node));
116
+ }
117
+
118
+ dropNodeFromPalette(typeId: string, clientX: number, clientY: number): string | null {
119
+ const rect = this.div.getBoundingClientRect();
120
+ const [x, y] = this.canvas.viewToWorld(clientX - rect.left, clientY - rect.top);
121
+ const definition = this.catalog.getNodeType(typeId) ?? new Node({ id: typeId, name: typeId });
122
+ const id = this.generateNodeId(definition.id);
123
+ const node = new DiagramNode({
124
+ id,
125
+ typeId: definition.id,
126
+ name: definition.name,
127
+ description: definition.description,
128
+ groupName: definition.groupName,
129
+ inPorts: definition.inPorts.map((port) => port.clone()),
130
+ outPorts: definition.outPorts.map((port) => port.clone()),
131
+ icon: definition.icon,
132
+ parameters: definition.parameters.map((parameter) => ({ ...parameter, options: [...parameter.options] })),
133
+ openAction: definition.openAction,
134
+ x,
135
+ y,
136
+ });
137
+ this.canvas.addDiagramNode(this.toCanvasNode(node));
138
+ this.canvas.selectNodeById(id);
139
+ return id;
140
+ }
141
+
142
+ removeDiagramNode(nodeId: string): void {
143
+ this.canvas.removeDiagramNode(nodeId);
144
+ }
145
+
146
+ moveNode(nodeId: string, x: number, y: number): void {
147
+ this.canvas.moveNode(nodeId, x, y);
148
+ }
149
+
150
+ getNodeBounds(nodeId: string): DiagramNodeBounds | null {
151
+ const node = this.canvas.findNode(nodeId);
152
+ return node === undefined
153
+ ? null
154
+ : { x: node.x, y: node.y, width: node.w, height: node.h };
155
+ }
156
+
157
+ getPortPosition(
158
+ nodeId: string,
159
+ direction: PortDirection,
160
+ portId: string,
161
+ ): DiagramPoint | null {
162
+ const node = this.canvas.findNode(nodeId);
163
+ const port = direction === 'in'
164
+ ? node?.inPorts.find((candidate) => candidate.id === portId)
165
+ : node?.outPorts.find((candidate) => candidate.id === portId);
166
+ return port === undefined ? null : { x: port.cx, y: port.cy };
167
+ }
168
+
169
+ worldToView(x: number, y: number): DiagramPoint {
170
+ const [viewX, viewY] = this.canvas.worldToView(x, y);
171
+ return { x: viewX, y: viewY };
172
+ }
173
+
174
+ setGridSnap(enabled: boolean, size?: number): void {
175
+ this.canvas.setGridSnap(enabled, size);
176
+ }
177
+
178
+ getGridSnap(): DiagramGridSettings {
179
+ return this.canvas.getGridSnap();
180
+ }
181
+
182
+ nudgeSelection(dx: number, dy: number): boolean {
183
+ return this.canvas.nudgeSelection(dx, dy);
184
+ }
185
+
186
+ addLink(link: Link): boolean {
187
+ return this.canvas.addLink(this.toCanvasLink(link));
188
+ }
189
+
190
+ validateLink(link: Link, excludeLinkId?: string): LinkValidationResult {
191
+ return this.canvas.validateLink(this.toCanvasLink(link), excludeLinkId);
192
+ }
193
+
194
+ relink(linkId: string, link: Link): LinkValidationResult {
195
+ return this.canvas.relink(linkId, this.toCanvasLink(link));
196
+ }
197
+
198
+ removeLink(link: Link): void {
199
+ this.canvas.removeLink(this.toCanvasLink(link));
200
+ }
201
+
202
+ addPort(nodeId: string, direction: PortDirection, port: Port): void {
203
+ this.canvas.addPort(nodeId, direction, this.toCanvasPort(port));
204
+ }
205
+
206
+ removePort(nodeId: string, direction: PortDirection, portId: string): void {
207
+ this.canvas.removePort(nodeId, direction, portId);
208
+ }
209
+
210
+ updatePortType(nodeId: string, direction: PortDirection, portId: string, type: string): void {
211
+ this.canvas.updatePortType(nodeId, direction, portId, type);
212
+ }
213
+
214
+ updatePort(nodeId: string, direction: PortDirection, portId: string, patch: PortUpdate): boolean {
215
+ return this.canvas.updatePort(nodeId, direction, portId, patch);
216
+ }
217
+
218
+ setNodePorts(
219
+ nodeId: string,
220
+ inPorts: ReadonlyArray<{ key: string; name: string; description: string; type: string; maxLinks: number; availableTypes?: string[]; isDynamic?: boolean; dynamicMode?: string }>,
221
+ outPorts: ReadonlyArray<{ key: string; name: string; description: string; type: string; maxLinks: number; availableTypes?: string[]; isDynamic?: boolean; dynamicMode?: string }>,
222
+ ): void {
223
+ const current = this.canvas.findNode(nodeId);
224
+ if (current === undefined) return;
225
+ const convert = (port: typeof inPorts[number]): CanvasPortInit => ({
226
+ id: port.key,
227
+ name: port.name,
228
+ description: port.description,
229
+ type: port.type,
230
+ maxLinks: port.maxLinks,
231
+ availableTypes: [...(port.availableTypes ?? [])],
232
+ isDynamic: port.isDynamic ?? false,
233
+ dynamicMode: port.dynamicMode ?? '',
234
+ });
235
+ const nextIn = inPorts.map(convert);
236
+ const nextOut = outPorts.map(convert);
237
+ for (const sibling of current.inPorts.filter((port) => port.isSibling)) {
238
+ if (!nextIn.some((port) => port.id === sibling.id)) nextIn.push(sibling.toInit());
239
+ }
240
+ for (const sibling of current.outPorts.filter((port) => port.isSibling)) {
241
+ if (!nextOut.some((port) => port.id === sibling.id)) nextOut.push(sibling.toInit());
242
+ }
243
+ this.canvas.setNodePorts(nodeId, nextIn, nextOut);
244
+ }
245
+
246
+ updateNode(nodeId: string, patch: { name?: string; description?: string; color?: string; border?: string }): void {
247
+ this.canvas.updateNode(nodeId, patch);
248
+ }
249
+
250
+ setNodeMessage(nodeId: string, message: string): void {
251
+ this.canvas.updateNode(nodeId, { message });
252
+ }
253
+
254
+ setNodeError(nodeId: string, message: string, options: NodeErrorOptions = {}): boolean {
255
+ return this.canvas.setNodeError(nodeId, message, options);
256
+ }
257
+
258
+ clearNodeError(nodeId: string, kind?: NodeErrorKind): boolean {
259
+ return this.canvas.clearNodeError(nodeId, kind);
260
+ }
261
+
262
+ getRuntimeState(): DiagramRuntimeState {
263
+ return this.canvas.getRuntimeState();
264
+ }
265
+
266
+ setRuntimeState(state: DiagramRuntimeState): void {
267
+ this.canvas.setRuntimeState(state);
268
+ }
269
+
270
+ clearRuntimeState(): void {
271
+ this.canvas.clearRuntimeState();
272
+ }
273
+
274
+ setActiveNode(nodeId: string | null): boolean {
275
+ return this.canvas.setActiveNode(nodeId);
276
+ }
277
+
278
+ setPortRuntimeState(
279
+ nodeId: string,
280
+ direction: PortDirection,
281
+ portId: string,
282
+ patch: Partial<DiagramPortRuntimeState>,
283
+ ): boolean {
284
+ return this.canvas.setPortRuntimeState(nodeId, direction, portId, patch);
285
+ }
286
+
287
+ setGlobalError(message: string | null, kind: DiagramGlobalErrorKind = 'invalid'): void {
288
+ this.canvas.setGlobalError(message, kind);
289
+ }
290
+
291
+ setNodeParamValue(nodeId: string, paramName: string, value: string | undefined): void {
292
+ this.canvas.setNodeParamValue(nodeId, paramName, value);
293
+ }
294
+
295
+ setNodeName(nodeId: string, value: string): void {
296
+ this.canvas.updateNode(nodeId, { name: value });
297
+ }
298
+
299
+ /** Groups host-driven document edits into one undo/redo operation. */
300
+ transaction<T>(label: string, action: () => T): T {
301
+ return this.canvas.withTransaction(label, action);
302
+ }
303
+
304
+ setShowNodeMessages(show: boolean): void {
305
+ this.canvas.setShowNodeMessages(show);
306
+ }
307
+
308
+ setReadOnly(readonly: boolean): void {
309
+ this.canvas.setReadOnly(readonly);
310
+ }
311
+
312
+ getInteractionPermissions(): DiagramInteractionPermissions {
313
+ return this.canvas.getInteractionPermissions();
314
+ }
315
+
316
+ setInteractionPermissions(patch: Partial<DiagramInteractionPermissions>): void {
317
+ this.canvas.setInteractionPermissions(patch);
318
+ }
319
+
320
+ applySocketTheme(): void {
321
+ this.canvas.setTypeColors(this.portTypeColors());
322
+ }
323
+
324
+ applyOverviewTheme(): void {
325
+ this.canvas.requestRedraw();
326
+ }
327
+
328
+ setOverviewVisible(visible: boolean): void {
329
+ this.canvas.setOverviewVisible(visible);
330
+ this.overviewContainer?.classList.toggle('hidden', !visible);
331
+ }
332
+
333
+ zoomToFit(): void {
334
+ this.canvas.zoomToFit();
335
+ }
336
+
337
+ setZoom(scale: number): void {
338
+ this.canvas.setZoom(scale);
339
+ }
340
+
341
+ getViewState(): DiagramViewState {
342
+ return this.canvas.getViewState();
343
+ }
344
+
345
+ setViewState(state: DiagramViewState): void {
346
+ this.canvas.setViewState(state);
347
+ this.overviewContainer?.classList.toggle('hidden', !state.overviewVisible);
348
+ }
349
+
350
+ /** Serializes only viewport preferences; strategy data stays in saveDocument(). */
351
+ saveViewState(space?: number): string {
352
+ return serializeDiagramViewState(this.canvas.getViewState(), space);
353
+ }
354
+
355
+ /** Restores a versioned viewport snapshot produced by saveViewState(). */
356
+ loadViewState(source: string | unknown): void {
357
+ this.setViewState(parseDiagramViewState(source));
358
+ }
359
+
360
+ /** Detached PNG-ready canvas; use scope: 'content' for the complete scheme. */
361
+ takeScreenshot(options: DiagramScreenshotOptions = {}): HTMLCanvasElement {
362
+ return this.canvas.takeScreenshot(options);
363
+ }
364
+
365
+ getSelection(): DiagramSelection {
366
+ return this.canvas.getSelection();
367
+ }
368
+
369
+ selectNodes(nodeIds: readonly string[]): void {
370
+ this.canvas.selectNodesById(nodeIds);
371
+ }
372
+
373
+ selectLink(linkId: string | null): void {
374
+ this.canvas.selectLinkById(linkId);
375
+ }
376
+
377
+ selectPort(nodeId: string, direction: PortDirection, portId: string): void {
378
+ this.canvas.selectPortById(nodeId, direction, portId);
379
+ }
380
+
381
+ resize(width: number, height: number): void {
382
+ this.canvas.resize(width, height);
383
+ }
384
+
385
+ isFullscreen(): boolean {
386
+ return this.fullscreen;
387
+ }
388
+
389
+ setFullscreenButtonVisible(visible: boolean): void {
390
+ this.fullscreenButtonVisible = visible;
391
+ this.fullscreenButton.hidden = !visible;
392
+ this.fullscreenButton.style.display = visible ? 'inline-flex' : 'none';
393
+ }
394
+
395
+ isFullscreenButtonVisible(): boolean {
396
+ return this.fullscreenButtonVisible;
397
+ }
398
+
399
+ /** Updates only the control's state after the host changed its own layout. */
400
+ setFullscreenState(fullscreen: boolean): void {
401
+ if (this.fullscreen === fullscreen) return;
402
+ this.fullscreen = fullscreen;
403
+ this.updateFullscreenButton();
404
+ this.emit('fullscreenChanged', { fullscreen });
405
+ }
406
+
407
+ setTheme(options: DiagramThemeOptions): void {
408
+ const {
409
+ diagramBackground,
410
+ overviewBackground,
411
+ gridColor,
412
+ linkMaxLightness,
413
+ overviewBorderColor,
414
+ overviewViewportColor,
415
+ overviewViewportFill,
416
+ } = options;
417
+ if (diagramBackground !== undefined) {
418
+ this.div.style.background = diagramBackground;
419
+ if (this.div.parentElement !== null) this.div.parentElement.style.background = diagramBackground;
420
+ }
421
+ if (this.overviewContainer !== null && overviewBackground !== undefined) {
422
+ this.overviewContainer.style.background = overviewBackground;
423
+ }
424
+ this.canvas.setTheme({
425
+ background: diagramBackground,
426
+ gridColor,
427
+ linkMaxLightness,
428
+ overviewBackground,
429
+ overviewBorderColor,
430
+ overviewViewportColor,
431
+ overviewViewportFill,
432
+ });
433
+ }
434
+
435
+ enableUndo(enabled: boolean): void { this.undoEnabled = enabled; }
436
+ enableRedo(enabled: boolean): void { this.redoEnabled = enabled; }
437
+ enableHelp(enabled: boolean): void { this.helpEnabled = enabled; }
438
+
439
+ canUndo(): boolean { return this.undoEnabled && this.canvas.canUndo(); }
440
+ canRedo(): boolean { return this.redoEnabled && this.canvas.canRedo(); }
441
+
442
+ undo(): void {
443
+ if (!this.canUndo()) return;
444
+ this.canvas.undo();
445
+ const snapshot = this.save();
446
+ this.emit('undoRequested', snapshot);
447
+ }
448
+
449
+ redo(): void {
450
+ if (!this.canRedo()) return;
451
+ this.canvas.redo();
452
+ const snapshot = this.save();
453
+ this.emit('redoRequested', snapshot);
454
+ }
455
+
456
+ cutSelection(): void { this.canvas.cutSelection(); }
457
+ copySelection(): void { this.canvas.copySelection(); }
458
+ pasteSelection(): void { this.canvas.pasteSelection(); }
459
+ deleteSelection(): void { this.canvas.deleteSelection(); }
460
+
461
+ async copySelectionToClipboard(): Promise<boolean> {
462
+ const document = this.canvas.copySelectionDocument();
463
+ if (document === null) return false;
464
+ if (this.clipboard !== null) {
465
+ try {
466
+ await this.clipboard.writeText(serializeDiagramDocument(document));
467
+ } catch {
468
+ // The in-memory document remains available as a safe fallback.
469
+ }
470
+ }
471
+ return true;
472
+ }
473
+
474
+ async pasteSelectionFromClipboard(): Promise<boolean> {
475
+ if (!this.canvas.getInteractionPermissions().paste) return false;
476
+ if (this.clipboard !== null) {
477
+ try {
478
+ const text = await this.clipboard.readText();
479
+ const document = parseDiagramDocument(text);
480
+ return this.canvas.pasteDocument(document).length > 0;
481
+ } catch {
482
+ // Fall through to the component's last valid in-memory copy.
483
+ }
484
+ }
485
+ const fallback = this.canvas.getClipboardDocument();
486
+ return fallback !== null && this.canvas.pasteDocument(fallback).length > 0;
487
+ }
488
+
489
+ getContextCommands(): Array<{ command: ContextCommand; enabled: boolean }> {
490
+ return this.contextActions.states(this.contextActionContext()).map(({ id, enabled }) => ({
491
+ command: id,
492
+ enabled,
493
+ }));
494
+ }
495
+
496
+ executeContextCommand(command: ContextCommand): boolean {
497
+ const context = this.contextActionContext();
498
+ if (!this.contextActions.execute(command, context)) return false;
499
+ this.emit('contextCommand', { command, nodes: context.nodes, links: context.links });
500
+ return true;
501
+ }
502
+
503
+ clear(): void {
504
+ this.loading = true;
505
+ try {
506
+ this.canvas.load([], []);
507
+ } finally {
508
+ this.loading = false;
509
+ }
510
+ this.emit('runtimeStateChanged', { state: this.canvas.getRuntimeState() });
511
+ }
512
+
513
+ load(nodes: DiagramNode[], links: Link[], options: DiagramLoadOptions = {}): void {
514
+ this.loading = true;
515
+ try {
516
+ this.canvas.load(nodes.map((node) => this.toCanvasNode(node)), links.map((link) => this.toCanvasLink(link)));
517
+ for (const [nodeId, message] of Object.entries(options.nodeErrors ?? {})) {
518
+ this.canvas.setNodeError(nodeId, message, { kind: 'load', animate: false });
519
+ }
520
+ } finally {
521
+ this.loading = false;
522
+ }
523
+ this.emit('runtimeStateChanged', { state: this.canvas.getRuntimeState() });
524
+ const snapshot = this.save();
525
+ this.emit('loadFinished', snapshot);
526
+ }
527
+
528
+ save(): { nodes: DiagramNode[]; links: Link[] } {
529
+ const snapshot = this.canvas.save();
530
+ return {
531
+ nodes: snapshot.nodes.map((node) => this.fromCanvasInit(node)),
532
+ links: snapshot.links.map((link) => this.fromCanvasLink(link)),
533
+ };
534
+ }
535
+
536
+ loadDocument(document: DiagramDocument | string, options: DiagramLoadOptions = {}): void {
537
+ let failure: Error | null = null;
538
+ this.loading = true;
539
+ try {
540
+ this.canvas.loadDocument(document);
541
+ for (const [nodeId, message] of Object.entries(options.nodeErrors ?? {})) {
542
+ this.canvas.setNodeError(nodeId, message, { kind: 'load', animate: false });
543
+ }
544
+ } catch (error) {
545
+ failure = error instanceof Error ? error : new Error(String(error));
546
+ this.canvas.setGlobalError(failure.message, 'load');
547
+ } finally {
548
+ this.loading = false;
549
+ }
550
+ this.emit('runtimeStateChanged', { state: this.canvas.getRuntimeState() });
551
+ if (failure !== null) {
552
+ this.emit('documentLoadFailed', { message: failure.message, error: failure });
553
+ throw failure;
554
+ }
555
+ const saved = this.canvas.saveDocument();
556
+ this.emit('documentLoaded', { document: saved });
557
+ const snapshot = this.save();
558
+ this.emit('loadFinished', snapshot);
559
+ }
560
+
561
+ saveDocument(): DiagramDocument {
562
+ return this.canvas.saveDocument();
563
+ }
564
+
565
+ destroy(): void {
566
+ if (this.destroyed) return;
567
+ this.destroyed = true;
568
+ for (const dispose of this.disposables.splice(0).reverse()) dispose();
569
+ this.canvas.destroy();
570
+ }
571
+
572
+ private prepareFullscreenButtonHost(): void {
573
+ const previous = this.div.style.position;
574
+ const position = typeof getComputedStyle === 'function'
575
+ ? getComputedStyle(this.div).position
576
+ : previous;
577
+ if (position !== '' && position !== 'static' && position !== undefined) return;
578
+ this.div.style.position = 'relative';
579
+ this.disposables.push(() => {
580
+ if (this.div.style.position === 'relative') this.div.style.position = previous;
581
+ });
582
+ }
583
+
584
+ private createFullscreenButton(): HTMLButtonElement {
585
+ const owner = this.div.ownerDocument ?? document;
586
+ const button = owner.createElement('button');
587
+ button.type = 'button';
588
+ button.className = 'ssdiagram-fullscreen-button';
589
+ button.setAttribute('data-ssdiagram-fullscreen-button', '');
590
+ Object.assign(button.style, {
591
+ position: 'absolute',
592
+ top: '8px',
593
+ right: '8px',
594
+ zIndex: '4',
595
+ display: 'inline-flex',
596
+ alignItems: 'center',
597
+ justifyContent: 'center',
598
+ width: '30px',
599
+ height: '30px',
600
+ padding: '0',
601
+ border: '1px solid var(--ssdiagram-control-border, var(--t-border, #3a4250))',
602
+ borderRadius: '5px',
603
+ background: 'var(--ssdiagram-control-background, var(--t-surface, rgba(18, 21, 28, 0.9)))',
604
+ color: 'var(--ssdiagram-control-color, var(--t-text, #eaecef))',
605
+ boxSizing: 'border-box',
606
+ cursor: 'pointer',
607
+ opacity: '0.9',
608
+ });
609
+ const click = (): void => {
610
+ this.emit('fullscreenRequested', { fullscreen: !this.fullscreen });
611
+ };
612
+ button.addEventListener('click', click);
613
+ this.div.appendChild(button);
614
+ this.disposables.push(() => {
615
+ button.removeEventListener('click', click);
616
+ button.remove();
617
+ });
618
+ button.hidden = !this.fullscreenButtonVisible;
619
+ button.style.display = this.fullscreenButtonVisible ? 'inline-flex' : 'none';
620
+ return button;
621
+ }
622
+
623
+ private updateFullscreenButton(): void {
624
+ const fullscreen = this.fullscreen;
625
+ const label = fullscreen ? 'Exit fullscreen' : 'Enter fullscreen';
626
+ const path = fullscreen
627
+ ? 'M4 9h5V4M20 9h-5V4M4 15h5v5M20 15h-5v5'
628
+ : 'M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5';
629
+ this.fullscreenButton.className = fullscreen
630
+ ? 'ssdiagram-fullscreen-button is-active'
631
+ : 'ssdiagram-fullscreen-button';
632
+ this.fullscreenButton.title = label;
633
+ this.fullscreenButton.setAttribute('aria-label', label);
634
+ this.fullscreenButton.setAttribute('aria-pressed', String(fullscreen));
635
+ this.fullscreenButton.innerHTML = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" `
636
+ + 'fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" '
637
+ + `stroke-linejoin="round"><path d="${path}"/></svg>`;
638
+ }
639
+
640
+ private bindCanvasEvents(): void {
641
+ this.disposables.push(
642
+ this.canvas.on('nodeAdded', ({ node }) => {
643
+ if (!this.loading) this.emit('nodeAdded', { nodes: [this.fromCanvasNode(node)] });
644
+ }),
645
+ this.canvas.on('nodeRemoved', ({ node }) => {
646
+ if (!this.loading) this.emit('nodeRemoved', { nodes: [this.fromCanvasNode(node)] });
647
+ }),
648
+ this.canvas.on('nodeMoved', ({ node }) => {
649
+ if (!this.loading) this.emit('nodeMoved', { node: this.fromCanvasNode(node) });
650
+ }),
651
+ this.canvas.on('nodeChanged', ({ node }) => {
652
+ if (!this.loading) this.emit('nodeEdit', { nodes: [this.fromCanvasNode(node)] });
653
+ }),
654
+ this.canvas.on('linkAdded', ({ link }) => {
655
+ if (!this.loading) this.emit('linkAdded', { links: [this.fromCanvasLink(link)] });
656
+ }),
657
+ this.canvas.on('linkRemoved', ({ link }) => {
658
+ if (!this.loading) this.emit('linkRemoved', { links: [this.fromCanvasLink(link)] });
659
+ }),
660
+ this.canvas.on('linkRelinked', ({ link, previous }) => {
661
+ if (!this.loading) this.emit('linkRelinked', {
662
+ link: this.fromCanvasLink(link),
663
+ previous: this.fromCanvasLink(previous),
664
+ });
665
+ }),
666
+ this.canvas.on('nodeSelected', ({ node, selected }) => {
667
+ if (node !== null) this.emit('nodeSelected', { node: this.fromCanvasNode(node), selected });
668
+ }),
669
+ this.canvas.on('linkSelected', ({ link, selected }) => {
670
+ if (link !== null) this.emit('linkSelected', { link: this.fromCanvasLink(link), selected });
671
+ }),
672
+ this.canvas.on('selectionChanged', (selection) => this.emit('selectionChanged', selection)),
673
+ this.canvas.on('runtimeStateChanged', ({ state }) => {
674
+ if (!this.loading) this.emit('runtimeStateChanged', { state });
675
+ }),
676
+ this.canvas.on('nodeHover', ({ node, hovering }) => {
677
+ this.emit('nodeHover', { node: this.fromCanvasNode(node), hovering });
678
+ }),
679
+ this.canvas.on('portSelected', ({ node, port }) => {
680
+ this.emit('portSelected', {
681
+ node: this.fromCanvasNode(node),
682
+ port: this.fromCanvasPort(port),
683
+ direction: port.direction,
684
+ });
685
+ }),
686
+ this.canvas.on('portClicked', ({ node, port, action, ctrlKey, shiftKey, altKey, metaKey }) => {
687
+ this.emit('portClicked', {
688
+ node: this.fromCanvasNode(node),
689
+ port: this.fromCanvasPort(port),
690
+ direction: port.direction,
691
+ action,
692
+ ctrlKey,
693
+ shiftKey,
694
+ altKey,
695
+ metaKey,
696
+ });
697
+ }),
698
+ this.canvas.on('portHover', ({ node, port, hovering }) => {
699
+ this.emit('portHover', {
700
+ node: this.fromCanvasNode(node),
701
+ port: this.fromCanvasPort(port),
702
+ direction: port.direction,
703
+ hovering,
704
+ });
705
+ }),
706
+ this.canvas.on('linkHover', ({ link, hovering }) => {
707
+ this.emit('linkHover', { link: this.fromCanvasLink(link), hovering });
708
+ }),
709
+ this.canvas.on('linkValidation', ({ fromNode, from, toNode, to, allowed, reason }) => {
710
+ this.emit('linkValidation', {
711
+ fromNode: this.fromCanvasNode(fromNode),
712
+ fromPort: this.fromCanvasPort(from),
713
+ toNode: this.fromCanvasNode(toNode),
714
+ toPort: this.fromCanvasPort(to),
715
+ allowed,
716
+ reason,
717
+ });
718
+ }),
719
+ this.canvas.on('nodeOpen', ({ node }) => {
720
+ this.emit('nodeOpen', { nodes: [this.fromCanvasNode(node)] });
721
+ }),
722
+ this.canvas.on('zoomChanged', () => {
723
+ this.updateZoomLabel();
724
+ this.emit('zoomChanged', this.canvas.getViewState());
725
+ }),
726
+ this.canvas.on('viewChanged', (state) => {
727
+ this.overviewContainer?.classList.toggle('hidden', !state.overviewVisible);
728
+ this.emit('viewChanged', state);
729
+ }),
730
+ this.canvas.on('undoStackChanged', (state) => this.emit('undoStackChanged', state)),
731
+ this.canvas.on('contextMenu', ({ x, y, node, link, port }) => {
732
+ this.emit('contextMenuRequested', {
733
+ x,
734
+ y,
735
+ node: node === null ? null : this.fromCanvasNode(node),
736
+ link: link === null ? null : this.fromCanvasLink(link),
737
+ port: port === null ? null : this.fromCanvasPort(port.port),
738
+ portDirection: port?.port.direction ?? null,
739
+ commands: this.getContextCommands(),
740
+ });
741
+ }),
742
+ );
743
+ }
744
+
745
+ private registerContextActions(): void {
746
+ const permissions = () => this.canvas.getInteractionPermissions();
747
+ this.contextActions.register({
748
+ id: 'undo',
749
+ canExecute: () => this.canUndo(),
750
+ execute: () => this.undo(),
751
+ });
752
+ this.contextActions.register({
753
+ id: 'redo',
754
+ canExecute: () => this.canRedo(),
755
+ execute: () => this.redo(),
756
+ });
757
+ this.contextActions.register({
758
+ id: 'cut',
759
+ canExecute: ({ nodes }) => nodes.length > 0 && permissions().copy && permissions().deleteSelection,
760
+ execute: () => this.cutSelection(),
761
+ });
762
+ this.contextActions.register({
763
+ id: 'copy',
764
+ canExecute: ({ nodes }) => nodes.length > 0 && permissions().copy,
765
+ execute: () => this.copySelection(),
766
+ });
767
+ this.contextActions.register({
768
+ id: 'paste',
769
+ canExecute: () => this.canvas.hasClipboard() && permissions().paste,
770
+ execute: () => this.pasteSelection(),
771
+ });
772
+ this.contextActions.register({
773
+ id: 'open',
774
+ canExecute: ({ nodes }) => nodes.length === 1 && nodes[0].openAction.length > 0,
775
+ execute: ({ nodes }) => this.emit('nodeOpen', { nodes }),
776
+ });
777
+ this.contextActions.register({
778
+ id: 'delete',
779
+ canExecute: ({ selection }) => permissions().deleteSelection
780
+ && (selection.nodeIds.length > 0 || selection.linkIds.length > 0),
781
+ execute: () => this.canvas.deleteSelection(),
782
+ });
783
+ this.contextActions.register({
784
+ id: 'properties',
785
+ canExecute: ({ nodes }) => nodes.length > 0,
786
+ execute: ({ nodes }) => this.emit('nodeProperties', { nodes }),
787
+ });
788
+ this.contextActions.register({
789
+ id: 'help',
790
+ canExecute: ({ nodes }) => this.helpEnabled && nodes.length > 0,
791
+ execute: ({ nodes }) => this.emit('nodeHelp', { nodes }),
792
+ });
793
+ }
794
+
795
+ private contextActionContext(): ContextActionContext {
796
+ const selection = this.canvas.getSelection();
797
+ const document = this.canvas.saveDocument();
798
+ const nodeIds = new Set(selection.nodeIds);
799
+ const linkIds = new Set(selection.linkIds);
800
+ return {
801
+ selection,
802
+ nodes: document.nodes
803
+ .filter((node) => nodeIds.has(node.id))
804
+ .map((node) => this.fromCanvasInit(node)),
805
+ links: document.links
806
+ .filter((link) => linkIds.has(link.id))
807
+ .map((link) => new Link({
808
+ id: link.id,
809
+ outNode: link.from.nodeId,
810
+ outPort: link.from.portId,
811
+ inNode: link.to.nodeId,
812
+ inPort: link.to.portId,
813
+ metadata: link.metadata,
814
+ })),
815
+ };
816
+ }
817
+
818
+ private toCanvasNode(node: DiagramNode): CanvasNodeInit {
819
+ return {
820
+ id: node.id,
821
+ typeId: node.typeId,
822
+ name: node.name,
823
+ description: node.description,
824
+ groupName: node.groupName,
825
+ color: node.color,
826
+ border: node.border,
827
+ icon: node.icon,
828
+ openAction: node.openAction,
829
+ message: node.message,
830
+ isPlaceholder: node.isPlaceholder,
831
+ parameters: node.parameters.map((parameter) => ({ ...parameter, options: [...parameter.options] })),
832
+ paramValues: { ...node.paramValues },
833
+ x: node.x,
834
+ y: node.y,
835
+ inPorts: node.inPorts.map((port) => this.toCanvasPort(port)),
836
+ outPorts: node.outPorts.map((port) => this.toCanvasPort(port)),
837
+ };
838
+ }
839
+
840
+ private fromCanvasNode(node: NodeModel): DiagramNode {
841
+ return this.fromCanvasInit(node.toInit(false));
842
+ }
843
+
844
+ private fromCanvasInit(node: CanvasNodeInit & { id: string }): DiagramNode {
845
+ return new DiagramNode({
846
+ id: node.id,
847
+ typeId: node.typeId,
848
+ name: node.name,
849
+ description: node.description,
850
+ groupName: node.groupName,
851
+ color: node.color,
852
+ border: node.border,
853
+ icon: node.icon,
854
+ openAction: node.openAction,
855
+ message: node.message,
856
+ isPlaceholder: node.isPlaceholder,
857
+ parameters: (node.parameters ?? []).map((parameter) => ({ ...parameter, options: [...parameter.options] })),
858
+ paramValues: { ...(node.paramValues ?? {}) },
859
+ x: node.x,
860
+ y: node.y,
861
+ inPorts: (node.inPorts ?? []).map((port) => this.fromCanvasPortInit(port)),
862
+ outPorts: (node.outPorts ?? []).map((port) => this.fromCanvasPortInit(port)),
863
+ });
864
+ }
865
+
866
+ private toCanvasPort(port: Port): CanvasPortInit {
867
+ return {
868
+ id: port.id,
869
+ name: port.name,
870
+ description: port.description,
871
+ type: port.type,
872
+ maxLinks: port.maxLinks,
873
+ availableTypes: [...port.availableTypes],
874
+ isDynamic: port.isDynamic,
875
+ dynamicMode: port.dynamicMode,
876
+ isSibling: port.isSibling,
877
+ };
878
+ }
879
+
880
+ private fromCanvasPort(port: PortModel): Port {
881
+ return this.fromCanvasPortInit(port.toInit());
882
+ }
883
+
884
+ private fromCanvasPortInit(port: CanvasPortInit): Port {
885
+ return new Port({
886
+ id: port.id,
887
+ name: port.name,
888
+ description: port.description,
889
+ type: port.type,
890
+ maxLinks: port.maxLinks,
891
+ availableTypes: [...(port.availableTypes ?? [])],
892
+ isDynamic: port.isDynamic,
893
+ dynamicMode: port.dynamicMode,
894
+ isSibling: port.isSibling,
895
+ });
896
+ }
897
+
898
+ private toCanvasLink(link: Link): CanvasLinkInit {
899
+ const idOf = (value: string | { id: string }): string => typeof value === 'string' ? value : value.id;
900
+ return {
901
+ id: link.id || undefined,
902
+ from: idOf(link.outNode),
903
+ fromPort: idOf(link.outPort),
904
+ to: idOf(link.inNode),
905
+ toPort: idOf(link.inPort),
906
+ metadata: link.metadata,
907
+ };
908
+ }
909
+
910
+ private fromCanvasLink(link: CanvasLinkInit | LinkModel): Link {
911
+ return new Link({
912
+ id: link.id,
913
+ outNode: link.from,
914
+ outPort: link.fromPort,
915
+ inNode: link.to,
916
+ inPort: link.toPort,
917
+ metadata: link.metadata,
918
+ });
919
+ }
920
+
921
+ private portTypeColors(): Record<string, string> {
922
+ return Object.fromEntries(this.catalog.getPortTypes().map((portType) => [portType.name, portType.color]));
923
+ }
924
+
925
+ private updateZoomLabel(): void {
926
+ if (this.zoomLabel !== null) this.zoomLabel.textContent = `${Math.round(this.canvas.getViewState().zoom * 100)}%`;
927
+ }
928
+
929
+ private resolveClipboard(explicit: DiagramClipboard | null | undefined): DiagramClipboard | null {
930
+ if (explicit !== undefined) return explicit;
931
+ if (typeof navigator === 'undefined' || navigator.clipboard === undefined) return null;
932
+ return {
933
+ readText: () => navigator.clipboard.readText(),
934
+ writeText: (value) => navigator.clipboard.writeText(value),
935
+ };
936
+ }
937
+
938
+ private generateNodeId(prefix: string): string {
939
+ const safePrefix = prefix.replace(/[^a-zA-Z0-9_-]/g, '') || 'node';
940
+ let id: string;
941
+ do id = `${safePrefix}_${this.idCounter++}`;
942
+ while (this.canvas.findNode(id) !== undefined);
943
+ return id;
944
+ }
945
+ }