@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
@@ -0,0 +1,66 @@
1
+ import type { EditRules } from '../types/common.js';
2
+ import type { Component } from './component.js';
3
+ import type { DrawingContext } from '../services/context.js';
4
+ import type { ElementClass } from '../services/types.js';
5
+
6
+ export type PropValue = string | number | boolean | null;
7
+ export type ComponentProps = Readonly<Record<string, PropValue>>;
8
+
9
+ /** Standard decorators cannot distinguish props from other fields in the TS type system. */
10
+ export type ComponentFields<Instance extends Component> = {
11
+ [
12
+ Key in Exclude<keyof Instance, keyof Component> as Instance[Key] extends PropValue ? Key : never
13
+ ]?: Instance[Key];
14
+ } & {
15
+ /** The saved size of a `resize: 'props'` component; other components reject it. */
16
+ readonly width?: number;
17
+ readonly height?: number;
18
+ };
19
+
20
+ export interface PropOptions {
21
+ readonly label?: string;
22
+ readonly unit?: string;
23
+ readonly min?: number;
24
+ readonly max?: number;
25
+ readonly step?: number;
26
+ readonly options?: readonly PropValue[];
27
+ readonly type?: 'color';
28
+ readonly notation?: 'si';
29
+ }
30
+
31
+ export interface ComponentOptions extends EditRules {
32
+ readonly type: string;
33
+ readonly name: string;
34
+ readonly category?: string;
35
+ readonly version?: number;
36
+ readonly migrate?: (props: ComponentProps, version: number) => ComponentProps;
37
+ /** Default size for `resize: 'props'`, in user units. Defaults to 100. */
38
+ readonly width?: number;
39
+ readonly height?: number;
40
+ }
41
+
42
+ export interface ComponentDefinition extends ComponentOptions {
43
+ readonly category: string;
44
+ readonly version: number;
45
+ readonly rotate: NonNullable<EditRules['rotate']>;
46
+ readonly flip: boolean;
47
+ readonly resize: NonNullable<EditRules['resize']>;
48
+ }
49
+
50
+ export interface ComponentClass<Instance extends Component = Component> extends ElementClass<
51
+ ComponentProps,
52
+ Instance
53
+ > {
54
+ new (context: DrawingContext, props?: ComponentProps, existing?: SVGGElement): Instance;
55
+ }
56
+
57
+ /** An `@action` method: its name and undo label. */
58
+ export interface ActionDefinition {
59
+ readonly name: string;
60
+ readonly label: string;
61
+ }
62
+
63
+ export interface PropDefinition extends PropOptions {
64
+ readonly name: string;
65
+ readonly initial: PropValue;
66
+ }
@@ -0,0 +1,123 @@
1
+ import type { AnyNode } from '../core/types.js';
2
+ import { Group } from '../elements/group.js';
3
+ import { Renderer, type TemplateInstance } from '../template/render.js';
4
+ import {
5
+ BindingPart,
6
+ TemplateCompiler,
7
+ type ContentItem,
8
+ type TemplateDescription,
9
+ } from '../template/parts.js';
10
+ import type { SvgTemplate } from '../template/svg-tag.js';
11
+ import { Guards } from '../mixins/guards.js';
12
+ import type { Component } from './component.js';
13
+ import { destroySubtrees } from '../core/destruction.js';
14
+ import { Attributes } from '../dom/attributes.js';
15
+
16
+ /** Reuses saved wrappers when their structure matches the class's current template. */
17
+ export class ComponentView {
18
+ #instance?: TemplateInstance;
19
+ #host?: AnyNode;
20
+ #strings?: TemplateStringsArray;
21
+ #parts: BindingPart[] = [];
22
+ readonly #owner: Component;
23
+ constructor(owner: Component) {
24
+ this.#owner = owner;
25
+ }
26
+
27
+ render(template: SvgTemplate): void {
28
+ if (
29
+ this.#instance &&
30
+ !this.#instance.isDestroyed &&
31
+ this.#instance.host.parent === this.#owner &&
32
+ this.#instance.template.strings === template.strings
33
+ ) {
34
+ this.#instance.update(template);
35
+ return;
36
+ }
37
+ if (
38
+ this.#host &&
39
+ !this.#host.isDestroyed &&
40
+ this.#host.parent === this.#owner &&
41
+ this.#strings === template.strings
42
+ ) {
43
+ this.#update(template);
44
+ return;
45
+ }
46
+ this.#parts = [];
47
+ // The first template-power phase owns structural holes; rebuild saved dynamic trees
48
+ // through its renderer instead of trying to infer their binding layout here.
49
+ const content = TemplateCompiler.content(this.#owner.context.dom, template.strings);
50
+ TemplateCompiler.validate(content, this.#owner.context.registry);
51
+ const descriptions = this.#hasChildHoles(content)
52
+ ? undefined
53
+ : content.filter((item): item is TemplateDescription => 'tag' in item);
54
+ const host = this.#owner.children.length === 1 ? this.#owner.children[0] : undefined;
55
+ if (
56
+ descriptions &&
57
+ host instanceof Group &&
58
+ this.#attributesMatch(host, {}) &&
59
+ this.#matches(host.children, descriptions)
60
+ ) {
61
+ this.#bind(host.children, descriptions);
62
+ this.#host = host;
63
+ this.#strings = template.strings;
64
+ this.#instance = undefined;
65
+ this.#update(template);
66
+ return;
67
+ }
68
+ const previous = [...this.#owner.children];
69
+ this.#instance = new Renderer(this.#owner).render(template);
70
+ if (previous.length) this.#owner[destroySubtrees](previous);
71
+ this.#host = undefined;
72
+ this.#strings = undefined;
73
+ }
74
+
75
+ #attributesMatch(node: AnyNode, attributes: Readonly<Record<string, unknown>>): boolean {
76
+ const names = node.attributes.entries().map(([name]) => name);
77
+ names.push(...node.el.getAttributeNames().map(Attributes.normalizeName));
78
+ return names.every((name) => Object.hasOwn(attributes, name));
79
+ }
80
+
81
+ #hasChildHoles(content: readonly ContentItem[]): boolean {
82
+ return content.some((item) => {
83
+ if (!('tag' in item)) return item.parts.some((part) => typeof part === 'number');
84
+ const Constructor = this.#owner.context.registry.get(item.tag);
85
+ return this.#hasChildHoles(Constructor?.isText ? item.children : item.content);
86
+ });
87
+ }
88
+
89
+ #matches(nodes: readonly AnyNode[], descriptions: readonly TemplateDescription[]): boolean {
90
+ return (
91
+ nodes.length === descriptions.length &&
92
+ nodes.every((node, index) => {
93
+ const description = descriptions[index];
94
+ return (
95
+ !(Guards.isTextual(node) && description.text.length && description.children.length) &&
96
+ node.el.localName === description.tag &&
97
+ this.#attributesMatch(node, description.attributes) &&
98
+ (Guards.isParent(node)
99
+ ? this.#matches(node.children, description.children)
100
+ : description.children.length === 0)
101
+ );
102
+ })
103
+ );
104
+ }
105
+
106
+ #bind(nodes: readonly AnyNode[], descriptions: readonly TemplateDescription[]): void {
107
+ nodes.forEach((node, index) => {
108
+ const description = descriptions[index];
109
+ for (const [name, parts] of Object.entries(description.attributes))
110
+ this.#parts.push(new BindingPart(node, name, parts));
111
+ if (description.text.length && Guards.isTextual(node)) {
112
+ this.#parts.push(new BindingPart(node, null, description.text));
113
+ }
114
+ if (Guards.isParent(node)) this.#bind(node.children, description.children);
115
+ });
116
+ }
117
+
118
+ #update(template: SvgTemplate): void {
119
+ const changes = this.#parts.map((part) => ({ part, value: part.evaluate(template.values) }));
120
+ for (const { part, value } of changes) part.validate(value);
121
+ for (const { part, value } of changes) part.commit(value);
122
+ }
123
+ }
@@ -0,0 +1,15 @@
1
+ /** Opt-in class component rendering, saved props and temporary live state. */
2
+ export { Component } from './components/component.js';
3
+ export { component, prop, state, action } from './components/decorators.js';
4
+ export { ComponentTypes, isComponentChild } from './components/registry.js';
5
+ export type {
6
+ ActionDefinition,
7
+ ComponentClass,
8
+ ComponentDefinition,
9
+ ComponentFields,
10
+ ComponentOptions,
11
+ ComponentProps,
12
+ PropValue,
13
+ PropOptions,
14
+ PropDefinition,
15
+ } from './components/types.js';
package/src/core/node.ts CHANGED
@@ -4,7 +4,8 @@ import { Attributes } from '../dom/attributes.js';
4
4
  import { Emitter } from '../events/emitter.js';
