@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.
Files changed (45) hide show
  1. package/README.md +105 -2
  2. package/dist/esm/canvas-renderer.js +116 -36
  3. package/dist/esm/canvas-renderer.js.map +1 -1
  4. package/dist/esm/diagram/context-menu.js +319 -0
  5. package/dist/esm/diagram/context-menu.js.map +1 -0
  6. package/dist/esm/diagram/stocksharp-diagram.js +121 -9
  7. package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
  8. package/dist/esm/draw-surface.js +2 -0
  9. package/dist/esm/draw-surface.js.map +1 -0
  10. package/dist/esm/embed.js +7 -4
  11. package/dist/esm/embed.js.map +1 -1
  12. package/dist/esm/i18n.js +15 -4
  13. package/dist/esm/i18n.js.map +1 -1
  14. package/dist/esm/index.js.map +1 -1
  15. package/dist/esm/svg-surface.js +341 -0
  16. package/dist/esm/svg-surface.js.map +1 -0
  17. package/dist/ssdiagram.js +978 -188
  18. package/dist/ssdiagram.js.map +4 -4
  19. package/dist/types/canvas-renderer.d.ts +13 -0
  20. package/dist/types/canvas-renderer.d.ts.map +1 -1
  21. package/dist/types/diagram/api.d.ts +42 -2
  22. package/dist/types/diagram/api.d.ts.map +1 -1
  23. package/dist/types/diagram/context-menu.d.ts +33 -0
  24. package/dist/types/diagram/context-menu.d.ts.map +1 -0
  25. package/dist/types/diagram/stocksharp-diagram.d.ts +18 -5
  26. package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
  27. package/dist/types/draw-surface.d.ts +52 -0
  28. package/dist/types/draw-surface.d.ts.map +1 -0
  29. package/dist/types/embed.d.ts.map +1 -1
  30. package/dist/types/i18n.d.ts +12 -0
  31. package/dist/types/i18n.d.ts.map +1 -1
  32. package/dist/types/index.d.ts +2 -1
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/dist/types/svg-surface.d.ts +74 -0
  35. package/dist/types/svg-surface.d.ts.map +1 -0
  36. package/package.json +1 -1
  37. package/src/canvas-renderer.ts +120 -27
  38. package/src/diagram/api.ts +53 -2
  39. package/src/diagram/context-menu.ts +363 -0
  40. package/src/diagram/stocksharp-diagram.ts +134 -10
  41. package/src/draw-surface.ts +61 -0
  42. package/src/embed.ts +7 -4
  43. package/src/i18n.ts +41 -6
  44. package/src/index.ts +10 -0
  45. package/src/svg-surface.ts +430 -0
@@ -27,11 +27,16 @@ import {
27
27
  type PortModel,
28
28
  } from '../canvas-renderer.js';
29
29
  import { StockSharpCatalog } from './catalog.js';
30
+ import { ContextMenuView } from './context-menu.js';
31
+ import { t } from '../i18n.js';
30
32
  import { EventEmitter } from './event-emitter.js';
