@rasamjs/core 0.2.0 → 0.3.1
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 +14 -4
- package/CHANGELOG.md +71 -0
- package/COMPONENTS.md +229 -0
- package/README.md +46 -6
- package/RELEASING.md +4 -2
- 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 +76 -28
- package/dist/changes/restore.js.map +1 -1
- package/dist/changes/service.d.ts +5 -0
- package/dist/changes/service.d.ts.map +1 -1
- package/dist/changes/service.js +18 -4
- package/dist/changes/service.js.map +1 -1
- package/dist/changes/state.d.ts.map +1 -1
- package/dist/changes/state.js +33 -27
- package/dist/changes/state.js.map +1 -1
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +20 -4
- 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 +83 -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/interchange/clone.d.ts.map +1 -1
- package/dist/interchange/clone.js +3 -2
- package/dist/interchange/clone.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/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/types.d.ts +2 -2
- package/dist/mixins/types.d.ts.map +1 -1
- package/dist/resources/references.d.ts +2 -0
- package/dist/resources/references.d.ts.map +1 -1
- package/dist/resources/references.js +3 -3
- 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/claims.d.ts +7 -0
- package/dist/services/claims.d.ts.map +1 -0
- package/dist/services/claims.js +11 -0
- package/dist/services/claims.js.map +1 -0
- 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 +47 -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/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/internals.d.ts +1 -0
- package/dist/text/internals.d.ts.map +1 -1
- package/dist/types/common.d.ts +27 -0
- package/dist/types/common.d.ts.map +1 -1
- package/package.json +10 -1
- package/reports/adoption.json +20 -20
- package/reports/animation-benchmark.json +613 -613
- package/reports/benchmark.json +27 -27
- package/reports/change-groups-benchmark.json +471 -0
- 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/phase-3-core-after.json +40 -0
- package/reports/phase-3-core-before.json +40 -0
- package/reports/public-api-contract.json +335 -17
- package/reports/size-components.json +103 -0
- package/reports/size-parity.json +50 -34
- package/reports/size.json +7 -4
- package/reports/svgjs-comparison.json +3049 -3049
- package/reports/svgjs-comparison.md +15 -15
- package/reports/svgjs-parity-current.json +133 -46
- 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 +78 -36
- package/src/changes/service.ts +23 -8
- package/src/changes/state.ts +37 -28
- package/src/changes.ts +24 -5
- package/src/components/changes.ts +131 -0
- package/src/components/component.ts +401 -0
- package/src/components/decorators.ts +106 -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/interchange/clone.ts +3 -2
- package/src/interchange/reconstruct.ts +16 -12
- package/src/interchange/references.ts +12 -4
- package/src/mixins/parent-internals.ts +37 -0
- package/src/mixins/parent.ts +13 -4
- package/src/mixins/types.ts +2 -2
- package/src/resources/references.ts +5 -5
- package/src/services/catalog.ts +5 -3
- package/src/services/claims.ts +14 -0
- 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 +49 -10
- package/src/services/types.ts +21 -0
- 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/internals.ts +1 -0
- package/src/types/common.ts +29 -0
package/src/services/registry.ts
CHANGED
|
@@ -4,13 +4,19 @@ import { validateAttributeValue } from '../core/attribute-validation.js';
|
|
|
4
4
|
import { validateNativeElement } from '../core/native-validation.js';
|
|
5
5
|
import type { AnyNode } from '../core/types.js';
|
|
6
6
|
import type { ParentNode } from '../mixins/types.js';
|
|
7
|
+
import { withInternalStructure } from '../mixins/parent-internals.js';
|
|
8
|
+
import { elementKey } from './element-key.js';
|
|
9
|
+
import { initializeNode, withInitialization } from './initialization.js';
|
|
7
10
|
import type { DrawingContext } from './context.js';
|
|
8
11
|
import type { ElementClass } from './types.js';
|
|
12
|
+
import { recordClaim } from './claims.js';
|
|
9
13
|
|
|
10
14
|
export class Registry {
|
|
11
15
|
private static readonly identity = new WeakMap<SVGElement, AnyNode>();
|
|
12
16
|
private readonly wrappers = new Map<SVGElement, AnyNode>();
|
|
13
17
|
private readonly constructors = new Map<string, ElementClass>();
|
|
18
|
+
/** Tags that have variants, with the attributes that select them. */
|
|
19
|
+
private readonly variantAttributes = new Map<string, Set<string>>();
|
|
14
20
|
|
|
15
21
|
constructor(private readonly context: DrawingContext) {}
|
|
16
22
|
|
|
@@ -19,18 +25,45 @@ export class Registry {
|
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
register<Options, Result extends AnyNode>(Constructor: ElementClass<Options, Result>): this {
|
|
22
|
-
const
|
|
28
|
+
const key = elementKey(Constructor);
|
|
29
|
+
const previous = this.constructors.get(key);
|
|
23
30
|
if (previous === Constructor) return this;
|
|
24
|
-
if (previous
|
|
25
|
-
throw new Error(
|
|
26
|
-
this.constructors.set(
|
|
31
|
+
if (previous)
|
|
32
|
+
throw new Error(`${Constructor.variant ? 'Variant' : 'Tag'} already registered: ${key}`);
|
|
33
|
+
this.constructors.set(key, Constructor);
|
|
34
|
+
const variant = Constructor.variant;
|
|
35
|
+
if (variant) {
|
|
36
|
+
const attributes = this.variantAttributes.get(Constructor.tag) ?? new Set<string>();
|
|
37
|
+
this.variantAttributes.set(Constructor.tag, attributes.add(variant.attribute));
|
|
38
|
+
}
|
|
27
39
|
return this;
|
|
28
40
|
}
|
|
29
41
|
|
|
42
|
+
/** The tag's base constructor, ignoring variants. */
|
|
30
43
|
get(tag: string): ElementClass | undefined {
|
|
31
44
|
return this.constructors.get(tag);
|
|
32
45
|
}
|
|
33
46
|
|
|
47
|
+
/** The constructor registered for the same tag or variant as `Constructor`, if any. */
|
|
48
|
+
registered(Constructor: ElementClass): ElementClass | undefined {
|
|
49
|
+
return this.constructors.get(elementKey(Constructor));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
variant(tag: string, attribute: string, value: string): ElementClass | undefined {
|
|
53
|
+
return this.constructors.get(`${tag}[${attribute}="${value}"]`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The constructor for a native element: a matching variant, else the tag's base. */
|
|
57
|
+
resolve(element: SVGElement): ElementClass | undefined {
|
|
58
|
+
const tag = this.context.dom.tag(element);
|
|
59
|
+
for (const attribute of this.variantAttributes.get(tag) ?? []) {
|
|
60
|
+
const value = element.getAttribute(attribute);
|
|
61
|
+
const Constructor = value === null ? undefined : this.variant(tag, attribute, value);
|
|
62
|
+
if (Constructor) return Constructor;
|
|
63
|
+
}
|
|
64
|
+
return this.constructors.get(tag);
|
|
65
|
+
}
|
|
66
|
+
|
|
34
67
|
/** Creates a fresh wrapper for binding initialization, without constructor defaults or mounting. */
|
|
35
68
|
createEmpty(tag: string, context = this.context): AnyNode {
|
|
36
69
|
const Constructor = this.get(tag);
|
|
@@ -51,6 +84,7 @@ export class Registry {
|
|
|
51
84
|
if (!this.context.dom.owns(node.el)) throw new Error('Element belongs to another document');
|
|
52
85
|
Registry.identity.set(node.el, node);
|
|
53
86
|
this.wrappers.set(node.el, node);
|
|
87
|
+
recordClaim(node);
|
|
54
88
|
}
|
|
55
89
|
|
|
56
90
|
forget(node: AnyNode): void {
|
|
@@ -61,6 +95,7 @@ export class Registry {
|
|
|
61
95
|
for (const node of this.wrappers.values()) this.forget(node);
|
|
62
96
|
this.wrappers.clear();
|
|
63
97
|
this.constructors.clear();
|
|
98
|
+
this.variantAttributes.clear();
|
|
64
99
|
}
|
|
65
100
|
|
|
66
101
|
adopt(element: SVGElement, parent?: ParentNode): AnyNode {
|
|
@@ -77,14 +112,17 @@ export class Registry {
|
|
|
77
112
|
ancestor;
|
|
78
113
|
ancestor = this.context.dom.svgParent(ancestor)
|
|
79
114
|
) {
|
|
80
|
-
const Constructor = this.
|
|
115
|
+
const Constructor = this.resolve(ancestor);
|
|
81
116
|
if (Constructor?.opaqueContent)
|
|
82
117
|
throw new Error('Cannot adopt descendants across an opaque native boundary');
|
|
83
118
|
for (const tag of Constructor?.forbiddenDescendantTags ?? []) forbidden.add(tag);
|
|
84
119
|
}
|
|
85
120
|
this.validateTree(element, forbidden);
|
|
86
|
-
|
|
87
|
-
return
|
|
121
|
+
// Adoption reproduces a native tree exactly, so it may fill owned children.
|
|
122
|
+
return withInitialization(() => {
|
|
123
|
+
const wrapped = withInternalStructure(() => this.wrapTree(element));
|
|
124
|
+
return parent ? parent.add(wrapped) : wrapped;
|
|
125
|
+
});
|
|
88
126
|
}
|
|
89
127
|
|
|
90
128
|
private assertAdoptionParent(parent: ParentNode | undefined): void {
|
|
@@ -103,7 +141,7 @@ export class Registry {
|
|
|
103
141
|
if (existing.context !== this.context) throw new Error('Cross-root adoption is unsupported');
|
|
104
142
|
return;
|
|
105
143
|
}
|
|
106
|
-
const Constructor = this.
|
|
144
|
+
const Constructor = this.resolve(element);
|
|
107
145
|
if (!this.context.dom.isSvg(element) || !Constructor)
|
|
108
146
|
throw new TypeError(`Unsupported SVG tag: ${tag}`);
|
|
109
147
|
Constructor[validateNativeElement]?.(element);
|
|
@@ -130,16 +168,17 @@ export class Registry {
|
|
|
130
168
|
private wrapTree(element: SVGElement): AnyNode {
|
|
131
169
|
const existing = Registry.wrapper(element);
|
|
132
170
|
if (existing) return existing;
|
|
133
|
-
const Constructor = this.
|
|
171
|
+
const Constructor = this.resolve(element)!;
|
|
134
172
|
const children = this.managedChildren(element);
|
|
135
173
|
const node = new Constructor(this.context, {}, element);
|
|
136
174
|
if (Constructor.isContainer)
|
|
137
175
|
(node as ParentNode).adoptChildren(children.map((child) => this.wrapTree(child)));
|
|
176
|
+
initializeNode(node);
|
|
138
177
|
return node;
|
|
139
178
|
}
|
|
140
179
|
|
|
141
180
|
private managedChildren(element: SVGElement): SVGElement[] {
|
|
142
|
-
const Constructor = this.
|
|
181
|
+
const Constructor = this.resolve(element);
|
|
143
182
|
if (Constructor?.opaqueContent) return [];
|
|
144
183
|
return this.context.dom
|
|
145
184
|
.children(element)
|
package/src/services/types.ts
CHANGED
|
@@ -7,6 +7,23 @@ import { validateNativeElement } from '../core/native-validation.js';
|
|
|
7
7
|
|
|
8
8
|
// Registry
|
|
9
9
|
|
|
10
|
+
/** Type-only capability for constructors whose options come from instance fields. */
|
|
11
|
+
export declare const creationOptions: unique symbol;
|
|
12
|
+
export type CreationOptions<Options, Instance> = Instance extends {
|
|
13
|
+
readonly [creationOptions]: infer Fields;
|
|
14
|
+
}
|
|
15
|
+
? Fields
|
|
16
|
+
: Options;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Selects a subclass for elements that share a tag, by the exact value of one `data-*`
|
|
20
|
+
* attribute. Instances always carry that value, and it can't change.
|
|
21
|
+
*/
|
|
22
|
+
export interface ElementVariant {
|
|
23
|
+
readonly attribute: string;
|
|
24
|
+
readonly value: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
export interface ElementClass<Options = any, Result extends AnyNode = AnyNode> {
|
|
11
28
|
readonly tag: string;
|
|
12
29
|
readonly attributeNames: readonly string[];
|
|
@@ -15,6 +32,10 @@ export interface ElementClass<Options = any, Result extends AnyNode = AnyNode> {
|
|
|
15
32
|
readonly childTags?: readonly string[] | undefined;
|
|
16
33
|
readonly forbiddenDescendantTags?: readonly string[] | undefined;
|
|
17
34
|
readonly opaqueContent?: boolean;
|
|
35
|
+
/** Registers the class for one attribute value of its tag instead of the whole tag. */
|
|
36
|
+
readonly variant?: ElementVariant | undefined;
|
|
37
|
+
/** Children belong to the class: outside structural edits throw. */
|
|
38
|
+
readonly ownsChildren?: boolean;
|
|
18
39
|
[validateAttributeValue]?(name: string, value: AttributeValue): void;
|
|
19
40
|
[validateNativeElement]?(element: SVGElement): void;
|
|
20
41
|
new (context: DrawingContext, options: Options, existing?: any): Result;
|
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;
|