diff --git a/README.md b/README.md index 3f0111b2..f516e453 100644 --- a/README.md +++ b/README.md @@ -74,16 +74,16 @@ Otherwise, below are the steps to set it up manually: 6. Create a simple scene in `src/scenes/example.scene.tsx`: ```ts - import type {Scene} from '@motion-canvas/core/lib/Scene'; - import {ThreadGenerator} from '@motion-canvas/core/lib/threading'; + import {makeKonvaScene} from '@motion-canvas/core/lib/scenes'; import {waitFor} from '@motion-canvas/core/lib/flow'; - export default function* example(scene: Scene): ThreadGenerator { + export default makeKonvaScene(function* example(view) { yield* scene.transition(); - + + // animation code goes here + yield* waitFor(5); - scene.canFinish(); - } + }); ``` 7. Initialize the project with your scene in `src/project.ts`: @@ -132,6 +132,8 @@ npm run serve You can now open Motion Canvas in your browser by visiting [http://localhost:9000/](http://localhost:9000/). +The API documentation is available at [http://localhost:9000/api/](http://localhost:9000/api/). + In case of any problems, please visit [our discord server](https://www.patreon.com/posts/53003221). ## Developing Motion Canvas locally diff --git a/src/Project.ts b/src/Project.ts index 46057e93..3be740ae 100644 --- a/src/Project.ts +++ b/src/Project.ts @@ -1,52 +1,44 @@ import './patches'; -import {Stage, StageConfig} from 'konva/lib/Stage'; -import {Rect} from 'konva/lib/shapes/Rect'; -import {Layer} from 'konva/lib/Layer'; -import {Vector2d} from 'konva/lib/types'; -import {Konva} from 'konva/lib/Global'; -import {Thread, ThreadsCallback} from './threading'; -import {Scene, SceneRunner} from './Scene'; -import {KonvaNode} from './decorators'; +import {Scene, SceneDescription} from './scenes'; import {Meta, Metadata} from './Meta'; import {ValueDispatcher} from './events'; - -Konva.autoDrawEnabled = false; +import {Size} from './types'; export const ProjectSize = { FullHD: {width: 1920, height: 1080}, }; -export interface ProjectConfig extends Partial { - scenes: SceneRunner[]; - background: string | false; +export interface ProjectConfig { + name: string; + scenes: SceneDescription[]; + canvas?: HTMLCanvasElement; + background?: string | false; + width?: number; + height?: number; } export type ProjectMetadata = Metadata; -@KonvaNode() -export class Project extends Stage { +export class Project { public get onScenesChanged() { return this.scenes.subscribable; } private readonly scenes = new ValueDispatcher([]); + public get onCurrentSceneChanged() { + return this.currentScene.subscribable; + } + private readonly currentScene = new ValueDispatcher(null); + public readonly version = CORE_VERSION; public readonly meta: Meta; - public readonly background: Rect; - public readonly master: Layer; - public readonly center: Vector2d; - public threadsCallback: ThreadsCallback; public frame = 0; public get time(): number { return this.framesToSeconds(this.frame); } - public get thread(): Thread { - return this.currentThread; - } - public get framerate(): number { return this.framesPerSeconds; } @@ -57,78 +49,120 @@ export class Project extends Stage { } public set resolutionScale(value: number) { - this.master.canvas.setPixelRatio(value); + this._resolutionScale = value; + this.updateCanvas(); } - private framesPerSeconds = 30; - private readonly sceneLookup: Record = {}; - private previousScene: Scene = null; - private currentScene: Scene = null; - private currentThread: Thread = null; - - public constructor(config: ProjectConfig) { - const {scenes, ...rest} = config; - super({ - listening: false, - container: document.createElement('div'), - ...ProjectSize.FullHD, - ...rest, - }); - - this.meta = Meta.getMetaFor(PROJECT_FILE_NAME); - this.offset({ - x: this.width() / -2, - y: this.height() / -2, - }); - - this.center = { - x: this.width() / 2, - y: this.height() / 2, - }; - - this.background = new Rect({ - x: 0, - y: 0, - width: this.width(), - height: this.height(), - fill: config.background || '#ff00ff', - visible: config.background !== false, - }); - - this.master = new Layer({name: 'master'}); - this.master.add(this.background); - this.add(this.master); - - for (const scene of scenes) { - if ( - scene.name === undefined || - scene.name === '__WEBPACK_DEFAULT_EXPORT__' - ) { - console.warn('Runner without a name: ', scene); - } - const handle = new Scene(this, scene); - if (this.sceneLookup[scene.name]) { - console.warn('Duplicated scene name: ', scene.name); - handle.name(scene.name + Math.random()); - } - this.sceneLookup[handle.name()] = handle; - handle.threadsCallback = thread => { - if (this.currentScene === handle) { - this.currentThread = thread; - this.threadsCallback?.(thread); - } - }; + private updateCanvas() { + if (this.canvas) { + this.canvas.width = this.width * this._resolutionScale; + this.canvas.height = this.height * this._resolutionScale; } } - public draw(): this { - this.master.drawScene(); - return this; + public setCanvas(value: HTMLCanvasElement) { + this.canvas = value; + this.context = value?.getContext('2d'); + this.updateCanvas(); } - public reload(runners: SceneRunner[]) { + public setSize(size: Size): void; + public setSize(width: number, height: number): void; + public setSize(value: Size | number, height?: number): void { + if (typeof value === 'object') { + this.width = value.width; + this.height = value.height; + } else { + this.width = value; + this.height = height; + } + this.updateCanvas(); + } + + public getSize(): Size { + return { + width: this.width, + height: this.height, + }; + } + + public readonly name: string; + private _resolutionScale = 1; + private framesPerSeconds = 30; + private readonly sceneLookup: Record = {}; + private previousScene: Scene = null; + private background: string | false; + private canvas: HTMLCanvasElement; + private context: CanvasRenderingContext2D; + + private width: number; + private height: number; + + public constructor(config: ProjectConfig) { + this.setCanvas(config.canvas ?? null); + this.setSize( + config.width ?? ProjectSize.FullHD.width, + config.height ?? ProjectSize.FullHD.height, + ); + this.name = config.name; + this.background = config.background ?? false; + this.meta = Meta.getMetaFor(PROJECT_FILE_NAME); + + for (const scene of config.scenes) { + if (this.sceneLookup[scene.name]) { + console.error('Duplicated scene name: ', scene.name); + continue; + } + + this.sceneLookup[scene.name] = new scene.klass( + this, + scene.name, + scene.config, + ); + } + } + + public transformCanvas(context: CanvasRenderingContext2D) { + context.setTransform( + this._resolutionScale, + 0, + 0, + this._resolutionScale, + (this.width * this._resolutionScale) / 2, + (this.height * this._resolutionScale) / 2, + ); + } + + public render() { + if (!this.canvas) return; + + this.transformCanvas(this.context); + if (this.background) { + this.context.save(); + this.context.fillStyle = this.background; + this.context.fillRect( + this.width / -2, + this.height / -2, + this.width, + this.height, + ); + this.context.restore(); + } else { + this.context.clearRect( + this.width / -2, + this.height / -2, + this.width, + this.height, + ); + } + + this.previousScene?.render(this.context, this.canvas); + this.currentScene.current?.render(this.context, this.canvas); + } + + public reload(runners: SceneDescription[]) { for (const runner of runners) { - this.sceneLookup[runner.name]?.reload(runner); + this.sceneLookup[runner.name]?.reload(runner.config); } } @@ -141,88 +175,66 @@ export class Project extends Stage { public async next(speed = 1): Promise { if (this.previousScene) { await this.previousScene.next(); - if (!this.currentScene || this.currentScene.isAfterTransitionIn()) { - this.previousScene.remove(); + if ( + !this.currentScene.current || + this.currentScene.current.isAfterTransitionIn() + ) { this.previousScene = null; } } this.frame += speed; - if (this.currentScene) { - await this.currentScene.next(); - if (this.currentScene.canTransitionOut()) { - this.previousScene = this.currentScene; - this.currentScene = this.getNextScene(this.previousScene); - if (this.currentScene) { - await this.currentScene.reset(this.previousScene); - this.master.add(this.currentScene); + if (this.currentScene.current) { + await this.currentScene.current.next(); + if (this.currentScene.current.canTransitionOut()) { + this.previousScene = this.currentScene.current; + this.currentScene.current = this.getNextScene(this.previousScene); + if (this.currentScene.current) { + await this.currentScene.current.reset(this.previousScene); } } } - return !this.currentScene || this.currentScene.isFinished(); + return !this.currentScene.current || this.currentScene.current.isFinished(); } public async recalculate() { - const initialScene = this.currentScene; - this.previousScene?.remove(); this.previousScene = null; - this.currentScene = null; this.frame = 0; - let offset = 0; const scenes = Object.values(this.sceneLookup); for (const scene of scenes) { - if (scene.isMarkedAsCached()) { - scene.firstFrame += offset; - this.frame = scene.lastFrame; - } else { - this.currentScene = scene; - scene.firstFrame = this.frame; - scene.transitionDuration = -1; - await scene.reset(); - while (!scene.canTransitionOut()) { - if (scene.transitionDuration < 0 && scene.isAfterTransitionIn()) { - scene.transitionDuration = this.frame - scene.firstFrame; - } - this.frame++; - await scene.next(); - } - offset += this.frame - scene.lastFrame; - scene.lastFrame = this.frame; - scene.markAsCached(); - - await new Promise(resolve => setTimeout(resolve, 0)); - } + await scene.recalculate(); } - - this.currentScene = initialScene; this.scenes.current = scenes; } public async seek(frame: number, speed = 1): Promise { + if (this.currentScene.current && !this.currentScene.current.isCached()) { + console.warn( + 'Attempting to seek a project with an invalidated scene:', + this.currentScene.current.name, + ); + } + if ( frame <= this.frame || - !this.currentScene || - (this.currentScene.isMarkedAsCached() && - this.currentScene.lastFrame < frame) + !this.currentScene.current || + (this.currentScene.current.isCached() && + this.currentScene.current.lastFrame < frame) ) { const scene = this.findBestScene(frame); - if (scene !== this.currentScene) { - this.previousScene?.remove(); + if (scene !== this.currentScene.current) { this.previousScene = null; - this.currentScene?.remove(); - this.currentScene = scene; + this.currentScene.current = scene; - this.frame = this.currentScene.firstFrame; - this.master.add(this.currentScene); - await this.currentScene.reset(); + this.frame = this.currentScene.current.firstFrame; + await this.currentScene.current.reset(); } else if (this.frame >= frame) { - this.previousScene?.remove(); this.previousScene = null; - this.frame = this.currentScene.firstFrame; - await this.currentScene.reset(); + this.frame = this.currentScene.current.firstFrame; + await this.currentScene.current.reset(); } } @@ -237,7 +249,14 @@ export class Project extends Stage { private findBestScene(frame: number): Scene { let lastScene = null; for (const scene of Object.values(this.sceneLookup)) { - if (!scene.isMarkedAsCached() || scene.lastFrame > frame) { + if (!scene.isCached()) { + console.warn( + 'Attempting to seek a project with an invalidated scene:', + scene.name, + ); + return scene; + } + if (scene.lastFrame > frame) { return scene; } lastScene = scene; @@ -269,7 +288,7 @@ export class Project extends Stage { public async getBlob(): Promise { return new Promise(resolve => - this.master.getNativeCanvasElement().toBlob(resolve, 'image/png'), + this.canvas.toBlob(resolve, 'image/png'), ); } } diff --git a/src/Scene.ts b/src/Scene.ts deleted file mode 100644 index 82062e1e..00000000 --- a/src/Scene.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type {Project} from './Project'; -import {LayerConfig} from 'konva/lib/Layer'; -import { - isPromise, - ThreadGenerator, - threads, - ThreadsCallback, -} from './threading'; -import {Group} from 'konva/lib/Group'; -import {Shape} from 'konva/lib/Shape'; -import {SceneTransition} from './transitions'; -import {decorate, KonvaNode, threadable} from './decorators'; -import {setScene} from './utils'; -import {Meta, Metadata} from './Meta'; -import {SavedTimeEvent, TimeEvents} from './TimeEvents'; - -export interface SceneRunner { - (layer: Scene, project: Project): ThreadGenerator; -} - -export enum SceneState { - Initial, - AfterTransitionIn, - CanTransitionOut, - Finished, -} - -export interface SceneMetadata extends Metadata { - timeEvents: SavedTimeEvent[]; -} - -@KonvaNode() -export class Scene extends Group { - public threadsCallback: ThreadsCallback = null; - public firstFrame = 0; - public transitionDuration = 0; - public duration = 0; - public readonly meta: Meta; - public readonly timeEvents: TimeEvents; - - public get lastFrame() { - return this.firstFrame + this.duration; - } - public set lastFrame(value: number) { - this.duration = value - this.firstFrame; - } - - private previousScene: Scene = null; - private runner: ThreadGenerator; - private state: SceneState = SceneState.Initial; - private cached = false; - private counters: Record = {}; - - public constructor( - public readonly project: Project, - private runnerFactory: SceneRunner, - config: LayerConfig = {}, - ) { - super({ - name: runnerFactory.name, - width: project.width(), - height: project.height(), - ...config, - }); - decorate(runnerFactory, threadable()); - - this.meta = Meta.getMetaFor(`${this.name()}.scene`); - this.timeEvents = new TimeEvents(this); - } - - public markAsCached() { - this.cached = true; - this.timeEvents.onCache(); - } - - public isMarkedAsCached() { - return this.cached; - } - - public reload(runnerFactory?: SceneRunner) { - if (runnerFactory) { - this.runnerFactory = runnerFactory; - } - this.cached = false; - this.timeEvents.onReload(); - } - - public async reset(previousScene: Scene = null) { - this.x(0).y(0).opacity(1).show(); - this.counters = {}; - this.destroyChildren(); - this.previousScene = previousScene; - this.runner = threads( - () => this.runnerFactory(this, this.project), - (...args) => this.threadsCallback?.(...args), - ); - this.state = SceneState.Initial; - await this.next(); - } - - public async next() { - setScene(this); - let result = this.runner.next(); - this.updateLayout(); - while (result.value) { - if (isPromise(result.value)) { - const value = await result.value; - result = this.runner.next(value); - } else { - console.warn('Invalid value: ', result.value); - result = this.runner.next(); - } - this.updateLayout(); - } - - if (result.done) { - this.state = SceneState.Finished; - } - } - - public updateLayout(): boolean { - super.updateLayout(); - const result = this.wasDirty(); - let limit = 10; - while (this.wasDirty() && limit > 0) { - super.updateLayout(); - limit--; - } - - if (limit === 0) { - console.warn('Layout iteration limit exceeded'); - } - - return result; - } - - @threadable() - public *transition(transitionRunner?: SceneTransition) { - if (transitionRunner) { - yield* transitionRunner(this, this.previousScene); - } else { - this.previousScene?.hide(); - } - if (this.state === SceneState.Initial) { - this.state = SceneState.AfterTransitionIn; - } else { - console.warn( - `Scene ${this.name} transitioned in an unexpected state: `, - this.state, - ); - } - } - - public canFinish() { - if (this.state === SceneState.AfterTransitionIn) { - this.state = SceneState.CanTransitionOut; - } else { - console.warn( - `Scene ${this.name} was marked as finished in an unexpected state: `, - this.state, - ); - } - } - - public isAfterTransitionIn(): boolean { - return this.state === SceneState.AfterTransitionIn; - } - - public isFinished(): boolean { - return this.state === SceneState.Finished; - } - - public canTransitionOut(): boolean { - return ( - this.state === SceneState.CanTransitionOut || - this.state === SceneState.Finished - ); - } - - public add(...children: (Shape | Group)[]): this { - super.add(...children.flat()); - this.updateLayout(); - return this; - } - - public generateNodeId(type: string): string { - let id = 0; - if (type in this.counters) { - id = ++this.counters[type]; - } else { - this.counters[type] = id; - } - - return `${this.name()}.${type}.${id}`; - } -} diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 148dd5f7..cacfb4f4 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,6 +1,6 @@ import './globals'; import type {Size} from './types'; -import type {SceneRunner} from './Scene'; +import type {SceneDescription} from './scenes'; import {Project, ProjectSize} from './Project'; import {Player} from './player'; import {hot} from './hot'; @@ -8,7 +8,7 @@ import {AudioManager} from './media'; interface BootstrapConfig { name: string; - scenes: SceneRunner[]; + scenes: SceneDescription[]; size?: Size; background?: string | false; audio?: string; diff --git a/src/components/Connection.ts b/src/components/Connection.ts index 3538b3e3..49c59f91 100644 --- a/src/components/Connection.ts +++ b/src/components/Connection.ts @@ -6,7 +6,7 @@ import {Group} from 'konva/lib/Group'; import {ContainerConfig} from 'konva/lib/Container'; import {Arrow} from './Arrow'; import {map} from '../tweening'; -import {useScene} from '../utils'; +import {useKonvaView} from '../scenes'; export interface ConnectionConfig extends ContainerConfig { start?: Pin; @@ -180,17 +180,17 @@ export class Connection extends Group { return; } - const scene = useScene(); + const view = useKonvaView(); const crossing = this.crossing - ? this.crossing.getAbsolutePosition(scene) + ? this.crossing.getAbsolutePosition(view) : {x: 0.5, y: 0.5}; - const fromRect = this.start.getClientRect({relativeTo: scene}); + const fromRect = this.start.getClientRect({relativeTo: view}); fromRect.width /= 2; fromRect.height /= 2; fromRect.x += fromRect.width; fromRect.y += fromRect.height; - const toRect = this.end.getClientRect({relativeTo: scene}); + const toRect = this.end.getClientRect({relativeTo: view}); toRect.width /= 2; toRect.height /= 2; diff --git a/src/components/Pin.ts b/src/components/Pin.ts index 0245d0c6..e651ff7b 100644 --- a/src/components/Pin.ts +++ b/src/components/Pin.ts @@ -4,7 +4,7 @@ import {Center, flipOrigin, getOriginDelta, Origin} from '../types'; import {GetSet, IRect} from 'konva/lib/types'; import {getset, KonvaNode} from '../decorators'; import {Node} from 'konva/lib/Node'; -import {useScene} from '../utils'; +import {useKonvaView} from '../scenes'; export interface PinConfig extends ContainerConfig { target?: Node; @@ -40,7 +40,9 @@ export class Pin extends Group { const attach = this.attach(); if (attach) { const attachDirection = flipOrigin(attach.getOrigin(), this.direction()); - const rect = this.getClientRect({relativeTo: useScene()}); + const rect = this.getClientRect({ + relativeTo: useKonvaView(), + }); const offset = getOriginDelta(rect, Origin.TopLeft, attachDirection); attach.position({ x: rect.x + offset.x, diff --git a/src/flow/any.ts b/src/flow/any.ts index 7f6dc45f..d6120f58 100644 --- a/src/flow/any.ts +++ b/src/flow/any.ts @@ -8,7 +8,7 @@ decorate(any, threadable()); * Example: * ``` * // current time: 0s - * yield* all( + * yield* any( * rect.fill('#ff0000', 2), * rect.opacity(1, 1), * ); diff --git a/src/flow/index.ts b/src/flow/index.ts index b0855cff..05a488ad 100644 --- a/src/flow/index.ts +++ b/src/flow/index.ts @@ -9,5 +9,6 @@ export * from './chain'; export * from './delay'; export * from './every'; export * from './loop'; +export * from './noop'; export * from './scheduling'; export * from './sequence'; diff --git a/src/flow/noop.ts b/src/flow/noop.ts new file mode 100644 index 00000000..fdc9509c --- /dev/null +++ b/src/flow/noop.ts @@ -0,0 +1,8 @@ +import {ThreadGenerator} from '../threading'; + +/** + * Do nothing. + */ +export function* noop(): ThreadGenerator { + // do nothing +} diff --git a/src/flow/scheduling.ts b/src/flow/scheduling.ts index 745a6dd0..5db2929b 100644 --- a/src/flow/scheduling.ts +++ b/src/flow/scheduling.ts @@ -3,25 +3,6 @@ import {ThreadGenerator} from '../threading'; import {useProject, useScene} from '../utils'; decorate(waitUntil, threadable()); -/** - * Wait until the given time. - * - * Example: - * ```ts - * // current time: 0s - * yield waitUntil(2); - * // current time: 2s - * yield waitUntil(3); - * // current time: 3s - * ``` - * - * @param time Absolute time in seconds. - * @param after - */ -export function waitUntil( - time: number, - after?: ThreadGenerator, -): ThreadGenerator; /** * Wait until the given time event. * @@ -36,19 +17,12 @@ export function waitUntil( * @param event Name of the time event. * @param after */ -export function waitUntil( - event: string, - after?: ThreadGenerator, -): ThreadGenerator; export function* waitUntil( - targetTime: number | string = 0, + event: string, after?: ThreadGenerator, ): ThreadGenerator { const scene = useScene(); - const frames = - typeof targetTime === 'string' - ? scene.timeEvents.register(targetTime) - : scene.project.secondsToFrames(targetTime); + const frames = scene.timeEvents.register(event); while (scene.project.frame < frames) { yield; diff --git a/src/helpers/CanvasHelper.ts b/src/helpers/CanvasHelper.ts index 4fa51a89..851fee02 100644 --- a/src/helpers/CanvasHelper.ts +++ b/src/helpers/CanvasHelper.ts @@ -1,4 +1,4 @@ -import {Context} from 'konva/lib/Context'; +import type {Context} from 'konva/lib/Context'; import {PossibleSpacing, Spacing} from '../types'; export const CanvasHelper = { diff --git a/src/index.ts b/src/index.ts index 39ec6904..a48b663b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ export * from './bootstrap'; export * from './Meta'; export * from './Project'; -export * from './Scene'; export * from './symbols'; -export * from './TimeEvents'; diff --git a/src/patches/Node.ts b/src/patches/Node.ts index 1bfbe961..bc0521f5 100644 --- a/src/patches/Node.ts +++ b/src/patches/Node.ts @@ -341,8 +341,8 @@ Node.prototype._getTransform = function (this: Node) { const super_setAttrs = Node.prototype.setAttrs; Node.prototype.setAttrs = function (this: Node, config: unknown) { if (!(NODE_ID in this.attrs)) { - const scene = useScene(); - if (scene) { + const scene: any = useScene(); + if (scene && 'generateNodeId' in scene) { const type = this.className; this.attrs[NODE_ID] = scene.generateNodeId(type); } diff --git a/src/player/Player.ts b/src/player/Player.ts index c4e1a852..413ad66d 100644 --- a/src/player/Player.ts +++ b/src/player/Player.ts @@ -176,7 +176,7 @@ export class Player { public setScale(scale: number) { this.project.resolutionScale = scale; this.updateState({scale}); - this.project.draw(); + this.project.render(); } public requestPreviousFrame(): void { @@ -229,7 +229,7 @@ export class Player { const duration = this.project.frame; const frame = commands.seek < 0 ? this.frame.current : commands.seek; const finished = await this.project.seek(frame); - this.project.draw(); + this.project.render(); this.updateState({ duration, finished, @@ -260,7 +260,7 @@ export class Player { // Rendering if (state.render) { state.finished = await this.project.next(); - this.project.draw(); + this.project.render(); await this.frameRendered.dispatch({ frame: this.project.frame, data: await this.project.getBlob(), @@ -318,7 +318,7 @@ export class Player { } // Draw the project - this.project.draw(); + this.project.render(); // handle finishing if (state.finished) { diff --git a/src/scenes/GeneratorScene.ts b/src/scenes/GeneratorScene.ts new file mode 100644 index 00000000..04cf2818 --- /dev/null +++ b/src/scenes/GeneratorScene.ts @@ -0,0 +1,252 @@ +import {isPromise, Thread, ThreadGenerator, threads} from '../threading'; +import {Meta} from '../Meta'; +import {TimeEvents} from './TimeEvents'; +import {EventDispatcher, ValueDispatcher} from '../events'; +import {Project} from '../Project'; +import {decorate, threadable} from '../decorators'; +import {setScene} from '../utils'; +import {CachedSceneData, Scene, SceneMetadata} from './Scene'; +import { + isTransitionable, + SceneTransition, + Transitionable, + TransitionContext, +} from './Transitionable'; +import {Threadable} from './Threadable'; +import {Size} from '../types'; +import {SceneState} from './SceneState'; + +export interface ThreadGeneratorFactory { + (view: T): ThreadGenerator; +} + +/** + * The default implementation of the {@link Scene} interface. + * + * Uses generators to control the animation. + */ +export abstract class GeneratorScene + implements Scene>, Transitionable, Threadable +{ + public readonly timeEvents: TimeEvents; + public readonly meta: Meta; + + public get firstFrame() { + return this.cache.current.firstFrame; + } + + public get lastFrame() { + return this.firstFrame + this.cache.current.duration; + } + + public get onCacheChanged() { + return this.cache.subscribable; + } + private readonly cache = new ValueDispatcher({ + firstFrame: 0, + transitionDuration: 0, + duration: 0, + lastFrame: 0, + }); + + public get onReloaded() { + return this.reloaded.subscribable; + } + private readonly reloaded = new EventDispatcher(); + + public get onRecalculated() { + return this.recalculated.subscribable; + } + private readonly recalculated = new EventDispatcher(); + + public get onThreadChanged() { + return this.thread.subscribable; + } + private readonly thread = new ValueDispatcher(null); + + private previousScene: Scene = null; + private runner: ThreadGenerator; + private state: SceneState = SceneState.Initial; + private cached = false; + private counters: Record = {}; + + public constructor( + public readonly project: Project, + public readonly name: string, + private runnerFactory: ThreadGeneratorFactory, + ) { + decorate(this.runnerFactory, threadable(name)); + + this.meta = Meta.getMetaFor(`${name}.scene`); + this.timeEvents = new TimeEvents(this); + } + + public abstract getView(): T; + + /** + * Update the view. + * + * Invoked after each step of the main generator. + * Can be used for calculating layout. + * + * Can modify the state of the view. + */ + public update() { + // do nothing + } + + public abstract render( + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + ): void; + + public reload(runnerFactory?: ThreadGeneratorFactory) { + if (runnerFactory) { + this.runnerFactory = runnerFactory; + } + this.cached = false; + this.reloaded.dispatch(); + } + + public async recalculate() { + const cached = this.cache.current; + cached.firstFrame = this.project.frame; + cached.lastFrame = cached.firstFrame + cached.duration; + + if (this.isCached()) { + this.project.frame = cached.lastFrame; + this.cache.current = {...cached}; + return; + } + + cached.transitionDuration = -1; + await this.reset(); + while (!this.canTransitionOut()) { + if (cached.transitionDuration < 0 && this.isAfterTransitionIn()) { + cached.transitionDuration = this.project.frame - cached.firstFrame; + } + this.project.frame++; + await this.next(); + } + + cached.lastFrame = this.project.frame; + cached.duration = cached.lastFrame - cached.firstFrame; + // Prevent the page from becoming unresponsive. + await new Promise(resolve => setTimeout(resolve, 0)); + this.cached = true; + this.cache.current = {...cached}; + this.recalculated.dispatch(); + } + + public async next() { + setScene(this); + let result = this.runner.next(); + this.update(); + while (result.value) { + if (isPromise(result.value)) { + const value = await result.value; + result = this.runner.next(value); + } else { + console.warn('Invalid value: ', result.value); + result = this.runner.next(); + } + this.update(); + } + + if (result.done) { + this.state = SceneState.Finished; + } + } + + public async reset(previousScene: Scene = null) { + this.counters = {}; + this.previousScene = previousScene; + this.runner = threads( + () => this.runnerFactory(this.getView()), + thread => { + this.thread.current = thread; + }, + ); + this.state = SceneState.Initial; + setScene(this); + await this.next(); + } + + public getSize(): Size { + return this.project.getSize(); + } + + public isAfterTransitionIn(): boolean { + return this.state === SceneState.AfterTransitionIn; + } + + public canTransitionOut(): boolean { + return ( + this.state === SceneState.CanTransitionOut || + this.state === SceneState.Finished + ); + } + + public isFinished(): boolean { + return this.state === SceneState.Finished; + } + + public enterCanTransitionOut() { + if (this.state === SceneState.AfterTransitionIn) { + this.state = SceneState.CanTransitionOut; + } else { + console.warn( + `Scene ${this.name} was marked as finished in an unexpected state: `, + this.state, + ); + } + } + + public enterAfterTransitionIn() { + if (this.state === SceneState.Initial) { + this.state = SceneState.AfterTransitionIn; + } else { + console.warn( + `Scene ${this.name} transitioned in an unexpected state: `, + this.state, + ); + } + } + + public isCached() { + return this.cached; + } + + public generateNodeId(type: string): string { + let id = 0; + if (type in this.counters) { + id = ++this.counters[type]; + } else { + this.counters[type] = id; + } + + return `${this.name}.${type}.${id}`; + } + + //#region Transitionable Interface + + @threadable() + public *transition(transitionRunner?: SceneTransition): ThreadGenerator { + const previous = isTransitionable(this.previousScene) + ? this.previousScene.getTransitionContext() + : null; + + if (!transitionRunner) { + previous?.visible(false); + this.enterAfterTransitionIn(); + return; + } + + yield* transitionRunner(this.getTransitionContext(), previous); + this.enterAfterTransitionIn(); + } + + public abstract getTransitionContext(): TransitionContext; + + //#endregion +} diff --git a/src/scenes/Inspectable.ts b/src/scenes/Inspectable.ts new file mode 100644 index 00000000..fc5d5620 --- /dev/null +++ b/src/scenes/Inspectable.ts @@ -0,0 +1,89 @@ +import {Rect, Vector2} from '../types'; + +/** + * Represents an element to inspect. + * + * The type is not important because the UI does not interact with it. + * It serves as a key that will be passed back to an Inspectable scene to + * receive more information about said element. + */ +export type InspectedElement = unknown; + +/** + * Represents attributes of an inspected element. + */ +export type InspectedAttributes = Record; + +/** + * Represents different sizes and/or coordinates of an inspected element. + */ +export interface InspectedSize { + /** + * Bounding box of the element (with padding). + */ + rect?: Rect; + + /** + * Bounding box of the content of this element (without padding). + */ + contentRect?: Rect; + + /** + * Bounding box of the element (with margin). + */ + marginRect?: Rect; + + /** + * The absolute position of the object's origin. + */ + position?: Vector2; +} + +/** + * Scenes can implement this interface to make their components + * inspectable through the UI. + */ +export interface Inspectable { + /** + * Get a possible element to inspect at a given position. + * + * @param x + * @param y + */ + inspectPosition(x: number, y: number): InspectedElement | null; + + /** + * Validate if the inspected element is still valid. + * + * If a scene destroys and recreates its components upon every reset, the + * reference may no longer be valid. Even though the component is still + * present. This method should check that and return a new reference. + * + * See {@link KonvaScene.validateInspection()} for a sample implementation. + * + * @param element + */ + validateInspection(element: InspectedElement | null): InspectedElement | null; + + /** + * Return the attributes of the inspected element. + * + * This information will be displayed in the "Properties" panel. + * + * @param element + */ + inspectAttributes(element: InspectedElement): InspectedAttributes | null; + + /** + * Return the sizes of the inspected element. + * + * This information will be used to draw the bounding boxes on the screen. + * + * @param element + */ + inspectBoundingBox(element: InspectedElement): InspectedSize; +} + +export function isInspectable(value: any): value is Inspectable { + return value && typeof value === 'object' && 'validateInspection' in value; +} diff --git a/src/scenes/KonvaScene.ts b/src/scenes/KonvaScene.ts new file mode 100644 index 00000000..7af38f39 --- /dev/null +++ b/src/scenes/KonvaScene.ts @@ -0,0 +1,192 @@ +import {Container} from 'konva/lib/Container'; +import {Scene, SceneDescription} from './Scene'; +import {HitCanvas, SceneCanvas} from 'konva/lib/Canvas'; +import {Shape, shapes} from 'konva/lib/Shape'; +import {Group} from 'konva/lib/Group'; +import {GeneratorScene, ThreadGeneratorFactory} from './GeneratorScene'; +import {useScene} from '../utils'; +import {SceneTransition, TransitionContext} from './Transitionable'; +import { + Inspectable, + InspectedElement, + InspectedAttributes, + InspectedSize, +} from './Inspectable'; +import {Util} from 'konva/lib/Util'; +import {Node} from 'konva/lib/Node'; +import {Konva} from 'konva/lib/Global'; +import {NODE_ID} from '../symbols'; +import {ThreadGenerator} from '../threading'; + +Konva.autoDrawEnabled = false; + +const sceneCanvasMap = new Map(); + +export function useKonvaView(): KonvaView { + const scene = useScene(); + if (scene instanceof KonvaScene) { + return scene.view; + } + return null; +} + +/** + * Create a descriptor for a Konva scene. + * + * @example + * ```ts + * // example.scene.ts + * + * export default makeKonvaScene(function* example(view) { + * yield* view.transition(); + * // perform animation + * }); + * ``` + * + * @param factory + */ +export function makeKonvaScene( + factory: ThreadGeneratorFactory, +): SceneDescription { + return { + name: factory.name, + config: factory, + klass: KonvaScene, + }; +} + +class KonvaView extends Container implements TransitionContext { + public constructor(private readonly scene: KonvaScene) { + super(); + } + + /** + * Start transitioning out of the current scene. + */ + public canFinish() { + this.scene.enterCanTransitionOut(); + } + + /** + * @inheritDoc Transitionable.transition + */ + public transition(transitionRunner?: SceneTransition): ThreadGenerator { + return this.scene.transition(transitionRunner); + } + + public updateLayout() { + super.updateLayout(); + let limit = 10; + while (this.wasDirty() && limit > 0) { + super.updateLayout(); + limit--; + } + + if (limit === 0) { + console.warn('Layout iteration limit exceeded'); + } + } + + public add(...children: (Shape | Group)[]): this { + super.add(...children.flat()); + this.updateLayout(); + return this; + } + + public _validateAdd() { + // do nothing + } +} + +export class KonvaScene + extends GeneratorScene + implements Inspectable +{ + public readonly view = new KonvaView(this); + private hitCanvas = new HitCanvas({pixelRatio: 1}); + + public getView(): KonvaView { + return this.view; + } + + public update() { + this.view.updateLayout(); + } + + public render(context: CanvasRenderingContext2D, canvas: HTMLCanvasElement) { + let sceneCanvas = sceneCanvasMap.get(canvas); + if (!sceneCanvas) { + sceneCanvas = new SceneCanvas({ + width: canvas.width, + height: canvas.height, + pixelRatio: 1, + }); + sceneCanvas._canvas = canvas; + sceneCanvas.getContext()._context = context; + } + + this.view.drawScene(sceneCanvas); + } + + public reset(previousScene: Scene = null) { + this.view.x(0).y(0).opacity(1).show(); + this.view.destroyChildren(); + return super.reset(previousScene); + } + + public getTransitionContext(): TransitionContext { + return this.view; + } + + //#region Inspectable Interface + + public inspectPosition(x: number, y: number): InspectedElement | null { + this.hitCanvas.setSize(this.getSize().width, this.getSize().height); + this.project.transformCanvas(this.hitCanvas.context._context); + this.view.drawHit(this.hitCanvas, this.view); + + const color = this.hitCanvas.context.getImageData(x, y, 1, 1).data; + if (color[3] < 255) return null; + const key = Util._rgbToHex(color[0], color[1], color[2]); + return shapes[`#${key}`] ?? null; + } + + public validateInspection( + element: InspectedElement | null, + ): InspectedElement | null { + if (!(element instanceof Node)) return null; + if (element.isAncestorOf(this.view)) return element; + const id = element.attrs[NODE_ID]; + return ( + this.view.findOne((node: Node) => node.attrs[NODE_ID] === id) ?? null + ); + } + + public inspectAttributes( + element: InspectedElement, + ): InspectedAttributes | null { + if (!(element instanceof Node)) return null; + return element.attrs; + } + + public inspectBoundingBox(element: InspectedElement): InspectedSize { + if (!(element instanceof Node)) return {}; + + const rect = element.getClientRect({relativeTo: this.view}); + const scale = element.getAbsoluteScale(this.view); + const position = element.getAbsolutePosition(this.view); + const offset = element.getOriginOffset(); + + return { + rect, + contentRect: element.getPadd().scale(scale).shrink(rect), + marginRect: element.getMargin().scale(scale).expand(rect), + position: { + x: position.x + offset.x, + y: position.y + offset.y, + }, + }; + } + + //#endregion +} diff --git a/src/scenes/Scene.ts b/src/scenes/Scene.ts new file mode 100644 index 00000000..5edb60f9 --- /dev/null +++ b/src/scenes/Scene.ts @@ -0,0 +1,193 @@ +import type {Project} from '../Project'; +import {Meta, Metadata} from '../Meta'; +import {SavedTimeEvent, TimeEvents} from './TimeEvents'; +import {SubscribableEvent, SubscribableValueEvent} from '../events'; +import {Size} from '../types'; + +export interface SceneMetadata extends Metadata { + timeEvents: SavedTimeEvent[]; +} + +/** + * The constructor used when creating new scenes. + * + * Each class implementing the {@link Scene} interface should have a matching + * constructor. + * + * @template T The type of the configuration object. This object will be passed + * to the constructor from {@link SceneDescription.config}. + */ +export interface SceneConstructor { + new (project: Project, name: string, config: T): Scene; +} + +/** + * Describes a scene exposed by a `*.scene.tsx` file. + * + * @template T The type of the configuration object. + */ +export interface SceneDescription { + /** + * The class used to instantiate the scene. + */ + klass: SceneConstructor; + /** + * Name of the scene. + * + * Should match the first portion of the file name (`[name].scene.ts`). + */ + name: string; + /** + * Configuration object. + */ + config: T; +} + +/** + * Describes cached information about the timing of a scene. + */ +export interface CachedSceneData { + firstFrame: number; + lastFrame: number; + transitionDuration: number; + duration: number; +} + +/** + * The main interface for scenes. + * + * Any class implementing this interface should have a constructor matching + * {@link SceneConstructor}. + * + * @template T The type of the configuration object. + */ +export interface Scene { + /** + * Name of the scene. + * + * Will be passed as the second argument to the constructor. + */ + readonly name: string; + /** + * Reference to the project. + * + * Will be passed as the first argument to the constructor. + */ + readonly project: Project; + readonly timeEvents: TimeEvents; + readonly meta: Meta; + + /** + * The frame at which this scene starts. + */ + get firstFrame(): number; + + /** + * The frame at which this scene ends. + */ + get lastFrame(): number; + + /** + * Triggered when the cached data changes. + * + * @event CachedSceneData + */ + get onCacheChanged(): SubscribableValueEvent; + + /** + * Triggered when the scene is reloaded. + * + * @event void + */ + get onReloaded(): SubscribableEvent; + + /** + * Triggered after scene is recalculated. + * + * @event void + */ + get onRecalculated(): SubscribableEvent; + + /** + * Render the scene onto a canvas. + * + * @param context + * @param canvas + */ + render(context: CanvasRenderingContext2D, canvas: HTMLCanvasElement): void; + + /** + * Reload the scene. + * + * This method is called whenever something related to this scene has changed: + * time events, source code, metadata, etc. + * + * Should trigger {@link onReloaded}. + * + * @param config If present, a new configuration object. + */ + reload(config?: T): void; + + /** + * Recalculate the scene. + * + * The task of this method is to calculate new timings stored in the cache. + * When this method is invoked, `this.project.frame` is set to the frame at + * which this scene should start ({@link firstFrame}). + * + * At the end of execution, this method should set `this.project.frame` to the + * frame at which this scene ends ({@link lastFrame}). + * + * Should trigger {@link onRecalculated}. + */ + recalculate(): Promise; + + /** + * Progress this scene one frame forward. + */ + next(): Promise; + + /** + * Reset this scene to its initial state. + * + * @param previous If present, the previous scene. + */ + reset(previous?: Scene): Promise; + + /** + * Get the size of this scene. + * + * Usually return `this.project.getSize()`. + */ + getSize(): Size; + + /** + * Is this scene in the {@link SceneState.AfterTransitionIn} state? + */ + isAfterTransitionIn(): boolean; + + /** + * Is this scene in the {@link SceneState.CanTransitionOut} state? + */ + canTransitionOut(): boolean; + + /** + * Is this scene in the {@link SceneState.Finished} state? + */ + isFinished(): boolean; + + /** + * Enter the {@link SceneState.CanTransitionOut} state? + */ + enterCanTransitionOut(): void; + + /** + * Is this scene cached? + * + * Used only by {@link GeneratorScene}. Seeking through a project that + * contains at least one uncached scene will log a warning to the console. + * + * Should always return `true`. + */ + isCached(): boolean; +} diff --git a/src/scenes/SceneState.ts b/src/scenes/SceneState.ts new file mode 100644 index 00000000..e2d8a718 --- /dev/null +++ b/src/scenes/SceneState.ts @@ -0,0 +1,36 @@ +/** + * Describes the state of a scene. + */ +export enum SceneState { + /** + * The scene has just been created/reset. + */ + Initial, + + /** + * The scene has finished transitioning in. + * + * Informs the Project that the previous scene is no longer necessary and can + * be disposed of. + * + * If a scene doesn't implement the {@link Transitionable} interface, it + * should enter this state immediately, instead of {@link Initial}. + */ + AfterTransitionIn, + + /** + * The scene is ready to transition out. + * + * Informs the project that the next scene can begin. + * The {@link Scene.next()} method will still be invoked until the next scene + * enters {@link AfterTransitionIn}. + */ + CanTransitionOut, + + /** + * The scene has finished. + * + * Invoking {@link Scene.next()} won't have any effect. + */ + Finished, +} diff --git a/src/scenes/Threadable.ts b/src/scenes/Threadable.ts new file mode 100644 index 00000000..75b588ea --- /dev/null +++ b/src/scenes/Threadable.ts @@ -0,0 +1,21 @@ +import {SubscribableValueEvent} from '../events'; +import {Thread} from '../threading'; + +/** + * Scenes can implement this interface to display their thread hierarchy in the + * UI. + * + * This interface is only useful when a scene uses thread generators to run. + */ +export interface Threadable { + /** + * Triggered when the main thread changes. + * + * @event Thread + */ + get onThreadChanged(): SubscribableValueEvent; +} + +export function isThreadable(value: any): value is Threadable { + return value && typeof value === 'object' && 'onThreadChanged' in value; +} diff --git a/src/TimeEvents.ts b/src/scenes/TimeEvents.ts similarity index 71% rename from src/TimeEvents.ts rename to src/scenes/TimeEvents.ts index 1c193d04..71c4d373 100644 --- a/src/TimeEvents.ts +++ b/src/scenes/TimeEvents.ts @@ -1,5 +1,5 @@ -import type {Scene} from './Scene'; -import {ValueDispatcher} from './events'; +import type {Scene, SceneMetadata} from './Scene'; +import {ValueDispatcher} from '../events'; /** * Represents a time event at runtime. @@ -52,12 +52,12 @@ export class TimeEvents { private registeredEvents: Record = {}; private lookup: Record = {}; - private previousReference: SavedTimeEvent[]; - private ignoreSave = false; + private previousReference: SavedTimeEvent[] = []; + private didEventsChange = false; private preserveTiming = true; public constructor(private readonly scene: Scene) { - const storageKey = `scene-${scene.project.name()}-${scene.name()}`; + const storageKey = `scene-${scene.project.name}-${scene.name}`; const storedEvents = localStorage.getItem(storageKey); if (storedEvents) { console.warn('Migrating localStorage to meta files'); @@ -65,21 +65,13 @@ export class TimeEvents { localStorage.removeItem(storageKey); this.load(Object.values(JSON.parse(storedEvents))); } else { - this.load(scene.meta.getData().timeEvents ?? []); + this.previousReference = scene.meta.getData().timeEvents ?? []; + this.load(this.previousReference); } - scene.meta.onDataChanged.subscribe(event => { - // Ignore the event if `timeEvents` hasn't changed. - // This may happen when another part of metadata has changed triggering - // this event. - if (event.timeEvents === this.previousReference) return; - this.previousReference = event.timeEvents; - this.ignoreSave = true; - - this.load(event.timeEvents ?? []); - scene.reload(); - window.player.reload(); - }, false); + scene.onReloaded.subscribe(this.handleReload); + scene.onRecalculated.subscribe(this.handleRecalculated); + scene.meta.onDataChanged.subscribe(this.handleMetaChanged, false); } public get(name: string) { @@ -107,6 +99,7 @@ export class TimeEvents { }; this.registeredEvents[name] = this.lookup[name]; this.events.current = Object.values(this.registeredEvents); + this.didEventsChange = true; this.scene.reload(); } @@ -124,6 +117,7 @@ export class TimeEvents { this.scene.project.frame - this.scene.firstFrame, ); if (!this.lookup[name]) { + this.didEventsChange = true; this.lookup[name] = { name, initialTime, @@ -146,6 +140,7 @@ export class TimeEvents { const target = event.initialTime + event.offset; if (!this.preserveTiming && event.targetTime !== target) { + this.didEventsChange = true; event.targetTime = target; changed = true; } @@ -166,37 +161,47 @@ export class TimeEvents { /** * Called when the parent scene gets reloaded. */ - public onReload() { + private handleReload = () => { this.registeredEvents = {}; - } + }; /** - * Called when the parent scene gets cached. + * Called when the parent scene gets recalculated. */ - public onCache() { + private handleRecalculated = () => { this.preserveTiming = true; this.events.current = Object.values(this.registeredEvents); - if (this.ignoreSave) { - this.ignoreSave = false; - return; + if ( + this.didEventsChange || + this.previousReference.length !== this.events.current.length + ) { + this.didEventsChange = false; + this.previousReference = Object.values(this.registeredEvents).map( + event => ({ + name: event.name, + targetTime: event.targetTime, + }), + ); + this.scene.meta.setDataSync({ + timeEvents: this.previousReference, + }); } + }; - this.save(); - } - - private save() { - this.previousReference = Object.values(this.registeredEvents).map( - event => ({ - name: event.name, - targetTime: event.targetTime, - }), - ); - - this.scene.meta.setDataSync({ - timeEvents: this.previousReference, - }); - } + /** + * Called when the meta of the parent scene changes. + */ + private handleMetaChanged = (data: SceneMetadata) => { + // Ignore the event if `timeEvents` hasn't changed. + // This may happen when another part of metadata has changed triggering + // this event. + if (data.timeEvents === this.previousReference) return; + this.previousReference = data.timeEvents; + this.load(data.timeEvents ?? []); + this.scene.reload(); + window.player.reload(); + }; private load(events: SavedTimeEvent[]) { for (const event of events) { diff --git a/src/scenes/Transitionable.ts b/src/scenes/Transitionable.ts new file mode 100644 index 00000000..aea73fd4 --- /dev/null +++ b/src/scenes/Transitionable.ts @@ -0,0 +1,78 @@ +import {ThreadGenerator} from '../threading'; +import {Vector2} from '../types'; + +/** + * Describes the transition context of a scene. + * + * This context is used by scene transitions to modify the way scenes are + * displayed in a renderer-agnostic way. + * + * It allows transitions to work across different scenes. + */ +export interface TransitionContext { + /** + * Set the position of the scene. + * @param value + */ + position(value: Vector2): void; + + /** + * Set the scale of the scene. + * @param value + */ + scale(value: Vector2): void; + + /** + * Set the rotation of the scene. + * + * @param value + */ + rotation(value: number): void; + + /** + * Set the opacity of the scene. + * @param value + */ + opacity(value: number): void; + + /** + * Set whether the scene should be visible. + * + * @param value + */ + visible(value: boolean): void; +} + +/** + * Describes a function that performs a transition. + */ +export interface SceneTransition { + /** + * A function that performs a transition. + * + * @param next the context of the scene that should appear next. + * @param previous If present, the context of the scene that should disappear. + */ + (next: TransitionContext, previous?: TransitionContext): ThreadGenerator; +} + +/** + * Scenes can implement this interface to support transitions. + */ +export interface Transitionable { + /** + * Get the transition context for this scene. + */ + getTransitionContext(): TransitionContext; + + /** + * Perform the transition. + * + * @param transitionRunner A generator function that runs the transition. + */ + transition(transitionRunner?: SceneTransition): ThreadGenerator; +} + +export function isTransitionable(value: any): value is Transitionable { + return value && typeof value === 'object' && 'getTransitionContext' in value; +} diff --git a/src/scenes/index.ts b/src/scenes/index.ts new file mode 100644 index 00000000..69fc0a02 --- /dev/null +++ b/src/scenes/index.ts @@ -0,0 +1,8 @@ +export * from './GeneratorScene'; +export * from './Inspectable'; +export * from './KonvaScene'; +export * from './Scene'; +export * from './SceneState'; +export * from './Threadable'; +export * from './TimeEvents'; +export * from './Transitionable'; diff --git a/src/transitions/SceneTransition.ts b/src/transitions/SceneTransition.ts deleted file mode 100644 index 21e6725b..00000000 --- a/src/transitions/SceneTransition.ts +++ /dev/null @@ -1,6 +0,0 @@ -import {ThreadGenerator} from '../threading'; -import {Scene} from '../Scene'; - -export interface SceneTransition { - (next: Scene, previous?: Scene): ThreadGenerator; -} diff --git a/src/transitions/index.ts b/src/transitions/index.ts index 7065f89c..a0e4331e 100644 --- a/src/transitions/index.ts +++ b/src/transitions/index.ts @@ -1,2 +1 @@ export * from './slideTransition'; -export * from './SceneTransition'; diff --git a/src/transitions/slideTransition.ts b/src/transitions/slideTransition.ts index 1cab3d15..13077f0c 100644 --- a/src/transitions/slideTransition.ts +++ b/src/transitions/slideTransition.ts @@ -1,18 +1,14 @@ -import type {SceneTransition} from './SceneTransition'; +import type {SceneTransition} from '../scenes'; import {Direction, originPosition} from '../types'; import {easeInOutCubic, tween, vector2dTween} from '../tweening'; -import {useProject} from '../utils'; +import {useScene} from '../utils'; export function slideTransition( direction: Direction = Direction.Top, ): SceneTransition { return (next, previous) => { - const project = useProject(); - const position = originPosition( - direction, - project.width(), - project.height(), - ); + const size = useScene().getSize(); + const position = originPosition(direction, size.width, size.height); return tween(0.6, value => { previous?.position( vector2dTween( @@ -21,6 +17,7 @@ export function slideTransition( easeInOutCubic(value), ), ); + next.position( vector2dTween(position, {x: 0, y: 0}, easeInOutCubic(value)), ); diff --git a/src/tweening/Animator.ts b/src/tweening/Animator.ts index 78bc8de5..841b9da4 100644 --- a/src/tweening/Animator.ts +++ b/src/tweening/Animator.ts @@ -131,11 +131,8 @@ export class Animator { return this; } - public waitUntil(event: string): this; - public waitUntil(time: number): this; - public waitUntil(time: number | string): this { - // @ts-ignore - this.keys.push(() => waitUntil(time)); + public waitUntil(event: string): this { + this.keys.push(() => waitUntil(event)); return this; } diff --git a/src/tweening/tweenFunctions.ts b/src/tweening/tweenFunctions.ts index d105ebcb..b3026eef 100644 --- a/src/tweening/tweenFunctions.ts +++ b/src/tweening/tweenFunctions.ts @@ -1,6 +1,5 @@ import mixColor from 'mix-color'; -import {IRect, Vector2d} from 'konva/lib/types'; -import {PossibleSpacing, Spacing} from '../types'; +import type {Rect, Vector2, PossibleSpacing, Spacing} from '../types'; export interface TweenFunction { (from: T, to: T, value: number, ...args: Rest): T; @@ -43,7 +42,7 @@ export function colorTween(from: string, to: string, value: number) { return mixColor(from, to, value); } -export function vector2dTween(from: Vector2d, to: Vector2d, value: number) { +export function vector2dTween(from: Vector2, to: Vector2, value: number) { return { x: map(from.x, to.x, value), y: map(from.y, to.y, value), @@ -64,8 +63,8 @@ export function spacingTween( } export function rectArcTween( - from: Partial, - to: Partial, + from: Partial, + to: Partial, value: number, reverse?: boolean, ratio?: number, @@ -96,10 +95,7 @@ export function rectArcTween( }; } -export function calculateRatio( - from: Partial, - to: Partial, -): number { +export function calculateRatio(from: Partial, to: Partial): number { let numberOfValues = 0; let ratio = 0; if (from.x) { diff --git a/src/types/Origin.ts b/src/types/Origin.ts index b4e5aba4..c0338e3d 100644 --- a/src/types/Origin.ts +++ b/src/types/Origin.ts @@ -1,5 +1,5 @@ -import {Vector2d} from 'konva/lib/types'; -import {Size} from './Size'; +import type {Vector2} from './Vector'; +import type {Size} from './Size'; export enum Center { Vertical = 1, @@ -54,8 +54,8 @@ export function originPosition( origin: Origin | Direction, width = 1, height = 1, -): Vector2d { - const position: Vector2d = {x: 0, y: 0}; +): Vector2 { + const position: Vector2 = {x: 0, y: 0}; if (origin === Origin.Middle) { return position; @@ -76,7 +76,7 @@ export function originPosition( return position; } -export function getOriginOffset(size: Size, origin: Origin): Vector2d { +export function getOriginOffset(size: Size, origin: Origin): Vector2 { return originPosition(origin, size.width / 2, size.height / 2); } diff --git a/src/types/Rect.ts b/src/types/Rect.ts new file mode 100644 index 00000000..9086a22b --- /dev/null +++ b/src/types/Rect.ts @@ -0,0 +1,6 @@ +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} diff --git a/src/types/Spacing.ts b/src/types/Spacing.ts index e4acc2bd..7b36abde 100644 --- a/src/types/Spacing.ts +++ b/src/types/Spacing.ts @@ -1,5 +1,6 @@ -import {Size} from './Size'; -import {IRect, Vector2d} from 'konva/lib/types'; +import type {Size} from './Size'; +import type {Rect} from './Rect'; +import type {Vector2} from './Vector'; interface ISpacing { top: number; @@ -67,7 +68,7 @@ export class Spacing implements ISpacing { return this; } - public expand(value: T): T { + public expand(value: T): T { const result = {...value}; result.width += this.x; @@ -80,7 +81,7 @@ export class Spacing implements ISpacing { return result; } - public shrink(value: T): T { + public shrink(value: T): T { const result = {...value}; result.width -= this.x; @@ -93,7 +94,7 @@ export class Spacing implements ISpacing { return result; } - public scale(scale: Vector2d): Spacing { + public scale(scale: Vector2): Spacing { return new Spacing([ this.top * scale.y, this.right * scale.x, diff --git a/src/types/Vector.ts b/src/types/Vector.ts new file mode 100644 index 00000000..1ff802c7 --- /dev/null +++ b/src/types/Vector.ts @@ -0,0 +1,10 @@ +export interface Vector2 { + x: number; + y: number; +} + +export interface Vector3 { + x: number; + y: number; + z: number; +} diff --git a/src/types/index.ts b/src/types/index.ts index f62a8403..d8fd0d15 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,5 @@ export * from './Origin'; +export * from './Rect'; export * from './Size'; export * from './Spacing'; +export * from './Vector'; diff --git a/src/utils/index.ts b/src/utils/index.ts index ce3228c8..4dd58be0 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,3 @@ -export * from './pop'; export * from './slide'; export * from './useAnimator'; export * from './useProject'; diff --git a/src/utils/pop.ts b/src/utils/pop.ts deleted file mode 100644 index 3b16550a..00000000 --- a/src/utils/pop.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {Node} from 'konva/lib/Node'; -import {useScene} from './useScene'; - -export function pop(node: T): [T, () => void] { - const clone: T = node.clone(); - clone.moveTo(useScene()); - clone.position(node.absolutePosition()); - node.hide(); - - return [ - clone, - () => { - clone.destroy(); - node.show(); - }, - ]; -} diff --git a/src/utils/slide.ts b/src/utils/slide.ts index dca21191..79e5cea0 100644 --- a/src/utils/slide.ts +++ b/src/utils/slide.ts @@ -1,11 +1,11 @@ -import {Container} from 'konva/lib/Container'; -import {Vector2d} from 'konva/lib/types'; +import type {Container} from 'konva/lib/Container'; +import type {Vector2} from '../types'; -export function slide(container: Container, offset: Vector2d): void; +export function slide(container: Container, offset: Vector2): void; export function slide(container: Container, x: number, y?: number): void; export function slide( container: Container, - offset: number | Vector2d, + offset: number | Vector2, y = 0, ): void { if (typeof offset === 'number') { diff --git a/src/utils/useScene.ts b/src/utils/useScene.ts index 26341e26..9f2b1b61 100644 --- a/src/utils/useScene.ts +++ b/src/utils/useScene.ts @@ -1,4 +1,4 @@ -import {Scene} from '../Scene'; +import type {Scene} from '../scenes'; let currentScene: Scene = null; diff --git a/typedoc.json b/typedoc.json index 85661abd..6aea0c54 100644 --- a/typedoc.json +++ b/typedoc.json @@ -11,6 +11,7 @@ "src/helpers", "src/media", "src/player", + "src/scenes", "src/styles", "src/themes", "src/threading",