@rasamjs/core 0.1.2 → 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 (240) hide show
  1. package/API.md +29 -9
  2. package/CHANGELOG.md +98 -1
  3. package/COMPONENTS.md +229 -0
  4. package/README.md +178 -25
  5. package/RELEASING.md +31 -21
  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 +10 -2
  19. package/dist/changes/service.d.ts.map +1 -1
  20. package/dist/changes/service.js +45 -6
  21. package/dist/changes/service.js.map +1 -1
  22. package/dist/changes/types.d.ts +9 -0
  23. package/dist/changes/types.d.ts.map +1 -1
  24. package/dist/changes/types.js.map +1 -1
  25. package/dist/changes.d.ts +5 -3
  26. package/dist/changes.d.ts.map +1 -1
  27. package/dist/changes.js +103 -2
  28. package/dist/changes.js.map +1 -1
  29. package/dist/components/changes.d.ts +34 -0
  30. package/dist/components/changes.d.ts.map +1 -0
  31. package/dist/components/changes.js +119 -0
  32. package/dist/components/changes.js.map +1 -0
  33. package/dist/components/component.d.ts +62 -0
  34. package/dist/components/component.d.ts.map +1 -0
  35. package/dist/components/component.js +355 -0
  36. package/dist/components/component.js.map +1 -0
  37. package/dist/components/decorators.d.ts +7 -0
  38. package/dist/components/decorators.d.ts.map +1 -0
  39. package/dist/components/decorators.js +81 -0
  40. package/dist/components/decorators.js.map +1 -0
  41. package/dist/components/metadata.d.ts +10 -0
  42. package/dist/components/metadata.d.ts.map +1 -0
  43. package/dist/components/metadata.js +85 -0
  44. package/dist/components/metadata.js.map +1 -0
  45. package/dist/components/registry.d.ts +24 -0
  46. package/dist/components/registry.d.ts.map +1 -0
  47. package/dist/components/registry.js +78 -0
  48. package/dist/components/registry.js.map +1 -0
  49. package/dist/components/types.d.ts +54 -0
  50. package/dist/components/types.d.ts.map +1 -0
  51. package/dist/components/types.js +2 -0
  52. package/dist/components/types.js.map +1 -0
  53. package/dist/components/view.d.ts +9 -0
  54. package/dist/components/view.d.ts.map +1 -0
  55. package/dist/components/view.js +104 -0
  56. package/dist/components/view.js.map +1 -0
  57. package/dist/components.d.ts +6 -0
  58. package/dist/components.d.ts.map +1 -0
  59. package/dist/components.js +5 -0
  60. package/dist/components.js.map +1 -0
  61. package/dist/core/node.d.ts +7 -0
  62. package/dist/core/node.d.ts.map +1 -1
  63. package/dist/core/node.js +30 -0
  64. package/dist/core/node.js.map +1 -1
  65. package/dist/dom/images.d.ts.map +1 -1
  66. package/dist/dom/images.js +3 -1
  67. package/dist/dom/images.js.map +1 -1
  68. package/dist/elements/image.d.ts.map +1 -1
  69. package/dist/elements/image.js +4 -1
  70. package/dist/elements/image.js.map +1 -1
  71. package/dist/interchange/clone.d.ts.map +1 -1
  72. package/dist/interchange/clone.js +4 -3
  73. package/dist/interchange/clone.js.map +1 -1
  74. package/dist/interchange/dom.d.ts +3 -1
  75. package/dist/interchange/dom.d.ts.map +1 -1
  76. package/dist/interchange/dom.js +29 -5
  77. package/dist/interchange/dom.js.map +1 -1
  78. package/dist/interchange/markup.d.ts.map +1 -1
  79. package/dist/interchange/markup.js +71 -3
  80. package/dist/interchange/markup.js.map +1 -1
  81. package/dist/interchange/policy.d.ts.map +1 -1
  82. package/dist/interchange/policy.js +4 -0
  83. package/dist/interchange/policy.js.map +1 -1
  84. package/dist/interchange/reconstruct.d.ts.map +1 -1
  85. package/dist/interchange/reconstruct.js +16 -12
  86. package/dist/interchange/reconstruct.js.map +1 -1
  87. package/dist/interchange/references.d.ts.map +1 -1
  88. package/dist/interchange/references.js +10 -4
  89. package/dist/interchange/references.js.map +1 -1
  90. package/dist/interchange/types.d.ts +10 -1
  91. package/dist/interchange/types.d.ts.map +1 -1
  92. package/dist/mixins/parent-internals.d.ts +6 -0
  93. package/dist/mixins/parent-internals.d.ts.map +1 -1
  94. package/dist/mixins/parent-internals.js +34 -0
  95. package/dist/mixins/parent-internals.js.map +1 -1
  96. package/dist/mixins/parent.d.ts.map +1 -1
  97. package/dist/mixins/parent.js +6 -1
  98. package/dist/mixins/parent.js.map +1 -1
  99. package/dist/mixins/transformable.d.ts.map +1 -1
  100. package/dist/mixins/transformable.js +25 -27
  101. package/dist/mixins/transformable.js.map +1 -1
  102. package/dist/mixins/types.d.ts +2 -2
  103. package/dist/mixins/types.d.ts.map +1 -1
  104. package/dist/raster/frame.d.ts +12 -0
  105. package/dist/raster/frame.d.ts.map +1 -0
  106. package/dist/raster/frame.js +43 -0
  107. package/dist/raster/frame.js.map +1 -0
  108. package/dist/raster/images.d.ts +2 -0
  109. package/dist/raster/images.d.ts.map +1 -0
  110. package/dist/raster/images.js +60 -0
  111. package/dist/raster/images.js.map +1 -0
  112. package/dist/raster/render.d.ts +3 -0
  113. package/dist/raster/render.d.ts.map +1 -0
  114. package/dist/raster/render.js +40 -0
  115. package/dist/raster/render.js.map +1 -0
  116. package/dist/raster/types.d.ts +10 -0
  117. package/dist/raster/types.d.ts.map +1 -0
  118. package/dist/raster/types.js +2 -0
  119. package/dist/raster/types.js.map +1 -0
  120. package/dist/raster.d.ts +6 -0
  121. package/dist/raster.d.ts.map +1 -0
  122. package/dist/raster.js +10 -0
  123. package/dist/raster.js.map +1 -0
  124. package/dist/resources/references.d.ts +2 -0
  125. package/dist/resources/references.d.ts.map +1 -1
  126. package/dist/resources/references.js +3 -1
  127. package/dist/resources/references.js.map +1 -1
  128. package/dist/services/catalog.d.ts.map +1 -1
  129. package/dist/services/catalog.js +5 -3
  130. package/dist/services/catalog.js.map +1 -1
  131. package/dist/services/defs-manager.d.ts +2 -0
  132. package/dist/services/defs-manager.d.ts.map +1 -1
  133. package/dist/services/defs-manager.js +4 -0
  134. package/dist/services/defs-manager.js.map +1 -1
  135. package/dist/services/element-key.d.ts +4 -0
  136. package/dist/services/element-key.d.ts.map +1 -0
  137. package/dist/services/element-key.js +12 -0
  138. package/dist/services/element-key.js.map +1 -0
  139. package/dist/services/initialization.d.ts +7 -0
  140. package/dist/services/initialization.d.ts.map +1 -0
  141. package/dist/services/initialization.js +27 -0
  142. package/dist/services/initialization.js.map +1 -0
  143. package/dist/services/registry.d.ts +8 -0
  144. package/dist/services/registry.d.ts.map +1 -1
  145. package/dist/services/registry.js +45 -10
  146. package/dist/services/registry.js.map +1 -1
  147. package/dist/services/types.d.ts +17 -0
  148. package/dist/services/types.d.ts.map +1 -1
  149. package/dist/services/viewport.d.ts +2 -0
  150. package/dist/services/viewport.d.ts.map +1 -1
  151. package/dist/services/viewport.js +4 -3
  152. package/dist/services/viewport.js.map +1 -1
  153. package/dist/template/parts.d.ts +12 -6
  154. package/dist/template/parts.d.ts.map +1 -1
  155. package/dist/template/parts.js +36 -29
  156. package/dist/template/parts.js.map +1 -1
  157. package/dist/template/render.d.ts +6 -7
  158. package/dist/template/render.d.ts.map +1 -1
  159. package/dist/template/render.js +188 -44
  160. package/dist/template/render.js.map +1 -1
  161. package/dist/template/svg-tag.d.ts +5 -1
  162. package/dist/template/svg-tag.d.ts.map +1 -1
  163. package/dist/template/svg-tag.js.map +1 -1
  164. package/dist/text/content.d.ts +0 -2
  165. package/dist/text/content.d.ts.map +1 -1
  166. package/dist/text/content.js +4 -10
  167. package/dist/text/content.js.map +1 -1
  168. package/dist/text/internals.d.ts +1 -0
  169. package/dist/text/internals.d.ts.map +1 -1
  170. package/dist/text/policies.d.ts +7 -0
  171. package/dist/text/policies.d.ts.map +1 -0
  172. package/dist/text/policies.js +13 -0
  173. package/dist/text/policies.js.map +1 -0
  174. package/dist/types/common.d.ts +27 -0
  175. package/dist/types/common.d.ts.map +1 -1
  176. package/package.json +156 -143
  177. package/reports/adoption.json +20 -20
  178. package/reports/animation-benchmark.json +616 -616
  179. package/reports/benchmark.json +65 -65
  180. package/reports/components-benchmark-before.json +10 -0
  181. package/reports/components-benchmark.json +10 -0
  182. package/reports/creation.json +26 -26
  183. package/reports/interchange-benchmark.json +85 -85
  184. package/reports/public-api-contract.json +353 -23
  185. package/reports/public-api-review.md +23 -0
  186. package/reports/size-components.json +102 -0
  187. package/reports/size-parity.json +82 -34
  188. package/reports/size.json +6 -4
  189. package/reports/svgjs-comparison.json +3466 -3466
  190. package/reports/svgjs-comparison.md +19 -19
  191. package/reports/svgjs-parity-current.json +177 -60
  192. package/src/advanced.ts +1 -1
  193. package/src/catalog/fragment.ts +2 -2
  194. package/src/changes/derived.ts +19 -0
  195. package/src/changes/restore.ts +27 -4
  196. package/src/changes/service.ts +60 -11
  197. package/src/changes/types.ts +9 -0
  198. package/src/changes.ts +93 -4
  199. package/src/components/changes.ts +131 -0
  200. package/src/components/component.ts +401 -0
  201. package/src/components/decorators.ts +104 -0
  202. package/src/components/metadata.ts +111 -0
  203. package/src/components/registry.ts +79 -0
  204. package/src/components/types.ts +66 -0
  205. package/src/components/view.ts +123 -0
  206. package/src/components.ts +15 -0
  207. package/src/core/node.ts +32 -1
  208. package/src/dom/images.ts +2 -1
  209. package/src/elements/image.ts +4 -1
  210. package/src/interchange/clone.ts +4 -3
  211. package/src/interchange/dom.ts +34 -5
  212. package/src/interchange/markup.ts +77 -3
  213. package/src/interchange/policy.ts +6 -0
  214. package/src/interchange/reconstruct.ts +16 -12
  215. package/src/interchange/references.ts +12 -4
  216. package/src/interchange/types.ts +10 -1
  217. package/src/mixins/parent-internals.ts +37 -0
  218. package/src/mixins/parent.ts +13 -4
  219. package/src/mixins/transformable.ts +23 -25
  220. package/src/mixins/types.ts +2 -2
  221. package/src/raster/frame.ts +53 -0
  222. package/src/raster/images.ts +73 -0
  223. package/src/raster/render.ts +45 -0
  224. package/src/raster/types.ts +9 -0
  225. package/src/raster.ts +13 -0
  226. package/src/resources/references.ts +5 -1
  227. package/src/services/catalog.ts +5 -3
  228. package/src/services/defs-manager.ts +5 -0
  229. package/src/services/element-key.ts +14 -0
  230. package/src/services/initialization.ts +25 -0
  231. package/src/services/registry.ts +47 -10
  232. package/src/services/types.ts +21 -0
  233. package/src/services/viewport.ts +5 -4
  234. package/src/template/parts.ts +54 -39
  235. package/src/template/render.ts +211 -54
  236. package/src/template/svg-tag.ts +6 -1
  237. package/src/text/content.ts +4 -14
  238. package/src/text/internals.ts +1 -0
  239. package/src/text/policies.ts +18 -0
  240. package/src/types/common.ts +29 -0
