@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,401 @@
1
+ import { Group } from '../elements/group.js';
2
+ import type { DrawingContext } from '../services/context.js';
3
+ import type { SvgTemplate } from '../template/svg-tag.js';
4
+ import { beginChangeGroup } from '../changes.js';
5
+ import { derivesChildren, markDerived } from '../changes/derived.js';
6
+ import { initializeElement } from '../services/initialization.js';
7
+ import { definition, isSize, validatePorts, validateProp } from './metadata.js';
8
+ import { ComponentView } from './view.js';
9
+ import { ComponentChanges } from './changes.js';
10
+ import { Attributes } from '../dom/attributes.js';
11
+ import type { AttributeValue, Length, PortDefinition } from '../types/common.js';
12
+ import type { creationOptions } from '../services/types.js';
13
+ import type {
14
+ ActionDefinition,
15
+ ComponentDefinition,
16
+ ComponentProps,
17
+ PropDefinition,
18
+ PropOptions,
19
+ PropValue,
20
+ ComponentFields,
21
+ } from './types.js';
22
+
23
+ export const installField = Symbol('installComponentField');
24
+ export const assertField = Symbol('assertComponentField');
25
+ export const editComponent = Symbol('editComponent');
26
+ export const registerAction = Symbol('registerComponentAction');
27
+ const restoredHooks = new WeakSet<Component>();
28
+ interface Field {
29
+ readonly kind: 'prop' | 'state';
30
+ readonly initial: unknown;
31
+ readonly options: PropOptions;
32
+ value: unknown;
33
+ }
34
+ const sizes = ['width', 'height'] as const;
35
+ const copy = <Value>(value: Value): Value =>
36
+ value && typeof value === 'object' ? structuredClone(value) : value;
37
+
38
+ /** A closed rasam group whose children are rendered from decorated props and live state. */
39
+ export abstract class Component extends Group {
40
+ declare readonly [creationOptions]: ComponentFields<this>;
41
+ static override readonly ownsChildren = true;
42
+ /** @internal History restores props; the component redraws its children after replay. */
43
+ static readonly [derivesChildren] = true;
44
+ readonly #fields = new Map<string, Field>();
45
+ readonly #view = new ComponentView(this);
46
+ #initialized = false;
47
+ #constructing = true;
48
+ #schedule?: () => void;
49
+ #observedProps: ComponentProps = {};
50
+ #propsCache?: { raw: string | number | undefined; props: ComponentProps };
51
+ #propDefinitions?: readonly PropDefinition[];
52
+ readonly #actions = new Map<string, string>();
53
+ #ports?: { props: ComponentProps; ports: readonly PortDefinition[] };
54
+ #readingPorts = false;
55
+
56
+ readonly #input: ComponentProps;
57
+ readonly #reserved: ReadonlySet<string>;
58
+
59
+ constructor(context: DrawingContext, input: ComponentProps = {}, existing?: SVGGElement) {
60
+ super(context, {}, existing);
61
+ this.#input = input;
62
+ this.#reserved = new Set(Object.getOwnPropertyNames(this));
63
+ }
64
+
65
+ get componentDefinition(): ComponentDefinition {
66
+ return definition(this.constructor);
67
+ }
68
+ get editRules() {
69
+ const { rotate, flip, resize } = this.componentDefinition;
70
+ return Object.freeze({ rotate, flip, resize });
71
+ }
72
+ get propDefinitions(): readonly PropDefinition[] {
73
+ return (this.#propDefinitions ??= Object.freeze(
74
+ [...this.#fields]
75
+ .filter(([, field]) => field.kind === 'prop')
76
+ .map(([name, field]) =>
77
+ Object.freeze({ ...field.options, name, initial: field.initial as PropValue }),
78
+ ),
79
+ ));
80
+ }
81
+
82
+ /** `@action` methods with their undo labels, in declaration order; overrides relabel. */
83
+ get actionDefinitions(): readonly ActionDefinition[] {
84
+ return Object.freeze([...this.#actions].map(([name, label]) => Object.freeze({ name, label })));
85
+ }
86
+
87
+ /** @internal The `@action` decorator records each method once per instance. */
88
+ [registerAction](name: string, label: string): void {
89
+ this.#actions.set(name, label);
90
+ }
91
+
92
+ render(): SvgTemplate {
93
+ throw new TypeError('Components must implement render()');
94
+ }
95
+
96
+ /**
97
+ * Override to declare connection points, in local coordinates at 0°, from props and
98
+ * the saved size. Live state never moves a port. Defaults to none.
99
+ */
100
+ ports(): readonly PortDefinition[] {
101
+ return [];
102
+ }
103
+
104
+ /** Validated, frozen `ports()`, computed again only when the saved props change. */
105
+ get portDefinitions(): readonly PortDefinition[] {
106
+ const props = this.#readProps();
107
+ if (this.#ports?.props === props) return this.#ports.ports;
108
+ this.#readingPorts = true;
109
+ try {
110
+ const ports = validatePorts(this.ports());
111
+ this.#ports = { props, ports };
112
+ return ports;
113
+ } finally {
114
+ this.#readingPorts = false;
115
+ }
116
+ }
117
+ created(): void {}
118
+ destroyed(): void {}
119
+ changed(_name: string): void {}
120
+
121
+ override validateAttribute(name: string, value: AttributeValue): void {
122
+ super.validateAttribute(name, value);
123
+ if (
124
+ Attributes.normalizeName(name) === 'data-rasam-props' &&
125
+ #initialized in this &&
126
+ this.#initialized
127
+ )
128
+ this.#assertProps(parseProps(value));
129
+ }
130
+
131
+ override setAttributeValue(name: string, value: AttributeValue): this {
132
+ if (restoredHooks.has(this) && Attributes.normalizeName(name) === 'data-rasam-props')
133
+ return this;
134
+ return super.setAttributeValue(name, value);
135
+ }
136
+
137
+ override attrs(values: Parameters<Group['attrs']>[0]): this {
138
+ if (restoredHooks.has(this))
139
+ values = Object.fromEntries(
140
+ Object.entries(values).filter(
141
+ ([name]) => Attributes.normalizeName(name) !== 'data-rasam-props',
142
+ ),
143
+ );
144
+ return super.attrs(values);
145
+ }
146
+
147
+ /** `resize: 'props'` components keep their size as saved `width` and `height` props. */
148
+ get #sized(): boolean {
149
+ return this.componentDefinition.resize === 'props';
150
+ }
151
+
152
+ override width(): Length | null;
153
+ override width(value: Length): this;
154
+ override width(value?: Length): Length | null | this {
155
+ if (!this.#sized) return value === undefined ? super.width() : super.width(value);
156
+ return value === undefined ? this.#size('width') : this.size(value, this.#size('height'));
157
+ }
158
+ override height(): Length | null;
159
+ override height(value: Length): this;
160
+ override height(value?: Length): Length | null | this {
161
+ if (!this.#sized) return value === undefined ? super.height() : super.height(value);
162
+ return value === undefined ? this.#size('height') : this.size(this.#size('width'), value);
163
+ }
164
+
165
+ /** For `resize: 'props'`, one undo step that redraws at once, so callers read new bounds. */
166
+ override size(width: Length, height?: Length): this {
167
+ if (!this.#sized) return super.size(width, height);
168
+ this.assertLive();
169
+ const next = {
170
+ width: toSize(width),
171
+ height: height === undefined ? undefined : toSize(height),
172
+ };
173
+ const target = {
174
+ width: next.width,
175
+ height: next.height ?? (next.width * this.#size('height')) / this.#size('width'),
176
+ };
177
+ if (restoredHooks.has(this)) return this;
178
+ if (target.width === this.#size('width') && target.height === this.#size('height')) return this;
179
+ this[editComponent](`Resize ${this.componentDefinition.name}`, () => {
180
+ this.setAttributeValue(
181
+ 'data-rasam-props',
182
+ JSON.stringify({ ...this.#readProps(), ...target }),
183
+ );
184
+ });
185
+ if (this.#initialized && this.#notifyProps()) this.#redraw();
186
+ return this;
187
+ }
188
+
189
+ #size(name: (typeof sizes)[number]): number {
190
+ const saved = this.#readProps()[name] ?? this.#input[name];
191
+ return isSize(saved) ? saved : this.componentDefinition[name]!;
192
+ }
193
+
194
+ resetState(): void {
195
+ this.assertLive();
196
+ for (const [name, field] of this.#fields)
197
+ if (field.kind === 'state') Reflect.set(this, name, copy(field.initial));
198
+ }
199
+
200
+ /** @internal Called after decorated fields and reconstructed children are available. */
201
+ [initializeElement](): void {
202
+ if (this.#initialized) return;
203
+ const metadata = this.componentDefinition;
204
+ const saved = this.#readProps();
205
+ const rawVersion = this.getAttributeValue('data-rasam-version');
206
+ const version = rawVersion === undefined ? metadata.version : Number(rawVersion);
207
+ if (!Number.isInteger(version) || version < 1 || version > metadata.version)
208
+ throw new TypeError(`Unsupported ${metadata.type} component version: ${rawVersion}`);
209
+ if (version < metadata.version && !metadata.migrate)
210
+ throw new TypeError(`No migration for ${metadata.type} version ${version}`);
211
+ const values = version < metadata.version ? metadata.migrate!(saved, version) : saved;
212
+ this.#assertProps(values);
213
+ this.#assertProps(this.#input);
214
+ const props: Record<string, PropValue> = {};
215
+ for (const [name, field] of this.#fields)
216
+ if (field.kind === 'prop') {
217
+ const value = Object.hasOwn(this.#input, name)
218
+ ? this.#input[name]
219
+ : Object.hasOwn(values, name)
220
+ ? values[name]
221
+ : field.initial;
222
+ validateProp(value, field.initial as PropValue, field.options, name);
223
+ props[name] = value;
224
+ }
225
+ if (metadata.resize === 'props')
226
+ for (const name of sizes)
227
+ props[name] = (this.#input[name] ?? values[name] ?? metadata[name]) as number;
228
+ this.#initialized = true;
229
+ if (!this.context.changes.replaying) {
230
+ this.setAttributeValue('data-rasam-version', metadata.version);
231
+ this.setAttributeValue('data-rasam-props', JSON.stringify(props));
232
+ this.#redraw();
233
+ }
234
+ this.#observedProps = this.#readProps();
235
+ const changes = ComponentChanges.for(this);
236
+ const binding = {
237
+ node: this,
238
+ derive: () => {
239
+ if (this.#notifyProps()) this.#redraw();
240
+ },
241
+ replay: () => {
242
+ this.#notifyProps();
243
+ },
244
+ paint: () => this.#redraw(),
245
+ };
246
+ const stop = changes.bind(binding);
247
+ this.#schedule = () => changes.schedule(binding);
248
+ this.onDispose(() => {
249
+ stop();
250
+ this.destroyed();
251
+ });
252
+ this.context.changes.local(() => {
253
+ if (this.adopting) restoredHooks.add(this);
254
+ try {
255
+ this.created();
256
+ } finally {
257
+ restoredHooks.delete(this);
258
+ this.#constructing = false;
259
+ }
260
+ // A newly constructed, detached node is not eligible for change derivation yet.
261
+ if (this.#readProps() !== this.#observedProps && this.#notifyProps()) this.#redraw();
262
+ });
263
+ }
264
+
265
+ /** @internal Field decorators install accessors after their initializers execute. */
266
+ [assertField](name: string): void {
267
+ if (name in Component.prototype || this.#fields.has(name) || this.#reserved.has(name))
268
+ throw new TypeError(`Component field conflicts with an inherited rasam member: ${name}`);
269
+ }
270
+
271
+ /** @internal Field decorators install accessors after their initializers execute. */
272
+ [installField](
273
+ name: string,
274
+ kind: 'prop' | 'state',
275
+ options: PropOptions,
276
+ initial: unknown,
277
+ ): void {
278
+ this[assertField](name);
279
+ if (kind === 'prop') validateProp(initial, initial as PropValue, options, name);
280
+ const field: Field = {
281
+ kind,
282
+ options: Object.freeze({
283
+ ...options,
284
+ ...(options.options ? { options: Object.freeze([...options.options]) } : {}),
285
+ }),
286
+ initial: copy(initial),
287
+ value: initial,
288
+ };
289
+ this.#fields.set(name, field);
290
+ this.#propDefinitions = undefined;
291
+ Object.defineProperty(this, name, {
292
+ enumerable: true,
293
+ configurable: false,
294
+ get: () => {
295
+ if (kind === 'state') {
296
+ if (this.#readingPorts) throw new TypeError(`ports() cannot read live state: ${name}`);
297
+ return field.value;
298
+ }
299
+ const props = this.#readProps();
300
+ return Object.hasOwn(props, name)
301
+ ? props[name]
302
+ : Object.hasOwn(this.#input, name)
303
+ ? this.#input[name]
304
+ : initial;
305
+ },
306
+ set: (value: unknown) => {
307
+ this.assertLive();
308
+ if (kind === 'state') {
309
+ if (Object.is(field.value, value)) return;
310
+ field.value = value;
311
+ if (this.#initialized) {
312
+ this.changed(name);
313
+ this.#schedule?.();
314
+ }
315
+ return;
316
+ }
317
+ if (restoredHooks.has(this)) return;
318
+ validateProp(value, initial as PropValue, options, name);
319
+ if (Object.is(Reflect.get(this, name), value)) return;
320
+ this[editComponent](`Set ${options.label ?? name}`, () => {
321
+ this.setAttributeValue(
322
+ 'data-rasam-props',
323
+ JSON.stringify({ ...this.#readProps(), [name]: value }),
324
+ );
325
+ });
326
+ },
327
+ });
328
+ }
329
+
330
+ /** @internal Actions share one change group across nested calls and instances. */
331
+ [editComponent]<Result>(label: string, callback: () => Result): Result {
332
+ this.assertLive();
333
+ if (this.#constructing || this.context.changes.currentGroup !== undefined) return callback();
334
+ const group = beginChangeGroup(this, { source: 'components', label });
335
+ try {
336
+ const result = group.run(callback);
337
+ if (result && typeof (result as PromiseLike<unknown>).then === 'function')
338
+ throw new TypeError('Component actions must be synchronous');
339
+ group.end();
340
+ return result;
341
+ } catch (error) {
342
+ try {
343
+ group.cancel();
344
+ } catch (rollback) {
345
+ throw new AggregateError([error, rollback], 'Component edit and rollback failed');
346
+ }
347
+ throw error;
348
+ }
349
+ }
350
+
351
+ #readProps(): ComponentProps {
352
+ // Retain ordinary field-read semantics for destroyed() and post-disposal inspection.
353
+ const raw = this.attributes.get('data-rasam-props');
354
+ if (this.#propsCache && this.#propsCache.raw === raw) return this.#propsCache.props;
355
+ this.#propsCache = { raw, props: Object.freeze(parseProps(raw)) };
356
+ return this.#propsCache.props;
357
+ }
358
+ #assertProps(props: ComponentProps): void {
359
+ if (!props || typeof props !== 'object' || Array.isArray(props))
360
+ throw new TypeError('Component props must be an object');
361
+ for (const [name, value] of Object.entries(props)) {
362
+ if (this.#sized && (sizes as readonly string[]).includes(name)) {
363
+ if (!isSize(value)) throw new RangeError(`Component ${name} must be a positive number`);
364
+ continue;
365
+ }
366
+ const field = this.#fields.get(name);
367
+ if (!field || field.kind !== 'prop') throw new TypeError(`Unknown component prop: ${name}`);
368
+ validateProp(value, field.initial as PropValue, field.options, name);
369
+ }
370
+ }
371
+ #notifyProps(): boolean {
372
+ const props = this.#readProps(),
373
+ previous = this.#observedProps;
374
+ if (props === previous) return false;
375
+ this.#observedProps = props;
376
+ const names = [...this.propDefinitions.map((field) => field.name), ...sizes].filter(
377
+ (name) => !Object.is(previous[name], props[name]),
378
+ );
379
+ for (const name of names) this.changed(name);
380
+ return names.length > 0;
381
+ }
382
+ #redraw(): void {
383
+ this.withOwnChildren(() => this.#view.render(this.render()));
384
+ markDerived(this);
385
+ }
386
+ }
387
+
388
+ /** Sizes are user units of at least 1, so a collapsing resize never reaches zero. */
389
+ function toSize(value: Length): number {
390
+ const size = typeof value === 'number' ? value : Number(value);
391
+ if (!Number.isFinite(size) || size < 0)
392
+ throw new RangeError('Component sizes must be finite non-negative numbers');
393
+ return Math.max(size, 1);
394
+ }
395
+
396
+ function parseProps(raw: AttributeValue): ComponentProps {
397
+ const props: unknown = raw == null ? {} : JSON.parse(String(raw));
398
+ if (!props || typeof props !== 'object' || Array.isArray(props))
399
+ throw new TypeError('Component props must be a JSON object');
400
+ return props as ComponentProps;
401
+ }
@@ -0,0 +1,104 @@
1
+ import {
2
+ Component,
3
+ installField,
4
+ assertField,
5
+ editComponent,
6
+ registerAction,
7
+ } from './component.js';
8
+ import { defineComponent } from './metadata.js';
9
+ import { initializeElement } from '../services/initialization.js';
10
+ import { destroySubtrees } from '../core/destruction.js';
11
+ import { withInternalStructure } from '../mixins/parent-internals.js';
12
+ import type { ComponentClass, ComponentOptions, PropOptions } from './types.js';
13
+
14
+ export function component(options: ComponentOptions) {
15
+ return <Class extends ComponentClass>(
16
+ target: Class,
17
+ _context: ClassDecoratorContext<Class>,
18
+ ): Class => {
19
+ if (!(target.prototype instanceof Component))
20
+ throw new TypeError('@component requires extends Component');
21
+ const Base = target as ComponentClass;
22
+ const Decorated = class extends Base {
23
+ constructor(...args: ConstructorParameters<ComponentClass>) {
24
+ const context = args[0],
25
+ before = new Set(context.registry.nodes);
26
+ try {
27
+ super(...args);
28
+ if (new.target === Decorated && !this.adopting) this[initializeElement]();
29
+ } catch (error) {
30
+ const created = context.registry.nodes.filter((node) => !before.has(node));
31
+ if (created.length) {
32
+ try {
33
+ withInternalStructure(() => created[0][destroySubtrees](created));
34
+ } catch (cleanup) {
35
+ throw new AggregateError(
36
+ [error, cleanup],
37
+ 'Component construction and cleanup failed',
38
+ );
39
+ }
40
+ }
41
+ throw error;
42
+ }
43
+ }
44
+ };
45
+ // Inherit edit rules from the decorated application base, not this wrapper's implementation base.
46
+ defineComponent(Decorated, options);
47
+ Object.defineProperty(Decorated, 'variant', {
48
+ value: Object.freeze({ attribute: 'data-rasam-component', value: options.type }),
49
+ });
50
+ return Decorated as Class;
51
+ };
52
+ }
53
+
54
+ function field(kind: 'prop' | 'state', options: PropOptions) {
55
+ return <Host extends Component, Value>(
56
+ _target: undefined,
57
+ context: ClassFieldDecoratorContext<Host, Value>,
58
+ ) => {
59
+ if (
60
+ context.kind !== 'field' ||
61
+ context.private ||
62
+ context.static ||
63
+ typeof context.name !== 'string'
64
+ )
65
+ throw new TypeError(`@${kind} requires a public instance field`);
66
+ context.addInitializer(function () {
67
+ this[installField](context.name as string, kind, options, context.access.get(this));
68
+ });
69
+ return function (this: Host, value: Value): Value {
70
+ // Validate before the JS field definition can overwrite a base instance member.
71
+ this[assertField](context.name as string);
72
+ return value;
73
+ };
74
+ };
75
+ }
76
+ export function prop(options: PropOptions = {}) {
77
+ return field('prop', options);
78
+ }
79
+ export function state() {
80
+ return field('state', {});
81
+ }
82
+
83
+ export function action(label: string) {
84
+ return <Host extends Component, Args extends unknown[], Result>(
85
+ method: (this: Host, ...args: Args) => Result,
86
+ context: ClassMethodDecoratorContext<Host, (this: Host, ...args: Args) => Result>,
87
+ ) => {
88
+ if (
89
+ context.static ||
90
+ context.private ||
91
+ typeof context.name !== 'string' ||
92
+ !label.trim() ||
93
+ method.constructor.name === 'AsyncFunction'
94
+ )
95
+ throw new TypeError('@action requires a synchronous public instance method and label');
96
+ const name = context.name;
97
+ context.addInitializer(function () {
98
+ this[registerAction](name, label);
99
+ });
100
+ return function (this: Host, ...args: Args): Result {
101
+ return this[editComponent](label, () => method.apply(this, args));
102
+ };
103
+ };
104
+ }
@@ -0,0 +1,111 @@
1
+ import type { PortDefinition } from '../types/common.js';
2
+ import type {
3
+ ComponentClass,
4
+ ComponentDefinition,
5
+ ComponentOptions,
6
+ PropOptions,
7
+ PropValue,
8
+ } from './types.js';
9
+
10
+ const definitions = new WeakMap<object, ComponentDefinition>();
11
+
12
+ export function definition(Constructor: object): ComponentDefinition {
13
+ const result = definitions.get(Constructor);
14
+ if (!result) throw new TypeError('Component classes require their own @component decorator');
15
+ return result;
16
+ }
17
+
18
+ export function defineComponent(Constructor: ComponentClass, options: ComponentOptions): void {
19
+ let parent = Object.getPrototypeOf(Constructor);
20
+ while (parent && !definitions.has(parent)) parent = Object.getPrototypeOf(parent);
21
+ const inherited = definitions.get(parent);
22
+ const result = {
23
+ category: 'General',
24
+ version: 1,
25
+ rotate: 'free',
26
+ flip: true,
27
+ resize: 'scale',
28
+ ...(inherited
29
+ ? {
30
+ rotate: inherited.rotate,
31
+ flip: inherited.flip,
32
+ resize: inherited.resize,
33
+ width: inherited.width,
34
+ height: inherited.height,
35
+ }
36
+ : {}),
37
+ ...options,
38
+ } as ComponentDefinition;
39
+ if (result.resize === 'props')
40
+ Object.assign(result, { width: result.width ?? 100, height: result.height ?? 100 });
41
+ if (!/^[a-z][a-z0-9-]*$/.test(result.type) || !result.name?.trim() || !result.category?.trim())
42
+ throw new TypeError('Components need a lowercase type, display name and category');
43
+ if (!Number.isInteger(result.version) || result.version < 1)
44
+ throw new TypeError('Component version must be a positive integer');
45
+ if (
46
+ result.rotate !== 'free' &&
47
+ result.rotate !== false &&
48
+ (!Number.isFinite(result.rotate) ||
49
+ result.rotate <= 0 ||
50
+ Math.abs(360 / result.rotate - Math.round(360 / result.rotate)) > 1e-8)
51
+ )
52
+ throw new TypeError('Rotation steps must be positive divisors of 360');
53
+ if (typeof result.flip !== 'boolean' || !['scale', 'props', false].includes(result.resize))
54
+ throw new TypeError('Invalid component flip or resize rule');
55
+ if (
56
+ result.resize === 'props'
57
+ ? !isSize(result.width) || !isSize(result.height)
58
+ : result.width !== undefined || result.height !== undefined
59
+ )
60
+ throw new TypeError("Default sizes need resize: 'props' and positive finite numbers");
61
+ definitions.set(Constructor, Object.freeze(result));
62
+ }
63
+
64
+ /** A saved size of a `resize: 'props'` component, in user units. */
65
+ export const isSize = (value: unknown): value is number =>
66
+ typeof value === 'number' && Number.isFinite(value) && value > 0;
67
+
68
+ export function validateProp(
69
+ value: unknown,
70
+ initial: PropValue,
71
+ options: PropOptions,
72
+ name: string,
73
+ ): asserts value is PropValue {
74
+ if (value !== null && !['string', 'number', 'boolean'].includes(typeof value))
75
+ throw new TypeError(`Invalid component prop: ${name}`);
76
+ if (value !== null && initial !== null && typeof value !== typeof initial)
77
+ throw new TypeError(`Wrong type for component prop: ${name}`);
78
+ if (
79
+ typeof value === 'number' &&
80
+ (!Number.isFinite(value) ||
81
+ value < (options.min ?? -Infinity) ||
82
+ value > (options.max ?? Infinity))
83
+ )
84
+ throw new RangeError(`Component prop is outside its limits: ${name}`);
85
+ if (options.options && !options.options.includes(value as PropValue))
86
+ throw new RangeError(`Unknown option for component prop: ${name}`);
87
+ }
88
+
89
+ const sides: readonly unknown[] = ['left', 'right', 'top', 'bottom'];
90
+
91
+ /** Checks what `ports()` returned and freezes a copy with only the known fields. */
92
+ export function validatePorts(ports: unknown): readonly PortDefinition[] {
93
+ if (!Array.isArray(ports)) throw new TypeError('Component ports() must return an array');
94
+ const names = new Set<string>();
95
+ return Object.freeze(
96
+ ports.map((port: Partial<PortDefinition> | null) => {
97
+ const { name, x, y, side } = port ?? {};
98
+ if (typeof name !== 'string' || !name)
99
+ throw new TypeError('Component port names must be non-empty strings');
100
+ if (names.has(name)) throw new TypeError(`Duplicate component port: ${name}`);
101
+ names.add(name);
102
+ if (!Number.isFinite(x) || !Number.isFinite(y))
103
+ throw new RangeError(`Component port ${name} needs finite x and y`);
104
+ if (side !== undefined && !sides.includes(side))
105
+ throw new TypeError(`Unknown side for component port ${name}: ${String(side)}`);
106
+ return Object.freeze(
107
+ side === undefined ? { name, x: x!, y: y! } : { name, x: x!, y: y!, side },
108
+ );
109
+ }),
110
+ );
111
+ }
@@ -0,0 +1,79 @@
1
+ import type { AnyNode } from '../core/types.js';
2
+ import type { ParentNode } from '../mixins/types.js';
3
+ import type { ElementClass } from '../services/types.js';
4
+ import { Guards } from '../mixins/guards.js';
5
+ import { ComponentChanges } from './changes.js';
6
+ import { Component } from './component.js';
7
+ import { definition } from './metadata.js';
8
+ import type { ComponentClass, ComponentDefinition } from './types.js';
9
+
10
+ /** Per-drawing type registration, palette metadata, icons and unknown-type warnings. */
11
+ export class ComponentTypes {
12
+ private readonly types = new Map<string, ComponentClass>();
13
+ private readonly warned = new Set<string>();
14
+ private readonly seen = new WeakSet<AnyNode>();
15
+ private readonly stop: () => void;
16
+ constructor(
17
+ private readonly drawing: AnyNode,
18
+ types: readonly ComponentClass[] = [],
19
+ ) {
20
+ for (const Constructor of types) this.register(Constructor);
21
+ this.stop = ComponentChanges.for(drawing).discover({
22
+ added: (node) => this.inspect(node),
23
+ identityChanged: (node) => this.warn(node),
24
+ });
25
+ drawing.onDispose(() => this.destroy());
26
+ this.inspect(drawing);
27
+ }
28
+ private inspect(node: AnyNode): void {
29
+ if (this.seen.has(node) || node.isDestroyed) return;
30
+ this.seen.add(node);
31
+ this.warn(node);
32
+ if (Guards.isParent(node)) for (const child of node.children) this.inspect(child);
33
+ }
34
+ private warn(node: AnyNode): void {
35
+ const type = node.getAttributeValue('data-rasam-component');
36
+ if (type !== undefined && !(node instanceof Component) && !this.warned.has(String(type))) {
37
+ this.warned.add(String(type));
38
+ console.warn(`Unknown rasam component type: ${type}; loaded as a plain group`);
39
+ }
40
+ }
41
+ register(Constructor: ComponentClass): this {
42
+ const metadata = definition(Constructor),
43
+ previous = this.types.get(metadata.type);
44
+ if (previous && previous !== Constructor)
45
+ throw new Error(`Component type already registered: ${metadata.type}`);
46
+ this.drawing.context.registry.register(Constructor as ElementClass);
47
+ this.types.set(metadata.type, Constructor);
48
+ return this;
49
+ }
50
+ list(category?: string): readonly ComponentDefinition[] {
51
+ return Object.freeze(
52
+ [...this.types.values()]
53
+ .map((Constructor) => definition(Constructor))
54
+ .filter((metadata) => category === undefined || metadata.category === category)
55
+ .sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)),
56
+ );
57
+ }
58
+ get(type: string): ComponentClass | undefined {
59
+ return this.types.get(type);
60
+ }
61
+ icon(type: string, parent: ParentNode): Component {
62
+ const Constructor = this.get(type);
63
+ if (!Constructor) throw new TypeError(`Unknown component type: ${type}`);
64
+ return parent.create(Constructor as ElementClass<{}, Component>, {});
65
+ }
66
+ resetState(): void {
67
+ for (const node of this.drawing.descendants()) if (node instanceof Component) node.resetState();
68
+ }
69
+ destroy(): void {
70
+ this.stop();
71
+ }
72
+ }
73
+
74
+ /** Compose with selection's existing filter to keep rendered children closed. */
75
+ export function isComponentChild(node: AnyNode): boolean {
76
+ for (let parent = node.parent; parent; parent = parent.parent)
77
+ if (parent instanceof Component) return true;
78
+ return false;
79
+ }