@rasamjs/core 0.2.0 → 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.
Files changed (162) hide show
  1. package/API.md +14 -4
  2. package/CHANGELOG.md +59 -0
  3. package/COMPONENTS.md +229 -0
  4. package/README.md +39 -6
  5. package/RELEASING.md +4 -2
  6. package/dist/advanced.d.ts +1 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/catalog/fragment.d.ts +2 -2
  9. package/dist/catalog/fragment.d.ts.map +1 -1
  10. package/dist/catalog/fragment.js.map +1 -1
  11. package/dist/changes/derived.d.ts +10 -0
  12. package/dist/changes/derived.d.ts.map +1 -0
  13. package/dist/changes/derived.js +14 -0
  14. package/dist/changes/derived.js.map +1 -0
  15. package/dist/changes/restore.d.ts.map +1 -1
  16. package/dist/changes/restore.js +22 -4
  17. package/dist/changes/restore.js.map +1 -1
  18. package/dist/changes/service.d.ts +5 -0
  19. package/dist/changes/service.d.ts.map +1 -1
  20. package/dist/changes/service.js +16 -2
  21. package/dist/changes/service.js.map +1 -1
  22. package/dist/components/changes.d.ts +34 -0
  23. package/dist/components/changes.d.ts.map +1 -0
  24. package/dist/components/changes.js +119 -0
  25. package/dist/components/changes.js.map +1 -0
  26. package/dist/components/component.d.ts +62 -0
  27. package/dist/components/component.d.ts.map +1 -0
  28. package/dist/components/component.js +355 -0
  29. package/dist/components/component.js.map +1 -0
  30. package/dist/components/decorators.d.ts +7 -0
  31. package/dist/components/decorators.d.ts.map +1 -0
  32. package/dist/components/decorators.js +81 -0
  33. package/dist/components/decorators.js.map +1 -0
  34. package/dist/components/metadata.d.ts +10 -0
  35. package/dist/components/metadata.d.ts.map +1 -0
  36. package/dist/components/metadata.js +85 -0
  37. package/dist/components/metadata.js.map +1 -0
  38. package/dist/components/registry.d.ts +24 -0
  39. package/dist/components/registry.d.ts.map +1 -0
  40. package/dist/components/registry.js +78 -0
  41. package/dist/components/registry.js.map +1 -0
  42. package/dist/components/types.d.ts +54 -0
  43. package/dist/components/types.d.ts.map +1 -0
  44. package/dist/components/types.js +2 -0
  45. package/dist/components/types.js.map +1 -0
  46. package/dist/components/view.d.ts +9 -0
  47. package/dist/components/view.d.ts.map +1 -0
  48. package/dist/components/view.js +104 -0
  49. package/dist/components/view.js.map +1 -0
  50. package/dist/components.d.ts +6 -0
  51. package/dist/components.d.ts.map +1 -0
  52. package/dist/components.js +5 -0
  53. package/dist/components.js.map +1 -0
  54. package/dist/core/node.d.ts +7 -0
  55. package/dist/core/node.d.ts.map +1 -1
  56. package/dist/core/node.js +30 -0
  57. package/dist/core/node.js.map +1 -1
  58. package/dist/interchange/clone.d.ts.map +1 -1
  59. package/dist/interchange/clone.js +3 -2
  60. package/dist/interchange/clone.js.map +1 -1
  61. package/dist/interchange/reconstruct.d.ts.map +1 -1
  62. package/dist/interchange/reconstruct.js +16 -12
  63. package/dist/interchange/reconstruct.js.map +1 -1
  64. package/dist/interchange/references.d.ts.map +1 -1
  65. package/dist/interchange/references.js +10 -4
  66. package/dist/interchange/references.js.map +1 -1
  67. package/dist/mixins/parent-internals.d.ts +6 -0
  68. package/dist/mixins/parent-internals.d.ts.map +1 -1
  69. package/dist/mixins/parent-internals.js +34 -0
  70. package/dist/mixins/parent-internals.js.map +1 -1
  71. package/dist/mixins/parent.d.ts.map +1 -1
  72. package/dist/mixins/parent.js +6 -1
  73. package/dist/mixins/parent.js.map +1 -1
  74. package/dist/mixins/types.d.ts +2 -2
  75. package/dist/mixins/types.d.ts.map +1 -1
  76. package/dist/resources/references.d.ts +2 -0
  77. package/dist/resources/references.d.ts.map +1 -1
  78. package/dist/resources/references.js +3 -3
  79. package/dist/resources/references.js.map +1 -1
  80. package/dist/services/catalog.d.ts.map +1 -1
  81. package/dist/services/catalog.js +5 -3
  82. package/dist/services/catalog.js.map +1 -1
  83. package/dist/services/defs-manager.d.ts +2 -0
  84. package/dist/services/defs-manager.d.ts.map +1 -1
  85. package/dist/services/defs-manager.js +4 -0
  86. package/dist/services/defs-manager.js.map +1 -1
  87. package/dist/services/element-key.d.ts +4 -0
  88. package/dist/services/element-key.d.ts.map +1 -0
  89. package/dist/services/element-key.js +12 -0
  90. package/dist/services/element-key.js.map +1 -0
  91. package/dist/services/initialization.d.ts +7 -0
  92. package/dist/services/initialization.d.ts.map +1 -0
  93. package/dist/services/initialization.js +27 -0
  94. package/dist/services/initialization.js.map +1 -0
  95. package/dist/services/registry.d.ts +8 -0
  96. package/dist/services/registry.d.ts.map +1 -1
  97. package/dist/services/registry.js +45 -10
  98. package/dist/services/registry.js.map +1 -1
  99. package/dist/services/types.d.ts +17 -0
  100. package/dist/services/types.d.ts.map +1 -1
  101. package/dist/template/parts.d.ts +12 -6
  102. package/dist/template/parts.d.ts.map +1 -1
  103. package/dist/template/parts.js +36 -29
  104. package/dist/template/parts.js.map +1 -1
  105. package/dist/template/render.d.ts +6 -7
  106. package/dist/template/render.d.ts.map +1 -1
  107. package/dist/template/render.js +188 -44
  108. package/dist/template/render.js.map +1 -1
  109. package/dist/template/svg-tag.d.ts +5 -1
  110. package/dist/template/svg-tag.d.ts.map +1 -1
  111. package/dist/template/svg-tag.js.map +1 -1
  112. package/dist/text/internals.d.ts +1 -0
  113. package/dist/text/internals.d.ts.map +1 -1
  114. package/dist/types/common.d.ts +27 -0
  115. package/dist/types/common.d.ts.map +1 -1
  116. package/package.json +156 -148
  117. package/reports/adoption.json +20 -20
  118. package/reports/animation-benchmark.json +612 -612
  119. package/reports/benchmark.json +40 -40
  120. package/reports/components-benchmark-before.json +10 -0
  121. package/reports/components-benchmark.json +10 -0
  122. package/reports/creation.json +26 -26
  123. package/reports/interchange-benchmark.json +85 -85
  124. package/reports/public-api-contract.json +334 -17
  125. package/reports/size-components.json +102 -0
  126. package/reports/size-parity.json +47 -34
  127. package/reports/size.json +6 -4
  128. package/reports/svgjs-comparison.json +3100 -3100
  129. package/reports/svgjs-comparison.md +16 -16
  130. package/reports/svgjs-parity-current.json +135 -42
  131. package/src/advanced.ts +1 -1
  132. package/src/catalog/fragment.ts +2 -2
  133. package/src/changes/derived.ts +19 -0
  134. package/src/changes/restore.ts +27 -4
  135. package/src/changes/service.ts +21 -6
  136. package/src/components/changes.ts +131 -0
  137. package/src/components/component.ts +401 -0
  138. package/src/components/decorators.ts +104 -0
  139. package/src/components/metadata.ts +111 -0
  140. package/src/components/registry.ts +79 -0
  141. package/src/components/types.ts +66 -0
  142. package/src/components/view.ts +123 -0
  143. package/src/components.ts +15 -0
  144. package/src/core/node.ts +32 -1
  145. package/src/interchange/clone.ts +3 -2
  146. package/src/interchange/reconstruct.ts +16 -12
  147. package/src/interchange/references.ts +12 -4
  148. package/src/mixins/parent-internals.ts +37 -0
  149. package/src/mixins/parent.ts +13 -4
  150. package/src/mixins/types.ts +2 -2
  151. package/src/resources/references.ts +5 -5
  152. package/src/services/catalog.ts +5 -3
  153. package/src/services/defs-manager.ts +5 -0
  154. package/src/services/element-key.ts +14 -0
  155. package/src/services/initialization.ts +25 -0
  156. package/src/services/registry.ts +47 -10
  157. package/src/services/types.ts +21 -0
  158. package/src/template/parts.ts +54 -39
  159. package/src/template/render.ts +211 -54
  160. package/src/template/svg-tag.ts +6 -1
  161. package/src/text/internals.ts +1 -0
  162. package/src/types/common.ts +29 -0