package/src/advanced.ts CHANGED
@@ -11,7 +11,7 @@ export { Namespace } from './dom/namespace.js';
11
11
  export { DrawingContext } from './services/context.js';
12
12
  export { Scheduler } from './services/scheduler.js';
13
13
  export { Registry } from './services/registry.js';
14
- export type { ElementClass } from './services/types.js';
14
+ export type { ElementClass, ElementVariant } from './services/types.js';
15
15
  export { BoundsService } from './services/bounds.js';
16
16
  export { DefsManager } from './services/defs-manager.js';
17
17
 
@@ -1,6 +1,6 @@
1
1
  import { Group } from '../elements/group.js';
2
2
  import type { AnyNode } from '../core/types.js';
3
- import type { ElementClass } from '../services/types.js';
3
+ import type { ElementClass, CreationOptions } from '../services/types.js';
4
4
  import type { ParentNode } from '../mixins/types.js';
5
5
  import { validateChild, validateIndex } from '../mixins/parent-internals.js';
6
6
  import { destroySubtrees } from '../core/destruction.js';
@@ -32,7 +32,7 @@ export class SvgFragment {
32
32
  }
33
33
  create<Options, Child extends AnyNode>(
34
34
  Constructor: ElementClass<Options, Child>,
35
- options: Options,
35
+ options: NoInfer<CreationOptions<Options, Child>>,
36
36
  ): Child {
37
37
  return this.#host.create(Constructor, options);
38
38
  }
