@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.
Files changed (178) hide show
  1. package/API.md +14 -4
  2. package/CHANGELOG.md +71 -0
  3. package/COMPONENTS.md +229 -0
  4. package/README.md +46 -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 +76 -28
  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 +18 -4
  21. package/dist/changes/service.js.map +1 -1
  22. package/dist/changes/state.d.ts.map +1 -1
  23. package/dist/changes/state.js +33 -27
  24. package/dist/changes/state.js.map +1 -1
  25. package/dist/changes.d.ts.map +1 -1
  26. package/dist/changes.js +20 -4
  27. package/dist/changes.js.map +1 -1
  28. package/dist/components/changes.d.ts +34 -0
  29. package/dist/components/changes.d.ts.map +1 -0
  30. package/dist/components/changes.js +119 -0
  31. package/dist/components/changes.js.map +1 -0
  32. package/dist/components/component.d.ts +62 -0
  33. package/dist/components/component.d.ts.map +1 -0
  34. package/dist/components/component.js +355 -0
  35. package/dist/components/component.js.map +1 -0
  36. package/dist/components/decorators.d.ts +7 -0
  37. package/dist/components/decorators.d.ts.map +1 -0
  38. package/dist/components/decorators.js +83 -0
  39. package/dist/components/decorators.js.map +1 -0
  40. package/dist/components/metadata.d.ts +10 -0
  41. package/dist/components/metadata.d.ts.map +1 -0
  42. package/dist/components/metadata.js +85 -0
  43. package/dist/components/metadata.js.map +1 -0
  44. package/dist/components/registry.d.ts +24 -0
  45. package/dist/components/registry.d.ts.map +1 -0
  46. package/dist/components/registry.js +78 -0
  47. package/dist/components/registry.js.map +1 -0
  48. package/dist/components/types.d.ts +54 -0
  49. package/dist/components/types.d.ts.map +1 -0
  50. package/dist/components/types.js +2 -0
  51. package/dist/components/types.js.map +1 -0
  52. package/dist/components/view.d.ts +9 -0
  53. package/dist/components/view.d.ts.map +1 -0
  54. package/dist/components/view.js +104 -0
  55. package/dist/components/view.js.map +1 -0
  56. package/dist/components.d.ts +6 -0
  57. package/dist/components.d.ts.map +1 -0
  58. package/dist/components.js +5 -0
  59. package/dist/components.js.map +1 -0
  60. package/dist/core/node.d.ts +7 -0
  61. package/dist/core/node.d.ts.map +1 -1
  62. package/dist/core/node.js +30 -0
  63. package/dist/core/node.js.map +1 -1
  64. package/dist/interchange/clone.d.ts.map +1 -1
  65. package/dist/interchange/clone.js +3 -2
  66. package/dist/interchange/clone.js.map +1 -1
  67. package/dist/interchange/reconstruct.d.ts.map +1 -1
  68. package/dist/interchange/reconstruct.js +16 -12
  69. package/dist/interchange/reconstruct.js.map +1 -1
  70. package/dist/interchange/references.d.ts.map +1 -1
  71. package/dist/interchange/references.js +10 -4
  72. package/dist/interchange/references.js.map +1 -1
  73. package/dist/mixins/parent-internals.d.ts +6 -0
  74. package/dist/mixins/parent-internals.d.ts.map +1 -1
  75. package/dist/mixins/parent-internals.js +34 -0
  76. package/dist/mixins/parent-internals.js.map +1 -1
  77. package/dist/mixins/parent.d.ts.map +1 -1
  78. package/dist/mixins/parent.js +6 -1
  79. package/dist/mixins/parent.js.map +1 -1
  80. package/dist/mixins/types.d.ts +2 -2
  81. package/dist/mixins/types.d.ts.map +1 -1
  82. package/dist/resources/references.d.ts +2 -0
  83. package/dist/resources/references.d.ts.map +1 -1
  84. package/dist/resources/references.js +3 -3
  85. package/dist/resources/references.js.map +1 -1
  86. package/dist/services/catalog.d.ts.map +1 -1
  87. package/dist/services/catalog.js +5 -3
  88. package/dist/services/catalog.js.map +1 -1
  89. package/dist/services/claims.d.ts +7 -0
  90. package/dist/services/claims.d.ts.map +1 -0
  91. package/dist/services/claims.js +11 -0
  92. package/dist/services/claims.js.map +1 -0
  93. package/dist/services/defs-manager.d.ts +2 -0
  94. package/dist/services/defs-manager.d.ts.map +1 -1
  95. package/dist/services/defs-manager.js +4 -0
  96. package/dist/services/defs-manager.js.map +1 -1
  97. package/dist/services/element-key.d.ts +4 -0
  98. package/dist/services/element-key.d.ts.map +1 -0
  99. package/dist/services/element-key.js +12 -0
  100. package/dist/services/element-key.js.map +1 -0
  101. package/dist/services/initialization.d.ts +7 -0
  102. package/dist/services/initialization.d.ts.map +1 -0
  103. package/dist/services/initialization.js +27 -0
  104. package/dist/services/initialization.js.map +1 -0
  105. package/dist/services/registry.d.ts +8 -0
  106. package/dist/services/registry.d.ts.map +1 -1
  107. package/dist/services/registry.js +47 -10
  108. package/dist/services/registry.js.map +1 -1
  109. package/dist/services/types.d.ts +17 -0
  110. package/dist/services/types.d.ts.map +1 -1
  111. package/dist/template/parts.d.ts +12 -6
  112. package/dist/template/parts.d.ts.map +1 -1
  113. package/dist/template/parts.js +36 -29
  114. package/dist/template/parts.js.map +1 -1
  115. package/dist/template/render.d.ts +6 -7
  116. package/dist/template/render.d.ts.map +1 -1
  117. package/dist/template/render.js +188 -44
  118. package/dist/template/render.js.map +1 -1
  119. package/dist/template/svg-tag.d.ts +5 -1
  120. package/dist/template/svg-tag.d.ts.map +1 -1
  121. package/dist/template/svg-tag.js.map +1 -1
  122. package/dist/text/internals.d.ts +1 -0
  123. package/dist/text/internals.d.ts.map +1 -1
  124. package/dist/types/common.d.ts +27 -0
  125. package/dist/types/common.d.ts.map +1 -1
  126. package/package.json +10 -1
  127. package/reports/adoption.json +20 -20
  128. package/reports/animation-benchmark.json +613 -613
  129. package/reports/benchmark.json +27 -27
  130. package/reports/change-groups-benchmark.json +471 -0
  131. package/reports/components-benchmark-before.json +10 -0
  132. package/reports/components-benchmark.json +10 -0
  133. package/reports/creation.json +26 -26
  134. package/reports/interchange-benchmark.json +85 -85
  135. package/reports/phase-3-core-after.json +40 -0
  136. package/reports/phase-3-core-before.json +40 -0
  137. package/reports/public-api-contract.json +335 -17
  138. package/reports/size-components.json +103 -0
  139. package/reports/size-parity.json +50 -34
  140. package/reports/size.json +7 -4
  141. package/reports/svgjs-comparison.json +3049 -3049
  142. package/reports/svgjs-comparison.md +15 -15
  143. package/reports/svgjs-parity-current.json +133 -46
  144. package/src/advanced.ts +1 -1
  145. package/src/catalog/fragment.ts +2 -2
  146. package/src/changes/derived.ts +19 -0
  147. package/src/changes/restore.ts +78 -36
  148. package/src/changes/service.ts +23 -8
  149. package/src/changes/state.ts +37 -28
  150. package/src/changes.ts +24 -5
  151. package/src/components/changes.ts +131 -0
  152. package/src/components/component.ts +401 -0
  153. package/src/components/decorators.ts +106 -0
  154. package/src/components/metadata.ts +111 -0
  155. package/src/components/registry.ts +79 -0
  156. package/src/components/types.ts +66 -0
  157. package/src/components/view.ts +123 -0
  158. package/src/components.ts +15 -0
  159. package/src/core/node.ts +32 -1
  160. package/src/interchange/clone.ts +3 -2
  161. package/src/interchange/reconstruct.ts +16 -12
  162. package/src/interchange/references.ts +12 -4
  163. package/src/mixins/parent-internals.ts +37 -0
  164. package/src/mixins/parent.ts +13 -4
  165. package/src/mixins/types.ts +2 -2
  166. package/src/resources/references.ts +5 -5
  167. package/src/services/catalog.ts +5 -3
  168. package/src/services/claims.ts +14 -0
  169. package/src/services/defs-manager.ts +5 -0
  170. package/src/services/element-key.ts +14 -0
  171. package/src/services/initialization.ts +25 -0
  172. package/src/services/registry.ts +49 -10
  173. package/src/services/types.ts +21 -0
  174. package/src/template/parts.ts +54 -39
  175. package/src/template/render.ts +211 -54
  176. package/src/template/svg-tag.ts +6 -1
  177. package/src/text/internals.ts +1 -0
  178. 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);
@@ -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],
@@ -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
- const targets = new Set(node.references.values());
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 {
@@ -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 { assertCanAddChildren, validateChild, validateIndex } from './parent-internals.js';
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
  }
@@ -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
  }
@@ -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 previous = pending.get(Constructor.tag) ?? registry.get(Constructor.tag);
9
+ const key = elementKey(Constructor);
10
+ const previous = pending.get(key) ?? registry.registered(Constructor);
9
11
  if (previous && previous !== Constructor)
10
- throw new Error(`Tag already registered: ${Constructor.tag}`);
11
- pending.set(Constructor.tag, Constructor);
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
+ }