@@ -0,0 +1,104 @@
1
+ import {
2
+ Component,
3
+ installField,
4
+ assertField,
5
+ editComponent,
6
+ registerAction,
7
+ } from './component.js';
8
+ import { defineComponent } from './metadata.js';
9
+ import { initializeElement } from '../services/initialization.js';
10
+ import { destroySubtrees } from '../core/destruction.js';
11
+ import { withInternalStructure } from '../mixins/parent-internals.js';
12
+ import type { ComponentClass, ComponentOptions, PropOptions } from './types.js';
13
+
14
+ export function component(options: ComponentOptions) {
15
+ return <Class extends ComponentClass>(
16
+ target: Class,
17
+ _context: ClassDecoratorContext<Class>,
18
+ ): Class => {
19
+ if (!(target.prototype instanceof Component))
20
+ throw new TypeError('@component requires extends Component');
21
+ const Base = target as ComponentClass;
22
+ const Decorated = class extends Base {
23
+ constructor(...args: ConstructorParameters<ComponentClass>) {
24
+ const context = args[0],
25
+ before = new Set(context.registry.nodes);
26
+ try {
27
+ super(...args);
28
+ if (new.target === Decorated && !this.adopting) this[initializeElement]();
29
+ } catch (error) {
30
+ const created = context.registry.nodes.filter((node) => !before.has(node));
31
+ if (created.length) {
32
+ try {
33
+ withInternalStructure(() => created[0][destroySubtrees](created));
34
+ } catch (cleanup) {
35
+ throw new AggregateError(
36
+ [error, cleanup],
37
+ 'Component construction and cleanup failed',
38
+ );
39
+ }
40
+ }
41
+ throw error;
42
+ }
43
+ }
44
+ };
45
+ // Inherit edit rules from the decorated application base, not this wrapper's implementation base.
46
+ defineComponent(Decorated, options);
47
+ Object.defineProperty(Decorated, 'variant', {
48
+ value: Object.freeze({ attribute: 'data-rasam-component', value: options.type }),
49
+ });
50
+ return Decorated as Class;
51
+ };
52
+ }
53
+
54
+ function field(kind: 'prop' | 'state', options: PropOptions) {
55
+ return <Host extends Component, Value>(
56
+ _target: undefined,
57
+ context: ClassFieldDecoratorContext<Host, Value>,
58
+ ) => {
59
+ if (
60
+ context.kind !== 'field' ||
61
+ context.private ||
62
+ context.static ||
63
+ typeof context.name !== 'string'
64
+ )
65
+ throw new TypeError(`@${kind} requires a public instance field`);
66
+ context.addInitializer(function () {
67
+ this[installField](context.name as string, kind, options, context.access.get(this));
68
+ });
69
+ return function (this: Host, value: Value): Value {
70
+ // Validate before the JS field definition can overwrite a base instance member.
71
+ this[assertField](context.name as string);
72
+ return value;
73
+ };
74
+ };
75
+ }
76
+ export function prop(options: PropOptions = {}) {
77
+ return field('prop', options);
78
+ }
79
+ export function state() {
80
+ return field('state', {});
81
+ }
82
+
83
+ export function action(label: string) {
84
+ return <Host extends Component, Args extends unknown[], Result>(
85
+ method: (this: Host, ...args: Args) => Result,
86
+ context: ClassMethodDecoratorContext<Host, (this: Host, ...args: Args) => Result>,
87
+ ) => {
88
+ if (
89
+ context.static ||
90
+ context.private ||
91
+ typeof context.name !== 'string' ||
92
+ !label.trim() ||
93
+ method.constructor.name === 'AsyncFunction'
94
+ )
95
+ throw new TypeError('@action requires a synchronous public instance method and label');
96
+ const name = context.name;
97
+ context.addInitializer(function () {
98
+ this[registerAction](name, label);
99
+ });
100
+ return function (this: Host, ...args: Args): Result {
101
+ return this[editComponent](label, () => method.apply(this, args));
102
+ };
103
+ };
104
+ }
@@ -0,0 +1,111 @@
1
+ import type { PortDefinition } from '../types/common.js';
2
+ import type {
3
+ ComponentClass,
4
+ ComponentDefinition,
5
+ ComponentOptions,
6
+ PropOptions,
7
+ PropValue,
8
+ } from './types.js';
9
+
10
+ const definitions = new WeakMap<object, ComponentDefinition>();
11
+
12
+ export function definition(Constructor: object): ComponentDefinition {
13
+ const result = definitions.get(Constructor);
14
+ if (!result) throw new TypeError('Component classes require their own @component decorator');
15
+ return result;
16
+ }
17
+
18
+ export function defineComponent(Constructor: ComponentClass, options: ComponentOptions): void {
19
+ let parent = Object.getPrototypeOf(Constructor);
20
+ while (parent && !definitions.has(parent)) parent = Object.getPrototypeOf(parent);
21
+ const inherited = definitions.get(parent);
22
+ const result = {
23
+ category: 'General',
24
+ version: 1,
25
+ rotate: 'free',
26
+ flip: true,
27
+ resize: 'scale',
28
+ ...(inherited
29
+ ? {
30
+ rotate: inherited.rotate,
31
+ flip: inherited.flip,
32
+ resize: inherited.resize,
33
+ width: inherited.width,
34
+ height: inherited.height,
35
+ }
36
+ : {}),
37
+ ...options,
38
+ } as ComponentDefinition;
39
+ if (result.resize === 'props')
40
+ Object.assign(result, { width: result.width ?? 100, height: result.height ?? 100 });
41
+ if (!/^[a-z][a-z0-9-]*$/.test(result.type) || !result.name?.trim() || !result.category?.trim())
42
+ throw new TypeError('Components need a lowercase type, display name and category');
43
+ if (!Number.isInteger(result.version) || result.version < 1)
44
+ throw new TypeError('Component version must be a positive integer');
45
+ if (
46
+ result.rotate !== 'free' &&
47
+ result.rotate !== false &&
48
+ (!Number.isFinite(result.rotate) ||
49
+ result.rotate <= 0 ||
50
+ Math.abs(360 / result.rotate - Math.round(360 / result.rotate)) > 1e-8)
51
+ )
52
+ throw new TypeError('Rotation steps must be positive divisors of 360');
53
+ if (typeof result.flip !== 'boolean' || !['scale', 'props', false].includes(result.resize))
54
+ throw new TypeError('Invalid component flip or resize rule');
55
+ if (
56
+ result.resize === 'props'
57
+ ? !isSize(result.width) || !isSize(result.height)
58
+ : result.width !== undefined || result.height !== undefined
59
+ )
60
+ throw new TypeError("Default sizes need resize: 'props' and positive finite numbers");
61
+ definitions.set(Constructor, Object.freeze(result));
62
+ }
63
+
64
+ /** A saved size of a `resize: 'props'` component, in user units. */
65
+ export const isSize = (value: unknown): value is number =>
66
+ typeof value === 'number' && Number.isFinite(value) && value > 0;
67
+
68
+ export function validateProp(
69
+ value: unknown,
70
+ initial: PropValue,
71
+ options: PropOptions,
72
+ name: string,
73
+ ): asserts value is PropValue {
74
+ if (value !== null && !['string', 'number', 'boolean'].includes(typeof value))
75
+ throw new TypeError(`Invalid component prop: ${name}`);
76
+ if (value !== null && initial !== null && typeof value !== typeof initial)
77
+ throw new TypeError(`Wrong type for component prop: ${name}`);
78
+ if (
79
+ typeof value === 'number' &&
80
+ (!Number.isFinite(value) ||
81
+ value < (options.min ?? -Infinity) ||
82
+ value > (options.max ?? Infinity))
83
+ )
84
+ throw new RangeError(`Component prop is outside its limits: ${name}`);
85
+ if (options.options && !options.options.includes(value as PropValue))
86
+ throw new RangeError(`Unknown option for component prop: ${name}`);
87
+ }
88
+
89
+ const sides: readonly unknown[] = ['left', 'right', 'top', 'bottom'];
90
+
91
+ /** Checks what `ports()` returned and freezes a copy with only the known fields. */
92
+ export function validatePorts(ports: unknown): readonly PortDefinition[] {
93
+ if (!Array.isArray(ports)) throw new TypeError('Component ports() must return an array');
94
+ const names = new Set<string>();
95
+ return Object.freeze(
96
+ ports.map((port: Partial<PortDefinition> | null) => {
97
+ const { name, x, y, side } = port ?? {};
98
+ if (typeof name !== 'string' || !name)
99
+ throw new TypeError('Component port names must be non-empty strings');
100
+ if (names.has(name)) throw new TypeError(`Duplicate component port: ${name}`);
101
+ names.add(name);
102
+ if (!Number.isFinite(x) || !Number.isFinite(y))
103
+ throw new RangeError(`Component port ${name} needs finite x and y`);
104
+ if (side !== undefined && !sides.includes(side))
105
+ throw new TypeError(`Unknown side for component port ${name}: ${String(side)}`);
106
+ return Object.freeze(
107
+ side === undefined ? { name, x: x!, y: y! } : { name, x: x!, y: y!, side },
108
+ );
109
+ }),
110
+ );
111
+ }
@@ -0,0 +1,79 @@
1
+ import type { AnyNode } from '../core/types.js';
2
+ import type { ParentNode } from '../mixins/types.js';
3
+ import type { ElementClass } from '../services/types.js';
4
+ import { Guards } from '../mixins/guards.js';
5
+ import { ComponentChanges } from './changes.js';
6
+ import { Component } from './component.js';
7
+ import { definition } from './metadata.js';
8
+ import type { ComponentClass, ComponentDefinition } from './types.js';
9
+
10
+ /** Per-drawing type registration, palette metadata, icons and unknown-type warnings. */
11
+ export class ComponentTypes {
12
+ private readonly types = new Map<string, ComponentClass>();
13
+ private readonly warned = new Set<string>();
14
+ private readonly seen = new WeakSet<AnyNode>();
15
+ private readonly stop: () => void;
16
+ constructor(
17
+ private readonly drawing: AnyNode,
18
+ types: readonly ComponentClass[] = [],
19
+ ) {
20
+ for (const Constructor of types) this.register(Constructor);
21
+ this.stop = ComponentChanges.for(drawing).discover({
22
+ added: (node) => this.inspect(node),
23
+ identityChanged: (node) => this.warn(node),
24
+ });
25
+ drawing.onDispose(() => this.destroy());
26
+ this.inspect(drawing);
27
+ }
28
+ private inspect(node: AnyNode): void {
29
+ if (this.seen.has(node) || node.isDestroyed) return;
30
+ this.seen.add(node);
31
+ this.warn(node);
32
+ if (Guards.isParent(node)) for (const child of node.children) this.inspect(child);
33
+ }
34
+ private warn(node: AnyNode): void {
35
+ const type = node.getAttributeValue('data-rasam-component');
36
+ if (type !== undefined && !(node instanceof Component) && !this.warned.has(String(type))) {
37
+ this.warned.add(String(type));
38
+ console.warn(`Unknown rasam component type: ${type}; loaded as a plain group`);
39
+ }
40
+ }
41
+ register(Constructor: ComponentClass): this {
42
+ const metadata = definition(Constructor),
43
+ previous = this.types.get(metadata.type);
44
+ if (previous && previous !== Constructor)
45
+ throw new Error(`Component type already registered: ${metadata.type}`);
46
+ this.drawing.context.registry.register(Constructor as ElementClass);
47
+ this.types.set(metadata.type, Constructor);
48
+ return this;
49
+ }
50
+ list(category?: string): readonly ComponentDefinition[] {
51
+ return Object.freeze(
52
+ [...this.types.values()]
53
+ .map((Constructor) => definition(Constructor))
54
+ .filter((metadata) => category === undefined || metadata.category === category)
55
+ .sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)),
56
+ );
57
+ }
58
+ get(type: string): ComponentClass | undefined {
59
+ return this.types.get(type);
60
+ }
61
+ icon(type: string, parent: ParentNode): Component {
62
+ const Constructor = this.get(type);
63
+ if (!Constructor) throw new TypeError(`Unknown component type: ${type}`);
64
+ return parent.create(Constructor as ElementClass<{}, Component>, {});
65
+ }
66
+ resetState(): void {
67
+ for (const node of this.drawing.descendants()) if (node instanceof Component) node.resetState();
68
+ }
69
+ destroy(): void {
70
+ this.stop();
71
+ }
72
+ }
73
+
74
+ /** Compose with selection's existing filter to keep rendered children closed. */
75
+ export function isComponentChild(node: AnyNode): boolean {
76
+ for (let parent = node.parent; parent; parent = parent.parent)
77
+ if (parent instanceof Component) return true;
78
+ return false;
79
+ }
@@ -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);
@@ -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
- for (const original of copies.keys())
28
- if (source.context.registry.get(original.el.localName) !== original.constructor)
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.get(element.localName) as ElementClass & {
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
- const nodes = roots.map((element) => owner.context.registry.adopt(element));
94
- for (const node of owner.context.registry.nodes)
95
- if (!before.has(node))
96
- for (const name of ['tabindex', 'role']) {
97
- const value = node.el.getAttribute(name);
98
- if (value !== null) {
99
- node.defineAttribute(name);
100
- node.setAttributeValue(name, value);
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
- return finish(nodes);
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],