31
33
  import type {
32
34
  DiagramEvents,
33
35
  DiagramClipboard,
34
36
  ContextCommand,
37
+ ContextCommandGroup,
38
+ ContextCommandState,
39
+ ContextMenuItemState,
35
40
  DiagramLoadOptions,
36
41
  DiagramFullscreenLabels,
37
42
  DiagramGridSettings,
@@ -58,8 +63,34 @@ interface ContextActionContext {
58
63
  selection: DiagramSelection;
59
64
  nodes: DiagramNode[];
60
65
  links: Link[];
66
+ /** The whole diagram, not the selection: export offers the picture as a whole. */
67
+ document: DiagramDocument;
61
68
  }
62
69
 
70
+ type ContextMenuEntry = ContextCommand | { group: ContextCommandGroup; commands: readonly ContextCommand[] };
71
+
72
+ /**
73
+ * Menu order, and the only place the nesting is declared. The registry is flat because
74
+ * every command must be runnable by id, so the shape of the menu cannot live there.
75
+ * A command missing from this list would be executable and never offered; the test suite
76
+ * walks both and refuses to let the two drift.
77
+ */
78
+ const CONTEXT_MENU: readonly ContextMenuEntry[] = [
79
+ 'undo', 'redo',
80
+ 'cut', 'copy', 'paste',
81
+ 'open',
82
+ 'delete',
83
+ { group: 'export', commands: ['exportDocument', 'exportPng', 'exportSvg'] },
84
+ 'overview',
85
+ 'properties',
86
+ 'help',
87
+ ];
88
+
89
+ /** Commands that toggle rather than do, so the menu reports their state instead of just enabling them. */
90
+ const CONTEXT_CHECKED: Partial<Record<ContextCommand, (diagram: StockSharpDiagram) => boolean>> = {
91
+ overview: (diagram) => diagram.getViewState().overviewVisible,
92
+ };
93
+
63
94
  export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
64
95
  private readonly div: HTMLElement;
65
96
  private readonly catalog: StockSharpCatalog;
@@ -77,8 +108,12 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
77
108
  private destroyed = false;
78
109
  private fullscreen = false;
79
110
  private fullscreenButtonVisible = true;
80
- private fullscreenLabels: DiagramFullscreenLabels = { enter: 'Enter fullscreen', exit: 'Exit fullscreen' };
111
+ // null means the host never named the button, so its wording comes from the shared bundle.
112
+ // Keeping the two apart is what lets an explicit label win while an untouched control still
113
+ // follows the page's language instead of being stuck in English.
114
+ private fullscreenLabels: DiagramFullscreenLabels | null = null;
81
115
  private readonly contextActions = new DiagramActionRegistry<ContextCommand, ContextActionContext>();
116
+ private contextMenu: ContextMenuView | null = null;
82
117
 
83
118
  constructor(options: DiagramOptions) {
84
119
  super();
@@ -94,10 +129,11 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
94
129
  gridSize: options.gridSize,
95
130
  });
96
131
  this.fullscreenButtonVisible = options.showFullscreenButton ?? true;
97
- this.fullscreenLabels = options.fullscreenLabels ?? this.fullscreenLabels;
132
+ this.fullscreenLabels = options.fullscreenLabels ?? null;
98
133
  this.prepareFullscreenButtonHost();
99
134
  this.fullscreenButton = this.createFullscreenButton();
100
135
  this.updateFullscreenButton();
136
+ this.setContextMenuEnabled(options.showContextMenu ?? true);
101
137
  this.registerContextActions();
102
138
  this.bindCanvasEvents();
103
139
  this.disposables.push(this.catalog.on('portTypesChanged', () => this.applySocketTheme()));
@@ -372,6 +408,11 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
372
408
  return this.canvas.takeScreenshot(options);
373
409
  }
374
410
 
411
+ /** The same picture as takeScreenshot, as an SVG document. */
412
+ takeSvg(options: DiagramScreenshotOptions = {}): string {
413
+ return this.canvas.takeSvg(options);
414
+ }
415
+
375
416
  getSelection(): DiagramSnapshot<DiagramSelection> {
376
417
  return this.canvas.getSelection();
377
418
  }
@@ -412,8 +453,24 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
412
453
  this.updateFullscreenButton();
413
454
  }
414
455
 
