@stocksharp/diagram 0.6.0 → 0.7.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 (57) hide show
  1. package/dist/esm/canvas-renderer.js +185 -34
  2. package/dist/esm/canvas-renderer.js.map +1 -1
  3. package/dist/esm/core/document.js +24 -12
  4. package/dist/esm/core/document.js.map +1 -1
  5. package/dist/esm/core/history.js +12 -3
  6. package/dist/esm/core/history.js.map +1 -1
  7. package/dist/esm/core/json.js +10 -0
  8. package/dist/esm/core/json.js.map +1 -1
  9. package/dist/esm/diagram/context-menu.js +28 -10
  10. package/dist/esm/diagram/context-menu.js.map +1 -1
  11. package/dist/esm/diagram/event-emitter.js +12 -1
  12. package/dist/esm/diagram/event-emitter.js.map +1 -1
  13. package/dist/esm/diagram/palette.js +9 -1
  14. package/dist/esm/diagram/palette.js.map +1 -1
  15. package/dist/esm/diagram/stocksharp-diagram.js +8 -0
  16. package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
  17. package/dist/esm/diagram/types.js +29 -2
  18. package/dist/esm/diagram/types.js.map +1 -1
  19. package/dist/esm/embed.js +63 -10
  20. package/dist/esm/embed.js.map +1 -1
  21. package/dist/esm/i18n.js.map +1 -1
  22. package/dist/esm/svg-surface.js +10 -2
  23. package/dist/esm/svg-surface.js.map +1 -1
  24. package/dist/ssdiagram.js +253 -74
  25. package/dist/ssdiagram.js.map +3 -3
  26. package/dist/types/canvas-renderer.d.ts +19 -1
  27. package/dist/types/canvas-renderer.d.ts.map +1 -1
  28. package/dist/types/core/document.d.ts.map +1 -1
  29. package/dist/types/core/history.d.ts +1 -0
  30. package/dist/types/core/history.d.ts.map +1 -1
  31. package/dist/types/core/json.d.ts +8 -0
  32. package/dist/types/core/json.d.ts.map +1 -1
  33. package/dist/types/diagram/context-menu.d.ts +6 -0
  34. package/dist/types/diagram/context-menu.d.ts.map +1 -1
  35. package/dist/types/diagram/event-emitter.d.ts.map +1 -1
  36. package/dist/types/diagram/palette.d.ts.map +1 -1
  37. package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
  38. package/dist/types/diagram/types.d.ts +5 -0
  39. package/dist/types/diagram/types.d.ts.map +1 -1
  40. package/dist/types/embed.d.ts.map +1 -1
  41. package/dist/types/i18n.d.ts +2 -0
  42. package/dist/types/i18n.d.ts.map +1 -1
  43. package/dist/types/svg-surface.d.ts +1 -1
  44. package/dist/types/svg-surface.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/canvas-renderer.ts +173 -34
  47. package/src/core/document.ts +25 -12
  48. package/src/core/history.ts +13 -3
  49. package/src/core/json.ts +11 -0
  50. package/src/diagram/context-menu.ts +30 -10
  51. package/src/diagram/event-emitter.ts +10 -1
  52. package/src/diagram/palette.ts +9 -2
  53. package/src/diagram/stocksharp-diagram.ts +5 -0
  54. package/src/diagram/types.ts +29 -3
  55. package/src/embed.ts +71 -10
  56. package/src/i18n.ts +2 -0
  57. package/src/svg-surface.ts +11 -2
@@ -124,6 +124,12 @@ export class ContextMenuView {
124
124
  if (SEPARATE_AFTER.has(id) && index < items.length - 1) root.appendChild(this.separator(owner));
125
125
  });
126
126
 
127
+ // A right-click inside our own menu is not "outside", so the dismissal watcher lets it
128
+ // through -- and the canvas listener that suppresses the native menu never sees it,
129
+ // because the menu is not a child of the canvas. Without this the browser drew its own
130
+ // menu on top of ours, leaving two stacked over each other.
131
+ this.on(root, 'contextmenu', (event) => { event.preventDefault(); });
132
+
127
133
  this.container.appendChild(root);
128
134
  this.root = root;
129
135
  this.place(root, x, y);
@@ -291,6 +297,17 @@ export class ContextMenuView {
291
297
  this.submenu = null;
292
298
  }
293
299
 
