@rasamjs/core 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +14 -4
- package/CHANGELOG.md +59 -0
- package/COMPONENTS.md +229 -0
- package/README.md +39 -6
- package/RELEASING.md +4 -2
- package/dist/advanced.d.ts +1 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/catalog/fragment.d.ts +2 -2
- package/dist/catalog/fragment.d.ts.map +1 -1
- package/dist/catalog/fragment.js.map +1 -1
- package/dist/changes/derived.d.ts +10 -0
- package/dist/changes/derived.d.ts.map +1 -0
- package/dist/changes/derived.js +14 -0
- package/dist/changes/derived.js.map +1 -0
- package/dist/changes/restore.d.ts.map +1 -1
- package/dist/changes/restore.js +22 -4
- package/dist/changes/restore.js.map +1 -1
- package/dist/changes/service.d.ts +5 -0
- package/dist/changes/service.d.ts.map +1 -1
- package/dist/changes/service.js +16 -2
- package/dist/changes/service.js.map +1 -1
- package/dist/components/changes.d.ts +34 -0
- package/dist/components/changes.d.ts.map +1 -0
- package/dist/components/changes.js +119 -0
- package/dist/components/changes.js.map +1 -0
- package/dist/components/component.d.ts +62 -0
- package/dist/components/component.d.ts.map +1 -0
- package/dist/components/component.js +355 -0
- package/dist/components/component.js.map +1 -0
- package/dist/components/decorators.d.ts +7 -0
- package/dist/components/decorators.d.ts.map +1 -0
- package/dist/components/decorators.js +81 -0
- package/dist/components/decorators.js.map +1 -0
- package/dist/components/metadata.d.ts +10 -0
- package/dist/components/metadata.d.ts.map +1 -0
- package/dist/components/metadata.js +85 -0
- package/dist/components/metadata.js.map +1 -0
- package/dist/components/registry.d.ts +24 -0
- package/dist/components/registry.d.ts.map +1 -0
- package/dist/components/registry.js +78 -0
- package/dist/components/registry.js.map +1 -0
- package/dist/components/types.d.ts +54 -0
- package/dist/components/types.d.ts.map +1 -0
- package/dist/components/types.js +2 -0
- package/dist/components/types.js.map +1 -0
- package/dist/components/view.d.ts +9 -0
- package/dist/components/view.d.ts.map +1 -0
- package/dist/components/view.js +104 -0
- package/dist/components/view.js.map +1 -0
- package/dist/components.d.ts +6 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +5 -0
- package/dist/components.js.map +1 -0
- package/dist/core/node.d.ts +7 -0
- package/dist/core/node.d.ts.map +1 -1
- package/dist/core/node.js +30 -0
- package/dist/core/node.js.map +1 -1
- package/dist/interchange/clone.d.ts.map +1 -1
- package/dist/interchange/clone.js +3 -2
- package/dist/interchange/clone.js.map +1 -1
- package/dist/interchange/reconstruct.d.ts.map +1 -1
- package/dist/interchange/reconstruct.js +16 -12
- package/dist/interchange/reconstruct.js.map +1 -1
- package/dist/interchange/references.d.ts.map +1 -1
- package/dist/interchange/references.js +10 -4
- package/dist/interchange/references.js.map +1 -1
- package/dist/mixins/parent-internals.d.ts +6 -0
- package/dist/mixins/parent-internals.d.ts.map +1 -1
- package/dist/mixins/parent-internals.js +34 -0
- package/dist/mixins/parent-internals.js.map +1 -1
- package/dist/mixins/parent.d.ts.map +1 -1
- package/dist/mixins/parent.js +6 -1
- package/dist/mixins/parent.js.map +1 -1
- package/dist/mixins/types.d.ts +2 -2
- package/dist/mixins/types.d.ts.map +1 -1
- package/dist/resources/references.d.ts +2 -0
- package/dist/resources/references.d.ts.map +1 -1
- package/dist/resources/references.js +3 -3
- package/dist/resources/references.js.map +1 -1
- package/dist/services/catalog.d.ts.map +1 -1
- package/dist/services/catalog.js +5 -3
- package/dist/services/catalog.js.map +1 -1
- package/dist/services/defs-manager.d.ts +2 -0
- package/dist/services/defs-manager.d.ts.map +1 -1
- package/dist/services/defs-manager.js +4 -0
- package/dist/services/defs-manager.js.map +1 -1
- package/dist/services/element-key.d.ts +4 -0
- package/dist/services/element-key.d.ts.map +1 -0
- package/dist/services/element-key.js +12 -0
- package/dist/services/element-key.js.map +1 -0
- package/dist/services/initialization.d.ts +7 -0
- package/dist/services/initialization.d.ts.map +1 -0
- package/dist/services/initialization.js +27 -0
- package/dist/services/initialization.js.map +1 -0
- package/dist/services/registry.d.ts +8 -0
- package/dist/services/registry.d.ts.map +1 -1
- package/dist/services/registry.js +45 -10
- package/dist/services/registry.js.map +1 -1
- package/dist/services/types.d.ts +17 -0
- package/dist/services/types.d.ts.map +1 -1
- package/dist/template/parts.d.ts +12 -6
- package/dist/template/parts.d.ts.map +1 -1
- package/dist/template/parts.js +36 -29
- package/dist/template/parts.js.map +1 -1
- package/dist/template/render.d.ts +6 -7
- package/dist/template/render.d.ts.map +1 -1
- package/dist/template/render.js +188 -44
- package/dist/template/render.js.map +1 -1
- package/dist/template/svg-tag.d.ts +5 -1
- package/dist/template/svg-tag.d.ts.map +1 -1
- package/dist/template/svg-tag.js.map +1 -1
- package/dist/text/internals.d.ts +1 -0
- package/dist/text/internals.d.ts.map +1 -1
- package/dist/types/common.d.ts +27 -0
- package/dist/types/common.d.ts.map +1 -1
- package/package.json +156 -148
- package/reports/adoption.json +20 -20
- package/reports/animation-benchmark.json +612 -612
- package/reports/benchmark.json +40 -40
- package/reports/components-benchmark-before.json +10 -0
- package/reports/components-benchmark.json +10 -0
- package/reports/creation.json +26 -26
- package/reports/interchange-benchmark.json +85 -85
- package/reports/public-api-contract.json +334 -17
- package/reports/size-components.json +102 -0
- package/reports/size-parity.json +47 -34
- package/reports/size.json +6 -4
- package/reports/svgjs-comparison.json +3100 -3100
- package/reports/svgjs-comparison.md +16 -16
- package/reports/svgjs-parity-current.json +135 -42
- package/src/advanced.ts +1 -1
- package/src/catalog/fragment.ts +2 -2
- package/src/changes/derived.ts +19 -0
- package/src/changes/restore.ts +27 -4
- package/src/changes/service.ts +21 -6
- package/src/components/changes.ts +131 -0
- package/src/components/component.ts +401 -0
- package/src/components/decorators.ts +104 -0
- package/src/components/metadata.ts +111 -0
- package/src/components/registry.ts +79 -0
- package/src/components/types.ts +66 -0
- package/src/components/view.ts +123 -0
- package/src/components.ts +15 -0
- package/src/core/node.ts +32 -1
- package/src/interchange/clone.ts +3 -2
- package/src/interchange/reconstruct.ts +16 -12
- package/src/interchange/references.ts +12 -4
- package/src/mixins/parent-internals.ts +37 -0
- package/src/mixins/parent.ts +13 -4
- package/src/mixins/types.ts +2 -2
- package/src/resources/references.ts +5 -5
- package/src/services/catalog.ts +5 -3
- package/src/services/defs-manager.ts +5 -0
- package/src/services/element-key.ts +14 -0
- package/src/services/initialization.ts +25 -0
- package/src/services/registry.ts +47 -10
- package/src/services/types.ts +21 -0
- package/src/template/parts.ts +54 -39
- package/src/template/render.ts +211 -54
- package/src/template/svg-tag.ts +6 -1
- package/src/text/internals.ts +1 -0
- package/src/types/common.ts +29 -0
|
@@ -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
|
+
}
|
|
@@ -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/interchange/clone.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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())
|
|
@@ -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.
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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],
|