456
+ /**
457
+ * Re-reads the shared bundle for text the control renders itself. Call it after changing
458
+ * the page language: the context menu is rebuilt on every open and needs nothing, but the
459
+ * fullscreen button is drawn once and would otherwise keep the old wording.
460
+ */
461
+ refreshLabels(): void {
462
+ this.updateFullscreenButton();
463
+ }
464
+
415
465
  getFullscreenLabels(): DiagramSnapshot<DiagramFullscreenLabels> {
416
- return { ...this.fullscreenLabels };
466
+ return { ...this.resolveFullscreenLabels() };
467
+ }
468
+
469
+ private resolveFullscreenLabels(): DiagramFullscreenLabels {
470
+ return this.fullscreenLabels ?? {
471
+ enter: t('fullscreenEnter', 'Enter fullscreen'),
472
+ exit: t('fullscreenExit', 'Exit fullscreen'),
473
+ };
417
474
  }
418
475
 
419
476
  /** Updates only the control's state after the host changed its own layout. */
@@ -509,11 +566,46 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
509
566
  return fallback !== null && this.canvas.pasteDocument(fallback).length > 0;
510
567
  }
511
568
 
512
- getContextCommands(): Array<{ command: ContextCommand; enabled: boolean }> {
513
- return this.contextActions.states(this.contextActionContext()).map(({ id, enabled }) => ({
514
- command: id,
515
- enabled,
516
- }));
569
+ /**
570
+ * Turns the built-in menu on or off. Off leaves `contextMenuRequested` untouched, so a
571
+ * host that draws its own is only trading one menu for another, never losing the event.
572
+ */
573
+ setContextMenuEnabled(enabled: boolean): void {
574
+ if (enabled === (this.contextMenu !== null)) return;
575
+ if (!enabled) {
576
+ this.contextMenu?.destroy();
577
+ this.contextMenu = null;
578
+ return;
579
+ }
580
+ this.contextMenu = new ContextMenuView({
581
+ container: this.div,
582
+ // Through the public entry point, so the built-in menu is exactly as privileged
583
+ // as a host's own -- same guards, same contextCommand event.
584
+ execute: (command) => { this.executeContextCommand(command); },
585
+ });
586
+ }
587
+
588
+ isContextMenuEnabled(): boolean {
589
+ return this.contextMenu !== null;
590
+ }
591
+
592
+ getContextCommands(): ContextMenuItemState[] {
593
+ const context = this.contextActionContext();
594
+ const state = (command: ContextCommand): ContextCommandState => {
595
+ const checked = CONTEXT_CHECKED[command];
596
+ return {
597
+ command,
598
+ enabled: this.contextActions.canExecute(command, context),
599
+ ...(checked === undefined ? {} : { checked: checked(this) }),
600
+ };
601
+ };
602
+ return CONTEXT_MENU.map((entry) => {
603
+ if (typeof entry === 'string') return state(entry);
604
+ const commands = entry.commands.map(state);
605
+ // A submenu that opens onto nothing but greyed items is itself dead, and
606
+ // saying so here spares every host from working the same thing out again.
607
+ return { group: entry.group, enabled: commands.some(({ enabled }) => enabled), commands };
608
+ });
517
609
  }
518
610
 
519
611
  executeContextCommand(command: ContextCommand): boolean {
@@ -588,6 +680,8 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
588
680
  destroy(): void {
589
681
  if (this.destroyed) return;
590
682
  this.destroyed = true;
683
+ this.contextMenu?.destroy();
684
+ this.contextMenu = null;
591
685
  for (const dispose of this.disposables.splice(0).reverse()) dispose();
592
686
  this.canvas.destroy();
593
687
  }
@@ -645,7 +739,8 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
645
739
 
646
740
  private updateFullscreenButton(): void {
647
741
  const fullscreen = this.fullscreen;
648
- const label = fullscreen ? this.fullscreenLabels.exit : this.fullscreenLabels.enter;
742
+ const labels = this.resolveFullscreenLabels();
743
+ const label = fullscreen ? labels.exit : labels.enter;
649
744
  const path = fullscreen
650
745
  ? 'M4 9h5V4M20 9h-5V4M4 15h5v5M20 15h-5v5'
651
746
  : 'M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5';
@@ -752,6 +847,7 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
752
847
  }),
753
848
  this.canvas.on('undoStackChanged', (state) => this.emit('undoStackChanged', state)),
754
849
  this.canvas.on('contextMenu', ({ x, y, node, link, port }) => {
850
+ const commands = this.getContextCommands();
755
851
  this.emit('contextMenuRequested', {
756
852
  x,
757
853
  y,
@@ -759,8 +855,11 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
759
855
  link: link === null ? null : this.fromCanvasLink(link),
760
856
  port: port === null ? null : this.fromCanvasPort(port.port),
761
857
  portDirection: port?.port.direction ?? null,
762
- commands: this.getContextCommands(),
858
+ commands,
763
859
  });
860
+ // After the event, so a host that draws its own menu has already been told
861
+ // and can have switched this one off in the handler.
862
+ this.contextMenu?.show(x, y, commands);
764
863
  }),
765
864
  );
