feat: decouple Konva from core (#54)

Konva has been decoupled from the core entirely. It is now possible to supply the project with custom implementations of the `Scene` interface.
Because of that, scene files need to export a special `SceneDescription` object instead of a simple generator function.

The preferred way of doing it is by using dedicated "make" functions that generate description objects for specific scenes.
For example this scene:
```ts
export default function* example(scene: Scene): ThreadGenerator {
  yield* scene.transition();
  // perform animation
}
```
should now be written as:
```ts
export default makeKonvaScene(function* example(view) {
  yield* view.transition();
  // perform animation
});
```

The core provides an abstract implementation of the `Scene` interface called `GeneratorScene`.
It takes care of all the logic related to generators, life cycles, caching, etc. And can be extended to easily provide custom rendering logic. See `KonvaScene` for an example of extending the `GeneratorScene`.

Alternatively, the `Scene` interface can be implemented from the ground up allowing for even more flexibility.

For the time being, features related to Konva are kept in the core repo.
After #49 they will be extracted to a separate package.

BREAKING CHANGE: change the type exported by scene files

Scene files need to export a special `SceneDescription` object instead of a simple generator function.

Closes: #31
This commit is contained in:
Jacob
2022-07-01 15:04:11 +02:00
committed by GitHub
parent 40007a9792
commit 02b5c75dba
38 changed files with 1158 additions and 491 deletions

View File

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

View File

@@ -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<StageConfig> {
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<Scene[]>([]);
public get onCurrentSceneChanged() {
return this.currentScene.subscribable;
}
private readonly currentScene = new ValueDispatcher<Scene>(null);
public readonly version = CORE_VERSION;
public readonly meta: Meta<ProjectMetadata>;
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<string, Scene> = {};
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<string, Scene> = {};
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<boolean> {
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<boolean> {
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<Blob> {
return new Promise<Blob>(resolve =>
this.master.getNativeCanvasElement().toBlob(resolve, 'image/png'),
this.canvas.toBlob(resolve, 'image/png'),
);
}
}

View File

@@ -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<SceneMetadata>;
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<string, number> = {};
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}`;
}
}

View File

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

View File

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

View File

@@ -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,

View File

@@ -8,7 +8,7 @@ decorate(any, threadable());
* Example:
* ```
* // current time: 0s
* yield* all(
* yield* any(
* rect.fill('#ff0000', 2),
* rect.opacity(1, 1),
* );

View File

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

8
src/flow/noop.ts Normal file
View File

@@ -0,0 +1,8 @@
import {ThreadGenerator} from '../threading';
/**
* Do nothing.
*/
export function* noop(): ThreadGenerator {
// do nothing
}

View File

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

View File

@@ -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 = {

View File

@@ -1,6 +1,4 @@
export * from './bootstrap';
export * from './Meta';
export * from './Project';
export * from './Scene';
export * from './symbols';
export * from './TimeEvents';

View File

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

View File

@@ -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) {

View File

@@ -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<T> {
(view: T): ThreadGenerator;
}
/**
* The default implementation of the {@link Scene} interface.
*
* Uses generators to control the animation.
*/
export abstract class GeneratorScene<T>
implements Scene<ThreadGeneratorFactory<T>>, Transitionable, Threadable
{
public readonly timeEvents: TimeEvents;
public readonly meta: Meta<SceneMetadata>;
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<CachedSceneData>({
firstFrame: 0,
transitionDuration: 0,
duration: 0,
lastFrame: 0,
});
public get onReloaded() {
return this.reloaded.subscribable;
}
private readonly reloaded = new EventDispatcher<void>();
public get onRecalculated() {
return this.recalculated.subscribable;
}
private readonly recalculated = new EventDispatcher<void>();
public get onThreadChanged() {
return this.thread.subscribable;
}
private readonly thread = new ValueDispatcher<Thread>(null);
private previousScene: Scene = null;
private runner: ThreadGenerator;
private state: SceneState = SceneState.Initial;
private cached = false;
private counters: Record<string, number> = {};
public constructor(
public readonly project: Project,
public readonly name: string,
private runnerFactory: ThreadGeneratorFactory<T>,
) {
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<T>) {
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
}

89
src/scenes/Inspectable.ts Normal file
View File

@@ -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<string, any>;
/**
* 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;
}

192
src/scenes/KonvaScene.ts Normal file
View File

@@ -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<HTMLCanvasElement, SceneCanvas>();
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<KonvaView>,
): 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<KonvaView>
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
}

193
src/scenes/Scene.ts Normal file
View File

@@ -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<T> {
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<T = unknown> {
/**
* The class used to instantiate the scene.
*/
klass: SceneConstructor<T>;
/**
* 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<T = unknown> {
/**
* 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<SceneMetadata>;
/**
* 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<CachedSceneData>;
/**
* Triggered when the scene is reloaded.
*
* @event void
*/
get onReloaded(): SubscribableEvent<void>;
/**
* Triggered after scene is recalculated.
*
* @event void
*/
get onRecalculated(): SubscribableEvent<void>;
/**
* 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<void>;
/**
* Progress this scene one frame forward.
*/
next(): Promise<void>;
/**
* Reset this scene to its initial state.
*
* @param previous If present, the previous scene.
*/
reset(previous?: Scene): Promise<void>;
/**
* 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;
}

36
src/scenes/SceneState.ts Normal file
View File

@@ -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,
}

21
src/scenes/Threadable.ts Normal file
View File

@@ -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<Thread>;
}
export function isThreadable(value: any): value is Threadable {
return value && typeof value === 'object' && 'onThreadChanged' in value;
}

View File

@@ -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<string, TimeEvent> = {};
private lookup: Record<string, TimeEvent> = {};
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<TimeEvent>(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) {

View File

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

8
src/scenes/index.ts Normal file
View File

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

View File

@@ -1,6 +0,0 @@
import {ThreadGenerator} from '../threading';
import {Scene} from '../Scene';
export interface SceneTransition {
(next: Scene, previous?: Scene): ThreadGenerator;
}

View File

@@ -1,2 +1 @@
export * from './slideTransition';
export * from './SceneTransition';

View File

@@ -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)),
);

View File

@@ -131,11 +131,8 @@ export class Animator<Type, This> {
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;
}

View File

@@ -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<T, Rest extends unknown[] = unknown[]> {
(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<IRect>,
to: Partial<IRect>,
from: Partial<Rect>,
to: Partial<Rect>,
value: number,
reverse?: boolean,
ratio?: number,
@@ -96,10 +95,7 @@ export function rectArcTween(
};
}
export function calculateRatio(
from: Partial<IRect>,
to: Partial<IRect>,
): number {
export function calculateRatio(from: Partial<Rect>, to: Partial<Rect>): number {
let numberOfValues = 0;
let ratio = 0;
if (from.x) {

View File

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

6
src/types/Rect.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}

View File

@@ -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<T extends Size | IRect>(value: T): T {
public expand<T extends Size | Rect>(value: T): T {
const result = {...value};
result.width += this.x;
@@ -80,7 +81,7 @@ export class Spacing implements ISpacing {
return result;
}
public shrink<T extends Size | IRect>(value: T): T {
public shrink<T extends Size | Rect>(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,

10
src/types/Vector.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface Vector2 {
x: number;
y: number;
}
export interface Vector3 {
x: number;
y: number;
z: number;
}

View File

@@ -1,3 +1,5 @@
export * from './Origin';
export * from './Rect';
export * from './Size';
export * from './Spacing';
export * from './Vector';

View File

@@ -1,4 +1,3 @@
export * from './pop';
export * from './slide';
export * from './useAnimator';
export * from './useProject';

View File

@@ -1,17 +0,0 @@
import {Node} from 'konva/lib/Node';
import {useScene} from './useScene';
export function pop<T extends Node>(node: T): [T, () => void] {
const clone: T = node.clone();
clone.moveTo(useScene());
clone.position(node.absolutePosition());
node.hide();
return [
clone,
() => {
clone.destroy();
node.show();
},
];
}

View File

@@ -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') {

View File

@@ -1,4 +1,4 @@
import {Scene} from '../Scene';
import type {Scene} from '../scenes';
let currentScene: Scene = null;

View File

@@ -11,6 +11,7 @@
"src/helpers",
"src/media",
"src/player",
"src/scenes",
"src/styles",
"src/themes",
"src/threading",