5
5
  import { Matrix } from '../math/matrix.js';
6
6
  import type { DrawingContext } from '../services/context.js';
7
- import type { ElementClass } from '../services/types.js';
7
+ import type { ElementClass, ElementVariant } from '../services/types.js';
8
+ import { assertChildrenEditable, withOwnedChildren } from '../mixins/parent-internals.js';
8
9
  import type { ParentNode } from '../mixins/types.js';
9
10
  import type { AttributeValue, Bounds, CommonAttributes } from '../types/common.js';
10
11
  import type { AnyNode, NodeEvents } from './types.js';
@@ -28,6 +29,10 @@ export class Node<
28
29
  static readonly isText: boolean = false;
29
30
  static readonly childTags: readonly string[] | undefined = undefined;
30
31
  declare static readonly forbiddenDescendantTags: readonly string[] | undefined;
32
+ /** See `ElementVariant`: one `data-*` attribute value selects this class for its tag. */
33
+ static readonly variant: ElementVariant | undefined = undefined;
34
+ /** When true, only the class itself changes its children, through `withOwnChildren()`. */
35
+ static readonly ownsChildren: boolean = false;
31
36
 
32
37
  readonly el: Element;
33
38
  readonly attributes: Attributes;
@@ -61,6 +66,8 @@ export class Node<
61
66
  this.allowedAttributes = schema;