766
865
  }
@@ -800,6 +899,30 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
800
899
  && (selection.nodeIds.length > 0 || selection.linkIds.length > 0),
801
900
  execute: () => this.canvas.deleteSelection(),
802
901
  },
902
+ // Export asks rather than does. Where the result goes -- a download, an upload,
903
+ // a clipboard, a print preview -- is the host's decision, and so is which options
904
+ // to render with, so the control reports the request and stops there.
905
+ // Availability follows the diagram, not the selection: all three export the whole
906
+ // picture, and an empty diagram has none to give. They stay on in read-only mode,
907
+ // which forbids changing the diagram, not looking at it.
908
+ exportDocument: {
909
+ canExecute: ({ document }) => document.nodes.length > 0,
910
+ execute: () => this.emit('exportRequested', { format: 'document' }),
911
+ },
912
+ exportPng: {
913
+ canExecute: ({ document }) => document.nodes.length > 0,
914
+ execute: () => this.emit('exportRequested', { format: 'png' }),
915
+ },
916
+ exportSvg: {
917
+ canExecute: ({ document }) => document.nodes.length > 0,
918
+ execute: () => this.emit('exportRequested', { format: 'svg' }),
919
+ },
920
+ // Always available: it shows the overview as readily as it hides it, and an empty
921
+ // diagram is exactly when someone may want the panel out of the way.
922
+ overview: {
923
+ canExecute: () => true,
924
+ execute: () => this.setOverviewVisible(!this.getViewState().overviewVisible),
925
+ },
803
926
  properties: {
804
927
  canExecute: ({ nodes }) => nodes.length > 0,
805
928
  execute: ({ nodes }) => this.emit('nodeProperties', { nodes }),
@@ -818,6 +941,7 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
818
941
  const linkIds = new Set(selection.linkIds);
819
942
  return {
820
943
  selection,
944
+ document,
821
945
  nodes: document.nodes
822
946
  .filter((node) => nodeIds.has(node.id))
823
947
  .map((node) => this.fromCanvasInit(node)),
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The drawing operations the renderer actually uses, and nothing more.
3
+ *
4
+ * The renderer paints straight onto a 2D context, which means the picture only ever exists as
5
+ * pixels: a screenshot can be taken, but there is no vector form to export. Naming the surface it
6
+ * draws against is what makes a second backend possible without a second renderer -- one that emits
7
+ * SVG. Two renderers would be two truths about how a node looks, and they would drift.
8
+ *
9
+ * The member list is deliberately a subset of CanvasRenderingContext2D with identical names and
10
+ * semantics. TypeScript matches types structurally, so the browser's own context satisfies this
11
+ * without a wrapper: the on-screen path keeps calling the real thing directly, at no cost and with
12
+ * no behaviour to re-verify.
13
+ */
14
+ export interface DrawSurface {
15
+ // ----- state -----
16
+ fillStyle: string | CanvasGradient | CanvasPattern;
17
+ strokeStyle: string | CanvasGradient | CanvasPattern;
18
+ lineWidth: number;
19
+ lineJoin: CanvasLineJoin;
20
+ lineCap: CanvasLineCap;
21
+ globalAlpha: number;
22
+ font: string;
23
+ textAlign: CanvasTextAlign;
24
+ textBaseline: CanvasTextBaseline;
25
+
26
+ save(): void;
27
+ restore(): void;
28
+ setLineDash(segments: readonly number[]): void;
29
+
30
+ // ----- transform -----
31
+ setTransform(a: number, b: number, c: number, d: number, e: number, f: number): void;
32
+ translate(x: number, y: number): void;
33
+ rotate(angle: number): void;
34
+
35
+ // ----- paths -----
36
+ beginPath(): void;
37
+ closePath(): void;
38
+ moveTo(x: number, y: number): void;
39
+ lineTo(x: number, y: number): void;
40
+ arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, counterclockwise?: boolean): void;
41
+ arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void;
42
+ fill(): void;
43
+ stroke(): void;
44
+ clip(): void;
45
+
46
+ // ----- rectangles -----
47
+ clearRect(x: number, y: number, w: number, h: number): void;
48
+ fillRect(x: number, y: number, w: number, h: number): void;
49
+ strokeRect(x: number, y: number, w: number, h: number): void;
50
+
51
+ // ----- text -----
52
+ fillText(text: string, x: number, y: number, maxWidth?: number): void;
53
+ /**
54
+ * Only `width` is read by the renderer. An SVG backend has no font engine of its own and
55
+ * borrows a real 2D context for this, so text lands in exactly the same place in both outputs.
56
+ */
57
+ measureText(text: string): { width: number };
58
+
59
+ // ----- images -----
60
+ drawImage(image: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void;
61
+ }
package/src/embed.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { t } from './i18n.js';
1
2
  // Shared read-only diagram embed layer. Rendering, palette loading and theming
2
3
  // live next to the diagram engine so web applications do not copy that logic.
3
4
  //
@@ -407,7 +408,7 @@ async function renderSchemeAtRevision(
407
408
  diagram.setLinkValidator(() => true);
408
409
  diagram.setOverviewVisible(false);
409
410
  const missingTypeTemplate = div.dataset.diagramMissingElement
410
- ?? 'Element type "{typeId}" is missing from the palette.';
411
+ ?? t('embedMissingElement', 'Element type "{typeId}" is missing from the palette.');
411
412
  const converted = toDiagramNodes(
412
413
  scheme,
413
414
  catalog,
@@ -508,10 +509,12 @@ interface EmbedErrorTexts {
508
509
  // default would never fire and the note would render blank.
509
510
  function errorTexts(div: HTMLElement): EmbedErrorTexts {
510
511
  const parts = (div.dataset.diagramErrors ?? '').split('|');
512
+ // The attribute stays the override; without it the wording comes from the shared bundle, so
513
+ // a page that already translated the menu does not have to repeat itself per embed.
511
514
  return {
512
- load: parts[0] || 'Diagram source could not be loaded.',
513
- empty: parts[1] || 'Diagram is empty or malformed.',
514
- draw: parts[2] || 'Diagram could not be rendered.',
515
+ load: parts[0] || t('embedErrorLoad', 'Diagram source could not be loaded.'),
516
+ empty: parts[1] || t('embedErrorEmpty', 'Diagram is empty or malformed.'),
517
+ draw: parts[2] || t('embedErrorDraw', 'Diagram could not be rendered.'),
515
518
  };
516
519
  }
517
520
 
package/src/i18n.ts CHANGED
@@ -95,8 +95,35 @@ export interface DesignerI18n {
95
95
  copy?: string;
96
96
  paste?: string;
97
97
  ctxOpen?: string;
98
+ // Deletes the selected nodes and links. Deliberately not the solution explorer's
99
+ // `delete`, which removes a whole strategy: same English word, but the object differs,
100
+ // and a language that declines it -- or a host that writes "Delete strategy..." -- cannot
101
+ // serve both from one key.
102
+ ctxDelete?: string;
103
+ // Submenu title, and deliberately not `ctxExport` -- that one is the solution
104
+ // explorer's "export this strategy", a different action that a host may well word
105
+ // differently. Reads as its own item too: "Export as > SVG image".
106
+ ctxExportAs?: string;
107
+ ctxExportDocument?: string;
108
+ ctxExportPng?: string;
109
+ ctxExportSvg?: string;
110
+ ctxOverview?: string;
98
111
  properties?: string;
99
112
  ctxHelp?: string;
113
+
114
+ // Everything else the control renders by itself. Each of these also has a per-instance
115
+ // override -- the fullscreenLabels option, or a data-diagram-* attribute on an embedded
116
+ // host -- and that override wins; these are the defaults when none was given.
117
+ fullscreenEnter?: string;
118
+ fullscreenExit?: string;
119
+ embedErrorLoad?: string;
120
+ embedErrorEmpty?: string;
121
+ embedErrorDraw?: string;
122
+ embedMissingElement?: string;
123
+
124
+ // Not the diagram menu: it has no collapse/expand command. Kept for hosts already
125
+ // shipping them, but moved out of the block above so nobody translates them expecting
126
+ // to see them in the context menu.
100
127
  collapse?: string;
101
128
  expand?: string;
102
129
 
@@ -198,13 +225,21 @@ export interface DesignerI18n {
198
225
  opterr_plotly_missing?: string;
199
226
  }
200
227
 
201
- const i18n: DesignerI18n =
202
- typeof window === 'undefined'
203
- ? {}
204
- : (window as unknown as { __designerI18n?: DesignerI18n }).__designerI18n ?? {};
205
-
206
228
  /// Localized string for `key`, or `fallback` (English) when the host
207
229
  /// didn't provide it.
230
+ ///
231
+ /// The bundle is read on every call rather than captured when this module loads. Capturing
232
+ /// it meant the host had to assign `window.__designerI18n` before the bundle evaluated --
233
+ /// the opposite of the usual order, and impossible for a lazily loaded chunk -- and a host
234
+ /// that assigned it afterwards silently got English with nothing to explain why. Reading it
235
+ /// live also makes assigning and mutating the object behave the same, and lets the language
236
+ /// change mid-session: the only thing reading these keys is the context menu, and it is
237
+ /// built fresh on every right-click.
238
+ ///
239
+ /// An empty string counts as missing. Half-finished translation exports routinely emit one,
240
+ /// and a blank menu entry is worse than an English one.
208
241
  export function t(key: keyof DesignerI18n, fallback: string): string {
209
- return i18n[key] ?? fallback;
242
+ if (typeof window === 'undefined') return fallback;
243
+ const value = (window as unknown as { __designerI18n?: DesignerI18n }).__designerI18n?.[key];
244
+ return value === undefined || value === '' ? fallback : value;
210
245
  }
package/src/index.ts CHANGED
@@ -85,11 +85,21 @@ export {
85
85
  StockSharpDiagram,
86
86
  } from './diagram/stocksharp-diagram.js';
87
87
 
88
+ // The menu resolves its labels through this, so a host needs the type to write a bundle the
89
+ // compiler can check. Without it the key names get hand-copied, and a typo shows up only as an
90
+ // English label on screen.
91
+ export type { DesignerI18n } from './i18n.js';
92
+
88
93
  export type {
89
94
  ContextCommand,
95
+ ContextCommandGroup,
96
+ ContextCommandGroupState,
90
97
  ContextCommandPayload,
91
98
  ContextCommandState,
99
+ ContextMenuItemState,
92
100
  ContextMenuRequestedPayload,
101
+ ExportFormat,
102
+ ExportRequestedPayload,
93
103
  DiagramEvents,
94
104
  DiagramClipboard,
95
105
  DiagramGridSettings,