diff --git a/packages/2d/src/components/Circle.ts b/packages/2d/src/components/Circle.ts index 7453c12d..0aae0bf3 100644 --- a/packages/2d/src/components/Circle.ts +++ b/packages/2d/src/components/Circle.ts @@ -1,7 +1,7 @@ import {Shape} from './Shape'; export class Circle extends Shape { - protected getPath(): Path2D { + protected override getPath(): Path2D { const path = new Path2D(); const {width, height} = this.computedSize(); path.ellipse(0, 0, width / 2, height / 2, 0, 0, Math.PI * 2); diff --git a/packages/2d/src/components/Image.ts b/packages/2d/src/components/Image.ts new file mode 100644 index 00000000..61f05db2 --- /dev/null +++ b/packages/2d/src/components/Image.ts @@ -0,0 +1,54 @@ +import {Node, NodeProps} from './Node'; +import {Signal} from '@motion-canvas/core/lib/utils'; +import {computed, property} from '../decorators'; + +export interface ImageProps extends NodeProps { + src?: string; +} + +export class Image extends Node { + @property() + public declare readonly src: Signal; + + public constructor(props: ImageProps) { + super({ + ...props, + layout: props.layout + ? {...props.layout, tagName: 'img'} + : {tagName: 'img'}, + }); + } + + protected override draw(context: CanvasRenderingContext2D) { + const image = this.layout.element; + const {width, height} = this.computedSize(); + + context.drawImage(image, width / -2, height / -2, width, height); + super.draw(context); + } + + protected override applyLayoutChanges() { + this.applySrc(); + } + + @computed() + protected applySrc() { + const src = this.src(); + const image = this.layout.element; + image.src = src; + return image; + } + + protected override collectAsyncResources(deps: Promise[]) { + super.collectAsyncResources(deps); + const image = this.applySrc(); + if (!image.complete) { + deps.push( + new Promise((resolve, reject) => { + image.addEventListener('load', resolve); + image.addEventListener('error', reject); + }), + ); + } + } +} diff --git a/packages/2d/src/components/Node.ts b/packages/2d/src/components/Node.ts index 54fa0ca9..45e48a85 100644 --- a/packages/2d/src/components/Node.ts +++ b/packages/2d/src/components/Node.ts @@ -33,7 +33,7 @@ import { import {Layout, LayoutProps, Length, ResolvedLayoutMode} from '../partials'; import {ComponentChild, ComponentChildren} from './types'; import {threadable} from '@motion-canvas/core/lib/decorators'; -import {ThreadGenerator} from '@motion-canvas/core/lib/threading'; +import {ThreadGenerator, Promisable} from '@motion-canvas/core/lib/threading'; export interface NodeProps { ref?: Reference; @@ -71,7 +71,9 @@ export interface NodeProps { composite?: boolean | Node; } -export class Node { +export class Node + implements Promisable +{ public declare isClass: boolean; public readonly layout: Layout; @@ -557,6 +559,7 @@ export class Node { } this.requestLayoutUpdate(); + this.requestFontUpdate(); const rect = this.layout.getComputedLayout(); const position = { @@ -577,6 +580,7 @@ export class Node { @computed() protected computedSize(): Size { this.requestLayoutUpdate(); + this.requestFontUpdate(); const rect = this.layout.getComputedLayout(); return { @@ -594,6 +598,7 @@ export class Node { const parent = this.parent(); if (mode === 'disabled' || mode === 'root' || !parent) { this.updateLayout(); + parent?.requestFontUpdate(); } else { parent.requestLayoutUpdate(); } @@ -604,30 +609,58 @@ export class Node { */ @computed() protected updateLayout() { - this.applyLayoutChanges(); + this.updateFont(); this.layout .setWidth(this.customWidth()) .setHeight(this.customHeight()) - .apply(); + .applyFlex(); + this.applyLayoutChanges(); for (const child of this.children()) { child.updateLayout(); } } /** - * Apply any custom layout changes to this node. + * Apply any custom layout-related changes to this node. */ protected applyLayoutChanges() { // do nothing } - public append(node: ComponentChildren) { + /** + * 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 append(node: ComponentChildren): this { const nodes: ComponentChild[] = Array.isArray(node) ? node : [node]; for (const node of nodes) { if (node instanceof Node) { node.moveTo(this); } } + + return this; } public remove() { @@ -768,7 +801,14 @@ export class Node { ); } - return rect.fromPoints(...points); + const result = rect.fromPoints(...points); + + return { + x: Math.floor(result.x), + y: Math.floor(result.y), + width: Math.ceil(result.width + 1), + height: Math.ceil(result.height + 1), + }; } @computed() @@ -854,6 +894,37 @@ export class Node { matrix.f, ); } + + /** + * Wait for any asynchronous resources that this node or its children have. + * + * @remarks + * Certain resources like images are always loaded asynchronously. + * Awaiting this method makes sure that all such resources are done loading + * before continuing the animation. + */ + public waitForAsyncResources() { + const deps: Promise[] = []; + this.collectAsyncResources(deps); + return Promise.all(deps); + } + + /** + * Collect all asynchronous resources used by this node and put them in the + * `resources` array. + * + * @param resources - An array to which resources should be collected. + */ + protected collectAsyncResources(resources: Promise[]) { + for (const child of this.children()) { + child.collectAsyncResources(resources); + } + } + + public async toPromise(): Promise { + await this.waitForAsyncResources(); + return this; + } } /*@__PURE__*/ diff --git a/packages/2d/src/components/Rect.ts b/packages/2d/src/components/Rect.ts index 4383d683..9ed934fa 100644 --- a/packages/2d/src/components/Rect.ts +++ b/packages/2d/src/components/Rect.ts @@ -1,7 +1,7 @@ import {Shape} from './Shape'; export class Rect extends Shape { - protected getPath(): Path2D { + protected override getPath(): Path2D { const path = new Path2D(); const {width, height} = this.computedSize(); path.rect(-width / 2, -height / 2, width, height); diff --git a/packages/2d/src/components/Shape.ts b/packages/2d/src/components/Shape.ts index a607199b..cf503bec 100644 --- a/packages/2d/src/components/Shape.ts +++ b/packages/2d/src/components/Shape.ts @@ -1,15 +1,8 @@ import {Node, NodeProps} from './Node'; import {Gradient, Pattern} from '../partials'; -import {compound, computed, property} from '../decorators'; +import {property} from '../decorators'; import {Signal} from '@motion-canvas/core/lib/utils'; -import { - Vector2, - Rect, - rect, - transformVector, - transformScalar, -} from '@motion-canvas/core/lib/types'; -import {vector2dLerp} from '@motion-canvas/core/lib/tweening'; +import {Rect, rect} from '@motion-canvas/core/lib/types'; export type CanvasStyle = null | string | Gradient | Pattern; @@ -42,16 +35,6 @@ export abstract class Shape extends Node { @property(0) public declare readonly lineDashOffset: Signal; - @computed() - protected hasShadow(): boolean { - return ( - !!this.shadowColor() || - !!this.shadowOffsetX() || - !!this.shadowOffsetY() || - !!this.shadowBlur() - ); - } - protected parseCanvasStyle( style: CanvasStyle, context: CanvasRenderingContext2D, @@ -101,5 +84,7 @@ export abstract class Shape extends Node { return rect.expand(super.getCacheRect(), this.lineWidth() / 2); } - protected abstract getPath(): Path2D; + protected getPath(): Path2D { + return new Path2D(); + } } diff --git a/packages/2d/src/components/Text.ts b/packages/2d/src/components/Text.ts new file mode 100644 index 00000000..fb7dd075 --- /dev/null +++ b/packages/2d/src/components/Text.ts @@ -0,0 +1,107 @@ +import {property} from '../decorators'; +import {Signal} from '@motion-canvas/core/lib/utils'; +import {textLerp} from '@motion-canvas/core/lib/tweening'; +import {Shape, ShapeProps} from './Shape'; +import {rect, Rect} from '@motion-canvas/core/lib/types'; + +export interface TextProps extends ShapeProps { + children?: string; + text?: string; +} + +export class Text extends Shape { + protected static segmenter; + + static { + try { + this.segmenter = new (Intl as any).Segmenter(undefined, { + granularity: 'grapheme', + }); + } catch (e) { + // do nothing + } + } + + @property('', textLerp) + public declare readonly text: Signal; + + public constructor({children, ...rest}: TextProps) { + super(rest); + if (children) { + this.text(children); + } + } + + protected override draw(context: CanvasRenderingContext2D) { + this.requestFontUpdate(); + this.applyStyle(context); + context.font = this.layout.styles.font; + context.textBaseline = 'middle'; + + const parentRect = this.layout.element.getBoundingClientRect(); + const {width, height} = this.computedSize(); + const range = document.createRange(); + let line = ''; + const lineRect = rect(); + for (const childNode of this.layout.element.childNodes) { + if (!childNode.textContent) { + continue; + } + + range.selectNodeContents(childNode); + const rangeRect = range.getBoundingClientRect(); + + const x = width / -2 + rangeRect.left - parentRect.left; + const y = height / -2 + rangeRect.top - parentRect.top; + + if (lineRect.y === y) { + lineRect.width += rangeRect.width; + line += childNode.textContent; + } else { + this.drawText(context, line, lineRect); + lineRect.x = x; + lineRect.y = y; + lineRect.width = rangeRect.width; + lineRect.height = rangeRect.height; + line = childNode.textContent; + } + } + + this.drawText(context, line, lineRect); + } + + protected drawText( + context: CanvasRenderingContext2D, + text: string, + rect: Rect, + ) { + 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); + } else { + context.fillText(text, rect.x, rect.y + rect.height / 2); + context.strokeText(text, rect.x, rect.y + rect.height / 2); + } + } + + protected override applyFontChanges() { + super.applyFontChanges(); + const wrap = this.layout.styles.whiteSpace !== 'nowrap'; + const text = this.text(); + + if (wrap && Text.segmenter) { + this.layout.element.innerText = ''; + for (const word of Text.segmenter.segment(text)) { + this.layout.element.appendChild(document.createTextNode(word.segment)); + } + } else { + this.layout.element.innerText = this.text(); + } + + if (wrap && !Text.segmenter) { + console.error('Wrapping is not supported'); + } + } +} diff --git a/packages/2d/src/components/index.ts b/packages/2d/src/components/index.ts index cee053db..283875b2 100644 --- a/packages/2d/src/components/index.ts +++ b/packages/2d/src/components/index.ts @@ -1,4 +1,6 @@ export * from './Circle'; +export * from './Image'; export * from './Node'; export * from './Rect'; +export * from './Text'; export * from './types'; diff --git a/packages/2d/src/decorators/property.ts b/packages/2d/src/decorators/property.ts index 8d0212e9..901661d5 100644 --- a/packages/2d/src/decorators/property.ts +++ b/packages/2d/src/decorators/property.ts @@ -7,7 +7,6 @@ import { } from '@motion-canvas/core/lib/tweening'; import {addInitializer} from './initializers'; import { - Signal, SignalValue, isReactive, createSignal, diff --git a/packages/2d/src/partials/Layout.ts b/packages/2d/src/partials/Layout.ts index 099d2c5b..b8f02a68 100644 --- a/packages/2d/src/partials/Layout.ts +++ b/packages/2d/src/partials/Layout.ts @@ -8,8 +8,10 @@ import { LayoutMode, Length, } from './types'; +import {TwoDView} from '../scenes'; export interface LayoutProps { + tagName?: keyof HTMLElementTagNameMap; mode?: LayoutMode; width?: number; height?: number; @@ -25,6 +27,14 @@ export interface LayoutProps { justifyContent?: JustifyContent; alignItems?: AlignItems; ratio?: string; + + fontFamily?: string; + fontSize?: number; + fontStyle?: string; + fontWeight?: number; + lineHeight?: number; + letterSpacing?: number; + wrap?: boolean; } export class Layout { @@ -58,11 +68,33 @@ export class Layout { @property('auto') public declare readonly alignItems: Signal; - public readonly element: HTMLDivElement; + @property(null) + public declare readonly fontFamily: Signal; + @property(null) + public declare readonly fontSize: Signal; + @property(null) + public declare readonly fontStyle: Signal; + @property(null) + public declare readonly fontWeight: Signal; + @property(null) + public declare readonly lineHeight: Signal; + @property(null) + public declare readonly letterSpacing: Signal; + @property(null) + public declare readonly wrap: Signal; + + public readonly element: HTMLElement; + public readonly styles: CSSStyleDeclaration; private sizeLockCounter = createSignal(0); - public constructor(props: LayoutProps) { - this.element = document.createElement('div'); + public constructor({tagName = 'div', ...props}: LayoutProps) { + const frame = document.querySelector( + `#${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'; @@ -116,7 +148,7 @@ export class Layout { } @computed() - public apply() { + public applyFlex() { const mode = this.mode(); this.element.style.position = mode === 'disabled' || mode === 'root' ? 'absolute' : 'relative'; @@ -137,4 +169,25 @@ export class Layout { this.element.style.flexGrow = this.sizeLockCounter() > 0 ? '0' : ''; this.element.style.flexShrink = this.sizeLockCounter() > 0 ? '0' : ''; } + + @computed() + public applyFont() { + this.element.style.fontFamily = this.fontFamily() ?? ''; + const fontSize = this.fontSize(); + this.element.style.fontSize = fontSize ? this.toPixels(fontSize) : ''; + this.element.style.fontStyle = this.fontStyle() ?? ''; + const lineHeight = this.lineHeight(); + this.element.style.lineHeight = + lineHeight === null ? '' : this.toPixels(lineHeight); + const fontWeight = this.fontWeight(); + this.element.style.fontWeight = + fontWeight === null ? '' : fontWeight.toString(); + const letterSpacing = this.letterSpacing(); + this.element.style.letterSpacing = letterSpacing + ? this.toPixels(letterSpacing) + : ''; + const wrap = this.wrap(); + this.element.style.whiteSpace = + wrap === null ? '' : wrap ? 'normal' : 'nowrap'; + } } diff --git a/packages/2d/src/scenes/TwoDScene.ts b/packages/2d/src/scenes/TwoDScene.ts index 58b7e445..41e16578 100644 --- a/packages/2d/src/scenes/TwoDScene.ts +++ b/packages/2d/src/scenes/TwoDScene.ts @@ -4,6 +4,7 @@ import { SceneRenderEvent, } from '@motion-canvas/core/lib/scenes'; import {TwoDView} from './TwoDView'; +import {Node} from '../components'; export class TwoDScene extends GeneratorScene { private readonly view = new TwoDView(); @@ -12,10 +13,7 @@ export class TwoDScene extends GeneratorScene { return this.view; } - public render( - context: CanvasRenderingContext2D, - canvas: HTMLCanvasElement, - ): void { + public render(context: CanvasRenderingContext2D): void { context.save(); this.renderLifecycle.dispatch([SceneRenderEvent.BeforeRender, context]); context.save(); diff --git a/packages/2d/src/scenes/TwoDView.ts b/packages/2d/src/scenes/TwoDView.ts index 95b9be27..4f8397ae 100644 --- a/packages/2d/src/scenes/TwoDView.ts +++ b/packages/2d/src/scenes/TwoDView.ts @@ -1,11 +1,22 @@ import {Node} from '../components'; export class TwoDView extends Node { - private static frameID = 'motion-canvas-2d-frame'; + public static frameID = 'motion-canvas-2d-frame'; public constructor() { - // TODO Sync with the project size - super({width: 1920, height: 1080, composite: true}); + super({ + // TODO Sync with the project size + width: 1920, + height: 1080, + composite: true, + layout: { + fontFamily: 'Roboto', + fontSize: 48, + lineHeight: 64, + wrap: false, + fontStyle: 'normal', + }, + }); let frame = document.querySelector( `#${TwoDView.frameID}`, @@ -61,7 +72,7 @@ export class TwoDView extends Node { super.render(context); } - protected transformContext(context: CanvasRenderingContext2D) { + protected override transformContext() { // do nothing } } diff --git a/packages/2d/tsconfig.json b/packages/2d/tsconfig.json index 452a3e6d..5e639515 100644 --- a/packages/2d/tsconfig.json +++ b/packages/2d/tsconfig.json @@ -3,6 +3,7 @@ "baseUrl": "src", "outDir": "lib", "strict": true, + "noImplicitOverride": true, "module": "esnext", "target": "es2020", "moduleResolution": "node", diff --git a/packages/core/src/scenes/GeneratorScene.ts b/packages/core/src/scenes/GeneratorScene.ts index 4b9e8890..63716531 100644 --- a/packages/core/src/scenes/GeneratorScene.ts +++ b/packages/core/src/scenes/GeneratorScene.ts @@ -1,4 +1,10 @@ -import {isPromise, Thread, ThreadGenerator, threads} from '../threading'; +import { + isPromisable, + isPromise, + Thread, + ThreadGenerator, + threads, +} from '../threading'; import {Meta} from '../Meta'; import {TimeEvents} from './TimeEvents'; import {EventDispatcher, ValueDispatcher} from '../events'; @@ -162,12 +168,13 @@ export abstract class GeneratorScene let result = this.runner.next(); this.update(); while (result.value) { - if (isPromise(result.value)) { - const value = await result.value; - result = this.runner.next(value); + if (isPromisable(result.value)) { + result = this.runner.next(await result.value.toPromise()); + } else if (isPromise(result.value)) { + result = this.runner.next(await result.value); } else { console.warn('Invalid value: ', result.value); - result = this.runner.next(); + result = this.runner.next(result.value); } this.update(); } diff --git a/packages/core/src/threading/ThreadGenerator.ts b/packages/core/src/threading/ThreadGenerator.ts index 8b840dd9..ffd9ed44 100644 --- a/packages/core/src/threading/ThreadGenerator.ts +++ b/packages/core/src/threading/ThreadGenerator.ts @@ -1,5 +1,13 @@ import {Thread} from './Thread'; +export interface Promisable { + toPromise(): Promise; +} + +export function isPromisable(value: any): value is Promisable { + return value && typeof value === 'object' && 'toPromise' in value; +} + /** * The main generator type produced by all generator functions in Motion Canvas. * @@ -29,7 +37,7 @@ import {Thread} from './Thread'; * [promise]: https://developer.mozilla.org/en-US/docs/web/javascript/reference/global_objects/promise */ export type ThreadGenerator = Generator< - ThreadGenerator | Promise, + ThreadGenerator | Promise | Promisable, void, Thread | any >; diff --git a/packages/core/src/types/Rect.ts b/packages/core/src/types/Rect.ts index 0bfdbb05..06b05684 100644 --- a/packages/core/src/types/Rect.ts +++ b/packages/core/src/types/Rect.ts @@ -8,12 +8,7 @@ export interface Rect { height: number; } -export function rect( - x: number, - y: number, - width: number, - height: number, -): Rect { +export function rect(x = 0, y = 0, width = 0, height = 0): Rect { return {x, y, width, height}; }