62
67
  for (const [name, value] of this.attributes.entries()) this.validateAttribute(name, value);
63
68
  if (!existing) {
69
+ const variant = Constructor.variant;
70
+ if (variant) this.setAttributeValue(variant.attribute, variant.value);
64
71
  this.attrs(options);
65
72
  for (const { attribute, initial } of fields)
66
73
  if (initial !== undefined && this.attributes.getNormalized(attribute) === undefined)
@@ -111,6 +118,19 @@ export class Node<
111
118
  name = Attributes.normalizeName(name);
112
119
  if (!this.allowedAttributes.has(name) && !/^(?:data|aria)-[\w-]+$/.test(name))
113
120
  throw new TypeError(`Unsupported attribute: ${name}`);
121
+ if (name.startsWith('data-')) {
122
+ // A variant's identifying attribute is fixed, and other classes can't claim its value.
123
+ const Constructor = this.constructor as ElementClass;
124
+ const variant = Constructor.variant;
125
+ if (
126
+ variant?.attribute === name
127
+ ? value !== variant.value
128
+ : value != null &&
129
+ (this.context.registry.variant(Constructor.tag, name, String(value)) ?? Constructor) !==
130
+ Constructor
131
+ )
132
+ throw new TypeError(`${name} selects the element class`);
133
+ }
114
134
  if (value != null) Attributes.normalize(name, value);
115
135
  if (name === 'transform' && value != null) Matrix.parse(String(value));
116
136
  if (
@@ -123,6 +143,12 @@ export class Node<
123
143
  (this.constructor as ElementClass)[validateAttributeValue]?.(name, value);
124
144
  }
125
145
 
146
+ /** For classes with `static ownsChildren`: change this node's own children. */
147
+ protected withOwnChildren<Result>(callback: () => Result): Result {
148
+ this.assertLive();
149
+ return withOwnedChildren(this, callback);
150
+ }
151
+
126
152
  getAttributeValue(name: string): string | number | undefined {
127
153
  this.assertLive();
128
154
  return this.attributes.get(name);
@@ -305,6 +331,7 @@ export class Node<
305
331
  remove(): this {
306
332
  return mutation(this, () => {
307
333
  this.assertLive();
334
+ assertChildrenEditable(this.parent);
308
335
  for (const node of this.descendants()) node.releaseCapturedPointers();
309
336
  this.detachNativeElement();
310
337
  this.context.bounds.invalidate();
@@ -316,6 +343,7 @@ export class Node<
316
343
  destroy(): void {
317
344
  if (this.isDestroyed) return;
318
345
  this.assertLive();
346
+ assertChildrenEditable(this.parent);
319
347
  this.context.defs.assertCanDestroy(
320
348
  new Set(this === this.context.root ? this.context.registry.nodes : this.descendants()),
321
349
  );
@@ -339,6 +367,9 @@ export class Node<
339
367
  disposalOrder.push(node);
340
368
  }
341
369
  }
370
+ // Preflight ownership before any disposal side effect; owners destroyed together pass.
371
+ for (const root of roots)
372
+ if (root.parent && !ownedNodes.has(root.parent)) assertChildrenEditable(root.parent);
342
373
  this.context.defs.assertCanDestroy(ownedNodes);
343
374
  this.context.changes.beforeDisposal([...ownedNodes]);
344
375
  for (const root of roots) this.context.changes.touchTree(root);
package/src/dom/images.ts CHANGED
@@ -20,7 +20,8 @@ export class ImageLoader {
20
20
  return () => {
21
21
  probe.removeEventListener('load', loaded);
22
22
  probe.removeEventListener('error', failed);
23
- probe.removeAttribute('src');
23
+ // Resetting an already completed source starts a second native error request.
24
+ if (!probe.complete) probe.removeAttribute('src');
24
25
  };
25
26
  }
26
27
  }
@@ -44,7 +44,10 @@ export class SvgImage extends Shape<SVGImageElement, ImageOptions> {
44
44
 
45
45
  constructor(context: DrawingContext, options: ImageOptions = {}, existing?: SVGImageElement) {
46
46
  super(context, options, existing);
47
- this.onDispose(() => this.cancelRequest());
47
+ this.onDispose(() => {
48
+ this.cancelRequest();
49
+ this.el.removeAttribute('href');
50
+ });
48
51
  if (this.source) this.context.scheduler.enqueue(this, '#image');
49
52
  }
50
53
 
@@ -7,7 +7,7 @@ import { dependencyRoots, remapIds } from './references.js';
7
7
  import { classifyReference } from '../resources/references.js';
8
8
  import { preflight, reconstruct, validateCloneContent } from './reconstruct.js';
9
9
  import type { CloneOptions } from './types.js';
10
- import { copyTextPolicies } from '../text/content.js';
10
+ import { copyTextPolicies } from '../text/policies.js';
11
11
 
12
12
  export function cloneNode<Node extends AnyNode>(source: Node, options: CloneOptions = {}): Node {
13
13
  return source.context.changes.run(source, () => {
@@ -24,8 +24,9 @@ export function cloneNode<Node extends AnyNode>(source: Node, options: CloneOpti
24
24
  const originals = [source, ...dependencies];
25
25
  const copies = new Map<AnyNode, SVGElement>();
26
26
  const roots = originals.map((node) => snapshot(node, copies));
27
- for (const original of copies.keys())
28
- if (source.context.registry.get(original.el.localName) !== original.constructor)
27
+ // Copies are rebuilt by adoption, so each must resolve back to its original's class.
28
+ for (const [original, element] of copies)
29
+ if (source.context.registry.resolve(element) !== original.constructor)
29
30
  throw new TypeError('Clone requires the exact registered constructor for each managed tag');
30
31
  for (const original of copies.keys())
31
32
  if (Guards.isTransformable(original) && !original.geometryMatrix().inverse())
@@ -2,19 +2,29 @@ import { SVG_NAMESPACE, XLINK_NAMESPACE } from '../catalog/namespaces.js';
2
2
 
3
3
  /** Parsing and serialization operate on detached nodes in the destination document. */
4
4
  export class InterchangeDom {
5
- static parse(owner: SVGElement, source: string): SVGElement[] {
5
+ static parse(owner: SVGElement, source: string, document = false): SVGElement[] {
6
6
  if (typeof source !== 'string' || source.length > 1_000_000)
7
7
  throw new TypeError('SVG import requires at most 1,000,000 characters');
8
- if (/<!\s*(?:DOCTYPE|ENTITY)|<\?/i.test(source))
8
+ // A document prolog may hold an XML declaration and a DOCTYPE without an internal subset.
9
+ const prolog = document
10
+ ? /^\s*(?:<\?xml\s[^?]*\?>)?(?:\s|<!--(?:[^-]|-(?!->))*-->)*(?:<!DOCTYPE\s+svg\b[^[>]*>)?/i.exec(
11
+ source,
12
+ )![0]
13
+ : '';
14
+ const body = source.slice(prolog.length);
15
+ if (/<!\s*(?:DOCTYPE|ENTITY)|<\?/i.test(body))
9
16
  throw new TypeError('SVG import rejects declarations, entities and processing instructions');
10
17
  const Parser = owner.ownerDocument.defaultView?.DOMParser ?? globalThis.DOMParser;
11
18
  if (!Parser) throw new Error('SVG import requires DOMParser');
12
19
  const parsed = new Parser().parseFromString(
13
- `<svg xmlns="${SVG_NAMESPACE}" xmlns:xlink="${XLINK_NAMESPACE}">${source}</svg>`,
20
+ `<svg xmlns="${SVG_NAMESPACE}" xmlns:xlink="${XLINK_NAMESPACE}">${body}</svg>`,
14
21
  'image/svg+xml',
15
22
  );
16
- if (parsed.getElementsByTagName('parsererror').length)
17
- throw new TypeError('Invalid SVG markup');
23
+ const failure = parsed.getElementsByTagName('parsererror')[0];
24
+ if (failure)
25
+ throw new SyntaxError(
26
+ `Not valid SVG: ${failure.textContent?.trim().split(/\r?\n/)[0] || 'Invalid SVG markup'}`,
27
+ );
18
28
  const roots: SVGElement[] = [];
19
29
  for (const node of parsed.documentElement.childNodes) {
20
30
  if (node.nodeType === 8 || (node.nodeType === 3 && !node.textContent.trim())) continue;
@@ -27,6 +37,25 @@ export class InterchangeDom {
27
37
  element.replaceChild(owner.ownerDocument.createTextNode(child.textContent), child);
28
38
  return roots;
29
39
  }
40
+ /** Called after passive validation so even discarded envelope attributes are checked. */
41
+ static unwrap(roots: SVGElement[], presentation: boolean): SVGElement[] {
42
+ const root = roots[0];
43
+ if (roots.length !== 1 || root.localName !== 'svg') return roots;
44
+ const children = [...root.children] as SVGElement[];
45
+ if (!presentation) return children;
46
+ const group = root.ownerDocument.createElementNS(SVG_NAMESPACE, 'g');
47
+ for (const attribute of [...root.attributes])
48
+ if (
49
+ !['x', 'y', 'width', 'height', 'viewBox', 'preserveAspectRatio', 'version'].includes(
50
+ attribute.name,
51
+ ) &&
52
+ attribute.namespaceURI !== 'http://www.w3.org/2000/xmlns/'
53
+ )
54
+ group.setAttributeNS(attribute.namespaceURI, attribute.name, attribute.value);
55
+ if (!group.attributes.length) return children;
56
+ group.append(...root.childNodes);
57
+ return [group];
58
+ }
30
59
  static serialize(element: SVGElement): string {
31
60
  const Serializer = element.ownerDocument.defaultView?.XMLSerializer ?? globalThis.XMLSerializer;
32
61
  if (!Serializer) throw new Error('SVG export requires XMLSerializer');
@@ -8,6 +8,31 @@ import { InterchangeDom } from './dom.js';
8
8
  import { preflight, reconstruct } from './reconstruct.js';
9
9
  import { validateImportContent } from './policy.js';
10
10
  import type { ExportOptions, ImportOptions } from './types.js';
11
+ import { BBox } from '../math/bbox.js';
12
+ import { isNestedViewport, viewportLength } from '../services/viewport.js';
13
+ import { Attributes } from '../dom/attributes.js';
14
+
15
+ function frameContent(source: AnyNode, envelope: SVGElement, padding: number): void {
16
+ const bounds = source.bounds('root');
17
+ if (bounds.status === 'unavailable')
18
+ throw new Error(`Cannot frame SVG content: ${bounds.reason}`);
19
+ const box =
20
+ 'box' in bounds
21
+ ? bounds.box
22
+ : {
23
+ x: 0,
24
+ y: 0,
25
+ width: BBox.numeric(source.context.root?.getAttributeValue('width'), 800) ?? 800,
26
+ height: BBox.numeric(source.context.root?.getAttributeValue('height'), 600) ?? 600,
27
+ };
28
+ const width = box.width + padding * 2,
29
+ height = box.height + padding * 2;
30
+ if (![box.x - padding, box.y - padding, width, height].every(Number.isFinite))
31
+ throw new RangeError('SVG content frame must be finite');
32
+ envelope.setAttribute('viewBox', `${box.x - padding} ${box.y - padding} ${width} ${height}`);
33
+ envelope.setAttribute('width', String(width));
34
+ envelope.setAttribute('height', String(height));
35
+ }
11
36
 
12
37
  export function exportSvg(source: AnyNode, options: ExportOptions = {}): string {
13
38
  source.assertLive();
@@ -15,6 +40,13 @@ export function exportSvg(source: AnyNode, options: ExportOptions = {}): string
15
40
  throw new TypeError('Unknown SVG export format');
16
41
  if (options.resources !== undefined && !['referenced', 'none'].includes(options.resources))
17
42
  throw new TypeError('Unknown export resource policy');
43
+ if (options.frame !== undefined && options.frame !== 'content')
44
+ throw new TypeError('Unknown SVG frame');
45
+ if (options.frame && options.format === 'subtree')
46
+ throw new TypeError('Content framing requires a document export');
47
+ const padding = options.padding ?? 0;
48
+ if (!Number.isFinite(padding) || padding < 0)
49
+ throw new RangeError('SVG padding must be nonnegative and finite');
18
50
  source.flush();
19
51
  const dependencies = options.resources === 'none' ? [] : dependencyRoots(source, 'local');
20
52
  const copies = new Map<AnyNode, SVGElement>();
@@ -25,20 +57,45 @@ export function exportSvg(source: AnyNode, options: ExportOptions = {}): string
25
57
  return InterchangeDom.serialize(element);
26
58
  }
27
59
  const document = element.ownerDocument;
60
+ const framedSubtree = options.frame === 'content' && source !== source.context.root;
28
61
  const envelope =
29
- element.localName === 'svg' ? element : document.createElementNS(SVG_NAMESPACE, 'svg');
62
+ element.localName === 'svg' && !framedSubtree
63
+ ? element
64
+ : document.createElementNS(SVG_NAMESPACE, 'svg');
30
65
  if (envelope !== element) {
31
66
  for (const name of ['width', 'height', 'viewBox', 'preserveAspectRatio']) {
32
67
  const value = source.context.root?.getAttributeValue(name);
33
68
  if (value !== undefined) envelope.setAttribute(name, String(value));
34
69
  }
35
- envelope.append(element);
70
+ if (framedSubtree && source.parent) {
71
+ // The snapshot retains its own transform and any nested SVG viewport. Only
72
+ // the omitted ancestors need mapping into the document-space content frame.
73
+ const mapping = document.createElementNS(SVG_NAMESPACE, 'g');
74
+ mapping.setAttribute('transform', source.context.bounds.matrix(source.parent).toString());
75
+ mapping.append(element);
76
+ envelope.append(mapping);
77
+ } else envelope.append(element);
36
78
  }
37
79
  if (dependencies.length) {
38
80
  const defs = document.createElementNS(SVG_NAMESPACE, 'defs');
39
81
  for (const dependency of dependencies) defs.append(snapshot(dependency, copies));
40
82
  envelope.prepend(defs);
41
83
  }
84
+ if (options.frame === 'content') {
85
+ frameContent(source, envelope, padding);
86
+ // Reframing changes percentage bases. Preserve each copied nested viewport's
87
+ // original placement and dimensions, including omitted 100% sizes.
88
+ for (const [node, copy] of copies) {
89
+ if (!isNestedViewport(node)) continue;
90
+ for (const name of ['x', 'y', 'width', 'height'] as const) {
91
+ const axis = name === 'x' || name === 'width' ? 'width' : 'height';
92
+ copy.setAttribute(
93
+ name,
94
+ String(viewportLength(node, name, axis, name === axis ? '100%' : 0)),
95
+ );
96
+ }
97
+ }
98
+ }
42
99
  return InterchangeDom.serialize(envelope);
43
100
  }
44
101
 
@@ -51,8 +108,22 @@ export function importSvg(
51
108
  assertCanAddChildren(parent);
52
109
  const index = options.index ?? parent.children.length;
53
110
  validateIndex(index, parent.children.length);
54
- const roots = InterchangeDom.parse(parent.el, markup);
111
+ if (
112
+ options.document !== undefined &&
113
+ typeof options.document !== 'boolean' &&
114
+ !['presentation', 'replace'].includes(options.document)
115
+ )
116
+ throw new TypeError('Unknown document import mode');
117
+ if (
118
+ options.document === 'replace' &&
119
+ (parent !== parent.context.root || parent.children.length)
120
+ )
121
+ throw new TypeError('Document replacement requires an empty owning drawing');
122
+ let roots = InterchangeDom.parse(parent.el, markup, !!options.document);
55
123
  validateImportContent(roots, options);
124
+ const replace =
125
+ options.document === 'replace' && roots.length === 1 && roots[0].localName === 'svg';
126
+ if (options.document) roots = InterchangeDom.unwrap(roots, options.document !== true);
56
127
  preflight(parent.context, roots);
57
128
  remapIds(parent, roots);
58
129
  return reconstruct(parent, roots, (nodes) => {
@@ -60,6 +131,9 @@ export function importSvg(
60
131
  for (const node of nodes) parent[validateChild](node);
61
132
  for (let position = 0; position < nodes.length; position++)
62
133
  parent.add(nodes[position], index + position);
134
+ if (replace)
135
+ for (const name of Attributes.common)
136
+ if (!['id', 'class'].includes(name)) parent.setAttributeValue(name, undefined);
63
137
  return Object.freeze(nodes);
64
138
  });
65
139
  });
@@ -89,6 +89,12 @@ function validateUrl(tag: string, name: string, value: string, options: ImportOp
89
89
  if (reference) return;
90
90
  if (name === 'href') {
91
91
  if (value === '') return;
92
+ // Image documents are passive resources; keep embedded image files restorable.
93
+ if (
94
+ tag === 'image' &&
95
+ /^data:image\/(?:png|jpe?g|gif|webp|avif|svg\+xml)(?:;[^,]*)?,/i.test(value)
96
+ )
97
+ return;
92
98
  if (
93
99
  options.externalUrls === 'http' &&
94
100
  ['image', 'a', 'use'].includes(tag) &&
@@ -8,6 +8,8 @@ import { validateNativeElement } from '../core/native-validation.js';
8
8
  import { destroySubtrees } from '../core/destruction.js';
9
9
  import { InterchangeDom } from './dom.js';
10
10
  import type { AttributeBindings } from '../catalog/namespaces.js';
11
+ import { withInternalStructure } from '../mixins/parent-internals.js';
12
+ import { withInitialization } from '../services/initialization.js';
11
13
 
12
14
  /** Pure validation for the complete forest, before Registry can claim a wrapper. */
13
15
  export function preflight(
@@ -16,7 +18,7 @@ export function preflight(
16
18
  schemas: ReadonlyMap<SVGElement, ReadonlySet<string>> = new Map(),
17
19
  ): void {
18
20
  function visit(element: SVGElement, forbidden: ReadonlySet<string>): void {
19
- const Constructor = context.registry.get(element.localName) as ElementClass & {
21
+ const Constructor = context.registry.resolve(element) as ElementClass & {
20
22
  attributeBindings?: AttributeBindings;
21
23
  };
22
24
  if (
@@ -90,22 +92,24 @@ export function reconstruct<Result>(
90
92
  ): Result {
91
93
  const before = new Set(owner.context.registry.nodes);
92
94
  try {
93
- const nodes = roots.map((element) => owner.context.registry.adopt(element));
94
- for (const node of owner.context.registry.nodes)
95
- if (!before.has(node))
96
- for (const name of ['tabindex', 'role']) {
97
- const value = node.el.getAttribute(name);
98
- if (value !== null) {
99
- node.defineAttribute(name);
100
- node.setAttributeValue(name, value);
95
+ return withInitialization(() => {
96
+ const nodes = roots.map((element) => owner.context.registry.adopt(element));
97
+ for (const node of owner.context.registry.nodes)
98
+ if (!before.has(node))
99
+ for (const name of ['tabindex', 'role']) {
100
+ const value = node.el.getAttribute(name);
101
+ if (value !== null) {
102
+ node.defineAttribute(name);
103
+ node.setAttributeValue(name, value);
104
+ }
101
105
  }
102
- }
103
- return finish(nodes);
106
+ return finish(nodes);
107
+ });
104
108
  } catch (error) {
105
109
  const created = owner.context.registry.nodes.filter((node) => !before.has(node));
106
110
  if (created.length) {
107
111
  try {
108
- owner[destroySubtrees](created);
112
+ withInternalStructure(() => owner[destroySubtrees](created));
109
113
  } catch (cleanupError) {
110
114
  throw new AggregateError(
111
115
  [error, cleanupError],
@@ -1,5 +1,5 @@
1
1
  import type { AnyNode } from '../core/types.js';
2
- import { classifyReference, resolveReference } from '../resources/references.js';
2
+ import { classifyReference, isLink, resolveReference } from '../resources/references.js';
3
3
  import { referenceValue } from '../services/reference-value.js';
4
4
  import { InterchangeDom } from './dom.js';
5
5
  import { XLINK_NAMESPACE } from '../catalog/namespaces.js';
@@ -10,10 +10,13 @@ export function dependencyRoots(source: AnyNode, references: 'typed' | 'local'):
10
10
  const pending = [...included];
11
11
  for (let index = 0; index < pending.length; index++) {
12
12
  const node = pending[index];
13
- const targets = new Set(node.references.values());
13
+ // Links are not dependencies: copying a connector never copies the shapes it joins.
14
+ const targets = new Set(
15
+ [...node.references].filter(([name]) => !isLink(name)).map(([, target]) => target),
16
+ );
14
17
  if (references === 'local')
15
18
  for (const [name, value] of node.attributes.entries()) {
16
- if (!classifyReference(name, value)) continue;
19
+ if (!classifyReference(name, value) || isLink(name)) continue;
17
20
  // Hyperlinks navigate; they are not rendering dependencies.
18
21
  if (name === 'href' && ['a', 'image'].includes(node.el.localName)) continue;
19
22
  const target = resolveReference(node, name);
@@ -87,10 +90,15 @@ export function restoreLocalReferences(nodes: readonly AnyNode[]): void {
87
90
  .map((node) => [String(node.getAttributeValue('id')), node]),
88
91
  );
89
92
  for (const node of all)
90
- for (const [name, value] of node.attributes.entries()) {
93
+ for (const [name, value] of [...node.attributes.entries()]) {
91
94
  const reference = classifyReference(name, value);
92
95
  if (!reference) continue;
93
96
  const target = ids.get(reference.id);
97
+ // A connector pasted without its target keeps that end loose.
98
+ if (!target && isLink(name)) {
99
+ node.setAttributeValue(name, undefined);
100
+ continue;
101
+ }
94
102
  if (!target)
95
103
  throw new TypeError(`Local reference is outside the imported forest: ${reference.id}`);
96
104
  if (name === 'href' && ['a', 'image'].includes(node.el.localName)) continue;
@@ -10,9 +10,18 @@ export interface ExportOptions {
10
10
  format?: 'document' | 'subtree';
11
11
  /** Include reachable typed and unambiguous local references. CSS URLs are not traversed. */
12
12
  resources?: 'referenced' | 'none';
13
+ /** Frame geometric content in document coordinates rather than the current camera. */
14
+ frame?: 'content';
15
+ /** Nonnegative drawing units around a content frame; defaults to zero. */
16
+ padding?: number;
13
17
  }
14
18
  export interface ImportOptions {
15
19
  index?: number;
16
- /** External URLs are rejected by default. Opt in to absolute HTTP(S) image/link/Use URLs. */
20
+ /** true unwraps children; 'presentation' retains scoped root attributes without
21
+ * the viewport. 'replace' additionally clears configured root presentation for a
22
+ * full document, and requires an empty owning drawing. Fragments keep defaults. */
23
+ document?: boolean | 'presentation' | 'replace';
24
+ /** External URLs are rejected by default; embedded data images are allowed on image elements.
25
+ * Opt in to absolute HTTP(S) image/link/Use URLs. */
17
26
  externalUrls?: 'reject' | 'http';
18
27
  }