@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
@@ -1,9 +1,11 @@
1
1
  import type { AnyNode } from '../core/types.js';
2
2
  import type { ParentNode } from '../mixins/types.js';
3
3
  import { destroySubtrees } from '../core/destruction.js';
4
- import { validateChild } from '../mixins/parent-internals.js';
4
+ import { validateChild, withInternalStructure } from '../mixins/parent-internals.js';
5
5
  import { readTransformState } from '../mixins/transform-state.js';
6
6
  import { referenceValue } from '../services/reference-value.js';
7
+ import { initializeNode } from '../services/initialization.js';
8
+ import { isDerivedChange, ownsDerived } from './derived.js';
7
9
  import { captureState, sameState, stateAdapter, combineChanges } from './state.js';
8
10
  import {
9
11
  ChangeConflictError,
@@ -28,6 +30,14 @@ function currentValue(snapshot: ModelSnapshot | null, change: ModelChange): unkn
28
30
  return snapshot[change.domain];
29
31
  }
30
32
 
33
+ /** An owner's derived child list is redrawn, not restored, so conflicts ignore it. */
34
+ function comparable(value: unknown, change: ModelChange): unknown {
35
+ const snapshot = value as ModelSnapshot | null;
36
+ return change.domain === 'existence' && snapshot && ownsDerived(snapshot.constructor)
37
+ ? { ...snapshot, children: undefined }
38
+ : value;
39
+ }
40
+
31
41
  function contentFeatures(content: ContentState): ModelFeatures['content'] {
32
42
  return {
33
43
  parts: content.parts.map((part) => (typeof part === 'string' ? part : part.node)),
@@ -80,7 +90,8 @@ function applyChanges(
80
90
  direction: 'undo' | 'redo',
81
91
  ): void {
82
92
  const service = owner.context.changes;
83
- changes = combineChanges(changes);
93
+ // An owner redraws its derived children after replay, from the state replay restored.
94
+ changes = combineChanges(changes).filter((change) => !isDerivedChange(change));
84
95
  service.resetOrders();
85
96
  service.configure(stateAdapter);
86
97
  try {
@@ -97,7 +108,8 @@ function applyChanges(
97
108
  domains.set(change.target, new Set());
98
109
  }
99
110
  const expected = direction === 'undo' ? change.after : change.before;
100
- if (!sameState(currentValue(before.get(change.target)!, change), expected))
111
+ const current = currentValue(before.get(change.target)!, change);
112
+ if (!sameState(comparable(current, change), comparable(expected, change)))
101
113
  throw new ChangeConflictError(
102
114
  `Conflicting ${change.domain} state on node ${change.target.id}`,
103
115
  );
@@ -176,7 +188,7 @@ function validateGraph(
176
188
  const removed = new Set([...states].filter(([, state]) => !state).map(([target]) => target.node));
177
189
  for (const [target, state] of states) {
178
190
  if (!state) continue;
179
- if (context.registry.get(state.constructor.tag) !== state.constructor)
191
+ if (context.registry.registered(state.constructor) !== state.constructor)
180
192
  throw new ChangeConflictError(`Constructor is not registered: ${state.constructor.tag}`);
181
193
  const id = state.properties.id?.value;
182
194
  if (
@@ -258,10 +270,19 @@ function validateGraph(
258
270
  throw new ChangeConflictError('A removed node would retain live typed references');
259
271
  }
260
272
 
273
+ /** Replay restores exact prior trees, including children that their class owns. */
261
274
  function applyPrepared(
262
275
  owner: AnyNode,
263
276
  states: Map<NodeHandle, ModelSnapshot | null>,
264
277
  domains: Map<NodeHandle, Set<string>>,
278
+ ): void {
279
+ withInternalStructure(() => applyStates(owner, states, domains));
280
+ }
281
+
282
+ function applyStates(
283
+ owner: AnyNode,
284
+ states: Map<NodeHandle, ModelSnapshot | null>,
285
+ domains: Map<NodeHandle, Set<string>>,
265
286
  ): void {
266
287
  const context = owner.context;
267
288
  const recreated = new Set<NodeHandle>();
@@ -343,6 +364,7 @@ function applyPrepared(
343
364
  for (const [target, state] of states)
344
365
  if (
345
366
  state?.children &&
367
+ !ownsDerived(state.constructor) &&
346
368
  (recreated.has(target) ||
347
369
  domains.get(target)!.has('children') ||
348
370
  domains.get(target)!.has('existence'))
@@ -371,5 +393,6 @@ function applyPrepared(
371
393
  );
372
394
  const roots = [...retired].filter((node) => !node.parent || !retired.has(node.parent));
373
395
  if (roots.length) owner[destroySubtrees](roots);
396
+ for (const target of recreated) initializeNode(target.node);
374
397
  context.bounds.invalidate();
375
398
  }
@@ -16,6 +16,10 @@ interface ChangeAdapter {
16
16
  diff(target: NodeHandle, before: ModelSnapshot, after: ModelSnapshot): ModelChange[];
17
17
  immutable<Value>(value: Value): Value;
18
18
  }
19
+ interface ChildOrder {
20
+ readonly nodes: readonly AnyNode[];
21
+ readonly indices: ReadonlyMap<AnyNode, number>;
22
+ }
19
23
 
20
24
  export class ChangeService {
21
25
  private readonly listeners = new Set<(event: ChangeEvent) => void>();
@@ -41,10 +45,8 @@ export class ChangeService {
41
45
  }[] = [];
42
46
  private adapter?: ChangeAdapter;
43
47
  private validationCounts?: ReadonlyMap<AnyNode, number>;
44
- private childOrders = new Map<
45
- AnyNode,
46
- { nodes: readonly AnyNode[]; indices: ReadonlyMap<AnyNode, number> }
47
- >();
48
+ private childOrders = new Map<AnyNode, ChildOrder>();
49
+ private liveOrders = new WeakMap<AnyNode, ChildOrder>();
48
50
 
49
51
  constructor(private readonly context: DrawingContext) {}
50
52
 
@@ -54,13 +56,21 @@ export class ChangeService {
54
56
  get replaying(): boolean {
55
57
  return this.replayDepth > 0;
56
58
  }
59
+ /** The surrounding synchronous edit, if one is already grouping model writes. */
60
+ get currentGroup(): number | undefined {
61
+ return this.metadata.group;
62
+ }
57
63
 
58
64
  children(node: AnyNode): readonly AnyNode[] | undefined {
59
65
  if (!('children' in node)) return undefined;
60
66
  let order = this.childOrders.get(node);
61
67
  if (!order) {
62
- const nodes = [...(node as AnyNode & { children: readonly AnyNode[] }).children];
63
- order = { nodes, indices: new Map(nodes.map((child, index) => [child, index])) };
68
+ order = this.liveOrders.get(node);
69
+ if (!order) {
70
+ const nodes = [...(node as AnyNode & { children: readonly AnyNode[] }).children];
71
+ order = { nodes, indices: new Map(nodes.map((child, index) => [child, index])) };
72
+ this.liveOrders.set(node, order);
73
+ }
64
74
  this.childOrders.set(node, order);
65
75
  }
66
76
  return order.nodes;
@@ -75,6 +85,10 @@ export class ChangeService {
75
85
  resetOrders(): void {
76
86
  this.childOrders = new Map();
77
87
  }
88
+ /** Keep the operation's original order, but invalidate the live order after a tree edit. */
89
+ invalidateOrder(node: AnyNode): void {
90
+ this.liveOrders.delete(node);
91
+ }
78
92
 
79
93
  reading<Result>(callback: () => Result): Result {
80
94
  const previous = this.capturing;
@@ -418,5 +432,6 @@ export class ChangeService {
418
432
  this.groups.clear();
419
433
  this.owner = null;
420
434
  this.childOrders.clear();
435
+ this.liveOrders = new WeakMap();
421
436
  }
422
437
  }
@@ -0,0 +1,131 @@
1
+ import type { AnyNode } from '../core/types.js';
2
+ import type { DrawingContext } from '../services/context.js';
3
+ import { deriveChanges, observeChanges, withChanges, type ChangeBatch } from '../changes.js';
4
+
5
+ export interface Binding {
6
+ readonly node: AnyNode;
7
+ derive(): void;
8
+ replay(): void;
9
+ paint(): void;
10
+ }
11
+ interface Discovery {
12
+ added(node: AnyNode): void;
13
+ identityChanged(node: AnyNode): void;
14
+ }
15
+ const drawings = new WeakMap<DrawingContext, ComponentChanges>();
16
+
17
+ /** Routes work by changed node, independent of the number of components in a drawing. */
18
+ export class ComponentChanges {
19
+ private readonly bindings = new WeakMap<AnyNode, Binding>();
20
+ private readonly discovery = new Set<Discovery>();
21
+ private bindingCount = 0;
22
+ private readonly dirty = new Set<Binding>();
23
+ private cancelFrame?: () => void;
24
+ private readonly stopDerive: () => void;
25
+ private readonly stopObserve: () => void;
26
+ private readonly context: DrawingContext;
27
+
28
+ static for(node: AnyNode): ComponentChanges {
29
+ let changes = drawings.get(node.context);
30
+ if (!changes) {
31
+ changes = new ComponentChanges(node);
32
+ drawings.set(node.context, changes);
33
+ }
34
+ return changes;
35
+ }
36
+
37
+ private constructor(drawing: AnyNode) {
38
+ this.context = drawing.context;
39
+ this.stopDerive = deriveChanges(drawing, ({ nodes }) => {
40
+ for (const node of nodes) this.bindings.get(node)?.derive();
41
+ });
42
+ this.stopObserve = observeChanges(drawing, (event) => {
43
+ if (event.type === 'change') this.dispatch(event);
44
+ });
45
+ }
46
+
47
+ bind(binding: Binding): () => void {
48
+ if (this.bindings.has(binding.node)) throw new Error('Component is already observing changes');
49
+ this.bindings.set(binding.node, binding);
50
+ this.bindingCount++;
51
+ return this.release(() => {
52
+ this.bindings.delete(binding.node);
53
+ this.dirty.delete(binding);
54
+ this.bindingCount--;
55
+ });
56
+ }
57
+
58
+ discover(discovery: Discovery): () => void {
59
+ this.discovery.add(discovery);
60
+ return this.release(() => this.discovery.delete(discovery));
61
+ }
62
+
63
+ private release(cleanup: () => void): () => void {
64
+ let active = true;
65
+ return () => {
66
+ if (!active) return;
67
+ active = false;
68
+ cleanup();
69
+ if (!this.bindingCount && !this.discovery.size) {
70
+ this.cancelFrame?.();
71
+ this.stopDerive();
72
+ this.stopObserve();
73
+ drawings.delete(this.context);
74
+ }
75
+ };
76
+ }
77
+
78
+ /** Live state from every instance renders in one shared frame, as one temporary change. */
79
+ schedule(binding: Binding): void {
80
+ this.dirty.add(binding);
81
+ this.cancelFrame ??= this.context.dom.requestFrame(() => {
82
+ this.cancelFrame = undefined;
83
+ this.paint(this.dirty);
84
+ });
85
+ }
86
+
87
+ /** Renders without an undo step; failures surface after every other instance rendered. */
88
+ paint(bindings: Iterable<Binding>): void {
89
+ const list = [...bindings].filter((binding) => !binding.node.isDestroyed);
90
+ for (const binding of list) this.dirty.delete(binding);
91
+ if (!list.length) return;
92
+ const failures: unknown[] = [];
93
+ withChanges(list[0].node, { source: 'components', temporary: true }, () => {
94
+ for (const binding of list)
95
+ try {
96
+ binding.paint();
97
+ } catch (error) {
98
+ failures.push(error);
99
+ }
100
+ });
101
+ if (failures.length > 1) throw new AggregateError(failures, 'Component renders failed');
102
+ if (failures.length) throw failures[0];
103
+ }
104
+
105
+ private dispatch(event: ChangeBatch): void {
106
+ // Replay skips rendered children, so restored and recreated instances redraw together.
107
+ const replayed = new Set<Binding>();
108
+ for (const change of event.changes) {
109
+ const node = change.target.node,
110
+ binding = this.bindings.get(node);
111
+ if (
112
+ binding &&
113
+ event.source === 'replay' &&
114
+ ((change.domain === 'property' && change.attribute === 'data-rasam-props') ||
115
+ (change.domain === 'existence' && change.after))
116
+ )
117
+ replayed.add(binding);
118
+ if (!this.discovery.size) continue;
119
+ if (
120
+ (change.domain === 'placement' && change.after.parent && !change.before.parent) ||
121
+ (change.domain === 'existence' && change.after)
122
+ )
123
+ for (const subscriber of this.discovery) subscriber.added(node);
124
+ else if (change.domain === 'property' && change.attribute === 'data-rasam-component')
125
+ for (const subscriber of this.discovery) subscriber.identityChanged(node);
126
+ }
127
+ if (!replayed.size) return;
128
+ for (const binding of replayed) binding.replay();
129
+ this.paint(replayed);
130
+ }
131
+ }
@@ -0,0 +1,401 @@
1
+ import { Group } from '../elements/group.js';
2
+ import type { DrawingContext } from '../services/context.js';
3
+ import type { SvgTemplate } from '../template/svg-tag.js';
4
+ import { beginChangeGroup } from '../changes.js';
5
+ import { derivesChildren, markDerived } from '../changes/derived.js';
6
+ import { initializeElement } from '../services/initialization.js';
7
+ import { definition, isSize, validatePorts, validateProp } from './metadata.js';
8
+ import { ComponentView } from './view.js';
9
+ import { ComponentChanges } from './changes.js';
10
+ import { Attributes } from '../dom/attributes.js';
11
+ import type { AttributeValue, Length, PortDefinition } from '../types/common.js';
12
+ import type { creationOptions } from '../services/types.js';
13
+ import type {
14
+ ActionDefinition,
15
+ ComponentDefinition,
16
+ ComponentProps,
17
+ PropDefinition,
18
+ PropOptions,
19
+ PropValue,
20
+ ComponentFields,
21
+ } from './types.js';
22
+
23
+ export const installField = Symbol('installComponentField');
24
+ export const assertField = Symbol('assertComponentField');
25
+ export const editComponent = Symbol('editComponent');
26
+ export const registerAction = Symbol('registerComponentAction');
27
+ const restoredHooks = new WeakSet<Component>();
28
+ interface Field {
29
+ readonly kind: 'prop' | 'state';
30
+ readonly initial: unknown;
31
+ readonly options: PropOptions;
32
+ value: unknown;
33
+ }
34
+ const sizes = ['width', 'height'] as const;
35
+ const copy = <Value>(value: Value): Value =>
36
+ value && typeof value === 'object' ? structuredClone(value) : value;
37
+
38
+ /** A closed rasam group whose children are rendered from decorated props and live state. */
39
+ export abstract class Component extends Group {
40
+ declare readonly [creationOptions]: ComponentFields<this>;
41
+ static override readonly ownsChildren = true;
42
+ /** @internal History restores props; the component redraws its children after replay. */
43
+ static readonly [derivesChildren] = true;
44
+ readonly #fields = new Map<string, Field>();
45
+ readonly #view = new ComponentView(this);
46
+ #initialized = false;
47
+ #constructing = true;
48
+ #schedule?: () => void;
49
+ #observedProps: ComponentProps = {};
50
+ #propsCache?: { raw: string | number | undefined; props: ComponentProps };
51
+ #propDefinitions?: readonly PropDefinition[];
52
+ readonly #actions = new Map<string, string>();
53
+ #ports?: { props: ComponentProps; ports: readonly PortDefinition[] };
54
+ #readingPorts = false;
55
+
56
+ readonly #input: ComponentProps;
57
+ readonly #reserved: ReadonlySet<string>;
58
+
59
+ constructor(context: DrawingContext, input: ComponentProps = {}, existing?: SVGGElement) {
60
+ super(context, {}, existing);
61
+ this.#input = input;
62
+ this.#reserved = new Set(Object.getOwnPropertyNames(this));
63
+ }
64
+
65
+ get componentDefinition(): ComponentDefinition {
66
+ return definition(this.constructor);
67
+ }
68
+ get editRules() {
69
+ const { rotate, flip, resize } = this.componentDefinition;
70
+ return Object.freeze({ rotate, flip, resize });
71
+ }
72
+ get propDefinitions(): readonly PropDefinition[] {
73
+ return (this.#propDefinitions ??= Object.freeze(
74
+ [...this.#fields]
75
+ .filter(([, field]) => field.kind === 'prop')
76
+ .map(([name, field]) =>
77
+ Object.freeze({ ...field.options, name, initial: field.initial as PropValue }),
78
+ ),
79
+ ));
80
+ }
81
+
82
+ /** `@action` methods with their undo labels, in declaration order; overrides relabel. */
83
+ get actionDefinitions(): readonly ActionDefinition[] {
84
+ return Object.freeze([...this.#actions].map(([name, label]) => Object.freeze({ name, label })));
85
+ }
86
+
87
+ /** @internal The `@action` decorator records each method once per instance. */
88
+ [registerAction](name: string, label: string): void {
89
+ this.#actions.set(name, label);
90
+ }
91
+
92
+ render(): SvgTemplate {
93
+ throw new TypeError('Components must implement render()');
94
+ }
95
+
96
+ /**
97
+ * Override to declare connection points, in local coordinates at 0°, from props and
98
+ * the saved size. Live state never moves a port. Defaults to none.
99
+ */
100
+ ports(): readonly PortDefinition[] {
101
+ return [];
102
+ }
103
+
104
+ /** Validated, frozen `ports()`, computed again only when the saved props change. */
105
+ get portDefinitions(): readonly PortDefinition[] {
106
+ const props = this.#readProps();
107
+ if (this.#ports?.props === props) return this.#ports.ports;
108
+ this.#readingPorts = true;
109
+ try {
110
+ const ports = validatePorts(this.ports());
111
+ this.#ports = { props, ports };
112
+ return ports;
113
+ } finally {
114
+ this.#readingPorts = false;
115
+ }
116
+ }
117
+ created(): void {}
118
+ destroyed(): void {}
119
+ changed(_name: string): void {}
120
+
121
+ override validateAttribute(name: string, value: AttributeValue): void {
122
+ super.validateAttribute(name, value);
123
+ if (
124
+ Attributes.normalizeName(name) === 'data-rasam-props' &&
125
+ #initialized in this &&
126
+ this.#initialized
127
+ )
128
+ this.#assertProps(parseProps(value));
129
+ }
130
+
131
+ override setAttributeValue(name: string, value: AttributeValue): this {
132
+ if (restoredHooks.has(this) && Attributes.normalizeName(name) === 'data-rasam-props')
133
+ return this;
134
+ return super.setAttributeValue(name, value);
135
+ }
136
+
137
+ override attrs(values: Parameters<Group['attrs']>[0]): this {
138
+ if (restoredHooks.has(this))
139
+ values = Object.fromEntries(
140
+ Object.entries(values).filter(
141
+ ([name]) => Attributes.normalizeName(name) !== 'data-rasam-props',
142
+ ),
143
+ );
144
+ return super.attrs(values);
145
+ }
146
+
147
+ /** `resize: 'props'` components keep their size as saved `width` and `height` props. */
148
+ get #sized(): boolean {
149
+ return this.componentDefinition.resize === 'props';
150
+ }
151
+
152
+ override width(): Length | null;
153
+ override width(value: Length): this;
154
+ override width(value?: Length): Length | null | this {
155
+ if (!this.#sized) return value === undefined ? super.width() : super.width(value);
156
+ return value === undefined ? this.#size('width') : this.size(value, this.#size('height'));
157
+ }
158
+ override height(): Length | null;
159
+ override height(value: Length): this;
160
+ override height(value?: Length): Length | null | this {
161
+ if (!this.#sized) return value === undefined ? super.height() : super.height(value);
162
+ return value === undefined ? this.#size('height') : this.size(this.#size('width'), value);
163
+ }
164
+
165
+ /** For `resize: 'props'`, one undo step that redraws at once, so callers read new bounds. */
166
+ override size(width: Length, height?: Length): this {
167
+ if (!this.#sized) return super.size(width, height);
168
+ this.assertLive();
169
+ const next = {
170
+ width: toSize(width),
171
+ height: height === undefined ? undefined : toSize(height),
172
+ };
173
+ const target = {
174
+ width: next.width,
175
+ height: next.height ?? (next.width * this.#size('height')) / this.#size('width'),
176
+ };
177
+ if (restoredHooks.has(this)) return this;
178
+ if (target.width === this.#size('width') && target.height === this.#size('height')) return this;
179
+ this[editComponent](`Resize ${this.componentDefinition.name}`, () => {
180
+ this.setAttributeValue(
181
+ 'data-rasam-props',
182
+ JSON.stringify({ ...this.#readProps(), ...target }),
183
+ );
184
+ });
185
+ if (this.#initialized && this.#notifyProps()) this.#redraw();
186
+ return this;
187
+ }
188
+
189
+ #size(name: (typeof sizes)[number]): number {
190
+ const saved = this.#readProps()[name] ?? this.#input[name];
191
+ return isSize(saved) ? saved : this.componentDefinition[name]!;
192
+ }
193
+
194
+ resetState(): void {
195
+ this.assertLive();
196
+ for (const [name, field] of this.#fields)
197
+ if (field.kind === 'state') Reflect.set(this, name, copy(field.initial));
198
+ }
199
+
200
+ /** @internal Called after decorated fields and reconstructed children are available. */
201
+ [initializeElement](): void {
202
+ if (this.#initialized) return;
203
+ const metadata = this.componentDefinition;
204
+ const saved = this.#readProps();
205
+ const rawVersion = this.getAttributeValue('data-rasam-version');
206
+ const version = rawVersion === undefined ? metadata.version : Number(rawVersion);
207
+ if (!Number.isInteger(version) || version < 1 || version > metadata.version)
208
+ throw new TypeError(`Unsupported ${metadata.type} component version: ${rawVersion}`);
209
+ if (version < metadata.version && !metadata.migrate)
210
+ throw new TypeError(`No migration for ${metadata.type} version ${version}`);
211
+ const values = version < metadata.version ? metadata.migrate!(saved, version) : saved;
212
+ this.#assertProps(values);
213
+ this.#assertProps(this.#input);
214
+ const props: Record<string, PropValue> = {};
215
+ for (const [name, field] of this.#fields)
216
+ if (field.kind === 'prop') {
217
+ const value = Object.hasOwn(this.#input, name)
218
+ ? this.#input[name]
219
+ : Object.hasOwn(values, name)
220
+ ? values[name]
221
+ : field.initial;
222
+ validateProp(value, field.initial as PropValue, field.options, name);
223
+ props[name] = value;
224
+ }
225
+ if (metadata.resize === 'props')
226
+ for (const name of sizes)
227
+ props[name] = (this.#input[name] ?? values[name] ?? metadata[name]) as number;
228
+ this.#initialized = true;
229
+ if (!this.context.changes.replaying) {
230
+ this.setAttributeValue('data-rasam-version', metadata.version);
231
+ this.setAttributeValue('data-rasam-props', JSON.stringify(props));
232
+ this.#redraw();
233
+ }
234
+ this.#observedProps = this.#readProps();
235
+ const changes = ComponentChanges.for(this);
236
+ const binding = {
237
+ node: this,
238
+ derive: () => {
239
+ if (this.#notifyProps()) this.#redraw();
240
+ },
241
+ replay: () => {
242
+ this.#notifyProps();
243
+ },
244
+ paint: () => this.#redraw(),
245
+ };
246
+ const stop = changes.bind(binding);
247
+ this.#schedule = () => changes.schedule(binding);
248
+ this.onDispose(() => {
249
+ stop();
250
+ this.destroyed();
251
+ });
252
+ this.context.changes.local(() => {
253
+ if (this.adopting) restoredHooks.add(this);
254
+ try {
255
+ this.created();
256
+ } finally {
257
+ restoredHooks.delete(this);
258
+ this.#constructing = false;
259
+ }
260
+ // A newly constructed, detached node is not eligible for change derivation yet.
261
+ if (this.#readProps() !== this.#observedProps && this.#notifyProps()) this.#redraw();
262
+ });
263
+ }
264
+
265
+ /** @internal Field decorators install accessors after their initializers execute. */
266
+ [assertField](name: string): void {
267
+ if (name in Component.prototype || this.#fields.has(name) || this.#reserved.has(name))
268
+ throw new TypeError(`Component field conflicts with an inherited rasam member: ${name}`);
269
+ }
270
+
271
+ /** @internal Field decorators install accessors after their initializers execute. */
272
+ [installField](
273
+ name: string,
274
+ kind: 'prop' | 'state',
275
+ options: PropOptions,
276
+ initial: unknown,
277
+ ): void {
278
+ this[assertField](name);
279
+ if (kind === 'prop') validateProp(initial, initial as PropValue, options, name);
280
+ const field: Field = {
281
+ kind,
282
+ options: Object.freeze({
283
+ ...options,
284
+ ...(options.options ? { options: Object.freeze([...options.options]) } : {}),
285
+ }),
286
+ initial: copy(initial),
287
+ value: initial,
288
+ };
289
+ this.#fields.set(name, field);
290
+ this.#propDefinitions = undefined;
291
+ Object.defineProperty(this, name, {
292
+ enumerable: true,
293
+ configurable: false,
294
+ get: () => {
295
+ if (kind === 'state') {
296
+ if (this.#readingPorts) throw new TypeError(`ports() cannot read live state: ${name}`);
297
+ return field.value;
298
+ }
299
+ const props = this.#readProps();
300
+ return Object.hasOwn(props, name)
301
+ ? props[name]
302
+ : Object.hasOwn(this.#input, name)
303
+ ? this.#input[name]
304
+ : initial;
305
+ },
306
+ set: (value: unknown) => {
307
+ this.assertLive();
308
+ if (kind === 'state') {
309
+ if (Object.is(field.value, value)) return;
310
+ field.value = value;
311
+ if (this.#initialized) {
312
+ this.changed(name);
313
+ this.#schedule?.();
314
+ }
315
+ return;
316
+ }
317
+ if (restoredHooks.has(this)) return;
318
+ validateProp(value, initial as PropValue, options, name);
319
+ if (Object.is(Reflect.get(this, name), value)) return;
320
+ this[editComponent](`Set ${options.label ?? name}`, () => {
321
+ this.setAttributeValue(
322
+ 'data-rasam-props',
323
+ JSON.stringify({ ...this.#readProps(), [name]: value }),
324
+ );
325
+ });
326
+ },
327
+ });
328
+ }
329
+
330
+ /** @internal Actions share one change group across nested calls and instances. */
331
+ [editComponent]<Result>(label: string, callback: () => Result): Result {
332
+ this.assertLive();
333
+ if (this.#constructing || this.context.changes.currentGroup !== undefined) return callback();
334
+ const group = beginChangeGroup(this, { source: 'components', label });
335
+ try {
336
+ const result = group.run(callback);
337
+ if (result && typeof (result as PromiseLike<unknown>).then === 'function')
338
+ throw new TypeError('Component actions must be synchronous');
339
+ group.end();
340
+ return result;
341
+ } catch (error) {
342
+ try {
343
+ group.cancel();
344
+ } catch (rollback) {
345
+ throw new AggregateError([error, rollback], 'Component edit and rollback failed');
346
+ }
347
+ throw error;
348
+ }
349
+ }
350
+
351
+ #readProps(): ComponentProps {
352
+ // Retain ordinary field-read semantics for destroyed() and post-disposal inspection.
353
+ const raw = this.attributes.get('data-rasam-props');
354
+ if (this.#propsCache && this.#propsCache.raw === raw) return this.#propsCache.props;
355
+ this.#propsCache = { raw, props: Object.freeze(parseProps(raw)) };
356
+ return this.#propsCache.props;
357
+ }
358
+ #assertProps(props: ComponentProps): void {
359
+ if (!props || typeof props !== 'object' || Array.isArray(props))
360
+ throw new TypeError('Component props must be an object');
361
+ for (const [name, value] of Object.entries(props)) {
362
+ if (this.#sized && (sizes as readonly string[]).includes(name)) {
363
+ if (!isSize(value)) throw new RangeError(`Component ${name} must be a positive number`);
364
+ continue;
365
+ }
366
+ const field = this.#fields.get(name);
367
+ if (!field || field.kind !== 'prop') throw new TypeError(`Unknown component prop: ${name}`);
368
+ validateProp(value, field.initial as PropValue, field.options, name);
369
+ }
370
+ }
371
+ #notifyProps(): boolean {
372
+ const props = this.#readProps(),
373
+ previous = this.#observedProps;
374
+ if (props === previous) return false;
375
+ this.#observedProps = props;
376
+ const names = [...this.propDefinitions.map((field) => field.name), ...sizes].filter(
377
+ (name) => !Object.is(previous[name], props[name]),
378
+ );
379
+ for (const name of names) this.changed(name);
380
+ return names.length > 0;
381
+ }
382
+ #redraw(): void {
383
+ this.withOwnChildren(() => this.#view.render(this.render()));
384
+ markDerived(this);
385
+ }
386
+ }
387
+
388
+ /** Sizes are user units of at least 1, so a collapsing resize never reaches zero. */
389
+ function toSize(value: Length): number {
390
+ const size = typeof value === 'number' ? value : Number(value);
391
+ if (!Number.isFinite(size) || size < 0)
392
+ throw new RangeError('Component sizes must be finite non-negative numbers');
393
+ return Math.max(size, 1);
394
+ }
395
+
396
+ function parseProps(raw: AttributeValue): ComponentProps {
397
+ const props: unknown = raw == null ? {} : JSON.parse(String(raw));
398
+ if (!props || typeof props !== 'object' || Array.isArray(props))
399
+ throw new TypeError('Component props must be a JSON object');
400
+ return props as ComponentProps;
401
+ }