feat: switch to signals (#64)

The base Node class now uses signals for most functionalities:

Parent and child relations between nodes are represented by signals.

Layout changes are propagated through signals.
It makes it possible to access the computed layout right after changing it.
Previously:
```ts
rect.layout.height(100);
yield;
rect.layout.height(200);
console.log(rect.height()); // 100
```
Now:
```ts
rect.layout.height(100);
yield;
rect.layout.height(200);
console.log(rect.height()); // 200
```

A new signal primitive called `Computed` has been added.
It behaves like a signal except it can only be set once - during initialization.

A corresponding `@computed` method decorator has been added.
It turns the given method into a `Computed` value.

Properties marked with the `@property` decorator can now implement custom getters,
setters and tween providers.
This commit is contained in:
Jacob
2022-09-16 22:32:07 +02:00
committed by GitHub
parent 9255490096
commit d22d237285
20 changed files with 745 additions and 270 deletions

View File

@@ -16,23 +16,18 @@ export class Circle extends Node<CircleProps> {
context.save();
this.transformContext(context);
const width = this.width();
const height = this.height();
context.save();
context.fillStyle = this.fill();
context.beginPath();
context.ellipse(
0,
0,
this.width() / 2,
this.height() / 2,
0,
0,
Math.PI * 2,
);
context.ellipse(0, 0, width / 2, height / 2, 0, 0, Math.PI * 2);
context.closePath();
context.fill();
context.restore();
for (const child of this.children) {
for (const child of this.children()) {
child.render(context);
}

View File

@@ -1,9 +1,23 @@
import {compoundProperty, initialize, property} from '../decorators';
import {Vector2} from '@motion-canvas/core/lib/types';
import {Reference, Signal, useSignal} from '@motion-canvas/core/lib/utils';
import {vector2dLerp} from '@motion-canvas/core/lib/tweening';
import {Layout, LayoutMode, LayoutProps} from '../layout';
import {compound, computed, initialize, property} from '../decorators';
import {
Vector2,
transformPoint,
transformAngle,
Rect,
Size,
} from '@motion-canvas/core/lib/types';
import {
createSignal,
isReactive,
Reference,
Signal,
SignalValue,
} from '@motion-canvas/core/lib/utils';
import {map, vector2dLerp, sizeLerp} from '@motion-canvas/core/lib/tweening';
import {Layout, LayoutProps} from '../layout';
import {ComponentChild, ComponentChildren} from './types';
import {threadable} from '@motion-canvas/core/lib/decorators';
import {ThreadGenerator} from '@motion-canvas/core/lib/threading';
export interface NodeProps {
ref?: Reference<Node>;
@@ -13,11 +27,14 @@ export interface NodeProps {
position?: Vector2;
width?: number;
height?: number;
size?: Size;
rotation?: number;
offsetX?: number;
offsetY?: number;
offset?: Vector2;
scaleX?: number;
scaleY?: number;
scale?: Vector2;
layout?: LayoutProps;
}
@@ -28,15 +45,101 @@ export class Node<TProps extends NodeProps = NodeProps> {
@property(0)
public declare readonly x: Signal<number, this>;
protected readonly customX = createSignal(0, map, this);
protected getX(): number {
const mode = this.realMode();
const parent = this.parent();
if (mode === 'enabled' && parent) {
const parentLayout = parent.computedLayout();
const thisLayout = this.computedLayout();
const offsetX = (thisLayout.width / 2) * this.offsetX();
return (
thisLayout.x -
parentLayout.x -
(parentLayout.width - thisLayout.width) / 2 +
offsetX
);
} else {
return this.customX();
}
}
protected setX(value: SignalValue<number>) {
this.customX(value);
}
@property(0)
public declare readonly y: Signal<number, this>;
protected readonly customY = createSignal(0, map, this);
protected getY(): number {
const mode = this.realMode();
const parent = this.parent();
if (mode === 'enabled' && parent) {
const parentLayout = parent.computedLayout();
const thisLayout = this.computedLayout();
const offsetX = (thisLayout.width / 2) * this.offsetX();
return (
thisLayout.y -
parentLayout.y -
(parentLayout.height - thisLayout.height) / 2 +
offsetX
);
} else {
return this.customY();
}
}
protected setY(value: SignalValue<number>) {
this.customY(value);
}
@property(0)
public declare readonly width: Signal<number, this>;
protected readonly customWidth = createSignal(0, map, this);
protected getWidth(): number {
const mode = this.realMode();
if (mode === 'disabled') {
return this.customWidth();
} else {
return this.computedLayout().width;
}
}
protected setWidth(value: SignalValue<number>) {
this.customWidth(value);
}
@property(0)
public declare readonly height: Signal<number, this>;
protected readonly customHeight = createSignal(0, map, this);
protected getHeight(): number {
const mode = this.realMode();
if (mode === 'disabled') {
return this.customHeight();
} else {
return this.computedLayout().height;
}
}
protected setHeight(value: SignalValue<number>) {
this.customHeight(value);
}
@compound(['width', 'height'])
@property(undefined, sizeLerp)
public declare readonly size: Signal<Size, this>;
@property(0)
public declare readonly rotation: Signal<number, this>;
@@ -47,23 +150,81 @@ export class Node<TProps extends NodeProps = NodeProps> {
@property(1)
public declare readonly scaleY: Signal<number, this>;
@compound({x: 'scaleX', y: 'scaleY'})
@property(undefined, vector2dLerp)
public declare readonly scale: Signal<Vector2, this>;
@property(0)
public declare readonly offsetX: Signal<number, this>;
@property(0)
public declare readonly offsetY: Signal<number, this>;
@compoundProperty(['x', 'y'], vector2dLerp)
@compound({x: 'offsetX', y: 'offsetY'})
@property(undefined, vector2dLerp)
public declare readonly offset: Signal<Vector2, this>;
@compound(['x', 'y'])
@property(undefined, vector2dLerp)
public declare readonly position: Signal<Vector2, this>;
public readonly absolutePosition = useSignal(() => {
const matrix = this.globalMatrix();
return {x: matrix.e, y: matrix.f};
});
@property(undefined, vector2dLerp)
public declare readonly absolutePosition: Signal<Vector2, this>;
protected readonly globalMatrix = useSignal(() => this.localMatrix());
protected getAbsolutePosition() {
const matrix = this.localToWorld();
return {x: matrix.m41, y: matrix.m42};
}
protected readonly localMatrix = useSignal(() => {
protected setAbsolutePosition(value: SignalValue<Vector2>) {
if (isReactive(value)) {
this.position(() => transformPoint(value(), this.worldToLocal()));
} else {
this.position(transformPoint(value, this.worldToLocal()));
}
}
@property()
public declare readonly absoluteRotation: Signal<Vector2, 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.worldToLocal()));
} else {
this.rotation(transformAngle(value, this.worldToLocal()));
}
}
protected children = createSignal<Node[]>([]);
protected parent = createSignal<Node | null>(null);
public constructor({children, layout, ...rest}: TProps) {
this.layout = new Layout(layout ?? {});
initialize(this, {defaults: rest});
this.append(children);
}
@computed()
protected localToWorld(): DOMMatrix {
const parent = this.parent();
return parent
? parent.localToWorld().multiply(this.localToParent())
: new DOMMatrix();
}
@computed()
protected worldToLocal(): DOMMatrix {
const parent = this.parent();
return parent ? parent.localToWorld().inverse() : new DOMMatrix();
}
@computed()
protected localToParent(): DOMMatrix {
const matrix = new DOMMatrix();
matrix.translateSelf(this.x(), this.y());
matrix.rotateSelf(0, 0, this.rotation());
@@ -74,15 +235,51 @@ export class Node<TProps extends NodeProps = NodeProps> {
);
return matrix;
});
}
protected children: Node[] = [];
protected parent: Node | null = null;
@computed()
protected computedLayout(): Rect {
this.requestLayoutUpdate();
const rect = this.layout.getComputedLayout();
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
};
}
public constructor({children, layout, ...rest}: TProps) {
initialize(this, {defaults: rest});
this.layout = new Layout(layout ?? {});
this.append(children);
/**
* Find the closest layout root and apply any new layout changes.
*/
@computed()
protected requestLayoutUpdate() {
const mode = this.layout.mode();
const parent = this.parent();
if (mode === 'disabled' || mode === 'root' || !parent) {
this.updateLayout();
} else {
parent.requestLayoutUpdate();
}
}
/**
* Apply any new layout changes to this node and its children.
*/
@computed()
protected updateLayout() {
this.applyLayoutChanges();
this.layout.apply();
for (const child of this.children()) {
child.updateLayout();
}
}
/**
* Apply any custom layout changes to this node.
*/
protected applyLayoutChanges() {
// do nothing
}
public append(node: ComponentChildren) {
@@ -99,46 +296,34 @@ export class Node<TProps extends NodeProps = NodeProps> {
}
protected moveTo(parent: Node | null) {
if (this.parent === parent) {
const current = this.parent();
if (current === parent) {
return;
}
if (this.parent) {
this.globalMatrix(() => this.localMatrix());
this.parent.layout.element.removeChild(this.layout.element);
this.parent.children = this.parent.children.filter(
child => child !== this,
);
this.parent = null;
if (current) {
current.layout.element.removeChild(this.layout.element);
current.children(current.children().filter(child => child !== this));
}
if (parent) {
this.globalMatrix(() =>
parent.globalMatrix().multiply(this.localMatrix()),
);
parent.layout.element.append(this.layout.element);
parent.children.push(this);
this.parent = parent;
parent.children([...parent.children(), this]);
}
this.parent(parent);
}
public removeChildren() {
for (const node of this.children) {
for (const node of this.children()) {
node.moveTo(null);
}
}
public updateLayout(): boolean {
let isDirty = this.layout.updateIfNecessary();
for (const child of this.children) {
isDirty ||= child.updateLayout();
}
return isDirty;
}
public handleLayoutChange(parentMode?: LayoutMode) {
@computed()
public realMode() {
const parent = this.parent();
const parentMode = parent?.realMode();
let mode = this.layout.mode();
if (mode === null) {
@@ -149,45 +334,14 @@ export class Node<TProps extends NodeProps = NodeProps> {
}
}
if (mode === 'enabled' && this.parent) {
//TODO Cache this call or pass it as an argument
const parentLayout = this.parent.layout.getComputedLayout();
const thisLayout = this.layout.getComputedLayout();
const offsetX = (thisLayout.width / 2) * this.offsetX();
const offsetY = (thisLayout.height / 2) * this.offsetY();
this.x(
thisLayout.x -
parentLayout.x -
(parentLayout.width - thisLayout.width) / 2 +
offsetX,
);
this.y(
thisLayout.y -
parentLayout.y -
(parentLayout.height - thisLayout.height) / 2 +
offsetY,
);
this.width(thisLayout.width);
this.height(thisLayout.height);
}
if (mode === 'pop' || mode === 'root') {
const thisLayout = this.layout.getComputedLayout();
this.width(thisLayout.width);
this.height(thisLayout.height);
}
for (const child of this.children) {
child.handleLayoutChange(mode);
}
return mode;
}
public render(context: CanvasRenderingContext2D) {
context.save();
this.transformContext(context);
for (const child of this.children) {
for (const child of this.children()) {
child.render(context);
}
@@ -195,7 +349,7 @@ export class Node<TProps extends NodeProps = NodeProps> {
}
protected transformContext(context: CanvasRenderingContext2D) {
const matrix = this.localMatrix();
const matrix = this.localToParent();
context.transform(
matrix.a,
matrix.b,
@@ -205,6 +359,26 @@ export class Node<TProps extends NodeProps = NodeProps> {
matrix.f,
);
}
@threadable()
public *tweenSize(update: (node: this) => void): ThreadGenerator {
const mode = this.realMode();
const initialSize = this.size();
if (mode === 'disabled') {
update(this);
const toSize = this.size();
this.size(initialSize);
yield* this.size(toSize, 2);
} else {
this.layout.width(null).height(null);
update(this);
const toSize = this.size();
this.layout.size(initialSize);
yield* this.layout.size(toSize, 2);
this.layout.width(null).height(null);
}
}
}
/*@__PURE__*/

View File

@@ -24,7 +24,7 @@ export class Rect extends Node<RectProps> {
context.fillRect(-width / 2, -height / 2, width, height);
context.restore();
for (const child of this.children) {
for (const child of this.children()) {
child.render(context);
}

View File

@@ -0,0 +1,78 @@
import {SignalValue, isReactive} from '@motion-canvas/core/lib/utils';
import {capitalize} from './property';
import {addInitializer} from './initializers';
/**
* 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.
*
* @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);
* }
* }
* ```
*
* @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.
*/
export function compound(
mapping: string[] | Record<string, string>,
): PropertyDecorator {
return (target: any, key) => {
const entries = Array.isArray(mapping)
? mapping.map(key => [key, key])
: Object.entries(mapping);
target.constructor.prototype[`get${capitalize(key.toString())}`] =
function () {
return Object.fromEntries(
entries.map(([key, property]) => [key, this[property]()]),
);
};
target.constructor.prototype[`set${capitalize(key.toString())}`] =
function set(value: SignalValue<any>) {
if (isReactive(value)) {
for (const [key, property] of entries) {
this[property](() => value()[key]);
}
} else {
for (const [key, property] of entries) {
this[property](value[key]);
}
}
};
addInitializer(target, (instance: any, context: any) => {
if (key in context.defaults) {
instance[key](context.defaults[key]);
}
});
};
}

View File

@@ -1,91 +0,0 @@
import {
deepLerp,
easeInOutCubic,
TimingFunction,
tween,
InterpolationFunction,
} from '@motion-canvas/core/lib/tweening';
import {addInitializer} from './initializers';
import {Signal, SignalValue, isReactive} from '@motion-canvas/core/lib/utils';
type SignalRecord<T extends keyof any> = {
[P in T]: Signal<any, any>;
};
type CompoundValue<T extends keyof any> = {
[P in T]: any;
};
export function createCompoundProperty<
TProperties extends keyof TNode,
TNode extends SignalRecord<TProperties>,
TValue extends CompoundValue<TProperties> = CompoundValue<TProperties>,
>(
node: TNode,
propertyKeys: TProperties[],
initial?: TValue,
defaultInterpolation: InterpolationFunction<TValue> = deepLerp,
): Signal<TValue, TNode> {
const handler = <Signal<TValue, TNode>>(
function (
newValue?: SignalValue<TValue>,
duration?: number,
timingFunction: TimingFunction = easeInOutCubic,
interpolationFunction: InterpolationFunction<TValue> = defaultInterpolation,
) {
if (duration !== undefined && newValue !== undefined) {
const from = <TValue>(
Object.fromEntries(propertyKeys.map(key => [key, node[key]()]))
);
return tween(duration, value => {
const interpolatedValue = interpolationFunction(
from,
isReactive(newValue) ? newValue() : newValue,
timingFunction(value),
);
for (const key of propertyKeys) {
node[key](interpolatedValue[key]);
}
});
}
if (newValue !== undefined) {
if (typeof newValue === 'function') {
for (const key of propertyKeys) {
node[key](() => newValue()[key]);
}
} else {
for (const key of propertyKeys) {
node[key](newValue[key]);
}
}
return node;
}
return Object.fromEntries(propertyKeys.map(key => [key, node[key]()]));
}
);
if (initial !== undefined) {
handler(initial);
}
return handler;
}
export function compoundProperty(
keys: string[],
mapper?: InterpolationFunction<any>,
): PropertyDecorator {
return (target: any, key) => {
addInitializer(target, (instance: any, context: any) => {
instance[key] = createCompoundProperty(
instance,
keys,
context.defaults[key],
mapper ?? deepLerp,
);
});
};
}

View File

@@ -0,0 +1,18 @@
import {addInitializer} from './initializers';
import {createComputed} from '@motion-canvas/core/lib/utils/createComputed';
/**
* Create a computed method decorator.
*
* @remarks
* This decorator turns the given method into a computed value.
* See {@link createComputed} for more information.
*/
export function computed(): MethodDecorator {
return (target: any, key) => {
const method = target[key];
addInitializer(target, (instance: any) => {
instance[key] = createComputed(method.bind(instance));
});
};
}

View File

@@ -1,3 +1,4 @@
export * from './compoundProperty';
export * from './initializers';
export * from './computed';
export * from './compound';
export * from './property';
export * from './initializers';

View File

@@ -1,17 +1,176 @@
import {InterpolationFunction, map} from '@motion-canvas/core/lib/tweening';
import {useSignal} from '@motion-canvas/core/lib/utils';
import {
deepLerp,
easeInOutCubic,
TimingFunction,
tween,
InterpolationFunction,
} from '@motion-canvas/core/lib/tweening';
import {addInitializer} from './initializers';
import {
Signal,
SignalValue,
isReactive,
createSignal,
SignalGetter,
SignalSetter,
SignalTween,
} from '@motion-canvas/core/lib/utils';
export function capitalize<T extends string>(value: T): Capitalize<T> {
return <Capitalize<T>>(value[0].toUpperCase() + value.slice(1));
}
export type PropertyOwner<TValue> = {
[key: `get${Capitalize<string>}`]: SignalGetter<TValue> | undefined;
[key: `set${Capitalize<string>}`]: SignalSetter<TValue> | undefined;
[key: `tween${Capitalize<string>}`]: SignalTween<TValue> | undefined;
};
export function createProperty<
TValue,
TNode extends PropertyOwner<TValue>,
TProperty extends string & keyof TNode,
>(
node: TNode,
property: TProperty,
initial?: TValue,
defaultInterpolation: InterpolationFunction<TValue> = deepLerp,
): Signal<TValue, TNode> {
let getter: () => TValue;
let setter: (value: SignalValue<TValue>) => void;
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`,
);
}
let signal: Signal<TValue, TNode> | null = null;
if (!originalGetter || !originalSetter) {
signal = createSignal(initial, defaultInterpolation, node);
if (!tweener) {
return signal;
}
getter = signal;
setter = signal;
} else {
getter = originalGetter.bind(node);
setter = originalSetter.bind(node);
}
const handler = <Signal<TValue, TNode>>(
function (
newValue?: SignalValue<TValue>,
duration?: number,
timingFunction: TimingFunction = easeInOutCubic,
interpolationFunction: InterpolationFunction<TValue> = defaultInterpolation,
) {
if (newValue === undefined) {
return getter();
}
if (duration === undefined) {
return setter(newValue);
}
if (tweener) {
return tweener.call(
node,
newValue,
duration,
timingFunction,
interpolationFunction,
);
}
const from = getter();
return tween(duration, value => {
setter(
interpolationFunction(
from,
isReactive(newValue) ? newValue() : newValue,
timingFunction(value),
),
);
});
}
);
Object.defineProperty(handler, 'reset', {
value: signal
? () => signal?.reset()
: initial !== undefined
? () => setter(initial)
: () => node,
});
Object.defineProperty(handler, 'save', {
value: () => setter(getter()),
});
if (initial !== undefined && originalSetter) {
setter(initial);
}
return handler;
}
/**
* Create a signal property decorator.
*
* @remarks
* This decorator turns the given property into a signal.
*
* The class using this decorator can implement the following methods:
* - `get[PropertyName]` - A property getter.
* - `get[PropertyName]` - A property setter.
* - `tween[PropertyName]` - a tween provider.
*
* See the {@link PropertyOwner} type for more detailed method signatures.
*
* @example
* ```ts
* class Example {
* \@property()
* public declare color: Signal<Color, this>;
*
* \@customProperty()
* public declare colorString: Signal<string, this>;
*
* protected getColorString() {
* return this.color().toString();
* }
*
* protected setColorString(value: SignalValue<string>) {
* this.color(
* isReactive(value)
* ? () => new Color(value())
* : new Color(value)
* );
* }
* }
* ```
*
* @param initial - An option initial value of the property.
* @param interpolationFunction - The default function used to interpolate
* between values.
*/
export function property<T>(
initial?: T,
mapper?: InterpolationFunction<T>,
interpolationFunction?: InterpolationFunction<T>,
): PropertyDecorator {
return (target: any, key) => {
addInitializer(target, (instance: any, context: any) => {
instance[key] = useSignal(
context.defaults[key] ?? initial,
mapper ?? map,
instance[key] = createProperty(
instance,
<string>key,
context.defaults[key] ?? initial,
interpolationFunction ?? deepLerp,
);
});
};

View File

@@ -28,7 +28,7 @@ export function jsx(
config: JSXProps,
): ComponentChildren {
const {ref, children, ...rest} = config;
const flatChildren = Array.isArray(children) ? children.flat() : [children];
const flatChildren = Array.isArray(children) ? children.flat() : children;
if (type === Fragment) {
return flatChildren;

View File

@@ -1,7 +1,8 @@
import {initialize, property} from '../decorators';
import {Signal, useSignal} from '@motion-canvas/core/lib/utils';
import {Rect} from '@motion-canvas/core/lib/types';
import {compound, computed, initialize, property} from '../decorators';
import {Signal} from '@motion-canvas/core/lib/utils';
import {Rect, Size} from '@motion-canvas/core/lib/types';
import {AlignItems, FlexDirection, JustifyContent, LayoutMode} from './types';
import {sizeLerp} from '@motion-canvas/core/lib/tweening';
export interface LayoutProps {
mode?: LayoutMode;
@@ -11,6 +12,10 @@ export interface LayoutProps {
marginBottom?: number;
marginLeft?: number;
marginRight?: number;
paddingTop?: number;
paddingBottom?: number;
paddingLeft?: number;
paddingRight?: number;
direction?: FlexDirection;
justifyContent?: JustifyContent;
alignItems?: AlignItems;
@@ -21,9 +26,13 @@ export class Layout {
@property(null)
public declare readonly mode: Signal<LayoutMode, this>;
@property(null)
public declare readonly width: Signal<number | `${number}%`, this>;
public declare readonly width: Signal<null | number | `${number}%`, this>;
@property(null)
public declare readonly height: Signal<number | `${number}%`, this>;
public declare readonly height: Signal<null | number | `${number}%`, this>;
@property(undefined, sizeLerp)
@compound(['width', 'height'])
public declare readonly size: Signal<Size, this>;
@property(0)
public declare readonly marginTop: Signal<number, this>;
@property(0)
@@ -32,6 +41,16 @@ export class Layout {
public declare readonly marginLeft: Signal<number, this>;
@property(0)
public declare readonly marginRight: Signal<number, this>;
@property(0)
public declare readonly paddingTop: Signal<number, this>;
@property(0)
public declare readonly paddingBottom: Signal<number, this>;
@property(0)
public declare readonly paddingLeft: Signal<number, this>;
@property(0)
public declare readonly paddingRight: Signal<number, this>;
@property('row')
public declare readonly direction: Signal<FlexDirection, this>;
@property('none')
@@ -43,16 +62,12 @@ export class Layout {
public readonly element: HTMLDivElement;
private isDirty = true;
public constructor(props: LayoutProps) {
this.element = document.createElement('div');
this.element.style.display = 'flex';
this.element.style.boxSizing = 'border-box';
initialize(this, {defaults: props});
this.update.onChanged.subscribe(() => {
this.isDirty = true;
});
}
public toPixels(value: number) {
@@ -69,17 +84,8 @@ export class Layout {
};
}
public updateIfNecessary(): boolean {
if (this.isDirty) {
this.update();
this.isDirty = false;
return true;
}
return false;
}
private readonly update = useSignal(() => {
@computed()
public apply() {
const mode = this.mode();
this.element.style.position =
mode === 'disabled' || mode === 'root' ? 'absolute' : '';
@@ -106,9 +112,13 @@ export class Layout {
this.element.style.marginBottom = this.toPixels(this.marginBottom());
this.element.style.marginLeft = this.toPixels(this.marginLeft());
this.element.style.marginRight = this.toPixels(this.marginRight());
this.element.style.paddingTop = this.toPixels(this.paddingTop());
this.element.style.paddingBottom = this.toPixels(this.paddingBottom());
this.element.style.paddingLeft = this.toPixels(this.paddingLeft());
this.element.style.paddingRight = this.toPixels(this.paddingRight());
this.element.style.flexDirection = this.direction();
this.element.style.aspectRatio = this.ratio();
this.element.style.justifyContent = this.justifyContent();
this.element.style.alignItems = this.alignItems();
});
}
}

View File

@@ -8,10 +8,6 @@ export class TwoDScene extends GeneratorScene<TwoDView> {
return this.view;
}
public update() {
this.view.updateLayout();
}
public render(
context: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,

View File

@@ -14,8 +14,8 @@ export class TwoDView extends Node<any> {
frame.id = TwoDView.frameID;
frame.style.position = 'absolute';
frame.style.pointerEvents = 'none';
frame.style.top = '0';
frame.style.left = '0';
frame.style.right = '0';
frame.style.opacity = '0';
frame.style.border = 'none';
@@ -27,19 +27,11 @@ export class TwoDView extends Node<any> {
}
}
public updateLayout(): boolean {
let isDirty = super.updateLayout();
let limit = 10;
while (isDirty && limit > 0) {
limit--;
this.handleLayoutChange();
isDirty = super.updateLayout();
}
if (limit === 0) {
console.warn('Layout iteration limit exceeded');
}
return false;
public override render(context: CanvasRenderingContext2D) {
this.x();
this.y();
this.width();
this.height();
super.render(context);
}
}

View File

@@ -1,5 +1,5 @@
import Color from 'colorjs.io';
import type {Rect, Vector2, PossibleSpacing, Spacing} from '../types';
import type {Rect, Vector2, PossibleSpacing, Spacing, Size} from '../types';
export interface InterpolationFunction<T, Rest extends unknown[] = unknown[]> {
(from: T, to: T, value: number, ...args: Rest): T;
@@ -162,6 +162,13 @@ export function vector2dLerp(from: Vector2, to: Vector2, value: number) {
};
}
export function sizeLerp(from: Size, to: Size, value: number) {
return {
width: map(from.width, to.width, value),
height: map(from.height, to.height, value),
};
}
export function spacingLerp(
from: Spacing,
to: Spacing,

View File

@@ -0,0 +1,24 @@
import {Vector2} from './Vector';
export function transformPoint(vector: Vector2, matrix: DOMMatrix) {
return {
x: vector.x * matrix.m11 + vector.y * matrix.m21 + matrix.m41,
y: vector.x * matrix.m12 + vector.y * matrix.m22 + matrix.m42,
};
}
export function transformVector(vector: Vector2, matrix: DOMMatrix) {
return {
x: vector.x * matrix.m11 + vector.y * matrix.m21,
y: vector.x * matrix.m12 + vector.y * matrix.m22,
};
}
export function transformAngle(angle: number, matrix: DOMMatrix) {
const radians = (angle / 180) * Math.PI;
const vector = transformVector(
{x: Math.cos(radians), y: Math.sin(radians)},
matrix,
);
return (Math.atan2(vector.y, vector.x) * 180) / Math.PI;
}

View File

@@ -1,4 +1,5 @@
export * from './Canvas';
export * from './Matrix';
export * from './Origin';
export * from './Rect';
export * from './Size';

View File

@@ -0,0 +1,43 @@
import {createSignal} from './createSignal';
import {createComputed} from './createComputed';
describe('createComputed()', () => {
test('Value is updated when its dependencies change', () => {
const a = createSignal(1);
const b = createSignal(true);
const c = createSignal(2);
const d = createComputed(() => (b() ? a() : c()));
expect(d()).toBe(1);
a(3);
expect(d()).toBe(3);
b(false);
expect(d()).toBe(2);
c(4);
expect(d()).toBe(4);
});
test('Value is cached and recalculated only when necessary', () => {
const a = createSignal(1);
const value = jest.fn(() => a() * 2);
const c = createComputed(value);
expect(value.mock.calls.length).toBe(0);
a(2);
expect(value.mock.calls.length).toBe(0);
c();
c();
expect(value.mock.calls.length).toBe(1);
});
});

View File

@@ -0,0 +1,38 @@
import {FlagDispatcher, Subscribable} from '../events';
import {
collect,
finishCollecting,
SignalGetter,
startCollecting,
} from './createSignal';
export type Computed<TValue> = SignalGetter<TValue>;
export function createComputed<TValue>(
factory: SignalGetter<TValue>,
): Computed<TValue> {
let last: TValue;
const dependencies = new Set<Subscribable<void>>();
const event = new FlagDispatcher();
function markDirty() {
event.raise();
}
const handler = <Computed<TValue>>function handler() {
if (event.isRaised()) {
dependencies.forEach(dep => dep.unsubscribe(markDirty));
dependencies.clear();
startCollecting([dependencies, markDirty]);
last = factory();
finishCollecting([dependencies, markDirty]);
}
event.reset();
collect(event.subscribable);
return last;
};
markDirty();
return handler;
}

View File

@@ -1,8 +1,8 @@
import {useSignal} from './useSignal';
import {createSignal} from './createSignal';
describe('useSignal()', () => {
describe('createSignal()', () => {
test('Works correctly with plain values', () => {
const signal = useSignal(7);
const signal = createSignal(7);
expect(signal()).toBe(7);
@@ -12,7 +12,7 @@ describe('useSignal()', () => {
});
test('Works correctly with computed values', () => {
const signal = useSignal(() => 7);
const signal = createSignal(() => 7);
expect(signal()).toBe(7);
@@ -22,10 +22,10 @@ describe('useSignal()', () => {
});
test('Value is updated when its dependencies change', () => {
const a = useSignal(1);
const b = useSignal(true);
const c = useSignal(2);
const d = useSignal(() => (b() ? a() : c()));
const a = createSignal(1);
const b = createSignal(true);
const c = createSignal(2);
const d = createSignal(() => (b() ? a() : c()));
expect(d()).toBe(1);
@@ -43,10 +43,10 @@ describe('useSignal()', () => {
});
test('Value is cached and recalculated only when necessary', () => {
const a = useSignal(1);
const a = createSignal(1);
const value = jest.fn(() => a() * 2);
const c = useSignal(value);
const c = createSignal(value);
expect(value.mock.calls.length).toBe(0);
@@ -59,19 +59,4 @@ describe('useSignal()', () => {
expect(value.mock.calls.length).toBe(1);
});
test('onChanged events are dispatched only once per handler', () => {
const handler = jest.fn();
const c = useSignal(0);
c(1);
c.onChanged.subscribe(handler);
expect(handler.mock.calls.length).toBe(1);
c(2);
c(3);
expect(handler.mock.calls.length).toBe(1);
});
});

View File

@@ -12,16 +12,56 @@ type DependencyContext = [Set<Subscribable<void>>, EventHandler<void>];
export type SignalValue<TValue> = TValue | (() => TValue);
export interface Signal<TValue, TReturn = void> {
(): TValue;
export interface SignalSetter<TValue, TReturn = void> {
(value: SignalValue<TValue>): TReturn;
}
export interface SignalGetter<TValue> {
(): TValue;
}
export interface SignalTween<TValue> {
(
value: SignalValue<TValue>,
time: number,
timingFunction?: TimingFunction,
interpolationFunction?: InterpolationFunction<TValue>,
): ThreadGenerator;
get onChanged(): Subscribable<void>;
}
export interface Signal<TValue, TReturn = void>
extends SignalSetter<TValue, TReturn>,
SignalGetter<TValue>,
SignalTween<TValue> {
/**
* Reset the signal to its initial value (if one has been set).
*
* @example
* ```ts
* const signal = createSignal(7);
*
* signal.reset();
* // same as:
* signal(7);
* ```
*/
reset(): TReturn;
/**
* Compute the current value of the signal and immediately set it.
*
* @remarks
* This method can be used to stop the signal from updating while keeping its
* current value.
*
* @example
* ```ts
* signal.save();
* // same as:
* signal(signal());
* ```
*/
save(): TReturn;
}
const collectionStack: DependencyContext[] = [];
@@ -48,7 +88,7 @@ export function isReactive<T>(value: SignalValue<T>): value is () => T {
return typeof value === 'function';
}
export function useSignal<TValue, TReturn = void>(
export function createSignal<TValue, TReturn = void>(
initial?: SignalValue<TValue>,
defaultInterpolation: InterpolationFunction<TValue> = deepLerp,
setterReturn?: TReturn,
@@ -60,7 +100,7 @@ export function useSignal<TValue, TReturn = void>(
function set(value: SignalValue<TValue>) {
if (current === value) {
return;
return setterReturn;
}
current = value;
@@ -74,10 +114,14 @@ export function useSignal<TValue, TReturn = void>(
if (!isReactive(value)) {
last = value;
}
return setterReturn;
}
function get(): TValue {
if (event.isRaised() && isReactive(current)) {
dependencies.forEach(dep => dep.unsubscribe(markDirty));
dependencies.clear();
startCollecting([dependencies, markDirty]);
last = current();
finishCollecting([dependencies, markDirty]);
@@ -98,18 +142,14 @@ export function useSignal<TValue, TReturn = void>(
timingFunction: TimingFunction = easeInOutCubic,
interpolationFunction: InterpolationFunction<TValue> = defaultInterpolation,
) {
// Getter
if (value === undefined) {
return get();
}
// Setter
if (duration === undefined) {
set(value);
return setterReturn;
return set(value);
}
// Tween
const from = get();
return tween(duration, v => {
set(
@@ -123,12 +163,16 @@ export function useSignal<TValue, TReturn = void>(
}
);
Object.defineProperty(handler, 'onChanged', {
value: event.subscribable,
Object.defineProperty(handler, 'reset', {
value: initial !== undefined ? () => set(initial) : () => setterReturn,
});
Object.defineProperty(handler, 'save', {
value: () => set(get()),
});
if (initial !== undefined) {
handler(initial);
set(initial);
}
return handler;

View File

@@ -1,8 +1,9 @@
export * from './createComputed';
export * from './createSignal';
export * from './useAnimator';
export * from './useProject';
export * from './useRef';
export * from './useScene';
export * from './useSignal';
export * from './useThread';
export * from './useTime';
export * from './useContext';