300
+ /**
301
+ * The window the menu actually lives in. A component created from one document and mounted
302
+ * into another -- a same-origin iframe, a popup -- must be measured and dismissed against
303
+ * that document's window, not the one this module happened to be evaluated in.
304
+ */
305
+ private get view(): (Window & typeof globalThis) | null {
306
+ const owner = this.container.ownerDocument ?? (typeof document === 'undefined' ? null : document);
307
+ const view = owner?.defaultView ?? (typeof window === 'undefined' ? null : window);
308
+ return view ?? null;
309
+ }
310
+
294
311
  // Reads the resolved writing direction so the submenu's side and its arrow agree with it.
295
312
  private rtl(): boolean {
296
313
  if (typeof getComputedStyle !== 'function') return false;
@@ -304,13 +321,14 @@ export class ContextMenuView {
304
321
 
305
322
  /** Keeps the panel inside the viewport, flipping it left when it would run off the edge. */
306
323
  private keepOnScreen(panel: HTMLElement): void {
307
- if (typeof panel.getBoundingClientRect !== 'function' || typeof window === 'undefined') return;
324
+ const view = this.view;
325
+ if (typeof panel.getBoundingClientRect !== 'function' || view === null) return;
308
326
  const box = panel.getBoundingClientRect();
309
- if (box.right > window.innerWidth - 4 || box.left < 4) {
327
+ if (box.right > view.innerWidth - 4 || box.left < 4) {
310
328
  panel.style.insetInlineStart = 'auto';
311
329
  panel.style.insetInlineEnd = '100%';
312
330
  }
313
- if (box.bottom > window.innerHeight - 4) {
331
+ if (box.bottom > view.innerHeight - 4) {
314
332
  panel.style.top = 'auto';
315
333
  panel.style.bottom = '-5px';
316
334
  }
@@ -319,12 +337,13 @@ export class ContextMenuView {
319
337
  private place(root: HTMLElement, x: number, y: number): void {
320
338
  root.style.left = `${x}px`;
321
339
  root.style.top = `${y}px`;
322
- if (typeof root.getBoundingClientRect !== 'function' || typeof window === 'undefined') return;
340
+ const view = this.view;
341
+ if (typeof root.getBoundingClientRect !== 'function' || view === null) return;
323
342
  const box = root.getBoundingClientRect();
324
343
  // Flip rather than clamp: a menu shoved back onto the screen would cover the very
325
344
  // 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`;
345
+ if (box.right > view.innerWidth - 4) root.style.left = `${Math.max(4, x - box.width)}px`;
346
+ if (box.bottom > view.innerHeight - 4) root.style.top = `${Math.max(4, y - box.height)}px`;
328
347
  }
329
348
 
330
349
  private watchForDismissal(owner: Document): void {
@@ -341,12 +360,13 @@ export class ContextMenuView {
341
360
  this.on(owner, 'keydown', (event: Event) => {
342
361
  if ((event as KeyboardEvent).key === 'Escape') this.hide();
343
362
  });
344
- if (typeof window !== 'undefined') {
363
+ const view = this.view;
364
+ if (view !== null) {
345
365
  // Scroll and resize move the diagram out from under the menu, which would otherwise
346
366
  // 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);
367
+ this.on(view, 'blur', dismiss);
368
+ this.on(view, 'resize', dismiss);
369
+ this.on(view, 'scroll', dismiss, true);
350
370
  }
351
371
  }
352
372
 
@@ -32,7 +32,13 @@ export class EventEmitter<TEvents extends object> {
32
32
  if (set === undefined) {
33
33
  return;
34
34
  }
35
- for (const handler of set) {
35
+ // Snapshot: a Set iterated live delivers to handlers subscribed during the very
36
+ // delivery, and the ordinary "unsubscribe then resubscribe" throttle appends to the
37
+ // set being walked -- with two such listeners that never terminates and hangs the tab.
38
+ // Re-checking membership then keeps an unsubscribe honest: a handler removed by an
39
+ // earlier one, or by clearEventHandlers/destroy, must not still be called.
40
+ for (const handler of [...set]) {
41
+ if (!set.has(handler)) continue;
36
42
  try {
37
43
  (handler as EventHandler<TEvents[K]>)(payload);
38
44
  } catch (err) {
@@ -50,6 +56,9 @@ export class EventEmitter<TEvents extends object> {
50
56
  }
51
57
 
52
58
  protected clearEventHandlers(): void {
59
+ // Empty the sets as well as the map: an emit already in flight holds a reference to its
60
+ // set, and dropping only the map entry would leave it delivering to a torn-down subject.
61
+ for (const set of this.handlers.values()) set.clear();
53
62
  this.handlers.clear();
54
63
  }
55
64
  }
@@ -1,6 +1,7 @@
1
+ import { t } from '../i18n.js';
1
2
  import { EventEmitter } from './event-emitter.js';
2
3
  import { StockSharpCatalog } from './catalog.js';
3
- import { Node } from './types.js';
4
+ import { DEFAULT_GROUP_NAME, Node } from './types.js';
4
5
 
5
6
  export interface PaletteOptions {
6
7
  div: HTMLElement;
@@ -300,7 +301,13 @@ export class StockSharpPalette extends EventEmitter<PaletteEvents> {
300
301
  }
301
302
 
302
303
  function groupNameOf(node: Node): string {
303
- return node.groupName.length > 0 ? node.groupName : 'Common';
304
+ // The only caption the palette invents. Left hardcoded it was the one English word surviving
305
+ // in a fully translated palette. The default arrives here either as an empty group name or as
306
+ // the substitute Node's constructor already put in, so both spellings resolve to the same key.
307
+ const group = node.groupName;
308
+ return group.length > 0 && group !== DEFAULT_GROUP_NAME
309
+ ? group
310
+ : t('paletteCommonGroup', DEFAULT_GROUP_NAME);
304
311
  }
305
312
 
306
313
  function normalizeTypeId(typeId: string): string {
@@ -134,6 +134,11 @@ export class StockSharpDiagram extends EventEmitter<DiagramEvents> {
134
134
  this.fullscreenButton = this.createFullscreenButton();
135
135
  this.updateFullscreenButton();
136
136
  this.setContextMenuEnabled(options.showContextMenu ?? true);
137
+ // The keyboard is an entry point like the menu and the toolbar, so it goes through the
138
+ // same undo()/redo() -- which respect enableUndo/enableRedo and emit undoRequested.
139
+ this.canvas.setHistoryShortcutHandler((direction) => {
140
+ if (direction === 'undo') this.undo(); else this.redo();
141
+ });
137
142
  this.registerContextActions();
138
143
  this.bindCanvasEvents();
139
144
  this.disposables.push(this.catalog.on('portTypesChanged', () => this.applySocketTheme()));
@@ -1,5 +1,25 @@
1
1
  import { toPortDynamicMode } from '../core/model.js';
2
- import type { JsonObject, PortDynamicMode } from '../core/model.js';
2
+ import { setJsonKey } from '../core/json.js';
3
+ import type { JsonObject, JsonValue, PortDynamicMode } from '../core/model.js';
4
+
5
+ /**
6
+ * Metadata handed to a public object is copied, never aliased. Event payloads are built from the
7
+ * live model, and a host writing its own note onto payload.link.metadata was reaching straight
8
+ * into that model -- past transactions and past history, so the write turned up in the next saved
9
+ * document and an undo/redo of the link silently reverted it.
10
+ */
11
+ function copyJsonValue(value: JsonValue): JsonValue {
12
+ if (Array.isArray(value)) return value.map(copyJsonValue);
13
+ if (value !== null && typeof value === 'object') return copyJsonObject(value);
14
+ return value;
15
+ }
16
+
17
+ function copyJsonObject(value: JsonObject | undefined): JsonObject {
18
+ if (value === undefined) return {};
19
+ const result: JsonObject = {};
20
+ for (const [key, item] of Object.entries(value)) setJsonKey(result, key, copyJsonValue(item));
21
+ return result;
22
+ }
3
23
 
4
24
  // Core data model — node/port catalog (palette) and the live diagram (DiagramNode + Link).
5
25
 
@@ -132,7 +152,7 @@ export class Node {
132
152
  this.id = init.id;
133
153
  this.name = init.name;
134
154
  this.description = init.description ?? '';
135
- this.groupName = init.groupName ?? 'Common';
155
+ this.groupName = init.groupName ?? DEFAULT_GROUP_NAME;
136
156
  this.inPorts = (init.inPorts ?? []).map((p) => (p instanceof Port ? p : new Port(p)));
137
157
  this.outPorts = (init.outPorts ?? []).map((p) => (p instanceof Port ? p : new Port(p)));
138
158
  this.icon = init.icon ?? '';
@@ -224,6 +244,12 @@ export class DiagramNode extends Node {
224
244
 
225
245
  export type LinkEndpoint = string | { id: string };
226
246
 
247
+ /**
248
+ * Heading an element falls under when it declares no group. Shared so the palette can recognise
249
+ * the value it substituted here and translate it, rather than every layer spelling it separately.
250
+ */
251
+ export const DEFAULT_GROUP_NAME = 'Common';
252
+
227
253
  export interface LinkInit {
228
254
  id?: string;
229
255
  outNode: LinkEndpoint;
@@ -247,7 +273,7 @@ export class Link {
247
273
  this.outPort = init.outPort;
248
274
  this.inNode = init.inNode;
249
275
  this.inPort = init.inPort;
250
- this.metadata = init.metadata ?? {};
276
+ this.metadata = copyJsonObject(init.metadata);
251
277
  }
252
278
  }
253
279
 
package/src/embed.ts CHANGED
@@ -73,12 +73,61 @@ function iconUrl(name: string): string {
73
73
 
74
74
  // Perceived luminance (0..255) of a #rrggbb colour; used to tell a light diagram canvas from a dark
75
75
  // one so the (dark-canvas-tuned) link palette can be darkened when the canvas is light.
76
- function luminance(hex: string): number {
77
- const m = hex.match(/^#?([0-9a-fA-F]{6})$/);
78
- if (m === null)
79
- return 0;
80
- const n = parseInt(m[1], 16);
81
- return 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255);
76
+ const CSS_COLOR_KEYWORDS: Readonly<Record<string, [number, number, number]>> = {
77
+ white: [255, 255, 255],
78
+ black: [0, 0, 0],
79
+ transparent: [255, 255, 255],
80
+ };
81
+
82
+ /**
83
+ * Perceived brightness of a CSS colour, or -1 when it cannot be read.
84
+ *
85
+ * Six-digit hex was the only form understood, and everything else answered 0 -- black. A page
86
+ * writing --diagram-bg as #fff, white or rgb(255 255 255) therefore had its white canvas
87
+ * classified as dark and got the dark link palette: near-white wires on a near-white background.
88
+ * Unreadable is now -1 rather than 0, so the caller can keep its own default instead of being
89
+ * told the page is black.
90
+ */
91
+ function luminance(color: string): number {
92
+ const value = color.trim().toLowerCase();
93
+ if (value === '')
94
+ return -1;
95
+
96
+ const keyword = CSS_COLOR_KEYWORDS[value];
97
+ if (keyword !== undefined)
98
+ return 0.299 * keyword[0] + 0.587 * keyword[1] + 0.114 * keyword[2];
99
+
100
+ const long = value.match(/^#?([0-9a-f]{6})$/);
101
+ if (long !== null) {
102
+ const n = parseInt(long[1], 16);
103
+ return 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255);
104
+ }
105
+
106
+ // #abc is shorthand for #aabbcc, and the eight-digit forms carry an alpha we ignore.
107
+ const short = value.match(/^#?([0-9a-f]{3})([0-9a-f])?$/);
108
+ if (short !== null) {
109
+ const [r, g, b] = [...short[1]].map((digit) => parseInt(digit + digit, 16));
110
+ return 0.299 * r + 0.587 * g + 0.114 * b;
111
+ }
112
+ const long8 = value.match(/^#?([0-9a-f]{6})[0-9a-f]{2}$/);
113
+ if (long8 !== null) {
114
+ const n = parseInt(long8[1], 16);
115
+ return 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255);
116
+ }
117
+
118
+ // rgb()/rgba(), both the comma and the space syntax.
119
+ const rgb = value.match(/^rgba?\(([^)]+)\)$/);
120
+ if (rgb !== null) {
121
+ const parts = rgb[1].split(/[\s,/]+/).filter((part) => part !== '');
122
+ if (parts.length >= 3) {
123
+ const [r, g, b] = parts.slice(0, 3).map((part) => part.endsWith('%')
124
+ ? (parseFloat(part) / 100) * 255
125
+ : parseFloat(part));
126
+ if ([r, g, b].every((channel) => Number.isFinite(channel)))
127
+ return 0.299 * r + 0.587 * g + 0.114 * b;
128
+ }
129
+ }
130
+ return -1;
82
131
  }
83
132
 
84
133
  function makePort(p: PalettePort): Port {
@@ -396,6 +445,7 @@ async function renderSchemeAtRevision(
396
445
  diagram.setTheme({
397
446
  diagramBackground: bg,
398
447
  gridColor,
448
+ // Unreadable colours keep the dark default rather than being treated as black.
399
449
  linkMaxLightness: luminance(bg) > 140 ? 0.42 : 1,
400
450
  });
401
451
  };
@@ -604,17 +654,28 @@ export function renderAll(root: ParentNode = document, options: DiagramEmbedOpti
604
654
  if (host.dataset.rendered === '1')
605
655
  return;
606
656
 
657
+ // The flag is a re-entry guard, not a record of success: it goes up before the async
658
+ // render so a second renderAll() cannot start the same host twice, and comes back down if
659
+ // that render produced nothing. Left up, a host whose source failed once -- an unreachable
660
+ // URL, a transient network error -- kept its error note for the life of the page, because
661
+ // every later renderAll() skipped it even after the source came back.
662
+ const started = (run: Promise<DiagramEmbedHandle | null>): void => {
663
+ host.dataset.rendered = '1';
664
+ void run.then(
665
+ (handle) => { if (handle === null) delete host.dataset.rendered; },
666
+ () => { delete host.dataset.rendered; },
667
+ );
668
+ };
669
+
607
670
  const src = host.dataset.diagramSrc;
608
671
  if (src) {
609
- host.dataset.rendered = '1';
610
- void renderFromSource(host, PALETTE_URL, src, options);
672
+ started(renderFromSource(host, PALETTE_URL, src, options));
611
673
  return;
612
674
  }
613
675
 
614
676
  const inline = host.querySelector('script[type="application/json"]');
615
677
  if (inline) {
616
- host.dataset.rendered = '1';
617
- void renderFromInline(host, PALETTE_URL, inline.textContent ?? '', options);
678
+ started(renderFromInline(host, PALETTE_URL, inline.textContent ?? '', options));
618
679
  }
619
680
  });
620
681
  }
package/src/i18n.ts CHANGED
@@ -108,6 +108,8 @@ export interface DesignerI18n {
108
108
  ctxExportPng?: string;
109
109
  ctxExportSvg?: string;
110
110
  ctxOverview?: string;
111
+ /** Palette heading for elements that declare no group of their own. */
112
+ paletteCommonGroup?: string;
111
113
  properties?: string;
112
114
  ctxHelp?: string;
113
115
 
@@ -333,17 +333,26 @@ export class SvgSurface implements DrawSurface {
333
333
 
334
334
  // ===== text ==========================================================
335
335
 
336
- fillText(text: string, x: number, y: number): void {
336
+ fillText(text: string, x: number, y: number, maxWidth?: number): void {
337
337
  if (text === '') return;
338
338
 
339
339
  const anchor = ANCHOR[this.state.textAlign] ?? 'start';
340
340
  const baseline = BASELINE[this.state.textBaseline] ?? 'alphabetic';
341
341
 
342
+ // Canvas squeezes the glyphs to fit maxWidth; SVG needs to be told, and only when the text
343
+ // would actually overflow -- textLength on text that already fits would stretch it.
344
+ // Dropping the argument left node titles and zone captions running past the box the
345
+ // renderer had sized for them, so the vector export disagreed with the raster one.
346
+ let constraint = '';
347
+ if (maxWidth !== undefined && maxWidth > 0 && this.measureText(text).width > maxWidth) {
348
+ constraint = ` textLength="${n(maxWidth)}" lengthAdjust="spacingAndGlyphs"`;
349
+ }
350
+
342
351
  // The canvas font string is CSS font shorthand, so it transfers verbatim -- no parsing, and
343
352
  // no chance of losing the weight or the fallback list on the way.
344
353
  this.emit(
345
354
  `<text x="${n(x)}" y="${n(y)}" style="font:${escapeXml(this.state.font)}" ` +
346
- `text-anchor="${anchor}" dominant-baseline="${baseline}" ${this.fillAttrs()}>` +
355
+ `text-anchor="${anchor}" dominant-baseline="${baseline}"${constraint} ${this.fillAttrs()}>` +
347
356
  `${escapeXml(text)}</text>`);
348
357
  }
349
358