mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-11 14:57:56 -05:00
feat: add default renderer (#63)
This is the initial draft of the new renderer described in #56. Yoga has been dropped in favor of the browser's built-in layout engine. The DOM API is mostly the same as Yoga's but requires significantly less effort to set up. It also opens the door for utilizing CSS in the future. This PR also contains an initial implementation of properties from #58. The name has been changed from atoms to signals to match the convention of other libraries.
This commit is contained in:
@@ -5,7 +5,7 @@ module.exports = {
|
||||
'scope-enum': [
|
||||
2,
|
||||
'always',
|
||||
['core', 'create', 'docs', 'legacy', 'ui', 'vite-plugin'],
|
||||
['2d', 'core', 'create', 'docs', 'legacy', 'ui', 'vite-plugin'],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
73
package-lock.json
generated
73
package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@motion-canvas/2d": "^12.0.0-alpha.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^17.0.3",
|
||||
"@commitlint/config-conventional": "^17.0.3",
|
||||
@@ -4903,6 +4906,10 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/@motion-canvas/2d": {
|
||||
"resolved": "packages/2d",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@motion-canvas/core": {
|
||||
"resolved": "packages/core",
|
||||
"link": true
|
||||
@@ -22421,9 +22428,21 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"packages/2d": {
|
||||
"name": "@motion-canvas/2d",
|
||||
"version": "12.0.0-alpha.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@motion-canvas/core": "^12.0.0-alpha.0",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@motion-canvas/core": "*"
|
||||
}
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@motion-canvas/core",
|
||||
"version": "11.1.0",
|
||||
"version": "12.0.0-alpha.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colorjs.io": "^0.3.0",
|
||||
@@ -22447,7 +22466,7 @@
|
||||
},
|
||||
"packages/create": {
|
||||
"name": "@motion-canvas/create",
|
||||
"version": "11.1.0",
|
||||
"version": "12.0.0-alpha.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prompts": "^2.4.2"
|
||||
@@ -22456,9 +22475,9 @@
|
||||
"create-motion-canvas": "index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@motion-canvas/core": "^11.1.0",
|
||||
"@motion-canvas/ui": "^11.1.0",
|
||||
"@motion-canvas/vite-plugin": "^11.1.0"
|
||||
"@motion-canvas/core": "^12.0.0-alpha.0",
|
||||
"@motion-canvas/ui": "^12.0.0-alpha.0",
|
||||
"@motion-canvas/vite-plugin": "^12.0.0-alpha.0"
|
||||
}
|
||||
},
|
||||
"packages/docs": {
|
||||
@@ -22488,21 +22507,24 @@
|
||||
},
|
||||
"packages/legacy": {
|
||||
"name": "@motion-canvas/legacy",
|
||||
"version": "11.1.0",
|
||||
"version": "12.0.0-alpha.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/three": "^0.141.0",
|
||||
"colorjs.io": "^0.3.0",
|
||||
"konva": "^8.3.9",
|
||||
"prismjs": "^1.28.0",
|
||||
"three": "^0.141.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@motion-canvas/core": "*",
|
||||
"typescript": "^4.7.4"
|
||||
"@motion-canvas/core": "^12.0.0-alpha.0",
|
||||
"typescript": "^4.7.4",
|
||||
"vite": "^3.0.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@motion-canvas/core": "*"
|
||||
"@motion-canvas/core": "*",
|
||||
"vite": "3.x"
|
||||
}
|
||||
},
|
||||
"packages/template": {
|
||||
@@ -22510,6 +22532,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@motion-canvas/2d": "*",
|
||||
"@motion-canvas/core": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -22520,10 +22543,10 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@motion-canvas/ui",
|
||||
"version": "11.1.0",
|
||||
"version": "12.0.0-alpha.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@motion-canvas/core": "^11.1.0",
|
||||
"@motion-canvas/core": "^12.0.0-alpha.0",
|
||||
"@preact/preset-vite": "^2.3.0",
|
||||
"preact": "10.7.3",
|
||||
"typescript": "^4.6.4",
|
||||
@@ -22545,7 +22568,7 @@
|
||||
},
|
||||
"packages/vite-plugin": {
|
||||
"name": "@motion-canvas/vite-plugin",
|
||||
"version": "11.1.0",
|
||||
"version": "12.0.0-alpha.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "^2.1.35"
|
||||
@@ -22553,7 +22576,7 @@
|
||||
"devDependencies": {
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"typescript": "^4.7.4",
|
||||
"vite": "^3.0.5"
|
||||
"vite": "^3.0.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "3.x"
|
||||
@@ -26284,6 +26307,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@motion-canvas/2d": {
|
||||
"version": "file:packages/2d",
|
||||
"requires": {
|
||||
"@motion-canvas/core": "^12.0.0-alpha.0",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
},
|
||||
"@motion-canvas/core": {
|
||||
"version": "file:packages/core",
|
||||
"requires": {
|
||||
@@ -26304,9 +26334,9 @@
|
||||
"@motion-canvas/create": {
|
||||
"version": "file:packages/create",
|
||||
"requires": {
|
||||
"@motion-canvas/core": "^11.1.0",
|
||||
"@motion-canvas/ui": "^11.1.0",
|
||||
"@motion-canvas/vite-plugin": "^11.1.0",
|
||||
"@motion-canvas/core": "^12.0.0-alpha.0",
|
||||
"@motion-canvas/ui": "^12.0.0-alpha.0",
|
||||
"@motion-canvas/vite-plugin": "^12.0.0-alpha.0",
|
||||
"prompts": "^2.4.2"
|
||||
}
|
||||
},
|
||||
@@ -26332,18 +26362,21 @@
|
||||
"@motion-canvas/legacy": {
|
||||
"version": "file:packages/legacy",
|
||||
"requires": {
|
||||
"@motion-canvas/core": "*",
|
||||
"@motion-canvas/core": "^12.0.0-alpha.0",
|
||||
"@types/prismjs": "^1.26.0",
|
||||
"@types/three": "^0.141.0",
|
||||
"colorjs.io": "^0.3.0",
|
||||
"konva": "^8.3.9",
|
||||
"prismjs": "^1.28.0",
|
||||
"three": "^0.141.0",
|
||||
"typescript": "^4.7.4"
|
||||
"typescript": "^4.7.4",
|
||||
"vite": "^3.0.9"
|
||||
}
|
||||
},
|
||||
"@motion-canvas/template": {
|
||||
"version": "file:packages/template",
|
||||
"requires": {
|
||||
"@motion-canvas/2d": "*",
|
||||
"@motion-canvas/core": "*",
|
||||
"@motion-canvas/ui": "*",
|
||||
"@motion-canvas/vite-plugin": "*",
|
||||
@@ -26353,7 +26386,7 @@
|
||||
"@motion-canvas/ui": {
|
||||
"version": "file:packages/ui",
|
||||
"requires": {
|
||||
"@motion-canvas/core": "^11.1.0",
|
||||
"@motion-canvas/core": "^12.0.0-alpha.0",
|
||||
"@preact/preset-vite": "^2.3.0",
|
||||
"preact": "10.7.3",
|
||||
"typescript": "^4.6.4",
|
||||
@@ -26374,7 +26407,7 @@
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"typescript": "^4.7.4",
|
||||
"vite": "^3.0.5"
|
||||
"vite": "^3.0.9"
|
||||
}
|
||||
},
|
||||
"@nodelib/fs.scandir": {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"core:build": "npm run build -w packages/core",
|
||||
"core:watch": "npm run watch -w packages/core",
|
||||
"core:test": "npm run test -w packages/core",
|
||||
"2d:build": "npm run build -w packages/2d",
|
||||
"2d:watch": "npm run watch -w packages/2d",
|
||||
"ui:build": "npm run build -w packages/ui",
|
||||
"ui:dev": "npm run dev -w packages/ui",
|
||||
"template:serve": "npm run serve -w packages/template",
|
||||
|
||||
33
packages/2d/package.json
Normal file
33
packages/2d/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@motion-canvas/2d",
|
||||
"version": "12.0.0-alpha.2",
|
||||
"description": "2D plugin for Motion Canvas",
|
||||
"author": "motion-canvas",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "lib/scenes/index.js",
|
||||
"types": "./lib/scenes/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc -w"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com/motion-canvas"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/motion-canvas/motion-canvas.git"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"src",
|
||||
"tsconfig.project.json"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@motion-canvas/core": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@motion-canvas/core": "^12.0.0-alpha.2",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
}
|
||||
41
packages/2d/src/components/Circle.ts
Normal file
41
packages/2d/src/components/Circle.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {Node, NodeProps} from './Node';
|
||||
import {Signal} from '@motion-canvas/core/lib/utils';
|
||||
import {property} from '../decorators';
|
||||
import {NodeChildren} from './types';
|
||||
|
||||
export interface CircleProps extends NodeProps {
|
||||
children?: NodeChildren;
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
export class Circle extends Node<CircleProps> {
|
||||
@property('#ffffff')
|
||||
public declare readonly fill: Signal<string, this>;
|
||||
|
||||
public override render(context: CanvasRenderingContext2D) {
|
||||
context.save();
|
||||
this.transformContext(context);
|
||||
|
||||
context.save();
|
||||
context.fillStyle = this.fill();
|
||||
context.beginPath();
|
||||
context.ellipse(
|
||||
0,
|
||||
0,
|
||||
this.width() / 2,
|
||||
this.height() / 2,
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.restore();
|
||||
|
||||
for (const child of this.children) {
|
||||
child.render(context);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
211
packages/2d/src/components/Node.ts
Normal file
211
packages/2d/src/components/Node.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
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 {ComponentChild, ComponentChildren} from './types';
|
||||
|
||||
export interface NodeProps {
|
||||
ref?: Reference<Node>;
|
||||
children?: ComponentChildren;
|
||||
x?: number;
|
||||
y?: number;
|
||||
position?: Vector2;
|
||||
width?: number;
|
||||
height?: number;
|
||||
rotation?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
layout?: LayoutProps;
|
||||
}
|
||||
|
||||
export class Node<TProps extends NodeProps = NodeProps> {
|
||||
public declare isClass: boolean;
|
||||
|
||||
public readonly layout: Layout;
|
||||
|
||||
@property(0)
|
||||
public declare readonly x: Signal<number, this>;
|
||||
|
||||
@property(0)
|
||||
public declare readonly y: Signal<number, this>;
|
||||
|
||||
@property(0)
|
||||
public declare readonly width: Signal<number, this>;
|
||||
|
||||
@property(0)
|
||||
public declare readonly height: Signal<number, this>;
|
||||
|
||||
@property(0)
|
||||
public declare readonly rotation: Signal<number, this>;
|
||||
|
||||
@property(1)
|
||||
public declare readonly scaleX: Signal<number, this>;
|
||||
|
||||
@property(1)
|
||||
public declare readonly scaleY: Signal<number, this>;
|
||||
|
||||
@property(0)
|
||||
public declare readonly offsetX: Signal<number, this>;
|
||||
|
||||
@property(0)
|
||||
public declare readonly offsetY: Signal<number, this>;
|
||||
|
||||
@compoundProperty(['x', 'y'], vector2dLerp)
|
||||
public declare readonly position: Signal<Vector2, this>;
|
||||
|
||||
public readonly absolutePosition = useSignal(() => {
|
||||
const matrix = this.globalMatrix();
|
||||
return {x: matrix.e, y: matrix.f};
|
||||
});
|
||||
|
||||
protected readonly globalMatrix = useSignal(() => this.localMatrix());
|
||||
|
||||
protected readonly localMatrix = useSignal(() => {
|
||||
const matrix = new DOMMatrix();
|
||||
matrix.translateSelf(this.x(), this.y());
|
||||
matrix.rotateSelf(0, 0, this.rotation());
|
||||
matrix.scaleSelf(this.scaleX(), this.scaleY());
|
||||
matrix.translateSelf(
|
||||
(this.width() / -2) * this.offsetX(),
|
||||
(this.height() / -2) * this.offsetY(),
|
||||
);
|
||||
|
||||
return matrix;
|
||||
});
|
||||
|
||||
protected children: Node[] = [];
|
||||
protected parent: Node | null = null;
|
||||
|
||||
public constructor({children, layout, ...rest}: TProps) {
|
||||
initialize(this, {defaults: rest});
|
||||
this.layout = new Layout(layout ?? {});
|
||||
this.append(children);
|
||||
}
|
||||
|
||||
public append(node: ComponentChildren) {
|
||||
const nodes: ComponentChild[] = Array.isArray(node) ? node : [node];
|
||||
for (const node of nodes) {
|
||||
if (node instanceof Node) {
|
||||
node.moveTo(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public remove() {
|
||||
this.moveTo(null);
|
||||
}
|
||||
|
||||
protected moveTo(parent: Node | null) {
|
||||
if (this.parent === 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 (parent) {
|
||||
this.globalMatrix(() =>
|
||||
parent.globalMatrix().multiply(this.localMatrix()),
|
||||
);
|
||||
parent.layout.element.append(this.layout.element);
|
||||
parent.children.push(this);
|
||||
this.parent = parent;
|
||||
}
|
||||
}
|
||||
|
||||
public removeChildren() {
|
||||
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) {
|
||||
let mode = this.layout.mode();
|
||||
|
||||
if (mode === null) {
|
||||
if (!parentMode || parentMode === 'disabled') {
|
||||
mode = 'disabled';
|
||||
} else {
|
||||
mode = 'enabled';
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public render(context: CanvasRenderingContext2D) {
|
||||
context.save();
|
||||
this.transformContext(context);
|
||||
|
||||
for (const child of this.children) {
|
||||
child.render(context);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
protected transformContext(context: CanvasRenderingContext2D) {
|
||||
const matrix = this.localMatrix();
|
||||
context.transform(
|
||||
matrix.a,
|
||||
matrix.b,
|
||||
matrix.c,
|
||||
matrix.d,
|
||||
matrix.e,
|
||||
matrix.f,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/*@__PURE__*/
|
||||
Node.prototype.isClass = true;
|
||||
33
packages/2d/src/components/Rect.ts
Normal file
33
packages/2d/src/components/Rect.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {Node, NodeProps} from './Node';
|
||||
import {Signal} from '@motion-canvas/core/lib/utils';
|
||||
import {property} from '../decorators';
|
||||
import {NodeChildren} from './types';
|
||||
|
||||
export interface RectProps extends NodeProps {
|
||||
children?: NodeChildren;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
export class Rect extends Node<RectProps> {
|
||||
@property('#ffffff')
|
||||
public declare readonly fill: Signal<string, this>;
|
||||
|
||||
public override render(context: CanvasRenderingContext2D) {
|
||||
context.save();
|
||||
this.transformContext(context);
|
||||
|
||||
const width = this.width();
|
||||
const height = this.height();
|
||||
|
||||
context.save();
|
||||
context.fillStyle = this.fill();
|
||||
context.fillRect(-width / 2, -height / 2, width, height);
|
||||
context.restore();
|
||||
|
||||
for (const child of this.children) {
|
||||
child.render(context);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
4
packages/2d/src/components/index.ts
Normal file
4
packages/2d/src/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './Circle';
|
||||
export * from './Node';
|
||||
export * from './Rect';
|
||||
export * from './types';
|
||||
28
packages/2d/src/components/types.ts
Normal file
28
packages/2d/src/components/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {Node} from './Node';
|
||||
import type {Reference} from '@motion-canvas/core/lib/utils';
|
||||
|
||||
export type ComponentChild =
|
||||
| Node
|
||||
| object
|
||||
| string
|
||||
| number
|
||||
| bigint
|
||||
| boolean
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type ComponentChildren = ComponentChild | ComponentChild[];
|
||||
export type NodeChildren = Node | Node[];
|
||||
|
||||
export interface JSXProps {
|
||||
children?: ComponentChildren;
|
||||
ref?: Reference<Node>;
|
||||
}
|
||||
|
||||
export interface FunctionComponent<T = any> {
|
||||
(props: T): Node<T> | null;
|
||||
}
|
||||
|
||||
export interface NodeConstructor<T = any> {
|
||||
new (props: T): Node<T>;
|
||||
}
|
||||
91
packages/2d/src/decorators/compoundProperty.ts
Normal file
91
packages/2d/src/decorators/compoundProperty.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
3
packages/2d/src/decorators/index.ts
Normal file
3
packages/2d/src/decorators/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './compoundProperty';
|
||||
export * from './initializers';
|
||||
export * from './property';
|
||||
34
packages/2d/src/decorators/initializers.ts
Normal file
34
packages/2d/src/decorators/initializers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
const INITIALIZERS = Symbol.for('initializers');
|
||||
|
||||
export type Initializer<T> = (instance: T, context: any) => void;
|
||||
|
||||
export function addInitializer<T>(target: any, initializer: Initializer<T>) {
|
||||
if (!target[INITIALIZERS]) {
|
||||
target[INITIALIZERS] = [];
|
||||
} else if (
|
||||
// if one of the prototypes has initializers
|
||||
target[INITIALIZERS] &&
|
||||
// and it's not the target object itself
|
||||
!Object.prototype.hasOwnProperty.call(target, INITIALIZERS)
|
||||
) {
|
||||
const props = [];
|
||||
let base = Object.getPrototypeOf(target);
|
||||
while (base) {
|
||||
if (Object.prototype.hasOwnProperty.call(base, INITIALIZERS)) {
|
||||
props.push(...base[INITIALIZERS]);
|
||||
}
|
||||
base = Object.getPrototypeOf(base);
|
||||
}
|
||||
target[INITIALIZERS] = props;
|
||||
}
|
||||
|
||||
target[INITIALIZERS].push(initializer);
|
||||
}
|
||||
|
||||
export function initialize(target: any, context: any) {
|
||||
if (target[INITIALIZERS]) {
|
||||
target[INITIALIZERS].forEach((initializer: Initializer<any>) =>
|
||||
initializer(target, context),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
packages/2d/src/decorators/property.ts
Normal file
18
packages/2d/src/decorators/property.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {InterpolationFunction, map} from '@motion-canvas/core/lib/tweening';
|
||||
import {useSignal} from '@motion-canvas/core/lib/utils';
|
||||
import {addInitializer} from './initializers';
|
||||
|
||||
export function property<T>(
|
||||
initial?: T,
|
||||
mapper?: InterpolationFunction<T>,
|
||||
): PropertyDecorator {
|
||||
return (target: any, key) => {
|
||||
addInitializer(target, (instance: any, context: any) => {
|
||||
instance[key] = useSignal(
|
||||
context.defaults[key] ?? initial,
|
||||
mapper ?? map,
|
||||
instance,
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
2
packages/2d/src/globals.d.ts
vendored
Normal file
2
packages/2d/src/globals.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-namespace */
|
||||
/// <reference types="@motion-canvas/core/project" />
|
||||
47
packages/2d/src/jsx-runtime.ts
Normal file
47
packages/2d/src/jsx-runtime.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
ComponentChildren,
|
||||
FunctionComponent,
|
||||
Node,
|
||||
NodeConstructor,
|
||||
JSXProps,
|
||||
} from './components';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
export namespace JSX {
|
||||
export type Element = Node;
|
||||
export type ElementClass = Node;
|
||||
export interface ElementChildrenAttribute {
|
||||
children: any;
|
||||
}
|
||||
}
|
||||
|
||||
function isClassComponent(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
fn: Function,
|
||||
): fn is new (...args: unknown[]) => unknown {
|
||||
return !!fn.prototype?.isClass;
|
||||
}
|
||||
|
||||
export const Fragment = Symbol.for('MotionCanvas2DFragment');
|
||||
export function jsx(
|
||||
type: NodeConstructor | FunctionComponent | typeof Fragment,
|
||||
config: JSXProps,
|
||||
): ComponentChildren {
|
||||
const {ref, children, ...rest} = config;
|
||||
const flatChildren = Array.isArray(children) ? children.flat() : [children];
|
||||
|
||||
if (type === Fragment) {
|
||||
return flatChildren;
|
||||
}
|
||||
|
||||
if (isClassComponent(type)) {
|
||||
const node = new type({...rest, children: flatChildren});
|
||||
if (ref) {
|
||||
ref.value = node;
|
||||
}
|
||||
return node;
|
||||
} else {
|
||||
return type({...rest, ref, children: flatChildren});
|
||||
}
|
||||
}
|
||||
export {jsx as jsxs};
|
||||
114
packages/2d/src/layout/Layout.ts
Normal file
114
packages/2d/src/layout/Layout.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import {initialize, property} from '../decorators';
|
||||
import {Signal, useSignal} from '@motion-canvas/core/lib/utils';
|
||||
import {Rect} from '@motion-canvas/core/lib/types';
|
||||
import {AlignItems, FlexDirection, JustifyContent, LayoutMode} from './types';
|
||||
|
||||
export interface LayoutProps {
|
||||
mode?: LayoutMode;
|
||||
width?: number;
|
||||
height?: number;
|
||||
marginTop?: number;
|
||||
marginBottom?: number;
|
||||
marginLeft?: number;
|
||||
marginRight?: number;
|
||||
direction?: FlexDirection;
|
||||
justifyContent?: JustifyContent;
|
||||
alignItems?: AlignItems;
|
||||
ratio?: string;
|
||||
}
|
||||
|
||||
export class Layout {
|
||||
@property(null)
|
||||
public declare readonly mode: Signal<LayoutMode, this>;
|
||||
@property(null)
|
||||
public declare readonly width: Signal<number | `${number}%`, this>;
|
||||
@property(null)
|
||||
public declare readonly height: Signal<number | `${number}%`, this>;
|
||||
@property(0)
|
||||
public declare readonly marginTop: Signal<number, this>;
|
||||
@property(0)
|
||||
public declare readonly marginBottom: Signal<number, this>;
|
||||
@property(0)
|
||||
public declare readonly marginLeft: Signal<number, this>;
|
||||
@property(0)
|
||||
public declare readonly marginRight: Signal<number, this>;
|
||||
@property('row')
|
||||
public declare readonly direction: Signal<FlexDirection, this>;
|
||||
@property('none')
|
||||
public declare readonly ratio: Signal<string, this>;
|
||||
@property('flex-start')
|
||||
public declare readonly justifyContent: Signal<JustifyContent, this>;
|
||||
@property('auto')
|
||||
public declare readonly alignItems: Signal<AlignItems, this>;
|
||||
|
||||
public readonly element: HTMLDivElement;
|
||||
|
||||
private isDirty = true;
|
||||
|
||||
public constructor(props: LayoutProps) {
|
||||
this.element = document.createElement('div');
|
||||
this.element.style.display = 'flex';
|
||||
|
||||
initialize(this, {defaults: props});
|
||||
this.update.onChanged.subscribe(() => {
|
||||
this.isDirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
public toPixels(value: number) {
|
||||
return `${value}px`;
|
||||
}
|
||||
|
||||
public getComputedLayout(): Rect {
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
};
|
||||
}
|
||||
|
||||
public updateIfNecessary(): boolean {
|
||||
if (this.isDirty) {
|
||||
this.update();
|
||||
this.isDirty = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private readonly update = useSignal(() => {
|
||||
const mode = this.mode();
|
||||
this.element.style.position =
|
||||
mode === 'disabled' || mode === 'root' ? 'absolute' : '';
|
||||
|
||||
const width = this.width();
|
||||
if (width === null) {
|
||||
this.element.style.width = 'auto';
|
||||
} else if (typeof width === 'string') {
|
||||
this.element.style.width = width;
|
||||
} else {
|
||||
this.element.style.width = this.toPixels(width);
|
||||
}
|
||||
|
||||
const height = this.height();
|
||||
if (height === null) {
|
||||
this.element.style.height = 'auto';
|
||||
} else if (typeof height === 'string') {
|
||||
this.element.style.height = height;
|
||||
} else {
|
||||
this.element.style.height = this.toPixels(height);
|
||||
}
|
||||
|
||||
this.element.style.marginTop = this.toPixels(this.marginTop());
|
||||
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.flexDirection = this.direction();
|
||||
this.element.style.aspectRatio = this.ratio();
|
||||
this.element.style.justifyContent = this.justifyContent();
|
||||
this.element.style.alignItems = this.alignItems();
|
||||
});
|
||||
}
|
||||
2
packages/2d/src/layout/index.ts
Normal file
2
packages/2d/src/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './Layout';
|
||||
export * from './types';
|
||||
21
packages/2d/src/layout/types.ts
Normal file
21
packages/2d/src/layout/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type FlexDirection = 'row' | 'row-reverse' | 'column' | 'column-reverse';
|
||||
|
||||
export type JustifyContent =
|
||||
| 'flex-start'
|
||||
| 'center'
|
||||
| 'flex-end'
|
||||
| 'space-between'
|
||||
| 'space-around'
|
||||
| 'space-evenly';
|
||||
|
||||
export type AlignItems =
|
||||
| 'auto'
|
||||
| 'flex-start'
|
||||
| 'center'
|
||||
| 'flex-end'
|
||||
| 'stretch'
|
||||
| 'baseline'
|
||||
| 'space-between'
|
||||
| 'space-around';
|
||||
|
||||
export type LayoutMode = 'disabled' | 'enabled' | 'root' | 'pop' | null;
|
||||
28
packages/2d/src/scenes/TwoDScene.ts
Normal file
28
packages/2d/src/scenes/TwoDScene.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {GeneratorScene, Scene} from '@motion-canvas/core/lib/scenes';
|
||||
import {TwoDView} from './TwoDView';
|
||||
|
||||
export class TwoDScene extends GeneratorScene<TwoDView> {
|
||||
private readonly view = new TwoDView();
|
||||
|
||||
public getView(): TwoDView {
|
||||
return this.view;
|
||||
}
|
||||
|
||||
public update() {
|
||||
this.view.updateLayout();
|
||||
}
|
||||
|
||||
public render(
|
||||
context: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
): void {
|
||||
context.save();
|
||||
this.view.render(context);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
public reset(previousScene?: Scene): Promise<void> {
|
||||
this.view.removeChildren();
|
||||
return super.reset(previousScene);
|
||||
}
|
||||
}
|
||||
45
packages/2d/src/scenes/TwoDView.ts
Normal file
45
packages/2d/src/scenes/TwoDView.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {Node} from '../components';
|
||||
|
||||
export class TwoDView extends Node<any> {
|
||||
private static frameID = 'motion-canvas-2d-frame';
|
||||
|
||||
public constructor() {
|
||||
super({layout: {width: 1920, height: 1080}, mode: 'none'});
|
||||
|
||||
let frame = document.querySelector<HTMLIFrameElement>(
|
||||
`#${TwoDView.frameID}`,
|
||||
);
|
||||
if (!frame) {
|
||||
frame = document.createElement('iframe');
|
||||
frame.id = TwoDView.frameID;
|
||||
frame.style.position = 'absolute';
|
||||
frame.style.pointerEvents = 'none';
|
||||
frame.style.left = '0';
|
||||
frame.style.right = '0';
|
||||
frame.style.opacity = '0';
|
||||
frame.style.border = 'none';
|
||||
|
||||
document.body.prepend(frame);
|
||||
}
|
||||
|
||||
if (frame.contentDocument) {
|
||||
frame.contentDocument.body.append(this.layout.element);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
3
packages/2d/src/scenes/index.ts
Normal file
3
packages/2d/src/scenes/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './make2DScene';
|
||||
export * from './TwoDScene';
|
||||
export * from './TwoDView';
|
||||
15
packages/2d/src/scenes/make2DScene.ts
Normal file
15
packages/2d/src/scenes/make2DScene.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
DescriptionOf,
|
||||
ThreadGeneratorFactory,
|
||||
} from '@motion-canvas/core/lib/scenes';
|
||||
import {TwoDView} from './TwoDView';
|
||||
import {TwoDScene} from './TwoDScene';
|
||||
|
||||
export function make2DScene(
|
||||
runner: ThreadGeneratorFactory<TwoDView>,
|
||||
): DescriptionOf<TwoDScene> {
|
||||
return {
|
||||
klass: TwoDScene,
|
||||
config: runner,
|
||||
};
|
||||
}
|
||||
22
packages/2d/tsconfig.json
Normal file
22
packages/2d/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"outDir": "lib",
|
||||
"strict": true,
|
||||
"module": "esnext",
|
||||
"target": "es2020",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@motion-canvas/2d/lib",
|
||||
"paths": {
|
||||
"@motion-canvas/2d/lib/jsx-runtime": ["jsx-runtime.ts"]
|
||||
},
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
packages/2d/tsconfig.project.json
Normal file
7
packages/2d/tsconfig.project.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@motion-canvas/core/tsconfig.project.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@motion-canvas/2d/lib"
|
||||
}
|
||||
}
|
||||
80
packages/core/src/events/FlagDispatcher.ts
Normal file
80
packages/core/src/events/FlagDispatcher.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
EventDispatcherBase,
|
||||
EventHandler,
|
||||
Subscribable,
|
||||
} from './EventDispatcherBase';
|
||||
|
||||
/**
|
||||
* Dispatches a {@link SubscribableFlagEvent}.
|
||||
*
|
||||
* @remarks
|
||||
* Subscribers are notified only when the flag is set.
|
||||
* Subsequent calls to {@link raise} don't trigger anything.
|
||||
* Any handlers added while the flag is raised are immediately invoked.
|
||||
*
|
||||
* Resetting the flag doesn't notify the subscribers, but raising it again does.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class Example {
|
||||
* // expose the event to external classes
|
||||
* public get onChanged {
|
||||
* return this.flag.subscribable;
|
||||
* }
|
||||
* // create a private dispatcher
|
||||
* private flag = new FlagDispatcher();
|
||||
*
|
||||
* private dispatchExample() {
|
||||
* // setting the flag will notify all subscribers
|
||||
* this.flag.raise();
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class FlagDispatcher extends EventDispatcherBase<void> {
|
||||
private value = false;
|
||||
|
||||
/**
|
||||
* Notify all current and future subscribers.
|
||||
*/
|
||||
public raise() {
|
||||
if (!this.value) {
|
||||
this.value = true;
|
||||
this.notifySubscribers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop notifying future subscribers.
|
||||
*/
|
||||
public reset() {
|
||||
this.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Are subscribers being notified?
|
||||
*/
|
||||
public isRaised() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public subscribe(handler: EventHandler<void>) {
|
||||
const unsubscribe = super.subscribe(handler);
|
||||
if (this.value) {
|
||||
handler();
|
||||
}
|
||||
return unsubscribe;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides safe access to the public interface of {@link FlagDispatcher}.
|
||||
*
|
||||
* @remarks
|
||||
* External classes can use it to subscribe to an event without being able to
|
||||
* dispatch it.
|
||||
*/
|
||||
export type SubscribableFlagEvent = Subscribable<void>;
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './AsyncEventDispatcher';
|
||||
export * from './EventDispatcher';
|
||||
export * from './EventDispatcherBase';
|
||||
export * from './FlagDispatcher';
|
||||
export * from './ValueDispatcher';
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface SceneMetadata extends Metadata {
|
||||
* {@link SceneDescription.config}.
|
||||
*/
|
||||
export interface SceneConstructor<T> {
|
||||
new (name: string, meta: Meta, config: T): Scene;
|
||||
new (name: string, meta: Meta<SceneMetadata>, config: T): Scene;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +40,10 @@ export interface SceneDescription<T = unknown> {
|
||||
config: T;
|
||||
}
|
||||
|
||||
export type DescriptionOf<TScene> = TScene extends Scene<infer TConfig>
|
||||
? SceneDescription<TConfig>
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Describes cached information about the timing of a scene.
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './useAnimator';
|
||||
export * from './useProject';
|
||||
export * from './useRef';
|
||||
export * from './useScene';
|
||||
export * from './useSignal';
|
||||
export * from './useThread';
|
||||
export * from './useTime';
|
||||
export * from './useContext';
|
||||
|
||||
77
packages/core/src/utils/useSignal.test.ts
Normal file
77
packages/core/src/utils/useSignal.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {useSignal} from './useSignal';
|
||||
|
||||
describe('useSignal()', () => {
|
||||
test('Works correctly with plain values', () => {
|
||||
const signal = useSignal(7);
|
||||
|
||||
expect(signal()).toBe(7);
|
||||
|
||||
signal(3);
|
||||
|
||||
expect(signal()).toBe(3);
|
||||
});
|
||||
|
||||
test('Works correctly with computed values', () => {
|
||||
const signal = useSignal(() => 7);
|
||||
|
||||
expect(signal()).toBe(7);
|
||||
|
||||
signal(() => 3);
|
||||
|
||||
expect(signal()).toBe(3);
|
||||
});
|
||||
|
||||
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()));
|
||||
|
||||
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 = useSignal(1);
|
||||
|
||||
const value = jest.fn(() => a() * 2);
|
||||
const c = useSignal(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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
135
packages/core/src/utils/useSignal.ts
Normal file
135
packages/core/src/utils/useSignal.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {EventHandler, FlagDispatcher, Subscribable} from '../events';
|
||||
import {
|
||||
deepLerp,
|
||||
easeInOutCubic,
|
||||
TimingFunction,
|
||||
tween,
|
||||
InterpolationFunction,
|
||||
} from '../tweening';
|
||||
import {ThreadGenerator} from '../threading';
|
||||
|
||||
type DependencyContext = [Set<Subscribable<void>>, EventHandler<void>];
|
||||
|
||||
export type SignalValue<TValue> = TValue | (() => TValue);
|
||||
|
||||
export interface Signal<TValue, TReturn = void> {
|
||||
(): TValue;
|
||||
(value: SignalValue<TValue>): TReturn;
|
||||
(
|
||||
value: SignalValue<TValue>,
|
||||
time: number,
|
||||
timingFunction?: TimingFunction,
|
||||
interpolationFunction?: InterpolationFunction<TValue>,
|
||||
): ThreadGenerator;
|
||||
get onChanged(): Subscribable<void>;
|
||||
}
|
||||
|
||||
const collectionStack: DependencyContext[] = [];
|
||||
|
||||
export function startCollecting(context: DependencyContext) {
|
||||
collectionStack.push(context);
|
||||
}
|
||||
|
||||
export function finishCollecting(context: DependencyContext) {
|
||||
if (collectionStack.pop()[0] !== context[0]) {
|
||||
throw new Error('collectStart/collectEnd was called out of order');
|
||||
}
|
||||
}
|
||||
|
||||
export function collect(subscribable: Subscribable<void>) {
|
||||
if (collectionStack.length > 0) {
|
||||
const [set, handler] = collectionStack.at(-1);
|
||||
set.add(subscribable);
|
||||
subscribable.subscribe(handler);
|
||||
}
|
||||
}
|
||||
|
||||
export function isReactive<T>(value: SignalValue<T>): value is () => T {
|
||||
return typeof value === 'function';
|
||||
}
|
||||
|
||||
export function useSignal<TValue, TReturn = void>(
|
||||
initial?: SignalValue<TValue>,
|
||||
defaultInterpolation: InterpolationFunction<TValue> = deepLerp,
|
||||
setterReturn?: TReturn,
|
||||
): Signal<TValue, TReturn> {
|
||||
let current: SignalValue<TValue>;
|
||||
let last: TValue;
|
||||
const dependencies = new Set<Subscribable<void>>();
|
||||
const event = new FlagDispatcher();
|
||||
|
||||
function set(value: SignalValue<TValue>) {
|
||||
if (current === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
current = value;
|
||||
markDirty();
|
||||
|
||||
if (dependencies.size > 0) {
|
||||
dependencies.forEach(dep => dep.unsubscribe(markDirty));
|
||||
dependencies.clear();
|
||||
}
|
||||
|
||||
if (!isReactive(value)) {
|
||||
last = value;
|
||||
}
|
||||
}
|
||||
|
||||
function get(): TValue {
|
||||
if (event.isRaised() && isReactive(current)) {
|
||||
startCollecting([dependencies, markDirty]);
|
||||
last = current();
|
||||
finishCollecting([dependencies, markDirty]);
|
||||
}
|
||||
event.reset();
|
||||
collect(event.subscribable);
|
||||
return last;
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
event.raise();
|
||||
}
|
||||
|
||||
const handler = <Signal<TValue, TReturn>>(
|
||||
function handler(
|
||||
value?: SignalValue<TValue>,
|
||||
duration?: number,
|
||||
timingFunction: TimingFunction = easeInOutCubic,
|
||||
interpolationFunction: InterpolationFunction<TValue> = defaultInterpolation,
|
||||
) {
|
||||
// Getter
|
||||
if (value === undefined) {
|
||||
return get();
|
||||
}
|
||||
|
||||
// Setter
|
||||
if (duration === undefined) {
|
||||
set(value);
|
||||
return setterReturn;
|
||||
}
|
||||
|
||||
// Tween
|
||||
const from = get();
|
||||
return tween(duration, v => {
|
||||
set(
|
||||
interpolationFunction(
|
||||
from,
|
||||
isReactive(value) ? value() : value,
|
||||
timingFunction(v),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
Object.defineProperty(handler, 'onChanged', {
|
||||
value: event.subscribable,
|
||||
});
|
||||
|
||||
if (initial !== undefined) {
|
||||
handler(initial);
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
Reference in New Issue
Block a user