@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
@@ -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)),
@@ -58,8 +68,8 @@ function applyStateChange(
58
68
  },
59
69
  };
60
70
  }
61
- if (change.domain === 'bounds') return { ...snapshot, contributesToBounds: value as boolean };
62
- return { ...snapshot, [change.domain]: value };
71
+ const field = change.domain === 'bounds' ? 'contributesToBounds' : change.domain;
72
+ return { ...snapshot, [field]: value };
63
73
  }
64
74
 
65
75
  /** Validates the whole patch before writing; rolls back logical state on failure. */
@@ -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
  );
@@ -174,18 +186,37 @@ function validateGraph(
174
186
  ): void {
175
187
  const context = owner.context;
176
188
  const removed = new Set([...states].filter(([, state]) => !state).map(([target]) => target.node));
189
+ let ids: Map<unknown, AnyNode | null> | undefined;
190
+ const siblingCounts = new Map<NodeHandle, number>();
191
+ for (const state of states.values()) {
192
+ const parent = state?.placement.parent;
193
+ if (!parent) continue;
194
+ let count = siblingCounts.get(parent);
195
+ if (count === undefined) {
196
+ count = 0;
197
+ if (!parent.node.isDestroyed && 'children' in parent.node)
198
+ for (const node of (parent.node as ParentNode).children)
199
+ if (!states.has(context.changes.handle(node))) count++;
200
+ }
201
+ siblingCounts.set(parent, count + 1);
202
+ }
177
203
  for (const [target, state] of states) {
178
204
  if (!state) continue;
179
- if (context.registry.get(state.constructor.tag) !== state.constructor)
205
+ if (context.registry.registered(state.constructor) !== state.constructor)
180
206
  throw new ChangeConflictError(`Constructor is not registered: ${state.constructor.tag}`);
181
207
  const id = state.properties.id?.value;
182
- if (
183
- id !== undefined &&
184
- context.registry.nodes.some(
185
- (node) => node !== target.node && !removed.has(node) && node.getAttributeValue('id') === id,
186
- )
187
- )
188
- throw new ChangeConflictError(`SVG ID is already in use: ${id}`);
208
+ if (id !== undefined) {
209
+ if (!ids) {
210
+ ids = new Map();
211
+ for (const node of context.registry.nodes) {
212
+ const value = node.getAttributeValue('id');
213
+ if (value !== undefined && !removed.has(node))
214
+ ids.set(value, ids.has(value) ? null : node);
215
+ }
216
+ }
217
+ if (ids.has(id) && ids.get(id) !== target.node)
218
+ throw new ChangeConflictError(`SVG ID is already in use: ${id}`);
219
+ }
189
220
  if (!target.node.isDestroyed) {
190
221
  for (const [attribute, property] of Object.entries(state.properties))
191
222
  target.node.validateAttribute(attribute, property.value);
@@ -209,22 +240,10 @@ function validateGraph(
209
240
  throw new ChangeConflictError('The reference value and its planned target ID disagree');
210
241
  }
211
242
  if (state.placement.parent) {
212
- const parent = state.placement.parent;
213
- const existing =
214
- !parent.node.isDestroyed && 'children' in parent.node
215
- ? (parent.node as ParentNode).children.map((node) => context.changes.handle(node))
216
- : [];
217
- const children = new Set(
218
- existing.filter(
219
- (child) => !states.has(child) || states.get(child)?.placement.parent === parent,
220
- ),
221
- );
222
- for (const [child, planned] of states)
223
- if (planned?.placement.parent === parent) children.add(child);
224
243
  if (
225
244
  !Number.isInteger(state.placement.index) ||
226
245
  state.placement.index < 0 ||
227
- state.placement.index >= children.size
246
+ state.placement.index >= siblingCounts.get(state.placement.parent)!
228
247
  )
229
248
  throw new ChangeConflictError('The history sibling position is no longer available');
230
249
  }
@@ -258,10 +277,19 @@ function validateGraph(
258
277
  throw new ChangeConflictError('A removed node would retain live typed references');
259
278
  }
260
279
 
280
+ /** Replay restores exact prior trees, including children that their class owns. */
261
281
  function applyPrepared(
262
282
  owner: AnyNode,
263
283
  states: Map<NodeHandle, ModelSnapshot | null>,
264
284
  domains: Map<NodeHandle, Set<string>>,
285
+ ): void {
286
+ withInternalStructure(() => applyStates(owner, states, domains));
287
+ }
288
+
289
+ function applyStates(
290
+ owner: AnyNode,
291
+ states: Map<NodeHandle, ModelSnapshot | null>,
292
+ domains: Map<NodeHandle, Set<string>>,
265
293
  ): void {
266
294
  const context = owner.context;
267
295
  const recreated = new Set<NodeHandle>();
@@ -321,28 +349,41 @@ function applyPrepared(
321
349
  domains.get(target)!.has('existence')),
322
350
  );
323
351
  for (const [target] of placements) if (target.node.parent) target.node.remove();
324
- const pending = [...placements];
325
- while (pending.length) {
326
- const ready = pending.filter(
327
- ([, state]) =>
328
- !state!.placement.parent || !pending.some(([target]) => target === state!.placement.parent),
352
+ const positions = new Map(placements.map(([target], index) => [target, index]));
353
+ const dependants = new Map<NodeHandle, typeof placements>();
354
+ let ready: typeof placements = [];
355
+ for (const entry of placements) {
356
+ const parent = entry[1]!.placement.parent;
357
+ if (!parent || !positions.has(parent)) ready.push(entry);
358
+ else {
359
+ let children = dependants.get(parent);
360
+ if (!children) dependants.set(parent, (children = []));
361
+ children.push(entry);
362
+ }
363
+ }
364
+ // Preflight has already rejected parent cycles.
365
+ while (ready.length) {
366
+ ready.sort(
367
+ (a, b) =>
368
+ a[1]!.placement.index - b[1]!.placement.index ||
369
+ positions.get(a[0])! - positions.get(b[0])!,
329
370
  );
330
- if (!ready.length) throw new ChangeConflictError('Cyclic placements');
331
- ready.sort((a, b) => a[1]!.placement.index - b[1]!.placement.index);
332
- for (const entry of ready) {
333
- const [target, state] = entry;
371
+ const next: typeof placements = [];
372
+ for (const [target, state] of ready) {
334
373
  const parent = state!.placement.parent?.node as ParentNode | undefined;
335
374
  if (parent) {
336
375
  parent[validateChild](target.node);
337
376
  parent.add(target.node, Math.min(state!.placement.index, parent.children.length));
338
377
  }
339
- pending.splice(pending.indexOf(entry), 1);
378
+ for (const child of dependants.get(target) ?? []) next.push(child);
340
379
  }
380
+ ready = next;
341
381
  }
342
382
  // Parent order is authoritative across compound edits and separate edit callbacks.
343
383
  for (const [target, state] of states)
344
384
  if (
345
385
  state?.children &&
386
+ !ownsDerived(state.constructor) &&
346
387
  (recreated.has(target) ||
347
388
  domains.get(target)!.has('children') ||
348
389
  domains.get(target)!.has('existence'))
@@ -371,5 +412,6 @@ function applyPrepared(
371
412
  );
372
413
  const roots = [...retired].filter((node) => !node.parent || !retired.has(node.parent));
373
414
  if (roots.length) owner[destroySubtrees](roots);
415
+ for (const target of recreated) initializeNode(target.node);
374
416
  context.bounds.invalidate();
375
417
  }
@@ -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;
@@ -391,8 +405,7 @@ export class ChangeService {
391
405
  this.delivering = true;
392
406
  const failures: unknown[] = [];
393
407
  try {
394
- while (this.queue.length) {
395
- const next = this.queue.shift()!;
408
+ for (const next of this.queue) {
396
409
  for (const listener of [...this.listeners]) {
397
410
  if (!this.listeners.has(listener)) continue;
398
411
  try {
@@ -403,6 +416,7 @@ export class ChangeService {
403
416
  }
404
417
  }
405
418
  } finally {
419
+ this.queue.length = 0;
406
420
  this.delivering = false;
407
421
  }
408
422
  if (failures.length === 1 && failures[0] instanceof ChangeConflictError) throw failures[0];
@@ -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
  }
@@ -93,25 +93,27 @@ export function diffState(
93
93
  if (!sameState(previous, value))
94
94
  changes.push({ target, domain: 'property', attribute, before: previous, after: value });
95
95
  }
96
- if (transform && before.transform && after.transform)
97
- changes.push({ target, domain: 'transform', before: before.transform, after: after.transform });
98
- if (!sameState(before.content, after.content) && before.content && after.content)
99
- changes.push({ target, domain: 'content', before: before.content, after: after.content });
100
- if (!sameState(before.presentation, after.presentation))
101
- changes.push({
102
- target,
103
- domain: 'presentation',
104
- before: before.presentation,
105
- after: after.presentation,
106
- });
107
- if (!sameState(before.custom, after.custom))
108
- changes.push({ target, domain: 'custom', before: before.custom, after: after.custom });
109
- if (!sameState(before.native, after.native) && before.native && after.native)
110
- changes.push({ target, domain: 'native', before: before.native, after: after.native });
111
- if (!sameState(before.placement, after.placement))
112
- changes.push({ target, domain: 'placement', before: before.placement, after: after.placement });
113
- if (!sameState(before.children, after.children) && before.children && after.children)
114
- changes.push({ target, domain: 'children', before: before.children, after: after.children });
96
+ for (const domain of [
97
+ 'transform',
98
+ 'content',
99
+ 'presentation',
100
+ 'custom',
101
+ 'native',
102
+ 'placement',
103
+ 'children',
104
+ ] as const) {
105
+ const previous = before[domain],
106
+ value = after[domain];
107
+ if (
108
+ domain !== 'presentation' &&
109
+ domain !== 'custom' &&
110
+ domain !== 'placement' &&
111
+ (!previous || !value)
112
+ )
113
+ continue;
114
+ if (domain === 'transform' ? transform : !sameState(previous, value))
115
+ changes.push({ target, domain, before: previous, after: value } as ModelChange);
116
+ }
115
117
  if (before.contributesToBounds !== after.contributesToBounds)
116
118
  changes.push({
117
119
  target,
@@ -138,7 +140,7 @@ export function combineChanges(changes: readonly ModelChange[]): readonly ModelC
138
140
  for (const [target, records] of lifecycle) {
139
141
  const first = records.findIndex((change) => change.domain === 'existence');
140
142
  if (first < 0) {
141
- normalized.push(...records);
143
+ for (const record of records) normalized.push(record);
142
144
  continue;
143
145
  }
144
146
  let last = records.length - 1;
@@ -160,14 +162,22 @@ export function combineChanges(changes: readonly ModelChange[]): readonly ModelC
160
162
  }
161
163
  const orders = new Map<
162
164
  NodeHandle,
163
- { before: readonly NodeHandle[]; after: readonly NodeHandle[] }
165
+ { before: ReadonlyMap<NodeHandle, number>; after: ReadonlyMap<NodeHandle, number> }
164
166
  >();
167
+ const indices = (children: readonly NodeHandle[]) => {
168
+ const positions = new Map<NodeHandle, number>();
169
+ // Reverse traversal retains indexOf's first match even for repeated handles.
170
+ for (let index = children.length - 1; index >= 0; index--)
171
+ positions.set(children[index], index);
172
+ return positions;
173
+ };
165
174
  for (const change of merged.values()) {
166
- if (change.domain === 'children') orders.set(change.target, change);
175
+ if (change.domain === 'children')
176
+ orders.set(change.target, { before: indices(change.before), after: indices(change.after) });
167
177
  if (change.domain === 'existence' && (change.before?.children || change.after?.children))
168
178
  orders.set(change.target, {
169
- before: change.before?.children ?? [],
170
- after: change.after?.children ?? [],
179
+ before: indices(change.before?.children ?? []),
180
+ after: indices(change.after?.children ?? []),
171
181
  });
172
182
  }
173
183
  const placement = (
@@ -176,7 +186,7 @@ export function combineChanges(changes: readonly ModelChange[]): readonly ModelC
176
186
  edge: 'before' | 'after',
177
187
  ) => {
178
188
  const siblings = value.parent ? orders.get(value.parent)?.[edge] : undefined;
179
- return siblings ? { ...value, index: siblings.indexOf(target) } : value;
189
+ return siblings ? { ...value, index: siblings.get(target) ?? -1 } : value;
180
190
  };
181
191
  return Object.freeze(
182
192
  [...merged.values()]
@@ -234,9 +244,8 @@ function patchSnapshot(
234
244
  if (transform.transform !== null) properties.transform = { value: transform.transform };
235
245
  return immutable({ ...snapshot, properties, transform });
236
246
  }
237
- if (change.domain === 'bounds')
238
- return immutable({ ...snapshot, contributesToBounds: change[edge] });
239
- return immutable({ ...snapshot, [change.domain]: change[edge] });
247
+ const field = change.domain === 'bounds' ? 'contributesToBounds' : change.domain;
248
+ return immutable({ ...snapshot, [field]: change[edge] });
240
249
  }
241
250
 
242
251
  export const stateAdapter = Object.freeze({ capture: captureState, diff: diffState, immutable });
package/src/changes.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { AnyNode } from './core/types.js';
2
- import type { ChangeEvent, ChangeGroup, ChangeMetadata, ModelChange } from './changes/types.js';
2
+ import type {
3
+ ChangeEvent,
4
+ ChangeGroup,
5
+ ChangeMetadata,
6
+ ModelChange,
7
+ NodeHandle,
8
+ } from './changes/types.js';
3
9
  import { ChangeConflictError, ChangeObserverError } from './changes/types.js';
4
10
  import { combineChanges, stateAdapter } from './changes/state.js';
5
11
  import { restoreChanges } from './changes/restore.js';
@@ -64,14 +70,25 @@ export function beginChangeGroup(drawing: AnyNode, metadata: ChangeMetadata = {}
64
70
  drawing.assertLive();
65
71
  const owner = drawing.context.root!;
66
72
  let id: number | undefined;
67
- let changes: readonly ModelChange[] = [];
73
+ let changes: ModelChange[] = [];
74
+ const targets = new Set<NodeHandle>();
68
75
  let invalidated = false;
69
76
  // Subscribe before groupstart so edits made by start observers are captured too.
70
77
  const unsubscribe = observeChanges(drawing, (event) => {
71
78
  if (event.type === 'dispose') invalidated = true;
72
79
  if (id === undefined && event.type === 'groupstart') id = event.group;
73
- if (event.type === 'change' && event.group === id && !event.temporary)
74
- changes = combineChanges([...changes, ...event.changes]);
80
+ if (event.type !== 'change') return;
81
+ if (event.group === id && !event.temporary) {
82
+ for (const change of event.changes) {
83
+ changes.push(change);
84
+ targets.add(change.target);
85
+ }
86
+ } else if (event.changes.some((change) => targets.has(change.target))) {
87
+ // A net-zero field must release its old baseline before an outside edit.
88
+ changes = [...combineChanges(changes)];
89
+ targets.clear();
90
+ for (const change of changes) targets.add(change.target);
91
+ }
75
92
  });
76
93
  let group: ReturnType<typeof drawing.context.changes.group>;
77
94
  try {
@@ -91,6 +108,7 @@ export function beginChangeGroup(drawing: AnyNode, metadata: ChangeMetadata = {}
91
108
  const finish = (cancelled: boolean, restored = false) => {
92
109
  active = false;
93
110
  changes = [];
111
+ targets.clear();
94
112
  unsubscribe();
95
113
  group.end(cancelled, restored);
96
114
  };
@@ -117,7 +135,8 @@ export function beginChangeGroup(drawing: AnyNode, metadata: ChangeMetadata = {}
117
135
  const failures: unknown[] = [];
118
136
  try {
119
137
  try {
120
- if (changes.length) restoreChanges(owner, changes, 'undo');
138
+ const combined = combineChanges(changes);
139
+ if (combined.length) restoreChanges(owner, combined, 'undo');
121
140
  } catch (error) {
122
141
  if (error instanceof ChangeObserverError) failures.push(error);
123
142
  else throw error;
@@ -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
+ }