@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.
- package/dist/esm/canvas-renderer.js +185 -34
- package/dist/esm/canvas-renderer.js.map +1 -1
- package/dist/esm/core/document.js +24 -12
- package/dist/esm/core/document.js.map +1 -1
- package/dist/esm/core/history.js +12 -3
- package/dist/esm/core/history.js.map +1 -1
- package/dist/esm/core/json.js +10 -0
- package/dist/esm/core/json.js.map +1 -1
- package/dist/esm/diagram/context-menu.js +28 -10
- package/dist/esm/diagram/context-menu.js.map +1 -1
- package/dist/esm/diagram/event-emitter.js +12 -1
- package/dist/esm/diagram/event-emitter.js.map +1 -1
- package/dist/esm/diagram/palette.js +9 -1
- package/dist/esm/diagram/palette.js.map +1 -1
- package/dist/esm/diagram/stocksharp-diagram.js +8 -0
- package/dist/esm/diagram/stocksharp-diagram.js.map +1 -1
- package/dist/esm/diagram/types.js +29 -2
- package/dist/esm/diagram/types.js.map +1 -1
- package/dist/esm/embed.js +63 -10
- package/dist/esm/embed.js.map +1 -1
- package/dist/esm/i18n.js.map +1 -1
- package/dist/esm/svg-surface.js +10 -2
- package/dist/esm/svg-surface.js.map +1 -1
- package/dist/ssdiagram.js +253 -74
- package/dist/ssdiagram.js.map +3 -3
- package/dist/types/canvas-renderer.d.ts +19 -1
- package/dist/types/canvas-renderer.d.ts.map +1 -1
- package/dist/types/core/document.d.ts.map +1 -1
- package/dist/types/core/history.d.ts +1 -0
- package/dist/types/core/history.d.ts.map +1 -1
- package/dist/types/core/json.d.ts +8 -0
- package/dist/types/core/json.d.ts.map +1 -1
- package/dist/types/diagram/context-menu.d.ts +6 -0
- package/dist/types/diagram/context-menu.d.ts.map +1 -1
- package/dist/types/diagram/event-emitter.d.ts.map +1 -1
- package/dist/types/diagram/palette.d.ts.map +1 -1
- package/dist/types/diagram/stocksharp-diagram.d.ts.map +1 -1
- package/dist/types/diagram/types.d.ts +5 -0
- package/dist/types/diagram/types.d.ts.map +1 -1
- package/dist/types/embed.d.ts.map +1 -1
- package/dist/types/i18n.d.ts +2 -0
- package/dist/types/i18n.d.ts.map +1 -1
- package/dist/types/svg-surface.d.ts +1 -1
- package/dist/types/svg-surface.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/canvas-renderer.ts +173 -34
- package/src/core/document.ts +25 -12
- package/src/core/history.ts +13 -3
- package/src/core/json.ts +11 -0
- package/src/diagram/context-menu.ts +30 -10
- package/src/diagram/event-emitter.ts +10 -1
- package/src/diagram/palette.ts +9 -2
- package/src/diagram/stocksharp-diagram.ts +5 -0
- package/src/diagram/types.ts +29 -3
- package/src/embed.ts +71 -10
- package/src/i18n.ts +2 -0
- 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
|
-
|
|
324
|
+
const view = this.view;
|
|
325
|
+
if (typeof panel.getBoundingClientRect !== 'function' || view === null) return;
|
|
308
326
|
const box = panel.getBoundingClientRect();
|
|
309
|
-
if (box.right >
|
|
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 >
|
|
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
|
-
|
|
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 >
|
|
327
|
-
if (box.bottom >
|
|
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
|
-
|
|
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(
|
|
348
|
-
this.on(
|
|
349
|
-
this.on(
|
|
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
|
-
|
|
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
|
}
|
package/src/diagram/palette.ts
CHANGED
|
@@ -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
|
-
|
|
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()));
|
package/src/diagram/types.ts
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import { toPortDynamicMode } from '../core/model.js';
|
|
2
|
-
import
|
|
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 ??
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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.
|
|
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
|
|
package/src/svg-surface.ts
CHANGED
|
@@ -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
|
|