@@ -0,0 +1,19 @@
1
+ import type { AnyNode } from '../core/types.js';
2
+ import type { ModelChange } from './types.js';
3
+
4
+ /** Marks a class whose children are drawn from its own state, such as a component. */
5
+ export const derivesChildren = Symbol('derivesChildren');
6
+ const derived = new WeakSet<AnyNode>();
7
+
8
+ export const ownsDerived = (Constructor: unknown): boolean =>
9
+ !!(Constructor as { [derivesChildren]?: boolean } | undefined)?.[derivesChildren];
10
+
11
+ /** Records the owner's current descendants as derived. The owner redraws them after replay. */
12
+ export function markDerived(owner: AnyNode): void {
13
+ for (const node of owner.descendants()) if (node !== owner) derived.add(node);
14
+ }
15
+
16
+ /** Replay skips derived nodes and their owners' child lists. */
17
+ export const isDerivedChange = (change: ModelChange): boolean =>
18
+ derived.has(change.target.node) ||
19
+ (change.domain === 'children' && ownsDerived(change.target.node.constructor));
@@ -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
  }
@@ -4,6 +4,7 @@ import type {
4
4
  ChangeEvent,
5
5
  ChangeGroup,
6
6
  ChangeMetadata,
7
+ ChangeDerivation,
7
8
  ModelChange,
8
9
  ModelSnapshot,
9
10
  NodeHandle,
@@ -15,9 +16,14 @@ interface ChangeAdapter {
15
16
  diff(target: NodeHandle, before: ModelSnapshot, after: ModelSnapshot): ModelChange[];
16
17
  immutable<Value>(value: Value): Value;
17
18
  }
19
+ interface ChildOrder {
20
+ readonly nodes: readonly AnyNode[];
21
+ readonly indices: ReadonlyMap<AnyNode, number>;
22
+ }
18
23
 
19
24
  export class ChangeService {
20
25
  private readonly listeners = new Set<(event: ChangeEvent) => void>();
26
+ readonly derivations = new Set<(event: ChangeDerivation) => void>();
21
27
  private readonly identities = new WeakMap<AnyNode, NodeHandle>();
22
28
  private readonly retained = new WeakSet<AnyNode>();
23
29
  private readonly queue: ChangeEvent[] = [];
@@ -39,26 +45,32 @@ export class ChangeService {
39
45
  }[] = [];
40
46
  private adapter?: ChangeAdapter;
41
47
  private validationCounts?: ReadonlyMap<AnyNode, number>;
42
- private childOrders = new Map<
43
- AnyNode,
44
- { nodes: readonly AnyNode[]; indices: ReadonlyMap<AnyNode, number> }
45
- >();
48
+ private childOrders = new Map<AnyNode, ChildOrder>();
49
+ private liveOrders = new WeakMap<AnyNode, ChildOrder>();
46
50
 
47
51
  constructor(private readonly context: DrawingContext) {}
48
52
 
49
53
  get observed(): boolean {
50
- return this.listeners.size > 0;
54
+ return this.listeners.size > 0 || this.derivations.size > 0;
51
55
  }
52
56
  get replaying(): boolean {
53
57
  return this.replayDepth > 0;
54
58
  }
59
+ /** The surrounding synchronous edit, if one is already grouping model writes. */
60
+ get currentGroup(): number | undefined {
61
+ return this.metadata.group;
62
+ }
55
63
 
56
64
  children(node: AnyNode): readonly AnyNode[] | undefined {
57
65
  if (!('children' in node)) return undefined;
58
66
  let order = this.childOrders.get(node);
59
67
  if (!order) {
60
- const nodes = [...(node as AnyNode & { children: readonly AnyNode[] }).children];
61
- 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
+ }
62
74
  this.childOrders.set(node, order);
63
75
  }
64
76
  return order.nodes;
@@ -73,6 +85,10 @@ export class ChangeService {
73
85
  resetOrders(): void {
74
86
  this.childOrders = new Map();
75
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
+ }
76
92
 
77
93
  reading<Result>(callback: () => Result): Result {
78
94
  const previous = this.capturing;
@@ -194,6 +210,20 @@ export class ChangeService {
194
210
  try {
195
211
  if (node) this.touch(node);
196
212
  result = callback();
213
+ if (this.before.size && this.derivations.size) {
214
+ const nodes = [...this.before]
215
+ .filter(
216
+ ([target, previous]) =>
217
+ target.node.isDestroyed ||
218
+ this.adapter!.diff(target, previous, this.adapter!.capture(target.node)).length,
219
+ )
220
+ .map(([target]) => target.node);
221
+ if (nodes.length) {
222
+ const event = { ...this.metadata, nodes };
223
+ for (const derive of [...this.derivations])
224
+ if (this.derivations.has(derive)) derive(event);
225
+ }
226
+ }
197
227
  } catch (error) {
198
228
  failure = { error };
199
229
  }
@@ -266,13 +296,22 @@ export class ChangeService {
266
296
  }
267
297
  }
268
298
 
269
- group(metadata: ChangeMetadata = {}): ChangeGroup {
299
+ group(metadata: ChangeMetadata = {}): Omit<ChangeGroup, 'cancel'> & {
300
+ end(cancelled?: boolean, restored?: boolean): void;
301
+ } {
270
302
  const id = ++this.nextGroup,
271
303
  source = metadata.source ?? 'application';
272
304
  let active = true;
273
305
  this.groups.add(id);
274
306
  try {
275
- this.publish(Object.freeze({ type: 'groupstart', group: id, source }));
307
+ this.publish(
308
+ Object.freeze({
309
+ type: 'groupstart',
310
+ group: id,
311
+ source,
312
+ ...(metadata.label === undefined ? {} : { label: metadata.label }),
313
+ }),
314
+ );
276
315
  } catch (error) {
277
316
  this.groups.delete(id);
278
317
  this.publish(Object.freeze({ type: 'groupend', group: id, source, cancelled: true }));
@@ -284,11 +323,19 @@ export class ChangeService {
284
323
  if (!active) throw new Error('Change group has ended');
285
324
  return this.scope({ ...metadata, group: id }, callback, null);
286
325
  },
287
- end: (cancelled = false) => {
326
+ end: (cancelled = false, restored = false) => {
288
327
  if (!active) return;
289
328
  active = false;
290
329
  this.groups.delete(id);
291
- this.publish(Object.freeze({ type: 'groupend', group: id, source, cancelled }));
330
+ this.publish(
331
+ Object.freeze({
332
+ type: 'groupend',
333
+ group: id,
334
+ source,
335
+ cancelled,
336
+ ...(restored ? { restored: true } : {}),
337
+ }),
338
+ );
292
339
  },
293
340
  });
294
341
  }
@@ -378,11 +425,13 @@ export class ChangeService {
378
425
 
379
426
  destroy(): void {
380
427
  this.listeners.clear();
428
+ this.derivations.clear();
381
429
  this.queue.length = 0;
382
430
  this.notifications.length = 0;
383
431
  this.replacements.length = 0;
384
432
  this.groups.clear();
385
433
  this.owner = null;
386
434
  this.childOrders.clear();
435
+ this.liveOrders = new WeakMap();
387
436
  }
388
437
  }
@@ -104,9 +104,14 @@ export type ModelChange =
104
104
 
105
105
  export interface ChangeMetadata {
106
106
  readonly source?: string;
107
+ readonly label?: string;
107
108
  readonly temporary?: boolean;
108
109
  readonly group?: number;
109
110
  }
111
+ /** Runs synchronously after an operation's writes, before its final change snapshot. */
112
+ export interface ChangeDerivation extends ChangeMetadata {
113
+ readonly nodes: readonly AnyNode[];
114
+ }
110
115
  export interface ChangeBatch {
111
116
  readonly type: 'change';
112
117
  readonly id: number;
@@ -126,7 +131,9 @@ export type ChangeEvent =
126
131
  readonly type: 'groupstart' | 'groupend';
127
132
  readonly group: number;
128
133
  readonly source: string;
134
+ readonly label?: string;
129
135
  readonly cancelled?: boolean;
136
+ readonly restored?: boolean;
130
137
  }
131
138
  | {
132
139
  readonly type: 'dispose' | 'willdispose';
@@ -136,6 +143,8 @@ export interface ChangeGroup {
136
143
  readonly id: number;
137
144
  run<Result>(callback: () => Result): Result;
138
145
  end(cancelled?: boolean): void;
146
+ /** Restore this group's own non-temporary changes, then end it as cancelled. */
147
+ cancel(): void;
139
148
  }
140
149
 
141
150
  export class ChangeConflictError extends Error {
package/src/changes.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import type { AnyNode } from './core/types.js';
2
- import type { ChangeEvent, ChangeMetadata } from './changes/types.js';
3
- import { stateAdapter } from './changes/state.js';
2
+ import type { ChangeEvent, ChangeGroup, ChangeMetadata, ModelChange } from './changes/types.js';
3
+ import { ChangeConflictError, ChangeObserverError } from './changes/types.js';
4
+ import { combineChanges, stateAdapter } from './changes/state.js';
5
+ import { restoreChanges } from './changes/restore.js';
4
6
 
5
7
  export type {
6
8
  ChangeBatch,
7
9
  ChangeEvent,
8
10
  ChangeGroup,
9
11
  ChangeMetadata,
12
+ ChangeDerivation,
10
13
  ModelChange,
11
14
  ModelSnapshot,
12
15
  ModelFeatures,
@@ -35,6 +38,20 @@ export function observeChanges(
35
38
  drawing.assertLive();
36
39
  return drawing.context.changes.observe(listener, stateAdapter);
37
40
  }
41
+ /** Derived writes join the same operation and group. Callbacks never run during restoration. */
42
+ export function deriveChanges(
43
+ drawing: AnyNode,
44
+ derive: (event: import('./changes/types.js').ChangeDerivation) => void,
45
+ ): () => void {
46
+ drawing.assertLive();
47
+ if (typeof derive !== 'function') throw new TypeError('Change derivation must be a function');
48
+ drawing.context.changes.configure(stateAdapter);
49
+ const callbacks = drawing.context.changes.derivations;
50
+ callbacks.add(derive);
51
+ return () => {
52
+ callbacks.delete(derive);
53
+ };
54
+ }
38
55
  export function withChanges<Result>(
39
56
  drawing: AnyNode,
40
57
  metadata: ChangeMetadata,
@@ -43,9 +60,81 @@ export function withChanges<Result>(
43
60
  drawing.assertLive();
44
61
  return drawing.context.changes.scope(metadata, callback, drawing);
45
62
  }
46
- export function beginChangeGroup(drawing: AnyNode, metadata: ChangeMetadata = {}) {
63
+ export function beginChangeGroup(drawing: AnyNode, metadata: ChangeMetadata = {}): ChangeGroup {
47
64
  drawing.assertLive();
48
- return drawing.context.changes.group(metadata);
65
+ const owner = drawing.context.root!;
66
+ let id: number | undefined;
67
+ let changes: readonly ModelChange[] = [];
68
+ let invalidated = false;
69
+ // Subscribe before groupstart so edits made by start observers are captured too.
70
+ const unsubscribe = observeChanges(drawing, (event) => {
71
+ if (event.type === 'dispose') invalidated = true;
72
+ 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]);
75
+ });
76
+ let group: ReturnType<typeof drawing.context.changes.group>;
77
+ try {
78
+ group = drawing.context.changes.group(metadata);
79
+ id = group.id;
80
+ } catch (error) {
81
+ unsubscribe();
82
+ throw error;
83
+ }
84
+ let active = true;
85
+ let cancelling = false;
86
+ let depth = 0;
87
+ const assertNotDisposed = () => {
88
+ if (invalidated)
89
+ throw new ChangeConflictError('Permanent disposal invalidated the change group');
90
+ };
91
+ const finish = (cancelled: boolean, restored = false) => {
92
+ active = false;
93
+ changes = [];
94
+ unsubscribe();
95
+ group.end(cancelled, restored);
96
+ };
97
+ return Object.freeze({
98
+ id: group.id,
99
+ run<Result>(callback: () => Result): Result {
100
+ if (!active || cancelling) throw new Error('Change group has ended or is cancelling');
101
+ assertNotDisposed();
102
+ depth++;
103
+ try {
104
+ return group.run(callback);
105
+ } finally {
106
+ depth--;
107
+ }
108
+ },
109
+ end(cancelled = false) {
110
+ if (active && !cancelling) finish(cancelled);
111
+ },
112
+ cancel() {
113
+ if (!active || cancelling) return;
114
+ if (depth) throw new Error('Finish the change callback before cancelling its group');
115
+ assertNotDisposed();
116
+ cancelling = true;
117
+ const failures: unknown[] = [];
118
+ try {
119
+ try {
120
+ if (changes.length) restoreChanges(owner, changes, 'undo');
121
+ } catch (error) {
122
+ if (error instanceof ChangeObserverError) failures.push(error);
123
+ else throw error;
124
+ }
125
+ try {
126
+ finish(true, true);
127
+ } catch (error) {
128
+ failures.push(error);
129
+ }
130
+ } finally {
131
+ cancelling = false;
132
+ }
133
+ if (failures.length === 1) throw failures[0];
134
+ if (failures.length)
135
+ throw new AggregateError(failures, 'Change group cancellation observers failed');
136
+ },
137
+ });
49
138
  }
50
139
 
51
140
  /** One recorder per context; ordinary change observers are unrestricted. */
@@ -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
+ }