feat: add basic transform to Node class (#83)

- The Node class now has a basic position, rotation and scale controls.
- Compound properties have been changed to make the code less repetitive.
The signals making up a compound property are now nested within it:
```ts
// before
node.scaleX();
// now
node.scale.x();
```
Which means that they don't need to be redeclared each time a compound property is used.
This commit is contained in:
Jacob
2022-11-27 10:29:36 +01:00
committed by GitHub
parent 4d7f2aee6d
commit 9e114c8830
19 changed files with 511 additions and 433 deletions

View File

@@ -3,20 +3,23 @@ import {
compound,
computed,
initial,
Property,
inspectable,
property,
Vector2LengthProperty,
Vector2Property,
vector2Property,
wrapper,
} from '../decorators';
import {
Origin,
PossibleSpacing,
Rect,
Spacing,
transformAngle,
Vector2,
originToOffset,
SerializedVector2,
PossibleVector2,
} from '@motion-canvas/core/lib/types';
import {isReactive, Signal, SignalValue} from '@motion-canvas/core/lib/utils';
import {createSignal, Signal, SignalValue} from '@motion-canvas/core/lib/utils';
import {
InterpolationFunction,
TimingFunction,
@@ -36,6 +39,7 @@ import {ThreadGenerator} from '@motion-canvas/core/lib/threading';
import {Node, NodeProps} from './Node';
import {View2D} from '../scenes';
import {drawLine, lineTo} from '../utils';
import {spacingProperty, SpacingProperty} from '../decorators/spacingProperty';
export interface LayoutProps extends NodeProps {
layout?: LayoutMode;
@@ -81,17 +85,10 @@ export interface LayoutProps extends NodeProps {
letterSpacing?: SignalValue<number>;
textWrap?: SignalValue<boolean>;
x?: SignalValue<number>;
y?: SignalValue<number>;
position?: SignalValue<Vector2>;
size?: SignalValue<Vector2>;
rotation?: SignalValue<number>;
size?: SignalValue<PossibleVector2>;
offsetX?: SignalValue<number>;
offsetY?: SignalValue<number>;
offset?: SignalValue<Vector2>;
scaleX?: SignalValue<number>;
scaleY?: SignalValue<number>;
scale?: SignalValue<Vector2>;
offset?: SignalValue<PossibleVector2>;
clip?: SignalValue<boolean>;
}
@@ -116,49 +113,11 @@ export class Layout extends Node {
@property()
public declare readonly ratio: Signal<number | null, this>;
@initial(0)
@property()
public declare readonly marginTop: Signal<number, this>;
@initial(0)
@property()
public declare readonly marginBottom: Signal<number, this>;
@initial(0)
@property()
public declare readonly marginLeft: Signal<number, this>;
@initial(0)
@property()
public declare readonly marginRight: Signal<number, this>;
@compound({
top: 'marginTop',
bottom: 'marginBottom',
left: 'marginLeft',
right: 'marginRight',
})
@wrapper(Spacing)
@property()
public declare readonly margin: Property<PossibleSpacing, Spacing, this>;
@spacingProperty('margin')
public declare readonly margin: SpacingProperty<this>;
@initial(0)
@property()
public declare readonly paddingTop: Signal<number, this>;
@initial(0)
@property()
public declare readonly paddingBottom: Signal<number, this>;
@initial(0)
@property()
public declare readonly paddingLeft: Signal<number, this>;
@initial(0)
@property()
public declare readonly paddingRight: Signal<number, this>;
@compound({
top: 'paddingTop',
bottom: 'paddingBottom',
left: 'paddingLeft',
right: 'paddingRight',
})
@wrapper(Spacing)
@property()
public declare readonly padding: Property<PossibleSpacing, Spacing, this>;
@spacingProperty('padding')
public declare readonly padding: SpacingProperty<this>;
@initial('row')
@property()
@@ -214,12 +173,10 @@ export class Layout extends Node {
@property()
public declare readonly textWrap: Signal<boolean | null, this>;
@initial(0)
@cloneable(false)
@inspectable(false)
@property()
protected declare readonly customX: Signal<number, this>;
@initial(0)
@property()
public declare readonly x: Signal<number, this>;
protected getX(): number {
if (this.isLayoutRoot()) {
return this.customX();
@@ -231,12 +188,11 @@ export class Layout extends Node {
this.customX(value);
}
@initial(0)
@cloneable(false)
@inspectable(false)
@property()
protected declare readonly customY: Signal<number, this>;
@initial(0)
@property()
public declare readonly y: Signal<number, this>;
protected getY(): number {
if (this.isLayoutRoot()) {
return this.customY();
@@ -248,12 +204,6 @@ export class Layout extends Node {
this.customY(value);
}
@initial(null)
@property()
protected declare readonly customWidth: Signal<Length, this>;
@initial(null)
@property()
public declare readonly width: Property<Length, number, this>;
protected getWidth(): number {
return this.computedSize().width;
}
@@ -272,34 +222,28 @@ export class Layout extends Node {
const lock = typeof width !== 'number' || typeof value !== 'number';
let from: number;
if (lock) {
from = this.width();
from = this.size.x();
} else {
from = width;
}
let to: number;
if (lock) {
this.width(value);
to = this.width();
this.size.x(value);
to = this.size.x();
} else {
to = value;
}
this.width(from);
this.size.x(from);
lock && this.lockSize();
yield* tween(time, value =>
this.width(interpolationFunction(from, to, timingFunction(value))),
this.size.x(interpolationFunction(from, to, timingFunction(value))),
);
this.width(value);
this.size.x(value);
lock && this.releaseSize();
}
@initial(null)
@property()
protected declare readonly customHeight: Signal<Length, this>;
@initial(null)
@property()
public declare readonly height: Property<Length, number, this>;
protected getHeight(): number {
return this.computedSize().height;
}
@@ -319,55 +263,57 @@ export class Layout extends Node {
let from: number;
if (lock) {
from = this.height();
from = this.size.y();
} else {
from = height;
}
let to: number;
if (lock) {
this.height(value);
to = this.height();
this.size.y(value);
to = this.size.y();
} else {
to = value;
}
this.height(from);
this.size.y(from);
lock && this.lockSize();
yield* tween(time, value =>
this.height(interpolationFunction(from, to, timingFunction(value))),
this.size.y(interpolationFunction(from, to, timingFunction(value))),
);
this.height(value);
this.size.y(value);
lock && this.releaseSize();
}
@compound({x: 'width', y: 'height'})
@cloneable(false)
@wrapper(Vector2)
@property()
public declare readonly size: Property<
{width: Length; height: Length},
Vector2,
this
>;
@compound({x: 'width', y: 'height'})
public declare readonly size: Vector2LengthProperty<this>;
@inspectable(false)
@property()
protected declare readonly customWidth: Signal<Length, this>;
@inspectable(false)
@property()
protected declare readonly customHeight: Signal<Length, this>;
@computed()
protected customSize(): {width: Length; height: Length} {
protected customSize(): SerializedVector2<Length> {
return {
width: this.customWidth(),
height: this.customHeight(),
x: this.customWidth(),
y: this.customHeight(),
};
}
@threadable()
protected *tweenSize(
value: SignalValue<{width: Length; height: Length}>,
value: SignalValue<SerializedVector2<Length>>,
time: number,
timingFunction: TimingFunction,
interpolationFunction: InterpolationFunction<Vector2>,
): ThreadGenerator {
const size = this.customSize();
let from: Vector2;
if (typeof size.height !== 'number' || typeof size.width !== 'number') {
if (typeof size.x !== 'number' || typeof size.y !== 'number') {
from = this.size();
} else {
from = <Vector2>size;
@@ -376,8 +322,8 @@ export class Layout extends Node {
let to: Vector2;
if (
typeof value === 'object' &&
typeof value.height === 'number' &&
typeof value.width === 'number'
typeof value.x === 'number' &&
typeof value.y === 'number'
) {
to = <Vector2>value;
} else {
@@ -394,101 +340,8 @@ export class Layout extends Node {
this.size(value);
}
@initial(0)
@property()
public declare readonly rotation: Signal<number, this>;
@initial(0)
@property()
public declare readonly offsetX: Signal<number, this>;
@initial(0)
@property()
public declare readonly offsetY: Signal<number, this>;
@compound({x: 'offsetX', y: 'offsetY'})
@wrapper(Vector2)
@property()
public declare readonly offset: Signal<Vector2, this>;
@initial(1)
@property()
public declare readonly scaleX: Signal<number, this>;
@initial(1)
@property()
public declare readonly scaleY: Signal<number, this>;
@compound({x: 'scaleX', y: 'scaleY'})
@wrapper(Vector2)
@property()
public declare readonly scale: Signal<Vector2, this>;
@wrapper(Vector2)
@cloneable(false)
@property()
public declare readonly absoluteScale: Signal<Vector2, this>;
protected getAbsoluteScale(): Vector2 {
const matrix = this.localToWorld();
return new Vector2(
Vector2.magnitude(matrix.m11, matrix.m12),
Vector2.magnitude(matrix.m21, matrix.m22),
);
}
protected setAbsoluteScale(value: SignalValue<Vector2>) {
if (isReactive(value)) {
this.scale(() => this.getRelativeScale(value()));
} else {
this.scale(this.getRelativeScale(value));
}
}
private getRelativeScale(scale: Vector2): Vector2 {
const parentScale = this.parentTransform()?.absoluteScale() ?? Vector2.one;
return scale.div(parentScale);
}
@compound(['x', 'y'])
@wrapper(Vector2)
@property()
public declare readonly position: Signal<Vector2, this>;
@wrapper(Vector2)
@cloneable(false)
@property()
public declare readonly absolutePosition: Signal<Vector2, this>;
protected getAbsolutePosition(): Vector2 {
const matrix = this.localToWorld();
return new Vector2(matrix.m41, matrix.m42);
}
protected setAbsolutePosition(value: SignalValue<Vector2>) {
if (isReactive(value)) {
this.position(() => value().transformAsPoint(this.worldToParent()));
} else {
this.position(value.transformAsPoint(this.worldToParent()));
}
}
@cloneable(false)
@property()
public declare readonly absoluteRotation: Signal<number, this>;
protected getAbsoluteRotation() {
const matrix = this.localToWorld();
return (Math.atan2(matrix.m12, matrix.m11) * 180) / Math.PI;
}
protected setAbsoluteRotation(value: SignalValue<number>) {
if (isReactive(value)) {
this.rotation(() => transformAngle(value(), this.worldToParent()));
} else {
this.rotation(transformAngle(value, this.worldToParent()));
}
}
@vector2Property('offset')
public declare readonly offset: Vector2Property<this>;
@initial(false)
@property()
@@ -497,9 +350,7 @@ export class Layout extends Node {
public readonly element: HTMLElement;
public readonly styles: CSSStyleDeclaration;
@initial(0)
@property()
protected declare readonly sizeLockCounter: Signal<number, this>;
protected readonly sizeLockCounter = createSignal(0);
public constructor({tagName = 'div', ...props}: LayoutProps) {
super(props);
@@ -561,14 +412,11 @@ export class Layout extends Node {
public override localToParent(): DOMMatrix {
const matrix = new DOMMatrix();
const size = this.computedSize();
matrix.translateSelf(this.x(), this.y());
const offset = this.size().mul(this.offset()).scale(-0.5);
matrix.translateSelf(this.position.x(), this.position.y());
matrix.rotateSelf(0, 0, this.rotation());
matrix.scaleSelf(this.scaleX(), this.scaleY());
matrix.translateSelf(
(size.width / -2) * this.offsetX(),
(size.height / -2) * this.offsetY(),
);
matrix.scaleSelf(this.scale.x(), this.scale.y());
matrix.translateSelf(offset.x, offset.y);
return matrix;
}
@@ -583,8 +431,8 @@ export class Layout extends Node {
const rect = this.getComputedLayout();
const position = new Vector2(
rect.x + (rect.width / 2) * this.offsetX(),
rect.y + (rect.height / 2) * this.offsetY(),
rect.x + (rect.width / 2) * this.offset.x(),
rect.y + (rect.height / 2) * this.offset.y(),
);
const parent = this.parentTransform();
@@ -626,23 +474,29 @@ export class Layout extends Node {
this.applyFont();
this.applyFlex();
if (this.layoutEnabled()) {
this.syncDOM();
const children = this.layoutChildren();
for (const child of children) {
child.updateLayout();
}
}
}
@computed()
protected syncDOM() {
protected layoutChildren(): Layout[] {
this.element.innerText = '';
const queue = [...this.children()];
const result: Layout[] = [];
while (queue.length) {
const child = queue.shift();
if (child instanceof Layout) {
this.element.append(child.element);
child.updateLayout();
result.push(child);
} else if (child) {
queue.push(...child.children());
}
}
return result;
}
/**
@@ -774,15 +628,15 @@ export class Layout extends Node {
this.element.style.minWidth = this.parseLength(this.minWidth());
this.element.style.aspectRatio = this.parseValue(this.ratio());
this.element.style.marginTop = this.parsePixels(this.marginTop());
this.element.style.marginBottom = this.parsePixels(this.marginBottom());
this.element.style.marginLeft = this.parsePixels(this.marginLeft());
this.element.style.marginRight = this.parsePixels(this.marginRight());
this.element.style.marginTop = this.parsePixels(this.margin.top());
this.element.style.marginBottom = this.parsePixels(this.margin.bottom());
this.element.style.marginLeft = this.parsePixels(this.margin.left());
this.element.style.marginRight = this.parsePixels(this.margin.right());
this.element.style.paddingTop = this.parsePixels(this.paddingTop());
this.element.style.paddingBottom = this.parsePixels(this.paddingBottom());
this.element.style.paddingLeft = this.parsePixels(this.paddingLeft());
this.element.style.paddingRight = this.parsePixels(this.paddingRight());
this.element.style.paddingTop = this.parsePixels(this.padding.top());
this.element.style.paddingBottom = this.parsePixels(this.padding.bottom());
this.element.style.paddingLeft = this.parsePixels(this.padding.left());
this.element.style.paddingRight = this.parsePixels(this.padding.right());
this.element.style.flexDirection = this.direction();
this.element.style.flexBasis = this.parseLength(this.basis());

View File

@@ -1,11 +1,15 @@
import {
compound,
cloneable,
ColorProperty,
colorProperty,
computed,
getPropertiesOf,
initial,
initialize,
Property,
property,
Vector2Property,
vector2Property,
wrapper,
} from '../decorators';
import {
@@ -13,7 +17,8 @@ import {
Rect,
transformScalar,
PossibleColor,
Color,
transformAngle,
PossibleVector2,
} from '@motion-canvas/core/lib/types';
import {
createSignal,
@@ -32,6 +37,15 @@ import {drawLine} from '../utils';
export interface NodeProps {
ref?: Reference<any>;
children?: ComponentChildren;
x?: SignalValue<number>;
y?: SignalValue<number>;
position?: SignalValue<PossibleVector2>;
rotation?: SignalValue<number>;
scaleX?: SignalValue<number>;
scaleY?: SignalValue<number>;
scale?: SignalValue<PossibleVector2>;
opacity?: SignalValue<number>;
blur?: SignalValue<number>;
brightness?: SignalValue<number>;
@@ -45,7 +59,7 @@ export interface NodeProps {
shadowBlur?: SignalValue<number>;
shadowOffsetX?: SignalValue<number>;
shadowOffsetY?: SignalValue<number>;
shadowOffset?: SignalValue<Vector2>;
shadowOffset?: SignalValue<PossibleVector2>;
cache?: SignalValue<boolean>;
composite?: SignalValue<boolean>;
compositeOperation?: SignalValue<GlobalCompositeOperation>;
@@ -54,6 +68,86 @@ export interface NodeProps {
export class Node implements Promisable<Node> {
public declare isClass: boolean;
@vector2Property()
public declare readonly position: Vector2Property<this>;
@wrapper(Vector2)
@cloneable(false)
@property()
public declare readonly absolutePosition: Property<
PossibleVector2,
Vector2,
this
>;
protected getAbsolutePosition(): Vector2 {
const matrix = this.localToWorld();
return new Vector2(matrix.m41, matrix.m42);
}
protected setAbsolutePosition(value: SignalValue<Vector2>) {
if (isReactive(value)) {
this.position(() => value().transformAsPoint(this.worldToParent()));
} else {
this.position(value.transformAsPoint(this.worldToParent()));
}
}
@initial(0)
@property()
public declare readonly rotation: Signal<number, this>;
@cloneable(false)
@property()
public declare readonly absoluteRotation: Signal<number, this>;
protected getAbsoluteRotation() {
const matrix = this.localToWorld();
return (Math.atan2(matrix.m12, matrix.m11) * 180) / Math.PI;
}
protected setAbsoluteRotation(value: SignalValue<number>) {
if (isReactive(value)) {
this.rotation(() => transformAngle(value(), this.worldToParent()));
} else {
this.rotation(transformAngle(value, this.worldToParent()));
}
}
@initial(Vector2.one)
@vector2Property('scale')
public declare readonly scale: Vector2Property<this>;
@wrapper(Vector2)
@cloneable(false)
@property()
public declare readonly absoluteScale: Property<
PossibleVector2,
Vector2,
this
>;
protected getAbsoluteScale(): Vector2 {
const matrix = this.localToWorld();
return new Vector2(
Vector2.magnitude(matrix.m11, matrix.m12),
Vector2.magnitude(matrix.m21, matrix.m22),
);
}
protected setAbsoluteScale(value: SignalValue<Vector2>) {
if (isReactive(value)) {
this.scale(() => this.getRelativeScale(value()));
} else {
this.scale(this.getRelativeScale(value));
}
}
private getRelativeScale(scale: Vector2): Vector2 {
const parentScale = this.parent()?.absoluteScale() ?? Vector2.one;
return scale.div(parentScale);
}
@initial(false)
@property()
public declare readonly cache: Signal<boolean, this>;
@@ -131,26 +225,15 @@ export class Node implements Promisable<Node> {
public declare readonly sepia: Signal<number, this>;
@initial('#0000')
@wrapper(Color)
@property()
public declare readonly shadowColor: Property<PossibleColor, Color, this>;
@colorProperty()
public declare readonly shadowColor: ColorProperty<this>;
@initial(0)
@property()
public declare readonly shadowBlur: Signal<number, this>;
@initial(0)
@property()
public declare readonly shadowOffsetX: Signal<number, this>;
@initial(0)
@property()
public declare readonly shadowOffsetY: Signal<number, this>;
@compound({x: 'shadowOffsetX', y: 'shadowOffsetY'})
@wrapper(Vector2)
@property()
public declare readonly shadowOffset: Signal<Vector2, this>;
@vector2Property('shadowOffset')
public declare readonly shadowOffset: Vector2Property<this>;
@computed()
protected hasFilters() {
@@ -171,8 +254,8 @@ export class Node implements Promisable<Node> {
return (
!!this.shadowColor() &&
(this.shadowBlur() > 0 ||
this.shadowOffsetX() !== 0 ||
this.shadowOffsetY() !== 0)
this.shadowOffset.x() !== 0 ||
this.shadowOffset.y() !== 0)
);
}
@@ -223,6 +306,9 @@ export class Node implements Promisable<Node> {
public constructor({children, ...rest}: NodeProps) {
initialize(this, {defaults: rest});
for (const {signal} of this) {
signal.reset();
}
this.add(children);
this.key = use2DView()?.registerNode(this) ?? '';
}
@@ -247,7 +333,12 @@ export class Node implements Promisable<Node> {
@computed()
public localToParent(): DOMMatrix {
return new DOMMatrix();
const matrix = new DOMMatrix();
matrix.translateSelf(this.position.x(), this.position.y());
matrix.rotateSelf(0, 0, this.rotation());
matrix.scaleSelf(this.scale.x(), this.scale.y());
return matrix;
}
@computed()
@@ -434,37 +525,40 @@ export class Node implements Promisable<Node> {
props.children ??= this.children().map(child => child.clone());
}
for (const key in this.properties) {
const meta = this.properties[key];
for (const {key, meta, signal} of this) {
if (!meta.cloneable || key in props) continue;
const signal = (<Record<string, Signal<any>>>(<unknown>this))[key];
props[key] = signal();
if (meta.compound) {
for (const [key, property] of meta.compoundEntries) {
props[property] = (<Record<string, Signal<any>>>(<unknown>signal))[
key
].raw();
}
} else {
props[key] = signal.raw();
}
}
return this.instantiate(props);
}
/**
* Create a raw copy of this node.
* Create a copy of this node.
*
* @remarks
* A raw copy preserves any reactive properties from the source node.
* Unlike {@link clone}, a snapshot clone calculates any reactive properties
* at the moment of cloning and passes the raw values to the copy.
*
* @param customProps - Properties to override.
*/
public rawClone(customProps: NodeProps = {}): this {
public snapshotClone(customProps: NodeProps = {}): this {
const props: NodeProps & Record<string, any> = {...customProps};
if (this.children().length > 0) {
props.children ??= this.children().map(child => child.rawClone());
props.children ??= this.children().map(child => child.snapshotClone());
}
for (const key in this.properties) {
const meta = this.properties[key];
for (const {key, meta, signal} of this) {
if (!meta.cloneable || key in props) continue;
const signal = (<Record<string, Signal<any>>>(<unknown>this))[key];
props[key] = signal.raw();
props[key] = signal();
}
return this.instantiate(props);
@@ -485,11 +579,8 @@ export class Node implements Promisable<Node> {
props.children ??= this.children().map(child => child.reactiveClone());
}
for (const key in this.properties) {
const meta = this.properties[key];
for (const {key, meta, signal} of this) {
if (!meta.cloneable || key in props) continue;
const signal = (<Record<string, Signal<any>>>(<unknown>this))[key];
props[key] = () => signal();
}
@@ -779,6 +870,14 @@ export class Node implements Promisable<Node> {
await this.waitForAsyncResources();
return this;
}
public *[Symbol.iterator]() {
for (const key in this.properties) {
const meta = this.properties[key];
const signal = (<Record<string, Signal<any>>>(<unknown>this))[key];
yield {meta, signal, key};
}
}
}
/*@__PURE__*/

View File

@@ -1,11 +1,17 @@
import {CanvasStyle, PossibleCanvasStyle} from '../partials';
import {computed, initial, parser, Property, property} from '../decorators';
import {PossibleCanvasStyle} from '../partials';
import {
computed,
initial,
property,
CanvasStyleProperty,
canvasStyleProperty,
} from '../decorators';
import {createSignal, Signal, SignalValue} from '@motion-canvas/core/lib/utils';
import {Rect} from '@motion-canvas/core/lib/types';
import {Layout, LayoutProps} from './Layout';
import {threadable} from '@motion-canvas/core/lib/decorators';
import {easeOutExpo, linear, map} from '@motion-canvas/core/lib/tweening';
import {resolveCanvasStyle, canvasStyleParser} from '../utils';
import {resolveCanvasStyle} from '../utils';
export interface ShapeProps extends LayoutProps {
fill?: SignalValue<PossibleCanvasStyle>;
@@ -19,22 +25,10 @@ export interface ShapeProps extends LayoutProps {
}
export abstract class Shape extends Layout {
@initial(null)
@parser(canvasStyleParser)
@property()
public declare readonly fill: Property<
PossibleCanvasStyle,
CanvasStyle,
this
>;
@initial(null)
@parser(canvasStyleParser)
@property()
public declare readonly stroke: Property<
PossibleCanvasStyle,
CanvasStyle,
this
>;
@canvasStyleProperty()
public declare readonly fill: CanvasStyleProperty<this>;
@canvasStyleProperty()
public declare readonly stroke: CanvasStyleProperty<this>;
@initial(false)
@property()
public declare readonly strokeFirst: Signal<boolean, this>;

View File

@@ -0,0 +1,17 @@
import {initial, parser, property, Property} from './property';
import {canvasStyleParser} from '../utils';
import {CanvasStyle, PossibleCanvasStyle} from '../partials';
export type CanvasStyleProperty<T> = Property<
PossibleCanvasStyle,
CanvasStyle,
T
>;
export function canvasStyleProperty(): PropertyDecorator {
return (target, key) => {
property()(target, key);
parser(canvasStyleParser)(target, key);
initial(null)(target, key);
};
}

View File

@@ -0,0 +1,11 @@
import {Color, PossibleColor} from '@motion-canvas/core/lib/types';
import {property, Property, wrapper} from './property';
export type ColorProperty<T> = Property<PossibleColor, Color, T>;
export function colorProperty(): PropertyDecorator {
return (target, key) => {
property()(target, key);
wrapper(Color)(target, key);
};
}

View File

@@ -1,92 +1,127 @@
import {SignalValue, isReactive} from '@motion-canvas/core/lib/utils';
import {capitalize, getPropertyMeta} from './property';
import {
capitalize,
createProperty,
getPropertyMetaOrCreate,
Property,
} from './property';
import {addInitializer} from './initializers';
import {deepLerp} from '@motion-canvas/core/lib/tweening';
/**
* Create a compound property decorator.
*
* @remarks
* This decorator generates a getter and setter for a compound property.
* These methods can then be used by the {@link property} decorator to create
* a signal that acts as a shortcut for accessing multiple other signals.
*
* Both the getter and setter operate on an object whose properties correspond
* to individual signals.
* For example, `\@property(['x', 'y'])` will operate on an object of type
* `{x: number, y: number}`. Here, the `x` property can retrieve or set the
* value of the `this.x` signal.
* This decorator turns a given property into a signal consisting of one or more
* nested signals.
*
* @example
* ```ts
* class Example {
* \@property(1)
* public declare readonly scaleX: Signal<number, this>;
*
* \@property(1)
* public declare readonly scaleY: Signal<number, this>;
*
* \@compound({x: 'scaleX', y: 'scaleY'})
* \@property(undefined, vector2dLerp)
* public declare readonly scale: Signal<Vector2, this>;
*
* public setScale() {
* this.scale({x: 7, y: 3});
* // same as:
* this.scaleX(7).scaleY(3);
* this.scale.x(7).scale.y(3);
* }
* }
* ```
*
* @param mapping - An array of signals to turn into a compound property or a
* record mapping the property in the compound object to the
* corresponding signal.
* @param entries - A record mapping the property in the compound object to the
* corresponding property on the owner node.
*/
export function compound(
mapping: string[] | Record<string, string>,
): PropertyDecorator {
return (target: any, key) => {
const meta = getPropertyMeta<any>(target, key);
if (!meta) {
console.error(`Missing property decorator for "${key.toString()}"`);
return;
}
const entries = Array.isArray(mapping)
? mapping.map(key => [key, key])
: Object.entries(mapping);
export function compound(entries: Record<string, string>): PropertyDecorator {
return (target, key) => {
const meta = getPropertyMetaOrCreate<any>(target, key);
meta.compound = true;
meta.cloneable = false;
for (const [, property] of entries) {
const propertyMeta = getPropertyMeta<any>(target, property);
if (!propertyMeta) {
console.error(
`Missing property decorator for "${property.toString()}"`,
);
meta.compoundEntries = Object.entries(entries);
addInitializer(target, (instance: any, context: any) => {
if (!meta.parser) {
console.error(`Missing parser decorator for "${key.toString()}"`);
return;
}
propertyMeta.compoundParent = key.toString();
propertyMeta.inspectable = false;
}
const parser = meta.parser;
const initial = context.defaults[key] ?? meta.default;
const initialWrapped: SignalValue<any> = isReactive(initial)
? () => parser(initial())
: parser(initial);
target.constructor.prototype[`get${capitalize(key.toString())}`] =
function () {
const signals: [string, Property<any, any, any>][] = [];
for (const [key, property] of meta.compoundEntries) {
signals.push([
key,
createProperty(
instance,
property,
context.defaults[property] ??
(isReactive(initialWrapped)
? () => initialWrapped()[key]
: initialWrapped[key]),
undefined,
undefined,
instance[`get${capitalize(<string>property)}`],
instance[`set${capitalize(<string>property)}`],
instance[`tween${capitalize(<string>property)}`],
),
]);
}
function getter() {
const object = Object.fromEntries(
entries.map(([key, property]) => [key, this[property]()]),
signals.map(([key, property]) => [key, property()]),
);
return meta?.parser ? meta.parser(object) : object;
};
return parser(object);
}
target.constructor.prototype[`set${capitalize(key.toString())}`] =
function set(value: SignalValue<any>) {
function setter(value: SignalValue<any>) {
if (isReactive(value)) {
for (const [key, property] of entries) {
this[property](() => value()[key]);
for (const [key, property] of signals) {
property(() => value()[key]);
}
} else {
for (const [key, property] of entries) {
this[property](value[key]);
for (const [key, property] of signals) {
property(value[key]);
}
}
};
}
const property = createProperty(
instance,
<string>key,
undefined,
meta.interpolationFunction ?? deepLerp,
parser,
getter,
setter,
instance[`tween${capitalize(<string>key)}`],
);
for (const [key, signal] of signals) {
Object.defineProperty(property, key, {value: signal});
}
Object.defineProperty(property, 'reset', {
value: () => {
for (const [, signal] of signals) {
signal.reset();
}
return instance;
},
});
Object.defineProperty(property, 'save', {
value: () => {
for (const [, signal] of signals) {
signal.save();
}
return instance;
},
});
instance[key] = property;
});
};
}

View File

@@ -1,4 +1,7 @@
export * from './computed';
export * from './canvasStyleProperty';
export * from './colorProperty';
export * from './compound';
export * from './property';
export * from './computed';
export * from './initializers';
export * from './property';
export * from './vector2Property';

View File

@@ -28,6 +28,7 @@ export interface PropertyMetadata<T> {
inspectable?: boolean;
compoundParent?: string;
compound?: boolean;
compoundEntries: [string, string][];
}
export interface Property<
@@ -56,14 +57,13 @@ export function createProperty<
initial?: TSetterValue,
defaultInterpolation: InterpolationFunction<TGetterValue> = deepLerp,
parser?: (value: TSetterValue) => TGetterValue,
originalGetter?: SignalGetter<TGetterValue>,
originalSetter?: SignalSetter<TSetterValue>,
tweener?: SignalTween<TGetterValue>,
): Property<TSetterValue, TGetterValue, TNode> {
let getter: SignalGetter<TGetterValue>;
let setter: SignalSetter<TSetterValue>;
const originalGetter = node[`get${capitalize(property)}`];
const originalSetter = node[`set${capitalize(property)}`];
const tweener = node[`tween${capitalize(property)}`];
if (!originalGetter !== !originalSetter) {
console.warn(
`The "${property}" property needs to provide either both the setter and getter or none of them`,
@@ -148,6 +148,7 @@ export function createProperty<
);
Object.defineProperty(handler, 'reset', {
configurable: true,
value: signal
? signal.reset
: initial !== undefined
@@ -156,6 +157,7 @@ export function createProperty<
});
Object.defineProperty(handler, 'save', {
configurable: true,
value: () => setter(getter()),
});
@@ -163,10 +165,6 @@ export function createProperty<
value: signal?.raw ?? getter,
});
if (initial !== undefined && !signal) {
setter(wrap(initial));
}
return handler;
}
@@ -179,6 +177,34 @@ export function getPropertyMeta<T>(
return object[PROPERTIES]?.[key] ?? null;
}
export function getPropertyMetaOrCreate<T>(
object: any,
key: string | symbol,
): PropertyMetadata<T> {
let lookup: Record<string | symbol, PropertyMetadata<T>>;
if (!object[PROPERTIES]) {
object[PROPERTIES] = lookup = {};
} else if (
object[PROPERTIES] &&
!Object.prototype.hasOwnProperty.call(object, PROPERTIES)
) {
object[PROPERTIES] = lookup = Object.fromEntries<PropertyMetadata<T>>(
Object.entries(
<Record<string | symbol, PropertyMetadata<T>>>object[PROPERTIES],
).map(([key, meta]) => [key, {...meta}]),
);
} else {
lookup = object[PROPERTIES];
}
lookup[key] ??= {
cloneable: true,
inspectable: true,
compoundEntries: [],
};
return lookup[key];
}
export function getPropertiesOf(
value: any,
): Record<string, PropertyMetadata<any>> {
@@ -212,26 +238,7 @@ export function getPropertiesOf(
*/
export function property<T>(): PropertyDecorator {
return (target: any, key) => {
let lookup: Record<string | symbol, PropertyMetadata<T>>;
if (!target[PROPERTIES]) {
target[PROPERTIES] = lookup = {};
} else if (
target[PROPERTIES] &&
!Object.prototype.hasOwnProperty.call(target, PROPERTIES)
) {
target[PROPERTIES] = lookup = Object.fromEntries<PropertyMetadata<T>>(
Object.entries(
<Record<string | symbol, PropertyMetadata<T>>>target[PROPERTIES],
).map(([key, meta]) => [key, {...meta}]),
);
} else {
lookup = target[PROPERTIES];
}
const meta = (lookup[key] = lookup[key] ?? {
cloneable: true,
inspectable: true,
});
const meta = getPropertyMetaOrCreate<T>(target, key);
addInitializer(target, (instance: any, context: any) => {
instance[key] = createProperty(
instance,
@@ -239,6 +246,9 @@ export function property<T>(): PropertyDecorator {
context.defaults[key] ?? meta.default,
meta.interpolationFunction ?? deepLerp,
meta.parser,
target[`get${capitalize(<string>key)}`],
target[`set${capitalize(<string>key)}`],
target[`tween${capitalize(<string>key)}`],
);
});
};
@@ -346,12 +356,11 @@ export function parser<T>(value: (value: any) => T): PropertyDecorator {
* Create a property wrapper decorator.
*
* @remarks
* This decorator specifies the wrapper of a property.
* Instead of returning the raw value, an instance of the wrapper is returned.
* The actual value is passed as the first parameter to the constructor.
* This is a shortcut decorator for setting both the {@link parser} and
* {@link interpolation}.
*
* If the wrapper class has a method called `lerp` it will be set as the
* default interpolation function for the property.
* The interpolation function will be set only if the wrapper class has a method
* called `lerp`, which will be used as said function.
*
* Must be specified before the {@link property} decorator.
*
@@ -361,6 +370,12 @@ export function parser<T>(value: (value: any) => T): PropertyDecorator {
* \@wrapper(Vector2)
* \@property()
* public declare offset: Signal<Vector2, this>;
*
* // same as:
* \@parser(value => new Vector2(value))
* \@interpolation(Vector2.lerp)
* \@property()
* public declare offset: Signal<Vector2, this>;
* }
* ```
*
@@ -416,7 +431,7 @@ export function cloneable<T>(value = true): PropertyDecorator {
}
/**
* Create a inspectable property decorator.
* Create an inspectable property decorator.
*
* @remarks
* This decorator specifies whether the property should be visible in the

View File

@@ -0,0 +1,23 @@
import {PossibleSpacing, Spacing} from '@motion-canvas/core/lib/types';
import {Property, wrapper} from './property';
import {compound} from './compound';
import {Signal} from '@motion-canvas/core/lib/utils';
export type SpacingProperty<T> = Property<PossibleSpacing, Spacing, T> & {
top: Signal<number, T>;
right: Signal<number, T>;
bottom: Signal<number, T>;
left: Signal<number, T>;
};
export function spacingProperty(prefix?: string): PropertyDecorator {
return (target, key) => {
compound({
top: prefix ? `${prefix}Top` : 'top',
right: prefix ? `${prefix}Right` : 'right',
bottom: prefix ? `${prefix}Bottom` : 'bottom',
left: prefix ? `${prefix}Left` : 'left',
})(target, key);
wrapper(Spacing)(target, key);
};
}

View File

@@ -0,0 +1,29 @@
import {PossibleVector2, Vector2} from '@motion-canvas/core/lib/types';
import {Property, wrapper} from './property';
import {compound} from './compound';
import {Signal} from '@motion-canvas/core/lib/utils';
import {Length} from '../partials';
export type Vector2Property<T> = Property<PossibleVector2, Vector2, T> & {
x: Signal<number, T>;
y: Signal<number, T>;
};
export type Vector2LengthProperty<TOwner> = Property<
PossibleVector2<Length>,
Vector2,
TOwner
> & {
x: Property<Length, number, TOwner>;
y: Property<Length, number, TOwner>;
};
export function vector2Property(prefix?: string): PropertyDecorator {
return (target, key) => {
compound({
x: prefix ? `${prefix}X` : 'x',
y: prefix ? `${prefix}Y` : 'y',
})(target, key);
wrapper(Vector2)(target, key);
};
}

View File

@@ -1,10 +1,10 @@
import {
compound,
computed,
initial,
initialize,
property,
wrapper,
Vector2Property,
vector2Property,
} from '../decorators';
import {Color, PossibleColor, Vector2} from '@motion-canvas/core/lib/types';
import {Signal} from '@motion-canvas/core/lib/utils';
@@ -35,27 +35,11 @@ export class Gradient {
@property()
public declare readonly type: Signal<GradientType, this>;
@initial(0)
@property()
public declare readonly fromX: Signal<number, this>;
@initial(0)
@property()
public declare readonly fromY: Signal<number, this>;
@compound({x: 'fromX', y: 'fromY'})
@wrapper(Vector2)
@property()
public declare readonly from: Signal<Vector2, this>;
@vector2Property('from')
public declare readonly from: Vector2Property<this>;
@initial(0)
@property()
public declare readonly toX: Signal<number, this>;
@initial(0)
@property()
public declare readonly toY: Signal<number, this>;
@compound({x: 'toX', y: 'toY'})
@wrapper(Vector2)
@property()
public declare readonly to: Signal<Vector2, this>;
@vector2Property('to')
public declare readonly to: Vector2Property<this>;
@initial(0)
@property()
@@ -80,26 +64,26 @@ export class Gradient {
switch (this.type()) {
case 'linear':
gradient = context.createLinearGradient(
this.fromX(),
this.fromY(),
this.toX(),
this.toY(),
this.from.x(),
this.from.y(),
this.to.x(),
this.to.y(),
);
break;
case 'conic':
gradient = context.createConicGradient(
this.angle(),
this.fromX(),
this.fromY(),
this.from.x(),
this.from.y(),
);
break;
case 'radial':
gradient = context.createRadialGradient(
this.fromX(),
this.fromY(),
this.from.x(),
this.from.y(),
this.fromRadius(),
this.toX(),
this.toY(),
this.to.x(),
this.to.y(),
this.toRadius(),
);
break;

View File

@@ -7,7 +7,7 @@ import {
SceneRenderEvent,
} from '@motion-canvas/core/lib/scenes';
import {View2D} from './View2D';
import {Signal, useScene} from '@motion-canvas/core/lib/utils';
import {useScene} from '@motion-canvas/core/lib/utils';
import {Node} from '../components';
import {Vector2} from '@motion-canvas/core/lib/types';
@@ -59,12 +59,9 @@ export class Scene2D extends GeneratorScene<View2D> implements Inspectable {
): InspectedAttributes | null {
if (!(element instanceof Node)) return null;
const attributes: Record<string, any> = {};
for (const key in element.properties) {
const meta = element.properties[key];
for (const {key, meta, signal} of element) {
if (!meta.inspectable) continue;
attributes[key] = (<Record<string, Signal<any>>>(<unknown>element))[
key
]();
attributes[key] = signal();
}
return attributes;

View File

@@ -62,15 +62,16 @@ export class View2D extends Layout {
customMatrix.e !== currentMatrix.e ||
customMatrix.f !== currentMatrix.f
) {
this.x(customMatrix.m41)
.y(customMatrix.m42)
.scaleX(
this.position
.x(customMatrix.m41)
.position.y(customMatrix.m42)
.scale.x(
Math.sqrt(
customMatrix.m11 * customMatrix.m11 +
customMatrix.m12 * customMatrix.m12,
),
)
.scaleY(
.scale.y(
Math.sqrt(
customMatrix.m21 * customMatrix.m21 +
customMatrix.m22 * customMatrix.m22,

View File

@@ -101,15 +101,33 @@ export function drawImage(
}
}
export function moveTo(context: CanvasRenderingContext2D, position: Vector2) {
export function moveTo(
context: CanvasRenderingContext2D | Path2D,
position: Vector2,
) {
context.moveTo(position.x, position.y);
}
export function lineTo(context: CanvasRenderingContext2D, position: Vector2) {
export function lineTo(
context: CanvasRenderingContext2D | Path2D,
position: Vector2,
) {
context.lineTo(position.x, position.y);
}
export function drawLine(context: CanvasRenderingContext2D, points: Vector2[]) {
export function arcTo(
context: CanvasRenderingContext2D | Path2D,
through: Vector2,
position: Vector2,
radius: number,
) {
context.arcTo(through.x, through.y, position.x, position.y, radius);
}
export function drawLine(
context: CanvasRenderingContext2D | Path2D,
points: Vector2[],
) {
if (points.length < 2) return;
moveTo(context, points[0]);
for (const point of points.slice(1)) {

View File

@@ -127,6 +127,10 @@ export class Rect implements Type {
return new Vector2(this.width, this.height);
}
public get center() {
return new Vector2(this.x + this.width / 2, this.y + this.height / 2);
}
public get left() {
return this.x;
}

View File

@@ -1,19 +1,17 @@
import {Rect} from './Rect';
import {arcLerp, map} from '../tweening';
import {Direction, Origin} from './Origin';
import {Type} from './Type';
export type SerializedVector2 = {
x: number;
y: number;
export type SerializedVector2<T = number> = {
x: T;
y: T;
};
export type PossibleVector2 =
| SerializedVector2
| {width: number; height: number}
| number
| [number, number]
| Rect;
export type PossibleVector2<T = number> =
| SerializedVector2<T>
| {width: T; height: T}
| T
| [T, T];
export class Vector2 implements Type {
public static readonly symbol = Symbol.for(
@@ -141,7 +139,7 @@ export class Vector2 implements Type {
return;
}
if (typeof one === 'number') {
if (typeof one !== 'object') {
this.x = one;
this.y = two ?? one;
return;

View File

@@ -9,6 +9,6 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"experimentalDecorators": true
}
}

View File

@@ -2,7 +2,6 @@ import {makeScene2D} from '@motion-canvas/2d';
import {Circle} from '@motion-canvas/2d/lib/components';
import {waitFor, waitUntil} from '@motion-canvas/core/lib/flow';
import {useRef} from '@motion-canvas/core/lib/utils';
import {Vector2} from '@motion-canvas/core/lib/types';
export default makeScene2D(function* (view) {
const circle = useRef<Circle>();
@@ -12,7 +11,7 @@ export default makeScene2D(function* (view) {
);
yield* waitUntil('circle');
yield* circle.value.scale(Vector2.fromScalar(2), 2);
yield* circle.value.scale(2, 2);
yield* waitFor(5);
});

View File

@@ -1,9 +1,6 @@
import {useMemo} from 'preact/hooks';
import {useCurrentScene, useCurrentFrame} from '../../hooks';
import type {
Inspectable,
InspectedAttributes,
} from '@motion-canvas/core/lib/scenes';
import type {Inspectable} from '@motion-canvas/core/lib/scenes';
import {isInspectable} from '@motion-canvas/core/lib/scenes/Inspectable';
import {Pane} from '../tabs';
import {useInspection} from '../../contexts';