feat: turn Layout into node (#75)

This commit is contained in:
Jacob
2022-10-25 21:36:17 +02:00
committed by GitHub
parent ddabec549b
commit cdf8dc0a35
14 changed files with 818 additions and 794 deletions

View File

@@ -1,52 +1,49 @@
import {Node, NodeProps} from './Node';
import {Signal} from '@motion-canvas/core/lib/utils';
import {computed, property} from '../decorators';
import {Layout, LayoutProps} from './Layout';
export interface ImageProps extends NodeProps {
export interface ImageProps extends LayoutProps {
src?: string;
}
export class Image extends Node<ImageProps> {
export class Image extends Layout {
@property()
public declare readonly src: Signal<string, this>;
protected readonly image: HTMLImageElement;
public constructor(props: ImageProps) {
super({
...props,
layout: props.layout
? {...props.layout, tagName: 'img'}
: {tagName: 'img'},
tagName: 'img',
});
this.image = <HTMLImageElement>this.element;
}
protected override draw(context: CanvasRenderingContext2D) {
const image = <HTMLImageElement>this.layout.element;
const {width, height} = this.computedSize();
context.drawImage(image, width / -2, height / -2, width, height);
context.drawImage(this.image, width / -2, height / -2, width, height);
super.draw(context);
}
protected override applyLayoutChanges() {
protected override updateLayout() {
this.applySrc();
super.updateLayout();
}
@computed()
protected applySrc() {
const src = this.src();
const image = <HTMLImageElement>this.layout.element;
image.src = src;
return image;
this.image.src = this.src();
}
protected override collectAsyncResources(deps: Promise<any>[]) {
super.collectAsyncResources(deps);
const image = this.applySrc();
if (!image.complete) {
this.applySrc();
if (!this.image.complete) {
deps.push(
new Promise((resolve, reject) => {
image.addEventListener('load', resolve);
image.addEventListener('error', reject);
this.image.addEventListener('load', resolve);
this.image.addEventListener('error', reject);
}),
);
}

View File

@@ -0,0 +1,645 @@
import {compound, computed, Property, property} from '../decorators';
import {
Vector2,
transformAngle,
Rect,
Size,
PossibleSpacing,
Spacing,
} from '@motion-canvas/core/lib/types';
import {isReactive, Signal, SignalValue} from '@motion-canvas/core/lib/utils';
import {
InterpolationFunction,
TimingFunction,
tween,
} from '@motion-canvas/core/lib/tweening';
import {
FlexAlign,
FlexBasis,
FlexDirection,
FlexJustify,
FlexWrap,
LayoutMode,
Length,
ResolvedLayoutMode,
} from '../partials';
import {threadable} from '@motion-canvas/core/lib/decorators';
import {ThreadGenerator} from '@motion-canvas/core/lib/threading';
import {Node, NodeProps} from './Node';
import {TwoDView} from '../scenes';
export interface LayoutProps extends NodeProps {
layout?: LayoutMode;
tagName?: keyof HTMLElementTagNameMap;
width?: Length;
height?: Length;
maxWidth?: Length;
maxHeight?: Length;
minWidth?: Length;
minHeight?: Length;
ratio?: number;
marginTop?: number;
marginBottom?: number;
marginLeft?: number;
marginRight?: number;
margin?: PossibleSpacing;
paddingTop?: number;
paddingBottom?: number;
paddingLeft?: number;
paddingRight?: number;
padding?: PossibleSpacing;
direction?: FlexDirection;
basis?: FlexBasis;
grow?: number;
shrink?: number;
wrap?: FlexWrap;
justifyContent?: FlexJustify;
alignItems?: FlexAlign;
rowGap?: Length;
columnGap?: Length;
gap?: Length;
fontFamily?: string;
fontSize?: number;
fontStyle?: string;
fontWeight?: number;
lineHeight?: number;
letterSpacing?: number;
textWrap?: boolean;
x?: number;
y?: number;
position?: Vector2;
size?: Size;
rotation?: number;
offsetX?: number;
offsetY?: number;
offset?: Vector2;
scaleX?: number;
scaleY?: number;
scale?: Vector2;
overflow?: boolean;
}
export class Layout extends Node {
@property(null)
public declare readonly layout: Signal<LayoutMode, this>;
@property(null)
public declare readonly maxWidth: Signal<Length, this>;
@property(null)
public declare readonly maxHeight: Signal<Length, this>;
@property(null)
public declare readonly minWidth: Signal<Length, this>;
@property(null)
public declare readonly minHeight: Signal<Length, this>;
@property(null)
public declare readonly ratio: Signal<number | null, 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>;
@compound({
top: 'marginTop',
bottom: 'marginBottom',
left: 'marginLeft',
right: 'marginRight',
})
@property(undefined, Spacing.lerp, Spacing)
public declare readonly margin: Property<PossibleSpacing, Spacing, 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>;
@compound({
top: 'paddingTop',
bottom: 'paddingBottom',
left: 'paddingLeft',
right: 'paddingRight',
})
@property(undefined, Spacing.lerp, Spacing)
public declare readonly padding: Property<PossibleSpacing, Spacing, this>;
@property('row')
public declare readonly direction: Signal<FlexDirection, this>;
@property(null)
public declare readonly basis: Signal<FlexBasis, this>;
@property(0)
public declare readonly grow: Signal<number, this>;
@property(1)
public declare readonly shrink: Signal<number, this>;
@property('nowrap')
public declare readonly wrap: Signal<FlexWrap, this>;
@property('normal')
public declare readonly justifyContent: Signal<FlexJustify, this>;
@property('normal')
public declare readonly alignItems: Signal<FlexAlign, this>;
@property(null)
public declare readonly gap: Signal<Length, this>;
@property(null)
public declare readonly rowGap: Signal<Length, this>;
@property(null)
public declare readonly columnGap: Signal<Length, this>;
@property(null)
public declare readonly fontFamily: Signal<string | null, this>;
@property(null)
public declare readonly fontSize: Signal<number | null, this>;
@property(null)
public declare readonly fontStyle: Signal<string | null, this>;
@property(null)
public declare readonly fontWeight: Signal<number | null, this>;
@property(null)
public declare readonly lineHeight: Signal<number | null, this>;
@property(null)
public declare readonly letterSpacing: Signal<number | null, this>;
@property(null)
public declare readonly textWrap: Signal<boolean | null, this>;
@property(0)
protected declare readonly customX: Signal<number, this>;
@property(0)
public declare readonly x: Signal<number, this>;
protected getX(): number {
const mode = this.resolvedMode();
if (mode !== 'enabled') {
return this.customX();
}
return this.computedPosition().x;
}
protected setX(value: SignalValue<number>) {
this.customX(value);
}
@property(0)
protected declare readonly customY: Signal<number, this>;
@property(0)
public declare readonly y: Signal<number, this>;
protected getY(): number {
const mode = this.resolvedMode();
if (mode !== 'enabled') {
return this.customY();
}
return this.computedPosition().y;
}
protected setY(value: SignalValue<number>) {
this.customY(value);
}
@property(null)
protected declare readonly customWidth: Signal<Length, this>;
@property(null)
public declare readonly width: Property<Length, number, this>;
protected getWidth(): number {
return this.computedSize().width;
}
protected setWidth(value: SignalValue<Length>) {
this.customWidth(value);
}
@threadable()
protected *tweenWidth(
value: SignalValue<Length>,
time: number,
timingFunction: TimingFunction,
interpolationFunction: InterpolationFunction<Length>,
): ThreadGenerator {
const width = this.customWidth();
const lock = typeof width !== 'number' || typeof value !== 'number';
let from: number;
if (lock) {
from = this.width();
} else {
from = width;
}
let to: number;
if (lock) {
this.width(value);
to = this.width();
} else {
to = value;
}
this.width(from);
lock && this.lockSize();
yield* tween(time, value =>
this.width(interpolationFunction(from, to, timingFunction(value))),
);
this.width(value);
lock && this.releaseSize();
}
@property(null)
protected declare readonly customHeight: Signal<Length, this>;
@property(null)
public declare readonly height: Property<Length, number, this>;
protected getHeight(): number {
return this.computedSize().height;
}
protected setHeight(value: SignalValue<Length>) {
this.customHeight(value);
}
@threadable()
protected *tweenHeight(
value: SignalValue<Length>,
time: number,
timingFunction: TimingFunction,
interpolationFunction: InterpolationFunction<Length>,
): ThreadGenerator {
const height = this.customHeight();
const lock = typeof height !== 'number' || typeof value !== 'number';
let from: number;
if (lock) {
from = this.height();
} else {
from = height;
}
let to: number;
if (lock) {
this.height(value);
to = this.height();
} else {
to = value;
}
this.height(from);
lock && this.lockSize();
yield* tween(time, value =>
this.height(interpolationFunction(from, to, timingFunction(value))),
);
this.height(value);
lock && this.releaseSize();
}
@compound(['width', 'height'])
@property(undefined, Size.lerp, Size)
public declare readonly size: Property<
{width: Length; height: Length},
Size,
this
>;
@computed()
protected customSize(): {width: Length; height: Length} {
return {
width: this.customWidth(),
height: this.customHeight(),
};
}
@threadable()
protected *tweenSize(
value: SignalValue<{width: Length; height: Length}>,
time: number,
timingFunction: TimingFunction,
interpolationFunction: InterpolationFunction<Size>,
): ThreadGenerator {
const size = this.customSize();
let from: Size;
if (typeof size.height !== 'number' || typeof size.width !== 'number') {
from = this.size();
} else {
from = <Size>size;
}
let to: Size;
if (
typeof value === 'object' &&
typeof value.height === 'number' &&
typeof value.width === 'number'
) {
to = <Size>value;
} else {
this.size(value);
to = this.size();
}
this.size(from);
this.lockSize();
yield* tween(time, value =>
this.size(interpolationFunction(from, to, timingFunction(value))),
);
this.releaseSize();
this.size(value);
}
@property(0)
public declare readonly rotation: Signal<number, this>;
@property(0)
public declare readonly offsetX: Signal<number, this>;
@property(0)
public declare readonly offsetY: Signal<number, this>;
@compound({x: 'offsetX', y: 'offsetY'})
@property(undefined, Vector2.lerp, Vector2)
public declare readonly offset: Signal<Vector2, this>;
@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, Vector2.lerp, Vector2)
public declare readonly scale: Signal<Vector2, this>;
@property(false)
public declare readonly overflow: Signal<boolean, this>;
@compound(['x', 'y'])
@property(undefined, Vector2.lerp, Vector2)
public declare readonly position: Signal<Vector2, this>;
@property(undefined, Vector2.lerp, Vector2)
public declare readonly absolutePosition: Signal<Vector2, this>;
protected getAbsolutePosition(): Vector2 {
const matrix = this.localToWorld();
return new Vector2(matrix.m41, matrix.m42);
}
protected setAbsolutePosition(value: SignalValue<Vector2>) {
if (isReactive(value)) {
this.position(() => value().transformAsPoint(this.worldToParent()));
} else {
this.position(value.transformAsPoint(this.worldToParent()));
}
}
@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.worldToParent()));
} else {
this.rotation(transformAngle(value, this.worldToParent()));
}
}
public readonly element: HTMLElement;
public readonly styles: CSSStyleDeclaration;
@property(0)
protected declare readonly sizeLockCounter: Signal<number, this>;
public constructor({tagName = 'div', ...props}: LayoutProps) {
super(props);
this.element = TwoDView.document.createElement(tagName);
this.element.style.display = 'flex';
this.element.style.boxSizing = 'border-box';
this.styles = getComputedStyle(this.element);
}
public lockSize() {
this.sizeLockCounter(this.sizeLockCounter() + 1);
}
public releaseSize() {
this.sizeLockCounter(this.sizeLockCounter() - 1);
}
@computed()
protected parentTransform(): Layout | null {
let parent: Node | null = this.parent();
while (parent) {
if (parent instanceof Layout) {
return parent;
}
parent = parent.parent();
}
return null;
}
/**
* Get the resolved layout mode of this node.
*
* @remarks
* When the mode is `null`, its value will be inherited from the parent.
*
* Use {@link Layout.mode} to get the raw mode set for this node (without
* inheritance).
*/
@computed()
protected resolvedMode(): ResolvedLayoutMode {
const parentMode = this.parentTransform()?.resolvedMode();
let mode = this.layout();
if (mode === null) {
if (!parentMode || parentMode === 'disabled') {
mode = 'disabled';
} else {
mode = 'enabled';
}
}
return mode;
}
protected override localToParent(): DOMMatrix {
const matrix = new DOMMatrix();
const size = this.computedSize();
matrix.translateSelf(this.x(), this.y());
matrix.rotateSelf(0, 0, this.rotation());
matrix.scaleSelf(this.scaleX(), this.scaleY());
matrix.translateSelf(
(size.width / -2) * this.offsetX(),
(size.height / -2) * this.offsetY(),
);
return matrix;
}
protected getComputedLayout(): Rect {
return new Rect(this.element.getBoundingClientRect());
}
@computed()
public computedPosition(): Vector2 {
this.requestLayoutUpdate();
const rect = this.getComputedLayout();
const position = new Vector2(
rect.x + (rect.width / 2) * this.offsetX(),
rect.y + (rect.height / 2) * this.offsetY(),
);
const parent = this.parentTransform();
if (parent) {
const parentRect = parent.getComputedLayout();
position.x -= parentRect.x + (parentRect.width - rect.width) / 2;
position.y -= parentRect.y + (parentRect.height - rect.height) / 2;
}
return position;
}
@computed()
protected computedSize(): Size {
this.requestLayoutUpdate();
return new Size(this.getComputedLayout());
}
/**
* Find the closest layout root and apply any new layout changes.
*/
@computed()
protected requestLayoutUpdate() {
const mode = this.resolvedMode();
const parent = this.parentTransform();
if (mode === 'disabled' || mode === 'root' || !parent) {
this.view()?.element.append(this.element);
parent?.requestFontUpdate();
this.updateLayout();
} else {
parent.requestLayoutUpdate();
}
}
/**
* Apply any new layout changes to this node and its children.
*/
@computed()
protected updateLayout() {
this.applyFont();
this.applyFlex();
this.syncDOM();
}
@computed()
protected syncDOM() {
this.element.innerText = '';
const queue = [...this.children()];
while (queue.length) {
const child = queue.shift()!;
if (child instanceof Layout) {
this.element.append(child.element);
child.updateLayout();
} else {
queue.push(...child.children());
}
}
}
/**
* Apply any new font changes to this node and all of its ancestors.
*/
@computed()
protected requestFontUpdate() {
this.parentTransform()?.requestFontUpdate();
this.applyFont();
}
protected override getCacheRect(): Rect {
const size = this.computedSize();
return new Rect(size.vector.scale(-0.5), size);
}
protected parseValue(value: number | string | null): string {
return value === null ? '' : value.toString();
}
protected parsePixels(value: number | null): string {
return value === null ? '' : `${value}px`;
}
protected parseLength(value: null | number | string): string {
if (value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
return `${value}px`;
}
@computed()
protected applyFlex() {
const mode = this.layout();
this.element.style.position =
mode === 'disabled' || mode === 'root' ? 'absolute' : 'relative';
this.element.style.width = this.parseLength(this.customWidth());
this.element.style.height = this.parseLength(this.customHeight());
this.element.style.maxWidth = this.parseLength(this.maxWidth());
this.element.style.minWidth = this.parseLength(this.minWidth());
this.element.style.maxHeight = this.parseLength(this.maxHeight());
this.element.style.minWidth = this.parseLength(this.minWidth());
this.element.style.aspectRatio = this.parseValue(this.ratio());
this.element.style.marginTop = this.parsePixels(this.marginTop());
this.element.style.marginBottom = this.parsePixels(this.marginBottom());
this.element.style.marginLeft = this.parsePixels(this.marginLeft());
this.element.style.marginRight = this.parsePixels(this.marginRight());
this.element.style.paddingTop = this.parsePixels(this.paddingTop());
this.element.style.paddingBottom = this.parsePixels(this.paddingBottom());
this.element.style.paddingLeft = this.parsePixels(this.paddingLeft());
this.element.style.paddingRight = this.parsePixels(this.paddingRight());
this.element.style.flexDirection = this.direction();
this.element.style.flexBasis = this.parseLength(this.basis());
this.element.style.flexWrap = this.wrap();
this.element.style.justifyContent = this.justifyContent();
this.element.style.alignItems = this.alignItems();
this.element.style.rowGap = this.parseLength(this.rowGap());
this.element.style.columnGap = this.parseLength(this.columnGap());
this.element.style.gap = this.parseLength(this.gap());
if (this.sizeLockCounter() > 0) {
this.element.style.flexGrow = '0';
this.element.style.flexShrink = '0';
} else {
this.element.style.flexGrow = this.parsePixels(this.grow());
this.element.style.flexShrink = this.parsePixels(this.shrink());
}
}
@computed()
protected applyFont() {
this.element.style.fontFamily = this.parseValue(this.fontFamily());
this.element.style.fontSize = this.parsePixels(this.fontSize());
this.element.style.fontStyle = this.parseValue(this.fontStyle());
this.element.style.lineHeight = this.parsePixels(this.lineHeight());
this.element.style.fontWeight = this.parseValue(this.fontWeight());
this.element.style.letterSpacing = this.parsePixels(this.letterSpacing());
const wrap = this.textWrap();
this.element.style.whiteSpace =
wrap === null ? '' : wrap ? 'normal' : 'nowrap';
}
}

View File

@@ -1,52 +1,13 @@
import {
compound,
computed,
initialize,
Property,
property,
} from '../decorators';
import {
Vector2,
transformAngle,
Rect,
Size,
transformScalar,
} from '@motion-canvas/core/lib/types';
import {
createSignal,
isReactive,
Reference,
Signal,
SignalValue,
} from '@motion-canvas/core/lib/utils';
import {
InterpolationFunction,
TimingFunction,
tween,
map,
} from '@motion-canvas/core/lib/tweening';
import {Layout, LayoutProps, Length, ResolvedLayoutMode} from '../partials';
import {compound, computed, initialize, property} from '../decorators';
import {Vector2, Rect, transformScalar} from '@motion-canvas/core/lib/types';
import {createSignal, Reference, Signal} from '@motion-canvas/core/lib/utils';
import {ComponentChild, ComponentChildren} from './types';
import {threadable} from '@motion-canvas/core/lib/decorators';
import {ThreadGenerator, Promisable} from '@motion-canvas/core/lib/threading';
import {Promisable} from '@motion-canvas/core/lib/threading';
import {TwoDView} from '../scenes';
export interface NodeProps {
ref?: Reference<Node>;
children?: ComponentChildren;
x?: number;
y?: number;
position?: Vector2;
width?: Length;
height?: Length;
size?: Size;
rotation?: number;
offsetX?: number;
offsetY?: number;
offset?: Vector2;
scaleX?: number;
scaleY?: number;
scale?: Vector2;
layout?: LayoutProps;
opacity?: number;
blur?: number;
brightness?: number;
@@ -61,208 +22,13 @@ export interface NodeProps {
shadowOffsetX?: number;
shadowOffsetY?: number;
shadowOffset?: Vector2;
overflow?: boolean;
cache?: boolean;
composite?: boolean | Node;
composite?: boolean;
}
export class Node<TProps extends NodeProps = NodeProps>
implements Promisable<Node>
{
export class Node implements Promisable<Node> {
public declare isClass: boolean;
public readonly layout: Layout;
@property(0)
public declare readonly x: Signal<number, this>;
protected readonly customX = createSignal(0, map, this);
protected getX(): number {
return this.computedPosition().x;
}
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 {
return this.computedPosition().y;
}
protected setY(value: SignalValue<number>) {
this.customY(value);
}
@property(null)
public declare readonly width: Property<Length, number, this>;
protected readonly customWidth = createSignal<Length, this>(
undefined,
undefined,
this,
);
protected getWidth(): number {
return this.computedSize().width;
}
protected setWidth(value: SignalValue<Length>) {
this.customWidth(value);
}
@threadable()
protected *tweenWidth(
value: SignalValue<Length>,
time: number,
timingFunction: TimingFunction,
interpolationFunction: InterpolationFunction<Length>,
): ThreadGenerator {
const width = this.customWidth();
let from: number;
if (typeof width === 'number') {
from = width;
} else {
from = this.width();
}
let to: number;
if (typeof value === 'number') {
to = value;
} else {
this.width(value);
to = this.width();
}
this.width(from);
this.layout.lockSize();
yield* tween(time, value =>
this.width(interpolationFunction(from, to, timingFunction(value))),
);
this.width(value);
this.layout.releaseSize();
}
@property(null)
public declare readonly height: Property<Length, number, this>;
protected readonly customHeight = createSignal<Length, this>(
undefined,
undefined,
this,
);
protected getHeight(): number {
return this.computedSize().height;
}
protected setHeight(value: SignalValue<Length>) {
this.customHeight(value);
}
@threadable()
protected *tweenHeight(
value: SignalValue<Length>,
time: number,
timingFunction: TimingFunction,
interpolationFunction: InterpolationFunction<Length>,
): ThreadGenerator {
const height = this.customHeight();
let from: number;
if (typeof height === 'number') {
from = height;
} else {
from = this.height();
}
let to: number;
if (typeof value === 'number') {
to = value;
} else {
this.height(value);
to = this.height();
}
this.height(from);
this.layout.lockSize();
yield* tween(time, value =>
this.height(interpolationFunction(from, to, timingFunction(value))),
);
this.height(value);
this.layout.releaseSize();
}
@compound(['width', 'height'])
@property(undefined, Size.lerp, Size)
public declare readonly size: Property<
{width: Length; height: Length},
Size,
this
>;
@computed()
protected customSize(): {width: Length; height: Length} {
return {
width: this.customWidth(),
height: this.customHeight(),
};
}
@threadable()
protected *tweenSize(
value: SignalValue<{width: Length; height: Length}>,
time: number,
timingFunction: TimingFunction,
interpolationFunction: InterpolationFunction<Size>,
): ThreadGenerator {
const size = this.customSize();
let from: Size;
if (typeof size.height !== 'number' || typeof size.width !== 'number') {
from = this.size();
} else {
from = <Size>size;
}
let to: Size;
if (
typeof value === 'object' &&
typeof value.height === 'number' &&
typeof value.width === 'number'
) {
to = <Size>value;
} else {
this.size(value);
to = this.size();
}
this.size(from);
this.layout.lockSize();
yield* tween(time, value =>
this.size(interpolationFunction(from, to, timingFunction(value))),
);
this.layout.releaseSize();
this.size(value);
}
@property(0)
public declare readonly rotation: Signal<number, this>;
@property(0)
public declare readonly offsetX: Signal<number, this>;
@property(0)
public declare readonly offsetY: Signal<number, this>;
@compound({x: 'offsetX', y: 'offsetY'})
@property(undefined, Vector2.lerp, Vector2)
public declare readonly offset: Signal<Vector2, this>;
@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, Vector2.lerp, Vector2)
public declare readonly scale: Signal<Vector2, this>;
@property(false)
public declare readonly overflow: Signal<boolean, this>;
@property(false)
public declare readonly cache: Signal<boolean, this>;
@@ -345,9 +111,13 @@ export class Node<TProps extends NodeProps = NodeProps>
protected filterString(): string {
let filters = '';
const blur = this.blur();
if (blur !== 0) {
filters += ` blur(${transformScalar(blur, this.compositeToWorld())}px)`;
const invert = this.invert();
if (invert !== 0) {
filters += ` invert(${invert * 100}%)`;
}
const sepia = this.sepia();
if (sepia !== 0) {
filters += ` sepia(${sepia * 100}%)`;
}
const brightness = this.brightness();
if (brightness !== 1) {
@@ -365,94 +135,26 @@ export class Node<TProps extends NodeProps = NodeProps>
if (hue !== 0) {
filters += ` hue-rotate(${hue}deg)`;
}
const invert = this.invert();
if (invert !== 0) {
filters += ` invert(${invert * 100}%)`;
}
const saturate = this.saturate();
if (saturate !== 1) {
filters += ` saturate(${saturate * 100}%)`;
}
const sepia = this.sepia();
if (sepia !== 0) {
filters += ` sepia(${sepia * 100}%)`;
const blur = this.blur();
if (blur !== 0) {
filters += ` blur(${transformScalar(blur, this.compositeToWorld())}px)`;
}
return filters;
}
@compound(['x', 'y'])
@property(undefined, Vector2.lerp, Vector2)
public declare readonly position: Signal<Vector2, this>;
public readonly children = createSignal<Node[]>([]);
public readonly parent = createSignal<Node | null>(null);
@property(undefined, Vector2.lerp, Vector2)
public declare readonly absolutePosition: Signal<Vector2, this>;
protected getAbsolutePosition(): Vector2 {
const matrix = this.localToWorld();
return new Vector2(matrix.m41, matrix.m42);
}
protected setAbsolutePosition(value: SignalValue<Vector2>) {
if (isReactive(value)) {
this.position(() => value().transformAsPoint(this.worldToParent()));
} else {
this.position(value.transformAsPoint(this.worldToParent()));
}
}
@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.worldToParent()));
} else {
this.rotation(transformAngle(value, this.worldToParent()));
}
}
protected readonly children = createSignal<Node[]>([]);
protected readonly parent = createSignal<Node | null>(null);
protected readonly quality = createSignal(false);
public constructor({children, layout, ...rest}: TProps) {
this.layout = new Layout(layout ?? {});
public constructor({children, ...rest}: NodeProps) {
initialize(this, {defaults: rest});
this.append(children);
}
/**
* Get the resolved layout mode of this node.
*
* @remarks
* When the mode is `null`, its value will be inherited from the parent.
*
* Use {@link Layout.mode} to get the raw mode set for this node (without
* inheritance).
*/
@computed()
public mode(): ResolvedLayoutMode {
const parent = this.parent();
const parentMode = parent?.mode();
let mode = this.layout.mode();
if (mode === null) {
if (!parentMode || parentMode === 'disabled') {
mode = 'disabled';
} else {
mode = 'enabled';
}
}
return mode;
}
@computed()
protected localToWorld(): DOMMatrix {
const parent = this.parent();
@@ -473,18 +175,7 @@ export class Node<TProps extends NodeProps = NodeProps>
@computed()
protected localToParent(): DOMMatrix {
const matrix = new DOMMatrix();
const size = this.computedSize();
const position = this.computedPosition();
matrix.translateSelf(position.x, position.y);
matrix.rotateSelf(0, 0, this.rotation());
matrix.scaleSelf(this.scaleX(), this.scaleY());
matrix.translateSelf(
(size.width / -2) * this.offsetX(),
(size.height / -2) * this.offsetY(),
);
return matrix;
return new DOMMatrix();
}
@computed()
@@ -544,101 +235,8 @@ export class Node<TProps extends NodeProps = NodeProps>
}
@computed()
protected computedPosition(): Vector2 {
const mode = this.mode();
if (mode !== 'enabled') {
return new Vector2(this.customX(), this.customY());
}
this.requestLayoutUpdate();
this.requestFontUpdate();
const rect = this.layout.getComputedLayout();
const position = new Vector2(
rect.x + (rect.width / 2) * this.offsetX(),
rect.y + (rect.height / 2) * this.offsetY(),
);
const parent = this.parent();
if (parent) {
const parentRect = parent.layout.getComputedLayout();
position.x -= parentRect.x + (parentRect.width - rect.width) / 2;
position.y -= parentRect.y + (parentRect.height - rect.height) / 2;
}
return position;
}
@computed()
protected computedSize(): Size {
this.requestLayoutUpdate();
this.requestFontUpdate();
const rect = this.layout.getComputedLayout();
return new Size(rect);
}
/**
* 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();
parent?.requestFontUpdate();
} else {
parent.requestLayoutUpdate();
}
}
/**
* Apply any new layout changes to this node and its children.
*/
@computed()
protected updateLayout() {
this.updateFont();
this.layout
.setWidth(this.customWidth())
.setHeight(this.customHeight())
.applyFlex();
this.applyLayoutChanges();
for (const child of this.children()) {
child.updateLayout();
}
}
/**
* Apply any custom layout-related changes to this node.
*/
protected applyLayoutChanges() {
// do nothing
}
/**
* Apply any new font changes to this node and all of its ancestors.
*/
@computed()
protected requestFontUpdate() {
this.parent()?.requestFontUpdate();
this.updateFont();
}
/**
* Apply any new font changes to this node.
*/
@computed()
protected updateFont() {
this.layout.applyFont();
this.applyFontChanges();
}
/**
* Apply any custom font-related changes to this node.
*/
protected applyFontChanges() {
// do nothing
public view(): TwoDView | null {
return this.parent()?.view() ?? null;
}
public append(node: ComponentChildren): this {
@@ -663,12 +261,10 @@ export class Node<TProps extends NodeProps = NodeProps>
}
if (current) {
current.layout.element.removeChild(this.layout.element);
current.children(current.children().filter(child => child !== this));
}
if (parent) {
parent.layout.element.append(this.layout.element);
parent.children([...parent.children(), this]);
}
@@ -726,8 +322,31 @@ export class Node<TProps extends NodeProps = NodeProps>
* The returned rectangle should be in local space.
*/
protected getCacheRect(): Rect {
const size = this.computedSize();
return new Rect(size.vector.scale(-0.5), size);
return new Rect();
}
/**
* Get a rectangle encapsulating the contents rendered by this node as well
* as its children.
*/
@computed()
protected cacheRect(): Rect {
const cache = this.getCacheRect();
const children = this.children();
if (children.length === 0) {
return cache.pixelPerfect;
}
const points: Vector2[] = cache.corners;
for (const child of children) {
const childCache = child.fullCacheRect();
const childMatrix = child.localToParent();
points.push(
...childCache.corners.map(r => r.transformAsPoint(childMatrix)),
);
}
return Rect.fromPoints(...points).pixelPerfect;
}
/**
@@ -737,12 +356,13 @@ export class Node<TProps extends NodeProps = NodeProps>
* @remarks
* The returned rectangle should be in local space.
*/
protected getFullCacheRect() {
@computed()
protected fullCacheRect(): Rect {
const matrix = this.compositeToLocal();
const shadowOffset = this.shadowOffset().transform(matrix);
const shadowBlur = transformScalar(this.shadowBlur(), matrix);
const result = this.getCacheRect().expand(this.blur() * 2 + shadowBlur);
const result = this.cacheRect().expand(this.blur() * 2 + shadowBlur);
if (shadowOffset.x < 0) {
result.x += shadowOffset.x;
@@ -761,35 +381,6 @@ export class Node<TProps extends NodeProps = NodeProps>
return result;
}
/**
* Get a rectangle encapsulating the contents rendered by this node as well
* as its children.
*/
@computed()
protected cacheRect(): Rect {
const cache = this.getCacheRect();
const children = this.children();
if (!this.overflow() || children.length === 0) {
return cache.pixelPerfect;
}
const points: Vector2[] = cache.corners;
for (const child of children) {
const childCache = child.fullCacheRect();
const childMatrix = child.localToParent();
points.push(
...childCache.corners.map(r => r.transformAsPoint(childMatrix)),
);
}
return Rect.fromPoints(...points).pixelPerfect;
}
@computed()
protected fullCacheRect(): Rect {
return Rect.fromRects(this.cacheRect(), this.getFullCacheRect());
}
/**
* Prepare the given context for drawing a cached node onto it.
*

View File

@@ -1,10 +1,37 @@
import {Shape} from './Shape';
import {Signal} from '@motion-canvas/core/lib/utils';
import {Shape, ShapeProps} from './Shape';
import {property} from '../decorators';
export interface RectProps extends ShapeProps {
radius?: number;
}
export class Rect extends Shape {
@property(0)
public declare readonly radius: Signal<number, this>;
public constructor(props: RectProps) {
super(props);
}
protected override getPath(): Path2D {
const path = new Path2D();
const {width, height} = this.computedSize();
path.rect(-width / 2, -height / 2, width, height);
const radius = this.radius();
const {width, height} = this.size();
const x = width / -2;
const y = height / -2;
if (radius > 0) {
const maxRadius = Math.min(height / 2, width / 2, radius);
path.moveTo(x + maxRadius, y);
path.arcTo(x + width, y, x + width, y + height, maxRadius);
path.arcTo(x + width, y + height, x, y + height, maxRadius);
path.arcTo(x, y + height, x, y, maxRadius);
path.arcTo(x, y, x + width, y, maxRadius);
} else {
path.rect(x, y, width, height);
}
return path;
}
}

View File

@@ -1,12 +1,12 @@
import {Node, NodeProps} from './Node';
import {Gradient, Pattern} from '../partials';
import {property} from '../decorators';
import {Signal} from '@motion-canvas/core/lib/utils';
import {Rect} from '@motion-canvas/core/lib/types';
import {Layout, LayoutProps} from './Layout';
export type CanvasStyle = null | string | Gradient | Pattern;
export interface ShapeProps extends NodeProps {
export interface ShapeProps extends LayoutProps {
fill?: CanvasStyle;
stroke?: CanvasStyle;
strokeFirst?: boolean;
@@ -17,7 +17,7 @@ export interface ShapeProps extends NodeProps {
lineDashOffset?: number;
}
export abstract class Shape<T extends ShapeProps = ShapeProps> extends Node<T> {
export abstract class Shape extends Layout {
@property(null)
public declare readonly fill: Signal<CanvasStyle, this>;
@property(null)
@@ -35,6 +35,10 @@ export abstract class Shape<T extends ShapeProps = ShapeProps> extends Node<T> {
@property(0)
public declare readonly lineDashOffset: Signal<number, this>;
public constructor(props: ShapeProps) {
super(props);
}
protected parseCanvasStyle(
style: CanvasStyle,
context: CanvasRenderingContext2D,

View File

@@ -9,7 +9,7 @@ export interface TextProps extends ShapeProps {
text?: string;
}
export class Text extends Shape<TextProps> {
export class Text extends Shape {
protected static segmenter;
static {
@@ -35,15 +35,14 @@ export class Text extends Shape<TextProps> {
protected override draw(context: CanvasRenderingContext2D) {
this.requestFontUpdate();
this.applyStyle(context);
context.font = this.layout.styles.font;
context.textBaseline = 'middle';
context.font = this.styles.font;
const parentRect = this.layout.element.getBoundingClientRect();
const {width, height} = this.computedSize();
const parentRect = this.element.getBoundingClientRect();
const {width, height} = this.size();
const range = document.createRange();
let line = '';
const lineRect = new Rect();
for (const childNode of this.layout.element.childNodes) {
for (const childNode of this.element.childNodes) {
if (!childNode.textContent) {
continue;
}
@@ -75,29 +74,36 @@ export class Text extends Shape<TextProps> {
text: string,
rect: Rect,
) {
const y =
rect.y +
rect.height / 2 +
context.measureText(text).fontBoundingBoxDescent;
if (this.lineWidth() <= 0) {
context.fillText(text, rect.x, rect.y + rect.height / 2);
} else if (this.strokeFirst()) {
context.strokeText(text, rect.x, rect.y + rect.height / 2);
context.fillText(text, rect.x, rect.y + rect.height / 2);
context.strokeText(text, rect.x, y);
context.fillText(text, rect.x, y);
} else {
context.fillText(text, rect.x, rect.y + rect.height / 2);
context.strokeText(text, rect.x, rect.y + rect.height / 2);
context.fillText(text, rect.x, y);
context.strokeText(text, rect.x, y);
}
}
protected override applyFontChanges() {
super.applyFontChanges();
const wrap = this.layout.styles.whiteSpace !== 'nowrap';
protected override updateLayout() {
this.applyFont();
this.applyFlex();
const wrap = this.styles.whiteSpace !== 'nowrap';
const text = this.text();
if (wrap && Text.segmenter) {
this.layout.element.innerText = '';
this.element.innerText = '';
for (const word of Text.segmenter.segment(text)) {
this.layout.element.appendChild(document.createTextNode(word.segment));
this.element.appendChild(document.createTextNode(word.segment));
}
} else {
this.layout.element.innerText = this.text();
this.element.innerText = this.text();
}
if (wrap && !Text.segmenter) {

View File

@@ -1,5 +1,6 @@
export * from './Circle';
export * from './Image';
export * from './Layout';
export * from './Node';
export * from './Rect';
export * from './Text';

View File

@@ -14,15 +14,21 @@ export type ComponentChild =
export type ComponentChildren = ComponentChild | ComponentChild[];
export type NodeChildren = Node | Node[];
export type PropsOf<T> = T extends NodeConstructor<infer P>
? P
: T extends FunctionComponent<infer P>
? P
: never;
export interface JSXProps {
children?: ComponentChildren;
ref?: Reference<Node>;
}
export interface FunctionComponent<T = any> {
(props: T): Node<T> | null;
(props: T): Node | null;
}
export interface NodeConstructor<T = any> {
new (props: T): Node<T>;
new (props: T): Node;
}

View File

@@ -10,8 +10,8 @@ import {createComputed} from '@motion-canvas/core/lib/utils/createComputed';
*/
export function computed(): MethodDecorator {
return (target: any, key) => {
const method = target[key];
addInitializer(target, (instance: any) => {
const method = Object.getPrototypeOf(instance)[key];
instance[key] = createComputed(method.bind(instance));
});
};

View File

@@ -1,267 +0,0 @@
import {
compound,
computed,
initialize,
Property,
property,
} from '../decorators';
import {createSignal, Signal} from '@motion-canvas/core/lib/utils';
import {
PossibleSpacing,
Rect,
Spacing,
Vector2,
} from '@motion-canvas/core/lib/types';
import {
FlexAlign,
FlexDirection,
FlexWrap,
FlexJustify,
LayoutMode,
Length,
FlexBasis,
} from './types';
import {TwoDView} from '../scenes';
export interface LayoutProps {
tagName?: keyof HTMLElementTagNameMap;
mode?: LayoutMode;
maxWidth?: Length;
maxHeight?: Length;
minWidth?: Length;
minHeight?: Length;
ratio?: number;
marginTop?: number;
marginBottom?: number;
marginLeft?: number;
marginRight?: number;
margin?: PossibleSpacing;
paddingTop?: number;
paddingBottom?: number;
paddingLeft?: number;
paddingRight?: number;
padding?: PossibleSpacing;
direction?: FlexDirection;
basis?: FlexBasis;
grow?: number;
shrink?: number;
wrap?: FlexWrap;
justifyContent?: FlexJustify;
alignItems?: FlexAlign;
rowGap?: Length;
columnGap?: Length;
gap?: Length;
fontFamily?: string;
fontSize?: number;
fontStyle?: string;
fontWeight?: number;
lineHeight?: number;
letterSpacing?: number;
textWrap?: boolean;
}
export class Layout {
@property(null)
public declare readonly mode: Signal<LayoutMode, this>;
@property(null)
public declare readonly maxWidth: Signal<Length, this>;
@property(null)
public declare readonly maxHeight: Signal<Length, this>;
@property(null)
public declare readonly minWidth: Signal<Length, this>;
@property(null)
public declare readonly minHeight: Signal<Length, this>;
@property(null)
public declare readonly ratio: Signal<number | null, 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>;
@compound({
top: 'marginTop',
bottom: 'marginBottom',
left: 'marginLeft',
right: 'marginRight',
})
@property(undefined, Spacing.lerp, Spacing)
public declare readonly margin: Property<PossibleSpacing, Spacing, 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>;
@compound({
top: 'paddingTop',
bottom: 'paddingBottom',
left: 'paddingLeft',
right: 'paddingRight',
})
@property(undefined, Spacing.lerp, Spacing)
public declare readonly padding: Property<PossibleSpacing, Spacing, this>;
@property('row')
public declare readonly direction: Signal<FlexDirection, this>;
@property(null)
public declare readonly basis: Signal<FlexBasis, this>;
@property(0)
public declare readonly grow: Signal<number, this>;
@property(1)
public declare readonly shrink: Signal<number, this>;
@property('nowrap')
public declare readonly wrap: Signal<FlexWrap, this>;
@property('normal')
public declare readonly justifyContent: Signal<FlexJustify, this>;
@property('normal')
public declare readonly alignItems: Signal<FlexAlign, this>;
@property(null)
public declare readonly gap: Signal<Length, this>;
@property(null)
public declare readonly rowGap: Signal<Length, this>;
@property(null)
public declare readonly columnGap: Signal<Length, this>;
@property(null)
public declare readonly fontFamily: Signal<string | null, this>;
@property(null)
public declare readonly fontSize: Signal<number | null, this>;
@property(null)
public declare readonly fontStyle: Signal<string | null, this>;
@property(null)
public declare readonly fontWeight: Signal<number | null, this>;
@property(null)
public declare readonly lineHeight: Signal<number | null, this>;
@property(null)
public declare readonly letterSpacing: Signal<number | null, this>;
@property(null)
public declare readonly textWrap: Signal<boolean | null, this>;
public readonly element: HTMLElement;
public readonly styles: CSSStyleDeclaration;
private sizeLockCounter = createSignal(0);
public constructor({tagName = 'div', ...props}: LayoutProps) {
const frame = document.querySelector<HTMLIFrameElement>(
`#${TwoDView.frameID}`,
);
this.element = (frame?.contentDocument ?? document).createElement(tagName);
this.styles = getComputedStyle(this.element);
this.element.style.display = 'flex';
this.element.style.boxSizing = 'border-box';
initialize(this, {defaults: props});
}
protected parseValue(value: number | string | null): string {
return value === null ? '' : value.toString();
}
protected parsePixels(value: number | null): string {
return value === null ? '' : `${value}px`;
}
protected parseLength(value: null | number | string): string {
if (value === null) {
return '';
}
if (typeof value === 'string') {
return value;
}
return `${value}px`;
}
public lockSize() {
this.sizeLockCounter(this.sizeLockCounter() + 1);
}
public releaseSize() {
this.sizeLockCounter(this.sizeLockCounter() - 1);
}
public getComputedLayout(): Rect {
return new Rect(this.element.getBoundingClientRect());
}
public setWidth(width: Length): this {
this.element.style.width = this.parseLength(width);
return this;
}
public setHeight(height: Length): this {
this.element.style.height = this.parseLength(height);
return this;
}
@computed()
public applyFlex() {
const mode = this.mode();
this.element.style.position =
mode === 'disabled' || mode === 'root' ? 'absolute' : 'relative';
this.element.style.maxWidth = this.parseLength(this.maxWidth());
this.element.style.minWidth = this.parseLength(this.minWidth());
this.element.style.maxHeight = this.parseLength(this.maxHeight());
this.element.style.minWidth = this.parseLength(this.minWidth());
this.element.style.aspectRatio = this.parseValue(this.ratio());
this.element.style.marginTop = this.parsePixels(this.marginTop());
this.element.style.marginBottom = this.parsePixels(this.marginBottom());
this.element.style.marginLeft = this.parsePixels(this.marginLeft());
this.element.style.marginRight = this.parsePixels(this.marginRight());
this.element.style.paddingTop = this.parsePixels(this.paddingTop());
this.element.style.paddingBottom = this.parsePixels(this.paddingBottom());
this.element.style.paddingLeft = this.parsePixels(this.paddingLeft());
this.element.style.paddingRight = this.parsePixels(this.paddingRight());
this.element.style.flexDirection = this.direction();
this.element.style.flexBasis = this.parseLength(this.basis());
this.element.style.flexWrap = this.wrap();
this.element.style.justifyContent = this.justifyContent();
this.element.style.alignItems = this.alignItems();
this.element.style.gap = this.parseLength(this.gap());
this.element.style.rowGap = this.parseLength(this.rowGap());
this.element.style.columnGap = this.parseLength(this.columnGap());
if (this.sizeLockCounter() > 0) {
this.element.style.flexGrow = '0';
this.element.style.flexShrink = '0';
} else {
this.element.style.flexGrow = this.parsePixels(this.grow());
this.element.style.flexShrink = this.parsePixels(this.shrink());
}
}
@computed()
public applyFont() {
this.element.style.fontFamily = this.parseValue(this.fontFamily());
this.element.style.fontSize = this.parsePixels(this.fontSize());
this.element.style.fontStyle = this.parseValue(this.fontStyle());
this.element.style.lineHeight = this.parsePixels(this.lineHeight());
this.element.style.fontWeight = this.parseValue(this.fontWeight());
this.element.style.letterSpacing = this.parsePixels(this.letterSpacing());
const wrap = this.textWrap();
this.element.style.whiteSpace =
wrap === null ? '' : wrap ? 'normal' : 'nowrap';
}
}

View File

@@ -1,4 +1,3 @@
export * from './Gradient';
export * from './Layout';
export * from './Pattern';
export * from './types';

View File

@@ -26,7 +26,7 @@ export class TwoDScene extends GeneratorScene<TwoDView> {
}
public override reset(previousScene?: Scene): Promise<void> {
this.view.removeChildren();
this.view.reset();
return super.reset(previousScene);
}
}

View File

@@ -1,23 +1,10 @@
import {Node} from '../components';
import {Layout} from '../components/Layout';
export class TwoDView extends Node {
export class TwoDView extends Layout {
public static frameID = 'motion-canvas-2d-frame';
public static document: Document;
public constructor() {
super({
// TODO Sync with the project size
width: 1920,
height: 1080,
composite: true,
layout: {
fontFamily: 'Roboto',
fontSize: 48,
lineHeight: 64,
textWrap: false,
fontStyle: 'normal',
},
});
static {
let frame = document.querySelector<HTMLIFrameElement>(
`#${TwoDView.frameID}`,
);
@@ -33,10 +20,29 @@ export class TwoDView extends Node {
document.body.prepend(frame);
}
this.document = frame.contentDocument ?? document;
}
if (frame.contentDocument) {
frame.contentDocument.body.append(this.layout.element);
}
public constructor() {
super({
// TODO Sync with the project size
width: 1920,
height: 1080,
composite: true,
fontFamily: 'Roboto',
fontSize: 48,
lineHeight: 64,
textWrap: false,
fontStyle: 'normal',
});
TwoDView.document.body.append(this.element);
this.applyFlex();
}
public reset() {
this.removeChildren();
this.element.innerText = '';
}
public override render(context: CanvasRenderingContext2D) {
@@ -75,4 +81,12 @@ export class TwoDView extends Node {
protected override transformContext() {
// do nothing
}
protected override requestLayoutUpdate() {
this.updateLayout();
}
public override view(): TwoDView | null {
return this;
}
}

View File

@@ -10,6 +10,7 @@ export type SerializedVector2 = {
export type PossibleVector2 =
| SerializedVector2
| number
| [number, number]
| Size
| Rect;
@@ -56,15 +57,15 @@ export class Vector2 {
public constructor();
public constructor(from: PossibleVector2);
public constructor(x: number, y?: number);
public constructor(one?: PossibleVector2 | number, two = 0) {
public constructor(x: number, y: number);
public constructor(one?: PossibleVector2 | number, two?: number) {
if (one === undefined || one === null) {
return;
}
if (typeof one === 'number') {
this.x = one;
this.y = two;
this.y = two ?? one;
return;
}