@rasamjs/core 0.1.2 → 0.3.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/API.md +29 -9
- package/CHANGELOG.md +98 -1
- package/COMPONENTS.md +229 -0
- package/README.md +178 -25
- package/RELEASING.md +31 -21
- package/dist/advanced.d.ts +1 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/catalog/fragment.d.ts +2 -2
- package/dist/catalog/fragment.d.ts.map +1 -1
- package/dist/catalog/fragment.js.map +1 -1
- package/dist/changes/derived.d.ts +10 -0
- package/dist/changes/derived.d.ts.map +1 -0
- package/dist/changes/derived.js +14 -0
- package/dist/changes/derived.js.map +1 -0
- package/dist/changes/restore.d.ts.map +1 -1
- package/dist/changes/restore.js +22 -4
- package/dist/changes/restore.js.map +1 -1
- package/dist/changes/service.d.ts +10 -2
- package/dist/changes/service.d.ts.map +1 -1
- package/dist/changes/service.js +45 -6
- package/dist/changes/service.js.map +1 -1
- package/dist/changes/types.d.ts +9 -0
- package/dist/changes/types.d.ts.map +1 -1
- package/dist/changes/types.js.map +1 -1
- package/dist/changes.d.ts +5 -3
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +103 -2
- package/dist/changes.js.map +1 -1
- package/dist/components/changes.d.ts +34 -0
- package/dist/components/changes.d.ts.map +1 -0
- package/dist/components/changes.js +119 -0
- package/dist/components/changes.js.map +1 -0
- package/dist/components/component.d.ts +62 -0
- package/dist/components/component.d.ts.map +1 -0
- package/dist/components/component.js +355 -0
- package/dist/components/component.js.map +1 -0
- package/dist/components/decorators.d.ts +7 -0
- package/dist/components/decorators.d.ts.map +1 -0
- package/dist/components/decorators.js +81 -0
- package/dist/components/decorators.js.map +1 -0
- package/dist/components/metadata.d.ts +10 -0
- package/dist/components/metadata.d.ts.map +1 -0
- package/dist/components/metadata.js +85 -0
- package/dist/components/metadata.js.map +1 -0
- package/dist/components/registry.d.ts +24 -0
- package/dist/components/registry.d.ts.map +1 -0
- package/dist/components/registry.js +78 -0
- package/dist/components/registry.js.map +1 -0
- package/dist/components/types.d.ts +54 -0
- package/dist/components/types.d.ts.map +1 -0
- package/dist/components/types.js +2 -0
- package/dist/components/types.js.map +1 -0
- package/dist/components/view.d.ts +9 -0
- package/dist/components/view.d.ts.map +1 -0
- package/dist/components/view.js +104 -0
- package/dist/components/view.js.map +1 -0
- package/dist/components.d.ts +6 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +5 -0
- package/dist/components.js.map +1 -0
- package/dist/core/node.d.ts +7 -0
- package/dist/core/node.d.ts.map +1 -1
- package/dist/core/node.js +30 -0
- package/dist/core/node.js.map +1 -1
- package/dist/dom/images.d.ts.map +1 -1
- package/dist/dom/images.js +3 -1
- package/dist/dom/images.js.map +1 -1
- package/dist/elements/image.d.ts.map +1 -1
- package/dist/elements/image.js +4 -1
- package/dist/elements/image.js.map +1 -1
- package/dist/interchange/clone.d.ts.map +1 -1
- package/dist/interchange/clone.js +4 -3
- package/dist/interchange/clone.js.map +1 -1
- package/dist/interchange/dom.d.ts +3 -1
- package/dist/interchange/dom.d.ts.map +1 -1
- package/dist/interchange/dom.js +29 -5
- package/dist/interchange/dom.js.map +1 -1
- package/dist/interchange/markup.d.ts.map +1 -1
- package/dist/interchange/markup.js +71 -3
- package/dist/interchange/markup.js.map +1 -1
- package/dist/interchange/policy.d.ts.map +1 -1
- package/dist/interchange/policy.js +4 -0
- package/dist/interchange/policy.js.map +1 -1
- package/dist/interchange/reconstruct.d.ts.map +1 -1
- package/dist/interchange/reconstruct.js +16 -12
- package/dist/interchange/reconstruct.js.map +1 -1
- package/dist/interchange/references.d.ts.map +1 -1
- package/dist/interchange/references.js +10 -4
- package/dist/interchange/references.js.map +1 -1
- package/dist/interchange/types.d.ts +10 -1
- package/dist/interchange/types.d.ts.map +1 -1
- package/dist/mixins/parent-internals.d.ts +6 -0
- package/dist/mixins/parent-internals.d.ts.map +1 -1
- package/dist/mixins/parent-internals.js +34 -0
- package/dist/mixins/parent-internals.js.map +1 -1
- package/dist/mixins/parent.d.ts.map +1 -1
- package/dist/mixins/parent.js +6 -1
- package/dist/mixins/parent.js.map +1 -1
- package/dist/mixins/transformable.d.ts.map +1 -1
- package/dist/mixins/transformable.js +25 -27
- package/dist/mixins/transformable.js.map +1 -1
- package/dist/mixins/types.d.ts +2 -2
- package/dist/mixins/types.d.ts.map +1 -1
- package/dist/raster/frame.d.ts +12 -0
- package/dist/raster/frame.d.ts.map +1 -0
- package/dist/raster/frame.js +43 -0
- package/dist/raster/frame.js.map +1 -0
- package/dist/raster/images.d.ts +2 -0
- package/dist/raster/images.d.ts.map +1 -0
- package/dist/raster/images.js +60 -0
- package/dist/raster/images.js.map +1 -0
- package/dist/raster/render.d.ts +3 -0
- package/dist/raster/render.d.ts.map +1 -0
- package/dist/raster/render.js +40 -0
- package/dist/raster/render.js.map +1 -0
- package/dist/raster/types.d.ts +10 -0
- package/dist/raster/types.d.ts.map +1 -0
- package/dist/raster/types.js +2 -0
- package/dist/raster/types.js.map +1 -0
- package/dist/raster.d.ts +6 -0
- package/dist/raster.d.ts.map +1 -0
- package/dist/raster.js +10 -0
- package/dist/raster.js.map +1 -0
- package/dist/resources/references.d.ts +2 -0
- package/dist/resources/references.d.ts.map +1 -1
- package/dist/resources/references.js +3 -1
- package/dist/resources/references.js.map +1 -1
- package/dist/services/catalog.d.ts.map +1 -1
- package/dist/services/catalog.js +5 -3
- package/dist/services/catalog.js.map +1 -1
- package/dist/services/defs-manager.d.ts +2 -0
- package/dist/services/defs-manager.d.ts.map +1 -1
- package/dist/services/defs-manager.js +4 -0
- package/dist/services/defs-manager.js.map +1 -1
- package/dist/services/element-key.d.ts +4 -0
- package/dist/services/element-key.d.ts.map +1 -0
- package/dist/services/element-key.js +12 -0
- package/dist/services/element-key.js.map +1 -0
- package/dist/services/initialization.d.ts +7 -0
- package/dist/services/initialization.d.ts.map +1 -0
- package/dist/services/initialization.js +27 -0
- package/dist/services/initialization.js.map +1 -0
- package/dist/services/registry.d.ts +8 -0
- package/dist/services/registry.d.ts.map +1 -1
- package/dist/services/registry.js +45 -10
- package/dist/services/registry.js.map +1 -1
- package/dist/services/types.d.ts +17 -0
- package/dist/services/types.d.ts.map +1 -1
- package/dist/services/viewport.d.ts +2 -0
- package/dist/services/viewport.d.ts.map +1 -1
- package/dist/services/viewport.js +4 -3
- package/dist/services/viewport.js.map +1 -1
- package/dist/template/parts.d.ts +12 -6
- package/dist/template/parts.d.ts.map +1 -1
- package/dist/template/parts.js +36 -29
- package/dist/template/parts.js.map +1 -1
- package/dist/template/render.d.ts +6 -7
- package/dist/template/render.d.ts.map +1 -1
- package/dist/template/render.js +188 -44
- package/dist/template/render.js.map +1 -1
- package/dist/template/svg-tag.d.ts +5 -1
- package/dist/template/svg-tag.d.ts.map +1 -1
- package/dist/template/svg-tag.js.map +1 -1
- package/dist/text/content.d.ts +0 -2
- package/dist/text/content.d.ts.map +1 -1
- package/dist/text/content.js +4 -10
- package/dist/text/content.js.map +1 -1
- package/dist/text/internals.d.ts +1 -0
- package/dist/text/internals.d.ts.map +1 -1
- package/dist/text/policies.d.ts +7 -0
- package/dist/text/policies.d.ts.map +1 -0
- package/dist/text/policies.js +13 -0
- package/dist/text/policies.js.map +1 -0
- package/dist/types/common.d.ts +27 -0
- package/dist/types/common.d.ts.map +1 -1
- package/package.json +156 -143
- package/reports/adoption.json +20 -20
- package/reports/animation-benchmark.json +616 -616
- package/reports/benchmark.json +65 -65
- package/reports/components-benchmark-before.json +10 -0
- package/reports/components-benchmark.json +10 -0
- package/reports/creation.json +26 -26
- package/reports/interchange-benchmark.json +85 -85
- package/reports/public-api-contract.json +353 -23
- package/reports/public-api-review.md +23 -0
- package/reports/size-components.json +102 -0
- package/reports/size-parity.json +82 -34
- package/reports/size.json +6 -4
- package/reports/svgjs-comparison.json +3466 -3466
- package/reports/svgjs-comparison.md +19 -19
- package/reports/svgjs-parity-current.json +177 -60
- package/src/advanced.ts +1 -1
- package/src/catalog/fragment.ts +2 -2
- package/src/changes/derived.ts +19 -0
- package/src/changes/restore.ts +27 -4
- package/src/changes/service.ts +60 -11
- package/src/changes/types.ts +9 -0
- package/src/changes.ts +93 -4
- package/src/components/changes.ts +131 -0
- package/src/components/component.ts +401 -0
- package/src/components/decorators.ts +104 -0
- package/src/components/metadata.ts +111 -0
- package/src/components/registry.ts +79 -0
- package/src/components/types.ts +66 -0
- package/src/components/view.ts +123 -0
- package/src/components.ts +15 -0
- package/src/core/node.ts +32 -1
- package/src/dom/images.ts +2 -1
- package/src/elements/image.ts +4 -1
- package/src/interchange/clone.ts +4 -3
- package/src/interchange/dom.ts +34 -5
- package/src/interchange/markup.ts +77 -3
- package/src/interchange/policy.ts +6 -0
- package/src/interchange/reconstruct.ts +16 -12
- package/src/interchange/references.ts +12 -4
- package/src/interchange/types.ts +10 -1
- package/src/mixins/parent-internals.ts +37 -0
- package/src/mixins/parent.ts +13 -4
- package/src/mixins/transformable.ts +23 -25
- package/src/mixins/types.ts +2 -2
- package/src/raster/frame.ts +53 -0
- package/src/raster/images.ts +73 -0
- package/src/raster/render.ts +45 -0
- package/src/raster/types.ts +9 -0
- package/src/raster.ts +13 -0
- package/src/resources/references.ts +5 -1
- package/src/services/catalog.ts +5 -3
- package/src/services/defs-manager.ts +5 -0
- package/src/services/element-key.ts +14 -0
- package/src/services/initialization.ts +25 -0
- package/src/services/registry.ts +47 -10
- package/src/services/types.ts +21 -0
- package/src/services/viewport.ts +5 -4
- package/src/template/parts.ts +54 -39
- package/src/template/render.ts +211 -54
- package/src/template/svg-tag.ts +6 -1
- package/src/text/content.ts +4 -14
- package/src/text/internals.ts +1 -0
- package/src/text/policies.ts +18 -0
- package/src/types/common.ts +29 -0
package/src/services/viewport.ts
CHANGED
|
@@ -17,7 +17,8 @@ export function unresolved(error: unknown): Bounds {
|
|
|
17
17
|
throw error;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/** Resolve an authored viewport length against its original ancestor viewport. */
|
|
21
|
+
export function viewportLength(node: AnyNode, name: string, axis: Axis, fallback: unknown): number {
|
|
21
22
|
return userLength(node.getAttributeValue(name) ?? fallback, () => {
|
|
22
23
|
// Percentages use the nearest ancestor viewport's viewBox, or its size without one.
|
|
23
24
|
let viewport = node === node.context.root ? null : node.parent;
|
|
@@ -30,15 +31,15 @@ function length(node: AnyNode, name: string, axis: Axis, fallback: unknown): num
|
|
|
30
31
|
|
|
31
32
|
/** A viewport's width or height in its parent's user units; omitted sizes are 100%. */
|
|
32
33
|
export function viewportSize(node: AnyNode, axis: Axis): number {
|
|
33
|
-
const size =
|
|
34
|
+
const size = viewportLength(node, axis, axis, '100%');
|
|
34
35
|
if (size < 0) throw new ViewportError('Negative viewport size');
|
|
35
36
|
return size;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
/** Placement and viewBox transform from a nested viewport's user space to its parent's. */
|
|
39
40
|
export function viewportMapping(node: AnyNode): Matrix {
|
|
40
|
-
const x =
|
|
41
|
-
y =
|
|
41
|
+
const x = viewportLength(node, 'x', 'width', 0),
|
|
42
|
+
y = viewportLength(node, 'y', 'height', 0),
|
|
42
43
|
viewBox = node.getAttributeValue('viewBox');
|
|
43
44
|
return viewBox === undefined
|
|
44
45
|
? Matrix.translation(x, y)
|
package/src/template/parts.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { AnyNode } from '../core/types.js';
|
|
2
2
|
import { Attributes } from '../dom/attributes.js';
|
|
3
3
|
import type { DomFactory } from '../dom/create.js';
|
|
4
|
-
import type {
|
|
4
|
+
import type { MarkupElement } from '../dom/types.js';
|
|
5
5
|
import { Guards } from '../mixins/guards.js';
|
|
6
6
|
import type { Registry } from '../services/registry.js';
|
|
7
|
+
import type { ElementClass } from '../services/types.js';
|
|
7
8
|
import type { TemplateValue } from './svg-tag.js';
|
|
8
9
|
import type { TextBinding } from '../text/internals.js';
|
|
9
10
|
import { classifyReference } from '../resources/references.js';
|
|
@@ -15,21 +16,36 @@ export interface TemplateDescription {
|
|
|
15
16
|
readonly attributes: Readonly<Record<string, readonly PartValue[]>>;
|
|
16
17
|
readonly children: readonly TemplateDescription[];
|
|
17
18
|
readonly text: readonly PartValue[];
|
|
18
|
-
readonly content: readonly
|
|
19
|
+
readonly content: readonly ContentItem[];
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** An element, or a text run whose number parts are hole indexes (child holes outside text). */
|
|
23
|
+
export type ContentItem = TemplateDescription | { readonly parts: readonly PartValue[] };
|
|
24
|
+
|
|
21
25
|
export class TemplateCompiler {
|
|
22
|
-
private static readonly cache = new WeakMap<
|
|
23
|
-
TemplateStringsArray,
|
|
24
|
-
readonly TemplateDescription[]
|
|
25
|
-
>();
|
|
26
|
+
private static readonly cache = new WeakMap<TemplateStringsArray, readonly ContentItem[]>();
|
|
26
27
|
private readonly counts: number[];
|
|
27
28
|
|
|
28
29
|
private constructor(strings: TemplateStringsArray) {
|
|
29
30
|
this.counts = new Array<number>(strings.length - 1).fill(0);
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** Top-level elements; templates with top-level child holes need `content()`. */
|
|
32
34
|
static compile(dom: DomFactory, strings: TemplateStringsArray): readonly TemplateDescription[] {
|
|
35
|
+
const content = TemplateCompiler.content(dom, strings);
|
|
36
|
+
if (
|
|
37
|
+
content.some(
|
|
38
|
+
(item) =>
|
|
39
|
+
!('tag' in item) &&
|
|
40
|
+
item.parts.some((part) => part !== '' && (typeof part == 'number' || part.trim())),
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
throw new TypeError('Template text must be inside a text element');
|
|
44
|
+
return content.filter((item): item is TemplateDescription => 'tag' in item);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Top-level content in order: elements and text runs that may hold child holes. */
|
|
48
|
+
static content(dom: DomFactory, strings: TemplateStringsArray): readonly ContentItem[] {
|
|
33
49
|
const cached = TemplateCompiler.cache.get(strings);
|
|
34
50
|
if (cached) return cached;
|
|
35
51
|
if (strings.some((part) => /rasamHole\d+End/.test(part)))
|
|
@@ -38,51 +54,55 @@ export class TemplateCompiler {
|
|
|
38
54
|
const source = strings
|
|
39
55
|
.map((part, index) => part + (index < strings.length - 1 ? `rasamHole${index}End` : ''))
|
|
40
56
|
.join('');
|
|
41
|
-
const result:
|
|
42
|
-
for (const node of dom.parse(source))
|
|
43
|
-
|
|
44
|
-
if (description) result.push(description);
|
|
45
|
-
}
|
|
57
|
+
const result: ContentItem[] = [];
|
|
58
|
+
for (const node of dom.parse(source))
|
|
59
|
+
result.push('text' in node ? { parts: compiler.split(node.text) } : compiler.describe(node));
|
|
46
60
|
if (compiler.counts.some((count) => count !== 1))
|
|
47
61
|
throw new TypeError('Bindings must appear in attribute values or text elements');
|
|
48
62
|
TemplateCompiler.cache.set(strings, result);
|
|
49
63
|
return result;
|
|
50
64
|
}
|
|
51
65
|
|
|
66
|
+
/** `parent` is the container class that receives `content`, for its child rules. */
|
|
52
67
|
static validate(
|
|
53
|
-
|
|
68
|
+
content: readonly ContentItem[],
|
|
54
69
|
registry: Registry,
|
|
55
70
|
forbidden: ReadonlySet<string> = new Set(),
|
|
71
|
+
parent?: ElementClass,
|
|
56
72
|
): void {
|
|
57
|
-
for (const description of
|
|
73
|
+
for (const description of content) {
|
|
74
|
+
if (!('tag' in description)) {
|
|
75
|
+
if (description.parts.some((part) => typeof part == 'string' && part.trim()))
|
|
76
|
+
throw new TypeError('Template text must be inside a text element');
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
58
79
|
const Constructor = registry.get(description.tag);
|
|
59
80
|
if (forbidden.has(description.tag))
|
|
60
81
|
throw new TypeError('Nested links or forbidden descendants are unsupported');
|
|
61
82
|
if (!Constructor) throw new TypeError(`Unsupported template tag: ${description.tag}`);
|
|
83
|
+
if (parent?.childTags && !parent.childTags.includes(description.tag))
|
|
84
|
+
throw new TypeError(`${parent.tag} only accepts ${parent.childTags.join(', ')} children`);
|
|
62
85
|
for (const name of Object.keys(description.attributes)) {
|
|
63
86
|
if (!Constructor.attributeNames.includes(name) && !/^(?:data|aria)-[\w-]+$/.test(name))
|
|
64
87
|
throw new TypeError(`Unsupported template attribute: ${name}`);
|
|
65
88
|
}
|
|
66
89
|
if (description.children.length > 0 && !Constructor.isContainer)
|
|
67
90
|
throw new TypeError('Only containers may have element children');
|
|
91
|
+
// Outside text elements, holes between children are child holes, so only containers take them.
|
|
68
92
|
if (
|
|
69
|
-
Constructor.
|
|
70
|
-
description.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`${description.tag} only accepts ${Constructor.childTags.join(', ')} children`,
|
|
74
|
-
);
|
|
75
|
-
if (
|
|
76
|
-
description.text.some((part) => typeof part === 'number' || part.trim()) &&
|
|
77
|
-
!Constructor.isText
|
|
93
|
+
!Constructor.isText &&
|
|
94
|
+
description.text.some((part) =>
|
|
95
|
+
typeof part == 'number' ? !Constructor.isContainer : part.trim(),
|
|
96
|
+
)
|
|
78
97
|
)
|
|
79
98
|
throw new TypeError('Text bindings require a text element');
|
|
80
99
|
TemplateCompiler.validate(
|
|
81
|
-
description.children,
|
|
100
|
+
Constructor.isText ? description.children : description.content,
|
|
82
101
|
registry,
|
|
83
102
|
Constructor.forbiddenDescendantTags
|
|
84
103
|
? new Set([...forbidden, ...Constructor.forbiddenDescendantTags])
|
|
85
104
|
: forbidden,
|
|
105
|
+
Constructor,
|
|
86
106
|
);
|
|
87
107
|
}
|
|
88
108
|
}
|
|
@@ -101,11 +121,7 @@ export class TemplateCompiler {
|
|
|
101
121
|
return parts;
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
private describe(node:
|
|
105
|
-
if ('text' in node) {
|
|
106
|
-
if (node.text.trim()) throw new TypeError('Template text must be inside a text element');
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
124
|
+
private describe(node: MarkupElement): TemplateDescription {
|
|
109
125
|
if (/rasamHole\d+End/.test(node.tag)) throw new TypeError('Dynamic tag names are unsupported');
|
|
110
126
|
const attributes: Record<string, readonly PartValue[]> = {};
|
|
111
127
|
for (const [rawName, value] of Object.entries(node.attributes)) {
|
|
@@ -121,7 +137,7 @@ export class TemplateCompiler {
|
|
|
121
137
|
}
|
|
122
138
|
const text: PartValue[] = [];
|
|
123
139
|
const children: TemplateDescription[] = [];
|
|
124
|
-
const content:
|
|
140
|
+
const content: ContentItem[] = [];
|
|
125
141
|
for (const child of node.children) {
|
|
126
142
|
if ('text' in child) {
|
|
127
143
|
const parts = this.split(child.text);
|
|
@@ -129,10 +145,8 @@ export class TemplateCompiler {
|
|
|
129
145
|
content.push({ parts });
|
|
130
146
|
} else {
|
|
131
147
|
const description = this.describe(child);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
content.push(description);
|
|
135
|
-
}
|
|
148
|
+
children.push(description);
|
|
149
|
+
content.push(description);
|
|
136
150
|
}
|
|
137
151
|
}
|
|
138
152
|
return { tag: node.tag, attributes, children, text, content };
|
|
@@ -140,8 +154,6 @@ export class TemplateCompiler {
|
|
|
140
154
|
}
|
|
141
155
|
|
|
142
156
|
export class BindingPart {
|
|
143
|
-
private previous: string | undefined;
|
|
144
|
-
|
|
145
157
|
constructor(
|
|
146
158
|
private readonly node: AnyNode,
|
|
147
159
|
private readonly name: string | null,
|
|
@@ -183,15 +195,18 @@ export class BindingPart {
|
|
|
183
195
|
}
|
|
184
196
|
}
|
|
185
197
|
|
|
186
|
-
commit(value: string | undefined
|
|
187
|
-
if (!initial && value === this.previous) return;
|
|
198
|
+
commit(value: string | undefined): void {
|
|
188
199
|
if (this.name === null) {
|
|
189
200
|
if (!Guards.isTextual(this.node))
|
|
190
201
|
throw new TypeError('Text binding requires the Textual mixin');
|
|
202
|
+
if ((this.textBinding ? this.textBinding.value : this.node.text()) === (value ?? '')) return;
|
|
191
203
|
if (this.textBinding) this.textBinding.set(value ?? '');
|
|
192
204
|
else this.node.text(value ?? '');
|
|
193
|
-
} else
|
|
194
|
-
|
|
205
|
+
} else {
|
|
206
|
+
const current = this.node.getAttributeValue(this.name);
|
|
207
|
+
if ((current === undefined ? undefined : String(current)) === value) return;
|
|
208
|
+
this.node.setAttributeValue(this.name, value);
|
|
209
|
+
}
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
private static primitive(value: TemplateValue): string {
|
package/src/template/render.ts
CHANGED
|
@@ -1,73 +1,180 @@
|
|
|
1
1
|
import { mutation } from '../changes/mutation.js';
|
|
2
2
|
import type { AnyNode } from '../core/types.js';
|
|
3
|
+
import { destroySubtrees } from '../core/destruction.js';
|
|
3
4
|
import type { ParentNode } from '../mixins/types.js';
|
|
4
5
|
import { Guards } from '../mixins/guards.js';
|
|
5
6
|
import { Group } from '../elements/group.js';
|
|
6
7
|
import type { Registry } from '../services/registry.js';
|
|
8
|
+
import type { ElementClass } from '../services/types.js';
|
|
7
9
|
import { BindingPart, TemplateCompiler } from './parts.js';
|
|
8
|
-
import type { TemplateDescription } from './parts.js';
|
|
9
|
-
import
|
|
10
|
+
import type { ContentItem, TemplateDescription } from './parts.js';
|
|
11
|
+
import { SvgTemplate } from './svg-tag.js';
|
|
12
|
+
import type { TemplateValue } from './svg-tag.js';
|
|
10
13
|
import { appendTextSlot, type TextBinding } from '../text/internals.js';
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
type Segment = AnyNode | ChildPart;
|
|
16
|
+
/** The nodes and child holes one template places in one parent, from index `base()`. */
|
|
17
|
+
interface Sequence {
|
|
18
|
+
parent: ParentNode;
|
|
19
|
+
readonly segments: Segment[];
|
|
20
|
+
base(): number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const size = (segments: readonly Segment[]) =>
|
|
24
|
+
segments.reduce((total, segment) => total + (segment instanceof ChildPart ? segment.size : 1), 0);
|
|
25
|
+
const flat = (segments: readonly Segment[]): AnyNode[] =>
|
|
26
|
+
segments.flatMap((segment) => (segment instanceof ChildPart ? segment.nodes : [segment]));
|
|
27
|
+
/** Semantic disposal: unlike `destroy()`, undo can restore it and history isn't cleared. */
|
|
28
|
+
const dispose = (nodes: readonly AnyNode[]) => {
|
|
29
|
+
const live = nodes.filter((node) => !node.isDestroyed);
|
|
30
|
+
if (live.length) live[0][destroySubtrees](live);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Flattens a child hole's value into templates. */
|
|
34
|
+
function templates(value: TemplateValue, out: SvgTemplate[] = []): SvgTemplate[] {
|
|
35
|
+
if (value instanceof SvgTemplate) out.push(value);
|
|
36
|
+
else if (typeof value == 'object' && value && Symbol.iterator in value)
|
|
37
|
+
for (const item of value) templates(item, out);
|
|
38
|
+
else if (value != null && typeof value != 'boolean' && value !== '')
|
|
39
|
+
throw new TypeError('Child bindings take templates, iterables of them, or null');
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Validates and builds an update without changing the drawing: new items are built in
|
|
45
|
+
* detached `stagings` groups. The returned commit applies it.
|
|
46
|
+
*/
|
|
47
|
+
type Prepare = (values: readonly TemplateValue[], stagings: Group[]) => () => void;
|
|
48
|
+
|
|
49
|
+
/** Prepares and commits, disposing the staging groups whether or not it succeeds. */
|
|
50
|
+
function commit(prepare: Prepare, values: readonly TemplateValue[]): void {
|
|
51
|
+
const stagings: Group[] = [];
|
|
52
|
+
try {
|
|
53
|
+
prepare(values, stagings)();
|
|
54
|
+
} finally {
|
|
55
|
+
dispose(stagings);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A hole between elements: the items it renders, reused by index while their literal matches. */
|
|
60
|
+
class ChildPart {
|
|
61
|
+
private items: Mount[] = [];
|
|
62
|
+
constructor(
|
|
63
|
+
private readonly sequence: Sequence,
|
|
64
|
+
private readonly hole: number,
|
|
65
|
+
private readonly registry: Registry,
|
|
66
|
+
) {}
|
|
67
|
+
|
|
68
|
+
get size(): number {
|
|
69
|
+
return this.items.reduce((total, item) => total + item.size, 0);
|
|
70
|
+
}
|
|
71
|
+
get nodes(): AnyNode[] {
|
|
72
|
+
return this.items.flatMap((item) => item.nodes);
|
|
73
|
+
}
|
|
74
|
+
/** The index of this part's first node, or of `item`'s first node. */
|
|
75
|
+
private start(item?: Mount): number {
|
|
76
|
+
const { segments } = this.sequence;
|
|
77
|
+
let index = this.sequence.base() + size(segments.slice(0, segments.indexOf(this)));
|
|
78
|
+
for (const other of item ? this.items : []) {
|
|
79
|
+
if (other === item) break;
|
|
80
|
+
index += other.size;
|
|
81
|
+
}
|
|
82
|
+
return index;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
readonly prepare: Prepare = (values, stagings) => {
|
|
86
|
+
const parent = this.sequence.parent;
|
|
87
|
+
const forbidden = new Set<string>();
|
|
88
|
+
for (let node: AnyNode | null = parent; node; node = node.parent)
|
|
89
|
+
for (const tag of (node.constructor as ElementClass).forbiddenDescendantTags ?? [])
|
|
90
|
+
forbidden.add(tag);
|
|
91
|
+
const plans = templates(values[this.hole]).map((template, index) => {
|
|
92
|
+
const item = this.items[index];
|
|
93
|
+
if (item?.template.strings === template.strings)
|
|
94
|
+
return { item, commit: item.prepare(template.values, stagings) };
|
|
95
|
+
const content = TemplateCompiler.content(parent.context.dom, template.strings);
|
|
96
|
+
TemplateCompiler.validate(
|
|
97
|
+
content,
|
|
98
|
+
this.registry,
|
|
99
|
+
forbidden,
|
|
100
|
+
parent.constructor as ElementClass,
|
|
101
|
+
);
|
|
102
|
+
const staging = new Group(parent.context);
|
|
103
|
+
stagings.push(staging);
|
|
104
|
+
return { item: new Mount(template, staging, this.registry, content) };
|
|
105
|
+
});
|
|
106
|
+
return () => {
|
|
107
|
+
const items = plans.map((plan) => plan.item);
|
|
108
|
+
dispose(this.items.filter((item) => !items.includes(item)).flatMap((item) => item.nodes));
|
|
109
|
+
this.items = items;
|
|
110
|
+
for (const { item, commit } of plans)
|
|
111
|
+
if (!commit) {
|
|
112
|
+
// Detach first, so history records new nodes rather than a move from staging.
|
|
113
|
+
for (const node of item.nodes) parent.add(node.remove());
|
|
114
|
+
item.sequence.parent = parent;
|
|
115
|
+
item.sequence.base = () => this.start(item);
|
|
116
|
+
}
|
|
117
|
+
for (const plan of plans) plan.commit?.();
|
|
118
|
+
let index = this.start();
|
|
119
|
+
for (const node of this.nodes) parent.add(node, index++);
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** One template's nodes and parts, mounted as a sequence inside a parent. */
|
|
125
|
+
class Mount {
|
|
126
|
+
readonly sequence: Sequence;
|
|
127
|
+
private readonly parts: (BindingPart | ChildPart)[] = [];
|
|
16
128
|
|
|
17
129
|
constructor(
|
|
18
|
-
parent: ParentNode,
|
|
19
130
|
readonly template: SvgTemplate,
|
|
20
|
-
|
|
21
|
-
|
|
131
|
+
parent: ParentNode,
|
|
132
|
+
private readonly registry: Registry,
|
|
133
|
+
content: readonly ContentItem[],
|
|
22
134
|
) {
|
|
23
|
-
this.
|
|
24
|
-
|
|
25
|
-
this.nodes = descriptions.map((description) =>
|
|
26
|
-
this.instantiate(description, this.host, registry),
|
|
27
|
-
);
|
|
28
|
-
} catch (error) {
|
|
29
|
-
this.host.destroy();
|
|
30
|
-
throw error;
|
|
31
|
-
}
|
|
135
|
+
this.sequence = { parent, segments: [], base: () => 0 };
|
|
136
|
+
this.fill(content, this.sequence);
|
|
32
137
|
}
|
|
33
138
|
|
|
34
|
-
get
|
|
35
|
-
return this.
|
|
139
|
+
get size(): number {
|
|
140
|
+
return size(this.sequence.segments);
|
|
141
|
+
}
|
|
142
|
+
get nodes(): AnyNode[] {
|
|
143
|
+
return flat(this.sequence.segments);
|
|
36
144
|
}
|
|
37
145
|
|
|
38
|
-
update
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
part.validate(value);
|
|
46
|
-
return { part, value };
|
|
47
|
-
});
|
|
48
|
-
this.host.batch(() => {
|
|
49
|
-
for (const { part, value } of changes) part.commit(value);
|
|
50
|
-
});
|
|
51
|
-
return this;
|
|
146
|
+
/** Validates every binding first, so an update changes all or nothing. */
|
|
147
|
+
readonly prepare: Prepare = (values, stagings) => {
|
|
148
|
+
const commits = this.parts.map((part) => {
|
|
149
|
+
if (part instanceof ChildPart) return part.prepare(values, stagings);
|
|
150
|
+
const value = part.evaluate(values);
|
|
151
|
+
part.validate(value);
|
|
152
|
+
return () => part.commit(value);
|
|
52
153
|
});
|
|
53
|
-
|
|
154
|
+
return () => commits.forEach((commit) => commit());
|
|
155
|
+
};
|
|
54
156
|
|
|
55
|
-
|
|
56
|
-
|
|
157
|
+
private fill(content: readonly ContentItem[], sequence: Sequence): void {
|
|
158
|
+
for (const item of content) {
|
|
159
|
+
if ('tag' in item) sequence.segments.push(this.element(item, sequence.parent));
|
|
160
|
+
else
|
|
161
|
+
for (const hole of item.parts)
|
|
162
|
+
if (typeof hole == 'number') {
|
|
163
|
+
const part = new ChildPart(sequence, hole, this.registry);
|
|
164
|
+
sequence.segments.push(part);
|
|
165
|
+
this.parts.push(part);
|
|
166
|
+
commit(part.prepare, this.template.values);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
57
169
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
description: TemplateDescription,
|
|
61
|
-
parent: ParentNode,
|
|
62
|
-
registry: Registry,
|
|
63
|
-
): AnyNode {
|
|
64
|
-
const node = registry.createEmpty(description.tag, parent.context);
|
|
170
|
+
private element(description: TemplateDescription, parent: ParentNode): AnyNode {
|
|
171
|
+
const node = this.registry.createEmpty(description.tag, parent.context);
|
|
65
172
|
parent.add(node);
|
|
66
173
|
for (const [name, values] of Object.entries(description.attributes))
|
|
67
174
|
this.bind(new BindingPart(node, name, values));
|
|
68
175
|
if (appendTextSlot in node && Guards.isParent(node)) {
|
|
69
176
|
for (const part of description.content) {
|
|
70
|
-
if ('tag' in part) this.
|
|
177
|
+
if ('tag' in part) this.element(part, node);
|
|
71
178
|
else {
|
|
72
179
|
const binding = (node[appendTextSlot] as (value: string) => TextBinding)('');
|
|
73
180
|
this.bind(new BindingPart(node, null, part.parts, binding));
|
|
@@ -75,22 +182,69 @@ export class TemplateInstance {
|
|
|
75
182
|
}
|
|
76
183
|
return node;
|
|
77
184
|
}
|
|
78
|
-
if (
|
|
79
|
-
this.bind(new BindingPart(node, null, description.text));
|
|
185
|
+
if (Guards.isTextual(node)) {
|
|
186
|
+
if (description.text.length > 0) this.bind(new BindingPart(node, null, description.text));
|
|
187
|
+
} else if (Guards.isParent(node)) {
|
|
188
|
+
this.fill(description.content, { parent: node, segments: [], base: () => 0 });
|
|
189
|
+
return node;
|
|
190
|
+
}
|
|
80
191
|
if (description.children.length > 0 && !Guards.isParent(node))
|
|
81
192
|
throw new TypeError('Children require the Parent mixin');
|
|
82
|
-
for (const child of description.children) this.
|
|
193
|
+
for (const child of description.children) this.element(child, node as ParentNode);
|
|
83
194
|
return node;
|
|
84
195
|
}
|
|
85
196
|
|
|
86
197
|
private bind(part: BindingPart): void {
|
|
87
198
|
const value = part.evaluate(this.template.values);
|
|
88
199
|
part.validate(value);
|
|
89
|
-
part.commit(value
|
|
200
|
+
part.commit(value);
|
|
90
201
|
this.parts.push(part);
|
|
91
202
|
}
|
|
92
203
|
}
|
|
93
204
|
|
|
205
|
+
export class TemplateInstance {
|
|
206
|
+
readonly host: Group;
|
|
207
|
+
private readonly mount: Mount;
|
|
208
|
+
|
|
209
|
+
constructor(
|
|
210
|
+
parent: ParentNode,
|
|
211
|
+
readonly template: SvgTemplate,
|
|
212
|
+
registry: Registry,
|
|
213
|
+
content: readonly ContentItem[],
|
|
214
|
+
) {
|
|
215
|
+
this.host = new Group(parent.context);
|
|
216
|
+
try {
|
|
217
|
+
this.mount = new Mount(template, this.host, registry, content);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
this.host.destroy();
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The current top-level nodes, including those that child holes render. */
|
|
225
|
+
get nodes(): readonly AnyNode[] {
|
|
226
|
+
return this.mount.nodes;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
get isDestroyed(): boolean {
|
|
230
|
+
return this.host.isDestroyed;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
update(template: SvgTemplate): this {
|
|
234
|
+
return mutation(this, () => {
|
|
235
|
+
this.host.assertLive();
|
|
236
|
+
if (template.strings !== this.template.strings)
|
|
237
|
+
throw new TypeError('An instance can only update its original template');
|
|
238
|
+
this.host.batch(() => commit(this.mount.prepare, template.values));
|
|
239
|
+
return this;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
destroy(): void {
|
|
244
|
+
this.host.destroy();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
94
248
|
export class Renderer {
|
|
95
249
|
private static readonly renderers = new WeakMap<ParentNode, Renderer>();
|
|
96
250
|
private instance: TemplateInstance | null = null;
|
|
@@ -126,12 +280,15 @@ export class Renderer {
|
|
|
126
280
|
this.instance.template.strings === template.strings
|
|
127
281
|
)
|
|
128
282
|
return this.instance.update(template);
|
|
129
|
-
const
|
|
130
|
-
TemplateCompiler.validate(
|
|
131
|
-
const instance = new TemplateInstance(this.parent, template, this.registry,
|
|
283
|
+
const content = TemplateCompiler.content(this.parent.context.dom, template.strings);
|
|
284
|
+
TemplateCompiler.validate(content, this.registry);
|
|
285
|
+
const instance = new TemplateInstance(this.parent, template, this.registry, content);
|
|
132
286
|
try {
|
|
133
|
-
|
|
134
|
-
this.parent.
|
|
287
|
+
// Replacing a template disposes the old one semantically, so undo restores it.
|
|
288
|
+
this.parent.context.changes.run(this.parent, () => {
|
|
289
|
+
if (this.instance) dispose([this.instance.host]);
|
|
290
|
+
this.parent.add(instance.host);
|
|
291
|
+
});
|
|
135
292
|
} catch (error) {
|
|
136
293
|
instance.destroy();
|
|
137
294
|
throw error;
|
package/src/template/svg-tag.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Attribute and text holes take primitives. A hole between elements also takes a nested
|
|
3
|
+
* template or an iterable of them; `null`, `undefined`, booleans and `''` render nothing.
|
|
4
|
+
*/
|
|
5
|
+
export type TemplateValue =
|
|
6
|
+
string | number | boolean | null | undefined | SvgTemplate | Iterable<TemplateValue>;
|
|
2
7
|
|
|
3
8
|
export class SvgTemplate {
|
|
4
9
|
constructor(
|
package/src/text/content.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { assertCanAddChildren, validateChild } from '../mixins/parent-internals.
|
|
|
7
7
|
import { destroySubtrees } from '../core/destruction.js';
|
|
8
8
|
import { TextDom } from '../dom/text.js';
|
|
9
9
|
import type { TextPolicies } from '../changes/types.js';
|
|
10
|
+
import { textContents } from './policies.js';
|
|
10
11
|
|
|
11
12
|
class TextSlot implements TextBinding {
|
|
12
13
|
active = true;
|
|
@@ -275,25 +276,14 @@ export class TextContent {
|
|
|
275
276
|
}
|
|
276
277
|
}
|
|
277
278
|
|
|
278
|
-
const contents = new WeakMap<AnyNode, TextContent>();
|
|
279
279
|
export function contentFor(host: ParentNode): TextContent {
|
|
280
|
-
let content =
|
|
280
|
+
let content = textContents.get(host);
|
|
281
281
|
if (!content) {
|
|
282
282
|
content = new TextContent(host);
|
|
283
|
-
|
|
283
|
+
textContents.set(host, content);
|
|
284
284
|
}
|
|
285
285
|
return content;
|
|
286
286
|
}
|
|
287
287
|
export function refreshLines(host: AnyNode): void {
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Internal reconstruction bridge; copies policies onto fresh children, never callback closures. */
|
|
292
|
-
export function copyTextPolicies(
|
|
293
|
-
source: AnyNode,
|
|
294
|
-
target: ParentNode,
|
|
295
|
-
copies: ReadonlyMap<AnyNode, AnyNode>,
|
|
296
|
-
): void {
|
|
297
|
-
const content = contents.get(source);
|
|
298
|
-
if (content) content.copyLinesTo(contentFor(target), copies);
|
|
288
|
+
textContents.get(host)?.refreshLines();
|
|
299
289
|
}
|
package/src/text/internals.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AnyNode } from '../core/types.js';
|
|
2
|
+
import type { TextContent } from './content.js';
|
|
3
|
+
|
|
4
|
+
/** Shared state without retaining the rich-text implementation in interchange bundles. */
|
|
5
|
+
export const textContents = new WeakMap<AnyNode, TextContent>();
|
|
6
|
+
|
|
7
|
+
/** Copies policies onto reconstructed text children, never callback closures. */
|
|
8
|
+
export function copyTextPolicies(
|
|
9
|
+
source: AnyNode,
|
|
10
|
+
target: AnyNode,
|
|
11
|
+
copies: ReadonlyMap<AnyNode, AnyNode>,
|
|
12
|
+
): void {
|
|
13
|
+
const content = textContents.get(source);
|
|
14
|
+
if (!content) return;
|
|
15
|
+
const destination = textContents.get(target);
|
|
16
|
+
if (!destination) throw new TypeError('Clone target must support the source text content');
|
|
17
|
+
content.copyLinesTo(destination, copies);
|
|
18
|
+
}
|
package/src/types/common.ts
CHANGED
|
@@ -101,3 +101,32 @@ export interface Disposable {
|
|
|
101
101
|
readonly isDestroyed: boolean;
|
|
102
102
|
destroy(): void;
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Per-node editing limits that tools read structurally from `node.editRules`, for
|
|
107
|
+
* example from a component type. A node without rules edits freely.
|
|
108
|
+
*/
|
|
109
|
+
export interface EditRules {
|
|
110
|
+
/** `'free'` (default), a positive step in degrees that divides 360, or `false`. */
|
|
111
|
+
readonly rotate?: 'free' | number | false;
|
|
112
|
+
/** Whether flips and mirroring resizes are allowed. Defaults to true. */
|
|
113
|
+
readonly flip?: boolean;
|
|
114
|
+
/**
|
|
115
|
+
* `'scale'` (default), `false`, or `'props'`: a resize changes the node's own width and
|
|
116
|
+
* height through `size()` instead of scaling it. Multi-node resizes don't apply to it.
|
|
117
|
+
*/
|
|
118
|
+
readonly resize?: 'scale' | 'props' | false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A named connection point that connectors read structurally from
|
|
123
|
+
* `node.portDefinitions`, for example from a component. `x` and `y` are in the node's
|
|
124
|
+
* local coordinates, before its own transform.
|
|
125
|
+
*/
|
|
126
|
+
export interface PortDefinition {
|
|
127
|
+
readonly name: string;
|
|
128
|
+
readonly x: number;
|
|
129
|
+
readonly y: number;
|
|
130
|
+
/** The side a link leaves from, before the node's transform. Elbow routing uses it. */
|
|
131
|
+
readonly side?: 'left' | 'right' | 'top' | 'bottom';
|
|
132
|
+
}
|