@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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { EditRules } from '../types/common.js';
|
|
2
|
+
import type { Component } from './component.js';
|
|
3
|
+
import type { DrawingContext } from '../services/context.js';
|
|
4
|
+
import type { ElementClass } from '../services/types.js';
|
|
5
|
+
|
|
6
|
+
export type PropValue = string | number | boolean | null;
|
|
7
|
+
export type ComponentProps = Readonly<Record<string, PropValue>>;
|
|
8
|
+
|
|
9
|
+
/** Standard decorators cannot distinguish props from other fields in the TS type system. */
|
|
10
|
+
export type ComponentFields<Instance extends Component> = {
|
|
11
|
+
[
|
|
12
|
+
Key in Exclude<keyof Instance, keyof Component> as Instance[Key] extends PropValue ? Key : never
|
|
13
|
+
]?: Instance[Key];
|
|
14
|
+
} & {
|
|
15
|
+
/** The saved size of a `resize: 'props'` component; other components reject it. */
|
|
16
|
+
readonly width?: number;
|
|
17
|
+
readonly height?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface PropOptions {
|
|
21
|
+
readonly label?: string;
|
|
22
|
+
readonly unit?: string;
|
|
23
|
+
readonly min?: number;
|
|
24
|
+
readonly max?: number;
|
|
25
|
+
readonly step?: number;
|
|
26
|
+
readonly options?: readonly PropValue[];
|
|
27
|
+
readonly type?: 'color';
|
|
28
|
+
readonly notation?: 'si';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ComponentOptions extends EditRules {
|
|
32
|
+
readonly type: string;
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly category?: string;
|
|
35
|
+
readonly version?: number;
|
|
36
|
+
readonly migrate?: (props: ComponentProps, version: number) => ComponentProps;
|
|
37
|
+
/** Default size for `resize: 'props'`, in user units. Defaults to 100. */
|
|
38
|
+
readonly width?: number;
|
|
39
|
+
readonly height?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ComponentDefinition extends ComponentOptions {
|
|
43
|
+
readonly category: string;
|
|
44
|
+
readonly version: number;
|
|
45
|
+
readonly rotate: NonNullable<EditRules['rotate']>;
|
|
46
|
+
readonly flip: boolean;
|
|
47
|
+
readonly resize: NonNullable<EditRules['resize']>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ComponentClass<Instance extends Component = Component> extends ElementClass<
|
|
51
|
+
ComponentProps,
|
|
52
|
+
Instance
|
|
53
|
+
> {
|
|
54
|
+
new (context: DrawingContext, props?: ComponentProps, existing?: SVGGElement): Instance;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An `@action` method: its name and undo label. */
|
|
58
|
+
export interface ActionDefinition {
|
|
59
|
+
readonly name: string;
|
|
60
|
+
readonly label: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PropDefinition extends PropOptions {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly initial: PropValue;
|
|
66
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { AnyNode } from '../core/types.js';
|
|
2
|
+
import { Group } from '../elements/group.js';
|
|
3
|
+
import { Renderer, type TemplateInstance } from '../template/render.js';
|
|
4
|
+
import {
|
|
5
|
+
BindingPart,
|
|
6
|
+
TemplateCompiler,
|
|
7
|
+
type ContentItem,
|
|
8
|
+
type TemplateDescription,
|
|
9
|
+
} from '../template/parts.js';
|
|
10
|
+
import type { SvgTemplate } from '../template/svg-tag.js';
|
|
11
|
+
import { Guards } from '../mixins/guards.js';
|
|
12
|
+
import type { Component } from './component.js';
|
|
13
|
+
import { destroySubtrees } from '../core/destruction.js';
|
|
14
|
+
import { Attributes } from '../dom/attributes.js';
|
|
15
|
+
|
|
16
|
+
/** Reuses saved wrappers when their structure matches the class's current template. */
|
|
17
|
+
export class ComponentView {
|
|
18
|
+
#instance?: TemplateInstance;
|
|
19
|
+
#host?: AnyNode;
|
|
20
|
+
#strings?: TemplateStringsArray;
|
|
21
|
+
#parts: BindingPart[] = [];
|
|
22
|
+
readonly #owner: Component;
|
|
23
|
+
constructor(owner: Component) {
|
|
24
|
+
this.#owner = owner;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
render(template: SvgTemplate): void {
|
|
28
|
+
if (
|
|
29
|
+
this.#instance &&
|
|
30
|
+
!this.#instance.isDestroyed &&
|
|
31
|
+
this.#instance.host.parent === this.#owner &&
|
|
32
|
+
this.#instance.template.strings === template.strings
|
|
33
|
+
) {
|
|
34
|
+
this.#instance.update(template);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (
|
|
38
|
+
this.#host &&
|
|
39
|
+
!this.#host.isDestroyed &&
|
|
40
|
+
this.#host.parent === this.#owner &&
|
|
41
|
+
this.#strings === template.strings
|
|
42
|
+
) {
|
|
43
|
+
this.#update(template);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.#parts = [];
|
|
47
|
+
// The first template-power phase owns structural holes; rebuild saved dynamic trees
|
|
48
|
+
// through its renderer instead of trying to infer their binding layout here.
|
|
49
|
+
const content = TemplateCompiler.content(this.#owner.context.dom, template.strings);
|
|
50
|
+
TemplateCompiler.validate(content, this.#owner.context.registry);
|
|
51
|
+
const descriptions = this.#hasChildHoles(content)
|
|
52
|
+
? undefined
|
|
53
|
+
: content.filter((item): item is TemplateDescription => 'tag' in item);
|
|
54
|
+
const host = this.#owner.children.length === 1 ? this.#owner.children[0] : undefined;
|
|
55
|
+
if (
|
|
56
|
+
descriptions &&
|
|
57
|
+
host instanceof Group &&
|
|
58
|
+
this.#attributesMatch(host, {}) &&
|
|
59
|
+
this.#matches(host.children, descriptions)
|
|
60
|
+
) {
|
|
61
|
+
this.#bind(host.children, descriptions);
|
|
62
|
+
this.#host = host;
|
|
63
|
+
this.#strings = template.strings;
|
|
64
|
+
this.#instance = undefined;
|
|
65
|
+
this.#update(template);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const previous = [...this.#owner.children];
|
|
69
|
+
this.#instance = new Renderer(this.#owner).render(template);
|
|
70
|
+
if (previous.length) this.#owner[destroySubtrees](previous);
|
|
71
|
+
this.#host = undefined;
|
|
72
|
+
this.#strings = undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#attributesMatch(node: AnyNode, attributes: Readonly<Record<string, unknown>>): boolean {
|
|
76
|
+
const names = node.attributes.entries().map(([name]) => name);
|
|
77
|
+
names.push(...node.el.getAttributeNames().map(Attributes.normalizeName));
|
|
78
|
+
return names.every((name) => Object.hasOwn(attributes, name));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#hasChildHoles(content: readonly ContentItem[]): boolean {
|
|
82
|
+
return content.some((item) => {
|
|
83
|
+
if (!('tag' in item)) return item.parts.some((part) => typeof part === 'number');
|
|
84
|
+
const Constructor = this.#owner.context.registry.get(item.tag);
|
|
85
|
+
return this.#hasChildHoles(Constructor?.isText ? item.children : item.content);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#matches(nodes: readonly AnyNode[], descriptions: readonly TemplateDescription[]): boolean {
|
|
90
|
+
return (
|
|
91
|
+
nodes.length === descriptions.length &&
|
|
92
|
+
nodes.every((node, index) => {
|
|
93
|
+
const description = descriptions[index];
|
|
94
|
+
return (
|
|
95
|
+
!(Guards.isTextual(node) && description.text.length && description.children.length) &&
|
|
96
|
+
node.el.localName === description.tag &&
|
|
97
|
+
this.#attributesMatch(node, description.attributes) &&
|
|
98
|
+
(Guards.isParent(node)
|
|
99
|
+
? this.#matches(node.children, description.children)
|
|
100
|
+
: description.children.length === 0)
|
|
101
|
+
);
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#bind(nodes: readonly AnyNode[], descriptions: readonly TemplateDescription[]): void {
|
|
107
|
+
nodes.forEach((node, index) => {
|
|
108
|
+
const description = descriptions[index];
|
|
109
|
+
for (const [name, parts] of Object.entries(description.attributes))
|
|
110
|
+
this.#parts.push(new BindingPart(node, name, parts));
|
|
111
|
+
if (description.text.length && Guards.isTextual(node)) {
|
|
112
|
+
this.#parts.push(new BindingPart(node, null, description.text));
|
|
113
|
+
}
|
|
114
|
+
if (Guards.isParent(node)) this.#bind(node.children, description.children);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
#update(template: SvgTemplate): void {
|
|
119
|
+
const changes = this.#parts.map((part) => ({ part, value: part.evaluate(template.values) }));
|
|
120
|
+
for (const { part, value } of changes) part.validate(value);
|
|
121
|
+
for (const { part, value } of changes) part.commit(value);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Opt-in class component rendering, saved props and temporary live state. */
|
|
2
|
+
export { Component } from './components/component.js';
|
|
3
|
+
export { component, prop, state, action } from './components/decorators.js';
|
|
4
|
+
export { ComponentTypes, isComponentChild } from './components/registry.js';
|
|
5
|
+
export type {
|
|
6
|
+
ActionDefinition,
|
|
7
|
+
ComponentClass,
|
|
8
|
+
ComponentDefinition,
|
|
9
|
+
ComponentFields,
|
|
10
|
+
ComponentOptions,
|
|
11
|
+
ComponentProps,
|
|
12
|
+
PropValue,
|
|
13
|
+
PropOptions,
|
|
14
|
+
PropDefinition,
|
|
15
|
+
} from './components/types.js';
|
package/src/core/node.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { Attributes } from '../dom/attributes.js';
|
|
|
4
4
|
import { Emitter } from '../events/emitter.js';
|
|
5
5
|
import { Matrix } from '../math/matrix.js';
|
|
6
6
|
import type { DrawingContext } from '../services/context.js';
|
|
7
|
-
import type { ElementClass } from '../services/types.js';
|
|
7
|
+
import type { ElementClass, ElementVariant } from '../services/types.js';
|
|
8
|
+
import { assertChildrenEditable, withOwnedChildren } from '../mixins/parent-internals.js';
|
|
8
9
|
import type { ParentNode } from '../mixins/types.js';
|
|
9
10
|
import type { AttributeValue, Bounds, CommonAttributes } from '../types/common.js';
|
|
10
11
|
import type { AnyNode, NodeEvents } from './types.js';
|
|
@@ -28,6 +29,10 @@ export class Node<
|
|
|
28
29
|
static readonly isText: boolean = false;
|
|
29
30
|
static readonly childTags: readonly string[] | undefined = undefined;
|
|
30
31
|
declare static readonly forbiddenDescendantTags: readonly string[] | undefined;
|
|
32
|
+
/** See `ElementVariant`: one `data-*` attribute value selects this class for its tag. */
|
|
33
|
+
static readonly variant: ElementVariant | undefined = undefined;
|
|
34
|
+
/** When true, only the class itself changes its children, through `withOwnChildren()`. */
|
|
35
|
+
static readonly ownsChildren: boolean = false;
|
|
31
36
|
|
|
32
37
|
readonly el: Element;
|
|
33
38
|
readonly attributes: Attributes;
|
|
@@ -61,6 +66,8 @@ export class Node<
|
|
|
61
66
|
this.allowedAttributes = schema;
|
|
62
67
|
for (const [name, value] of this.attributes.entries()) this.validateAttribute(name, value);
|
|
63
68
|
if (!existing) {
|
|
69
|
+
const variant = Constructor.variant;
|
|
70
|
+
if (variant) this.setAttributeValue(variant.attribute, variant.value);
|
|
64
71
|
this.attrs(options);
|
|
65
72
|
for (const { attribute, initial } of fields)
|
|
66
73
|
if (initial !== undefined && this.attributes.getNormalized(attribute) === undefined)
|
|
@@ -111,6 +118,19 @@ export class Node<
|
|
|
111
118
|
name = Attributes.normalizeName(name);
|
|
112
119
|
if (!this.allowedAttributes.has(name) && !/^(?:data|aria)-[\w-]+$/.test(name))
|
|
113
120
|
throw new TypeError(`Unsupported attribute: ${name}`);
|
|
121
|
+
if (name.startsWith('data-')) {
|
|
122
|
+
// A variant's identifying attribute is fixed, and other classes can't claim its value.
|
|
123
|
+
const Constructor = this.constructor as ElementClass;
|
|
124
|
+
const variant = Constructor.variant;
|
|
125
|
+
if (
|
|
126
|
+
variant?.attribute === name
|
|
127
|
+
? value !== variant.value
|
|
128
|
+
: value != null &&
|
|
129
|
+
(this.context.registry.variant(Constructor.tag, name, String(value)) ?? Constructor) !==
|
|
130
|
+
Constructor
|
|
131
|
+
)
|
|
132
|
+
throw new TypeError(`${name} selects the element class`);
|
|
133
|
+
}
|
|
114
134
|
if (value != null) Attributes.normalize(name, value);
|
|
115
135
|
if (name === 'transform' && value != null) Matrix.parse(String(value));
|
|
116
136
|
if (
|
|
@@ -123,6 +143,12 @@ export class Node<
|
|
|
123
143
|
(this.constructor as ElementClass)[validateAttributeValue]?.(name, value);
|
|
124
144
|
}
|
|
125
145
|
|
|
146
|
+
/** For classes with `static ownsChildren`: change this node's own children. */
|
|
147
|
+
protected withOwnChildren<Result>(callback: () => Result): Result {
|
|
148
|
+
this.assertLive();
|
|
149
|
+
return withOwnedChildren(this, callback);
|
|
150
|
+
}
|
|
151
|
+
|
|
126
152
|
getAttributeValue(name: string): string | number | undefined {
|
|
127
153
|
this.assertLive();
|
|
128
154
|
return this.attributes.get(name);
|
|
@@ -305,6 +331,7 @@ export class Node<
|
|
|
305
331
|
remove(): this {
|
|
306
332
|
return mutation(this, () => {
|
|
307
333
|
this.assertLive();
|
|
334
|
+
assertChildrenEditable(this.parent);
|
|
308
335
|
for (const node of this.descendants()) node.releaseCapturedPointers();
|
|
309
336
|
this.detachNativeElement();
|
|
310
337
|
this.context.bounds.invalidate();
|
|
@@ -316,6 +343,7 @@ export class Node<
|
|
|
316
343
|
destroy(): void {
|
|
317
344
|
if (this.isDestroyed) return;
|
|
318
345
|
this.assertLive();
|
|
346
|
+
assertChildrenEditable(this.parent);
|
|
319
347
|
this.context.defs.assertCanDestroy(
|
|
320
348
|
new Set(this === this.context.root ? this.context.registry.nodes : this.descendants()),
|
|
321
349
|
);
|
|
@@ -339,6 +367,9 @@ export class Node<
|
|
|
339
367
|
disposalOrder.push(node);
|
|
340
368
|
}
|
|
341
369
|
}
|
|
370
|
+
// Preflight ownership before any disposal side effect; owners destroyed together pass.
|
|
371
|
+
for (const root of roots)
|
|
372
|
+
if (root.parent && !ownedNodes.has(root.parent)) assertChildrenEditable(root.parent);
|
|
342
373
|
this.context.defs.assertCanDestroy(ownedNodes);
|
|
343
374
|
this.context.changes.beforeDisposal([...ownedNodes]);
|
|
344
375
|
for (const root of roots) this.context.changes.touchTree(root);
|
package/src/interchange/clone.ts
CHANGED
|
@@ -24,8 +24,9 @@ export function cloneNode<Node extends AnyNode>(source: Node, options: CloneOpti
|
|
|
24
24
|
const originals = [source, ...dependencies];
|
|
25
25
|
const copies = new Map<AnyNode, SVGElement>();
|
|
26
26
|
const roots = originals.map((node) => snapshot(node, copies));
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// Copies are rebuilt by adoption, so each must resolve back to its original's class.
|
|
28
|
+
for (const [original, element] of copies)
|
|
29
|
+
if (source.context.registry.resolve(element) !== original.constructor)
|
|
29
30
|
throw new TypeError('Clone requires the exact registered constructor for each managed tag');
|
|
30
31
|
for (const original of copies.keys())
|
|
31
32
|
if (Guards.isTransformable(original) && !original.geometryMatrix().inverse())
|
|
@@ -8,6 +8,8 @@ import { validateNativeElement } from '../core/native-validation.js';
|
|
|
8
8
|
import { destroySubtrees } from '../core/destruction.js';
|
|
9
9
|
import { InterchangeDom } from './dom.js';
|
|
10
10
|
import type { AttributeBindings } from '../catalog/namespaces.js';
|
|
11
|
+
import { withInternalStructure } from '../mixins/parent-internals.js';
|
|
12
|
+
import { withInitialization } from '../services/initialization.js';
|
|
11
13
|
|
|
12
14
|
/** Pure validation for the complete forest, before Registry can claim a wrapper. */
|
|
13
15
|
export function preflight(
|
|
@@ -16,7 +18,7 @@ export function preflight(
|
|
|
16
18
|
schemas: ReadonlyMap<SVGElement, ReadonlySet<string>> = new Map(),
|
|
17
19
|
): void {
|
|
18
20
|
function visit(element: SVGElement, forbidden: ReadonlySet<string>): void {
|
|
19
|
-
const Constructor = context.registry.
|
|
21
|
+
const Constructor = context.registry.resolve(element) as ElementClass & {
|
|
20
22
|
attributeBindings?: AttributeBindings;
|
|
21
23
|
};
|
|
22
24
|
if (
|
|
@@ -90,22 +92,24 @@ export function reconstruct<Result>(
|
|
|
90
92
|
): Result {
|
|
91
93
|
const before = new Set(owner.context.registry.nodes);
|
|
92
94
|
try {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
95
|
+
return withInitialization(() => {
|
|
96
|
+
const nodes = roots.map((element) => owner.context.registry.adopt(element));
|
|
97
|
+
for (const node of owner.context.registry.nodes)
|
|
98
|
+
if (!before.has(node))
|
|
99
|
+
for (const name of ['tabindex', 'role']) {
|
|
100
|
+
const value = node.el.getAttribute(name);
|
|
101
|
+
if (value !== null) {
|
|
102
|
+
node.defineAttribute(name);
|
|
103
|
+
node.setAttributeValue(name, value);
|
|
104
|
+
}
|
|
101
105
|
}
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
return finish(nodes);
|
|
107
|
+
});
|
|
104
108
|
} catch (error) {
|
|
105
109
|
const created = owner.context.registry.nodes.filter((node) => !before.has(node));
|
|
106
110
|
if (created.length) {
|
|
107
111
|
try {
|
|
108
|
-
owner[destroySubtrees](created);
|
|
112
|
+
withInternalStructure(() => owner[destroySubtrees](created));
|
|
109
113
|
} catch (cleanupError) {
|
|
110
114
|
throw new AggregateError(
|
|
111
115
|
[error, cleanupError],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AnyNode } from '../core/types.js';
|
|
2
|
-
import { classifyReference, resolveReference } from '../resources/references.js';
|
|
2
|
+
import { classifyReference, isLink, resolveReference } from '../resources/references.js';
|
|
3
3
|
import { referenceValue } from '../services/reference-value.js';
|
|
4
4
|
import { InterchangeDom } from './dom.js';
|
|
5
5
|
import { XLINK_NAMESPACE } from '../catalog/namespaces.js';
|
|
@@ -10,10 +10,13 @@ export function dependencyRoots(source: AnyNode, references: 'typed' | 'local'):
|
|
|
10
10
|
const pending = [...included];
|
|
11
11
|
for (let index = 0; index < pending.length; index++) {
|
|
12
12
|
const node = pending[index];
|
|
13
|
-
|
|
13
|
+
// Links are not dependencies: copying a connector never copies the shapes it joins.
|
|
14
|
+
const targets = new Set(
|
|
15
|
+
[...node.references].filter(([name]) => !isLink(name)).map(([, target]) => target),
|
|
16
|
+
);
|
|
14
17
|
if (references === 'local')
|
|
15
18
|
for (const [name, value] of node.attributes.entries()) {
|
|
16
|
-
if (!classifyReference(name, value)) continue;
|
|
19
|
+
if (!classifyReference(name, value) || isLink(name)) continue;
|
|
17
20
|
// Hyperlinks navigate; they are not rendering dependencies.
|
|
18
21
|
if (name === 'href' && ['a', 'image'].includes(node.el.localName)) continue;
|
|
19
22
|
const target = resolveReference(node, name);
|
|
@@ -87,10 +90,15 @@ export function restoreLocalReferences(nodes: readonly AnyNode[]): void {
|
|
|
87
90
|
.map((node) => [String(node.getAttributeValue('id')), node]),
|
|
88
91
|
);
|
|
89
92
|
for (const node of all)
|
|
90
|
-
for (const [name, value] of node.attributes.entries()) {
|
|
93
|
+
for (const [name, value] of [...node.attributes.entries()]) {
|
|
91
94
|
const reference = classifyReference(name, value);
|
|
92
95
|
if (!reference) continue;
|
|
93
96
|
const target = ids.get(reference.id);
|
|
97
|
+
// A connector pasted without its target keeps that end loose.
|
|
98
|
+
if (!target && isLink(name)) {
|
|
99
|
+
node.setAttributeValue(name, undefined);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
94
102
|
if (!target)
|
|
95
103
|
throw new TypeError(`Local reference is outside the imported forest: ${reference.id}`);
|
|
96
104
|
if (name === 'href' && ['a', 'image'].includes(node.el.localName)) continue;
|
|
@@ -4,11 +4,48 @@ import { assertNotDestroying, pendingDestruction } from '../core/destruction.js'
|
|
|
4
4
|
/** Structural preflight shared by Parent and the opt-in tree operations. */
|
|
5
5
|
export const validateChild = Symbol('validateChild');
|
|
6
6
|
|
|
7
|
+
let internalStructure = 0;
|
|
8
|
+
const unlocked = new Set<AnyNode>();
|
|
9
|
+
|
|
10
|
+
/** @internal Core's own exact-tree paths (adoption, replay, rollback) bypass child ownership. */
|
|
11
|
+
export function withInternalStructure<Result>(callback: () => Result): Result {
|
|
12
|
+
internalStructure++;
|
|
13
|
+
try {
|
|
14
|
+
return callback();
|
|
15
|
+
} finally {
|
|
16
|
+
internalStructure--;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @internal Lets a node whose class owns its children change them inside the callback. */
|
|
21
|
+
export function withOwnedChildren<Result>(parent: AnyNode, callback: () => Result): Result {
|
|
22
|
+
if (unlocked.has(parent)) return callback();
|
|
23
|
+
unlocked.add(parent);
|
|
24
|
+
try {
|
|
25
|
+
return callback();
|
|
26
|
+
} finally {
|
|
27
|
+
unlocked.delete(parent);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Throws for outside structural edits of a node whose class owns its children. */
|
|
32
|
+
export function assertChildrenEditable(parent: AnyNode | null | undefined): void {
|
|
33
|
+
if (
|
|
34
|
+
parent &&
|
|
35
|
+
(parent.constructor as { readonly ownsChildren?: boolean }).ownsChildren &&
|
|
36
|
+
!internalStructure &&
|
|
37
|
+
!unlocked.has(parent) &&
|
|
38
|
+
!pendingDestruction.has(parent)
|
|
39
|
+
)
|
|
40
|
+
throw new Error('This node owns its children');
|
|
41
|
+
}
|
|
42
|
+
|
|
7
43
|
export function assertCanAddChildren(parent: AnyNode): void {
|
|
8
44
|
parent.assertLive();
|
|
9
45
|
// Whole-drawing teardown already cleans late subscriptions and registrations.
|
|
10
46
|
const drawingCloses = parent.context.root && pendingDestruction.has(parent.context.root);
|
|
11
47
|
if (!drawingCloses) assertNotDestroying(parent);
|
|
48
|
+
assertChildrenEditable(parent);
|
|
12
49
|
}
|
|
13
50
|
|
|
14
51
|
export function validateIndex(index: number, length: number): void {
|
package/src/mixins/parent.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { mutation } from '../changes/mutation.js';
|
|
2
2
|
import type { Node } from '../core/node.js';
|
|
3
3
|
import type { AnyNode } from '../core/types.js';
|
|
4
|
-
import type { ElementClass } from '../services/types.js';
|
|
4
|
+
import type { ElementClass, CreationOptions } from '../services/types.js';
|
|
5
5
|
import { BBox } from '../math/bbox.js';
|
|
6
6
|
import type { Bounds } from '../types/common.js';
|
|
7
7
|
import type { MixinParent, NodeSelector } from './types.js';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
assertCanAddChildren,
|
|
10
|
+
assertChildrenEditable,
|
|
11
|
+
validateChild,
|
|
12
|
+
validateIndex,
|
|
13
|
+
} from './parent-internals.js';
|
|
9
14
|
import { assertNotDestroying } from '../core/destruction.js';
|
|
10
15
|
|
|
11
16
|
export const Parent: MixinParent = (BaseClass) => {
|
|
@@ -50,6 +55,7 @@ export const Parent: MixinParent = (BaseClass) => {
|
|
|
50
55
|
adopt(element: SVGElement): AnyNode {
|
|
51
56
|
return mutation(this, () => {
|
|
52
57
|
this.assertLive();
|
|
58
|
+
assertChildrenEditable(this);
|
|
53
59
|
const siblings = this.context.dom.children(this.el);
|
|
54
60
|
if (!siblings.includes(element))
|
|
55
61
|
throw new Error('Element is not a native child of this parent');
|
|
@@ -93,6 +99,7 @@ export const Parent: MixinParent = (BaseClass) => {
|
|
|
93
99
|
[validateChild](child: AnyNode): void {
|
|
94
100
|
assertCanAddChildren(this);
|
|
95
101
|
child.assertLive();
|
|
102
|
+
if (child.parent !== this) assertChildrenEditable(child.parent);
|
|
96
103
|
assertNotDestroying(child);
|
|
97
104
|
this.validateChildTag(this.context.dom.tag(child.el));
|
|
98
105
|
if (child.context !== this.context) throw new Error('Cross-root moves are unsupported');
|
|
@@ -110,13 +117,13 @@ export const Parent: MixinParent = (BaseClass) => {
|
|
|
110
117
|
|
|
111
118
|
create<Options, Child extends AnyNode>(
|
|
112
119
|
Constructor: ElementClass<Options, Child>,
|
|
113
|
-
options: Options,
|
|
120
|
+
options: NoInfer<CreationOptions<Options, Child>>,
|
|
114
121
|
): Child {
|
|
115
122
|
return mutation(this, () => {
|
|
116
123
|
assertCanAddChildren(this);
|
|
117
124
|
this.validateChildTag(Constructor.tag);
|
|
118
125
|
this.context.registry.register(Constructor);
|
|
119
|
-
return this.add(new Constructor(this.context, options));
|
|
126
|
+
return this.add(new Constructor(this.context, options as Options));
|
|
120
127
|
});
|
|
121
128
|
}
|
|
122
129
|
|
|
@@ -128,6 +135,7 @@ export const Parent: MixinParent = (BaseClass) => {
|
|
|
128
135
|
|
|
129
136
|
detachChild(child: AnyNode): void {
|
|
130
137
|
return mutation(this, () => {
|
|
138
|
+
assertChildrenEditable(this);
|
|
131
139
|
const index = this.#children.indexOf(child);
|
|
132
140
|
if (index >= 0) {
|
|
133
141
|
this.context.changes.touch(child);
|
|
@@ -139,6 +147,7 @@ export const Parent: MixinParent = (BaseClass) => {
|
|
|
139
147
|
}
|
|
140
148
|
|
|
141
149
|
private childrenChanged(): void {
|
|
150
|
+
this.context.changes.invalidateOrder(this);
|
|
142
151
|
this.context.bounds.invalidate();
|
|
143
152
|
this.context.defs.invalidateHost(this);
|
|
144
153
|
}
|
package/src/mixins/types.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type { Text } from '../elements/text.js';
|
|
|
16
16
|
import type { Use } from '../elements/use.js';
|
|
17
17
|
import type { Matrix } from '../math/matrix.js';
|
|
18
18
|
import type { Vec2 } from '../math/vec2.js';
|
|
19
|
-
import type { ElementClass } from '../services/types.js';
|
|
19
|
+
import type { ElementClass, CreationOptions } from '../services/types.js';
|
|
20
20
|
import type {
|
|
21
21
|
CommonAttributes,
|
|
22
22
|
Bounds,
|
|
@@ -62,7 +62,7 @@ export interface ParentFeature {
|
|
|
62
62
|
detachChild(child: AnyNode): void;
|
|
63
63
|
create<Options, Child extends AnyNode>(
|
|
64
64
|
Constructor: ElementClass<Options, Child>,
|
|
65
|
-
options: Options,
|
|
65
|
+
options: NoInfer<CreationOptions<Options, Child>>,
|
|
66
66
|
): Child;
|
|
67
67
|
remove(): this;
|
|
68
68
|
remove(child: AnyNode): this;
|
|
@@ -30,16 +30,16 @@ function localReference(
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Connector ends link to their targets; the targets are not rendering resources. */
|
|
34
|
+
export const isLink = (attribute: string): boolean =>
|
|
35
|
+
attribute === 'data-rasam-connector-start' || attribute === 'data-rasam-connector-end';
|
|
36
|
+
|
|
33
37
|
/** Classifies local fragments for lookup/remapping; it never acquires a typed binding. */
|
|
34
38
|
export function classifyReference(attribute: string, value: unknown): LocalReference | null {
|
|
35
39
|
attribute = Attributes.normalizeName(attribute);
|
|
36
40
|
if (typeof value !== 'string') return null;
|
|
37
41
|
const source = value.replace(/^[ \t\r\n\f]+|[ \t\r\n\f]+$/g, '');
|
|
38
|
-
if (
|
|
39
|
-
attribute === 'href' ||
|
|
40
|
-
attribute === 'data-rasam-connector-start' ||
|
|
41
|
-
attribute === 'data-rasam-connector-end'
|
|
42
|
-
) {
|
|
42
|
+
if (attribute === 'href' || isLink(attribute)) {
|
|
43
43
|
const match = /^#([^\s#<>"'()\\\u0000-\u001f\u007f]+)$/.exec(source);
|
|
44
44
|
return localReference('href', match?.[1]);
|
|
45
45
|
}
|
package/src/services/catalog.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { Registry } from './registry.js';
|
|
2
|
+
import { elementKey } from './element-key.js';
|
|
2
3
|
import type { ElementClass } from './types.js';
|
|
3
4
|
|
|
4
5
|
/** Preflight a per-drawing catalog before changing the registry. */
|
|
5
6
|
export function registerElements(registry: Registry, elements: readonly ElementClass[]): void {
|
|
6
7
|
const pending = new Map<string, ElementClass>();
|
|
7
8
|
for (const Constructor of elements) {
|
|
8
|
-
const
|
|
9
|
+
const key = elementKey(Constructor);
|
|
10
|
+
const previous = pending.get(key) ?? registry.registered(Constructor);
|
|
9
11
|
if (previous && previous !== Constructor)
|
|
10
|
-
throw new Error(
|
|
11
|
-
pending.set(
|
|
12
|
+
throw new Error(`${Constructor.variant ? 'Variant' : 'Tag'} already registered: ${key}`);
|
|
13
|
+
pending.set(key, Constructor);
|
|
12
14
|
}
|
|
13
15
|
for (const Constructor of pending.values()) registry.register(Constructor);
|
|
14
16
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AnyNode } from '../core/types.js';
|
|
2
|
+
|
|
3
|
+
/** Internal claim order. A node keeps the number from its first claim, even if reclaimed. */
|
|
4
|
+
const claimOrder = new WeakMap<AnyNode, number>();
|
|
5
|
+
/** Claims so far: a constant-time marker for `claimedSince`, instead of copying every node. */
|
|
6
|
+
export let claims = 0;
|
|
7
|
+
|
|
8
|
+
export function recordClaim(node: AnyNode): void {
|
|
9
|
+
if (!claimOrder.has(node)) claimOrder.set(node, ++claims);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Registered nodes first claimed after `mark`, in `nodes` order (pass `registry.nodes`). */
|
|
13
|
+
export const claimedSince = (nodes: readonly AnyNode[], mark: number): AnyNode[] =>
|
|
14
|
+
nodes.filter((node) => claimOrder.get(node)! > mark);
|
|
@@ -56,6 +56,11 @@ export class DefsManager {
|
|
|
56
56
|
return count;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Nodes holding a typed reference to `target`, detached ones included, from the index. */
|
|
60
|
+
consumersOf(target: AnyNode): AnyNode[] {
|
|
61
|
+
return [...(this.consumersByTarget.get(target)?.keys() ?? [])];
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
*geometryConsumers(target: AnyNode): IterableIterator<AnyNode> {
|
|
60
65
|
for (const [consumer, attributes] of this.consumersByTarget.get(target) ?? [])
|
|
61
66
|
if (attributes.has('href')) yield consumer;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ElementClass } from './types.js';
|
|
2
|
+
|
|
3
|
+
/** A tag, or `tag[attribute="value"]` for a variant. Variants share the constructor map. */
|
|
4
|
+
export function elementKey(Constructor: ElementClass): string {
|
|
5
|
+
const variant = Constructor.variant;
|
|
6
|
+
if (!variant) return Constructor.tag;
|
|
7
|
+
if (
|
|
8
|
+
!/^data-[a-z0-9_.-]+$/.test(variant.attribute) ||
|
|
9
|
+
typeof variant.value != 'string' ||
|
|
10
|
+
!variant.value
|
|
11
|
+
)
|
|
12
|
+
throw new TypeError('Variants need a lowercase data-* attribute and a value');
|
|
13
|
+
return `${Constructor.tag}[${variant.attribute}="${variant.value}"]`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AnyNode } from '../core/types.js';
|
|
2
|
+
|
|
3
|
+
/** Internal extension hook, after the entire native/reconstructed tree is available. */
|
|
4
|
+
export const initializeElement = Symbol('init');
|
|
5
|
+
type Initializable = AnyNode & { [initializeElement]?: () => void };
|
|
6
|
+
let pending: Initializable[] | undefined;
|
|
7
|
+
|
|
8
|
+
export function initializeNode(node: AnyNode): void {
|
|
9
|
+
if (pending) pending.push(node);
|
|
10
|
+
else (node as Initializable)[initializeElement]?.();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Delay hooks until clone/import has restored references and copied model features. */
|
|
14
|
+
export function withInitialization<Result>(callback: () => Result): Result {
|
|
15
|
+
if (pending) return callback();
|
|
16
|
+
pending = [];
|
|
17
|
+
try {
|
|
18
|
+
const result = callback();
|
|
19
|
+
// Initializers are idempotent; nested adoptions can append work while hooks run.
|
|
20
|
+
for (const node of pending) if (!node.isDestroyed) node[initializeElement]?.();
|
|
21
|
+
return result;
|
|
22
|
+
} finally {
|
|
23
|
+
pending = undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|