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:
Jacob
2022-09-13 17:14:38 +02:00
committed by GitHub
parent a956725cc4
commit 9255490096
30 changed files with 1157 additions and 22 deletions

View File

@@ -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
View File

@@ -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": {

View File

@@ -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
View 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"
}
}

View 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();
}
}

View 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;

View 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();
}
}

View File

@@ -0,0 +1,4 @@
export * from './Circle';
export * from './Node';
export * from './Rect';
export * from './types';

View 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>;
}

View 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,
);
});
};
}

View File

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

View 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),
);
}
}

View 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
View File

@@ -0,0 +1,2 @@
/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-namespace */
/// <reference types="@motion-canvas/core/project" />

View 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};

View 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();
});
}

View File

@@ -0,0 +1,2 @@
export * from './Layout';
export * from './types';

View 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;

View 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);
}
}

View 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;
}
}

View File

@@ -0,0 +1,3 @@
export * from './make2DScene';
export * from './TwoDScene';
export * from './TwoDView';

View 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
View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "@motion-canvas/core/tsconfig.project.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@motion-canvas/2d/lib"
}
}

View 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>;

View File

@@ -1,4 +1,5 @@
export * from './AsyncEventDispatcher';
export * from './EventDispatcher';
export * from './EventDispatcherBase';
export * from './FlagDispatcher';
export * from './ValueDispatcher';

View File

@@ -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.
*/

View File

@@ -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';

View 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);
});
});

View 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;
}