mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-12 07:18:01 -05:00
feat: general improvements
This commit is contained in:
33
package-lock.json
generated
33
package-lock.json
generated
@@ -12,6 +12,7 @@
|
||||
"@types/dom-webcodecs": "^0.1.4",
|
||||
"@types/wicg-file-system-access": "^2020.9.5",
|
||||
"canvas": "^2.9.1",
|
||||
"csv-loader": "^3.0.3",
|
||||
"image-size": "^1.0.1",
|
||||
"konva": "^8.3.2",
|
||||
"meow": "^10.1.2",
|
||||
@@ -1422,6 +1423,19 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/csv-loader": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/csv-loader/-/csv-loader-3.0.3.tgz",
|
||||
"integrity": "sha512-JMr83kH2sOFKbRO95fAQV1fLEc1Chx1osJpU7Gd5ZQhmXrsQN479P08sDuyZoO5LMiJ8IsR72Xtl/nSA7rh4Lw==",
|
||||
"dependencies": {
|
||||
"loader-utils": "^2.0.0",
|
||||
"papaparse": "^5.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "butmeacoffee.com/allenkoren"
|
||||
}
|
||||
},
|
||||
"node_modules/dashdash": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
|
||||
@@ -4047,6 +4061,11 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/papaparse": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.2.tgz",
|
||||
"integrity": "sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw=="
|
||||
},
|
||||
"node_modules/parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
@@ -7516,6 +7535,15 @@
|
||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
||||
"dev": true
|
||||
},
|
||||
"csv-loader": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/csv-loader/-/csv-loader-3.0.3.tgz",
|
||||
"integrity": "sha512-JMr83kH2sOFKbRO95fAQV1fLEc1Chx1osJpU7Gd5ZQhmXrsQN479P08sDuyZoO5LMiJ8IsR72Xtl/nSA7rh4Lw==",
|
||||
"requires": {
|
||||
"loader-utils": "^2.0.0",
|
||||
"papaparse": "^5.2.0"
|
||||
}
|
||||
},
|
||||
"dashdash": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
|
||||
@@ -9463,6 +9491,11 @@
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true
|
||||
},
|
||||
"papaparse": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.3.2.tgz",
|
||||
"integrity": "sha512-6dNZu0Ki+gyV0eBsFKJhYr+MdQYAzFUGlBMNj3GNrmHxmz1lfRa24CjFObPXtjcetlOv5Ad299MhIK0znp3afw=="
|
||||
},
|
||||
"parse-json": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@types/dom-webcodecs": "^0.1.4",
|
||||
"@types/wicg-file-system-access": "^2020.9.5",
|
||||
"canvas": "^2.9.1",
|
||||
"csv-loader": "^3.0.3",
|
||||
"image-size": "^1.0.1",
|
||||
"konva": "^8.3.2",
|
||||
"meow": "^10.1.2",
|
||||
|
||||
@@ -222,14 +222,6 @@ export class Project extends Stage {
|
||||
return lastScene;
|
||||
}
|
||||
|
||||
public secondsToFrames(seconds: number) {
|
||||
return Math.ceil(seconds * this.framesPerSeconds);
|
||||
}
|
||||
|
||||
public framesToSeconds(frames: number) {
|
||||
return frames / this.framesPerSeconds;
|
||||
}
|
||||
|
||||
private getNextScene(scene?: Scene): Scene {
|
||||
const scenes = Object.values(this.sceneLookup);
|
||||
if (!scene) {
|
||||
@@ -242,4 +234,18 @@ export class Project extends Stage {
|
||||
}
|
||||
return scenes[index + 1] ?? null;
|
||||
}
|
||||
|
||||
public secondsToFrames(seconds: number) {
|
||||
return Math.ceil(seconds * this.framesPerSeconds);
|
||||
}
|
||||
|
||||
public framesToSeconds(frames: number) {
|
||||
return frames / this.framesPerSeconds;
|
||||
}
|
||||
|
||||
public async getBlob(): Promise<Blob> {
|
||||
return new Promise<Blob>(resolve =>
|
||||
this.master.getNativeCanvasElement().toBlob(resolve, 'image/png'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export class Scene extends Group {
|
||||
});
|
||||
decorate(runnerFactory, threadable());
|
||||
|
||||
this.storageKey = `scene-${this.name()}`;
|
||||
this.storageKey = `scene-${this.project.name()}-${this.name()}`;
|
||||
const storedEvents = localStorage.getItem(this.storageKey);
|
||||
if (storedEvents) {
|
||||
for (const event of Object.values<TimeEvent>(JSON.parse(storedEvents))) {
|
||||
@@ -114,7 +114,7 @@ export class Scene extends Group {
|
||||
}
|
||||
|
||||
public async reset(previousScene: Scene = null) {
|
||||
this.x(0).y(0);
|
||||
this.x(0).y(0).opacity(1).show();
|
||||
this.counters = {};
|
||||
this.destroyChildren();
|
||||
this.previousScene = previousScene;
|
||||
@@ -170,6 +170,8 @@ export class Scene extends Group {
|
||||
public *transition(transitionRunner?: SceneTransition) {
|
||||
if (transitionRunner) {
|
||||
yield* transitionRunner(this, this.previousScene);
|
||||
} else {
|
||||
this.previousScene?.hide();
|
||||
}
|
||||
if (this.state === SceneState.Initial) {
|
||||
this.state = SceneState.AfterTransitionIn;
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
clampRemap,
|
||||
easeInExpo,
|
||||
easeInOutCubic,
|
||||
easeOutCubic,
|
||||
easeOutExpo,
|
||||
linear,
|
||||
map,
|
||||
rectArcTween,
|
||||
spacingTween,
|
||||
tween,
|
||||
} from '../tweening';
|
||||
@@ -54,8 +56,7 @@ export function showSurface(surface: Surface): ThreadGenerator {
|
||||
value => {
|
||||
surface.setMask({
|
||||
...toMask,
|
||||
width: easeInOutCubic(value, fromMask.width, toMask.width),
|
||||
height: easeInOutCubic(value, fromMask.height, toMask.height),
|
||||
...rectArcTween(fromMask, toMask, easeInOutCubic(value)),
|
||||
});
|
||||
surface.setMargin(
|
||||
spacingTween(marginFrom, margin, easeInOutCubic(value)),
|
||||
@@ -96,13 +97,14 @@ export function showCircle(
|
||||
|
||||
export function unravelSurface(surface: Surface): ThreadGenerator {
|
||||
const mask = surface.getMask();
|
||||
surface.show();
|
||||
surface.setMask({...mask, height: 0});
|
||||
return tween(
|
||||
0.5,
|
||||
value => {
|
||||
surface.setMask({
|
||||
...mask,
|
||||
height: map(0, mask.height, easeInOutCubic(value)),
|
||||
height: map(0, mask.height, easeOutCubic(value)),
|
||||
});
|
||||
},
|
||||
() => surface.setMask(null),
|
||||
|
||||
@@ -12,6 +12,7 @@ interface BootstrapConfig {
|
||||
audio?: {
|
||||
meta: Waveform;
|
||||
src: string;
|
||||
offset?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,7 +23,13 @@ export function bootstrap(config: BootstrapConfig) {
|
||||
background: config.background ?? '#141414',
|
||||
...(config.size ?? ProjectSize.FullHD),
|
||||
});
|
||||
const player = new Player(project, config.audio);
|
||||
const player = new Player(
|
||||
project,
|
||||
config.audio && {
|
||||
offset: 0,
|
||||
...config.audio,
|
||||
},
|
||||
);
|
||||
(<any>window).player = player;
|
||||
|
||||
//@ts-ignore
|
||||
|
||||
@@ -20,10 +20,17 @@ const UNITY = [
|
||||
),
|
||||
];
|
||||
|
||||
const OBJECT = [
|
||||
new Path2D(
|
||||
'M49.71,20.94c-.36-1.31-1.05-2.51-2-3.48-.5-.51-1.07-.95-1.71-1.32l-12-6.92c-1.24-.72-2.62-1.08-4-1.08s-2.76,.36-4,1.08l-12,6.92c-.64,.37-1.21,.81-1.71,1.32-.95,.97-1.64,2.17-2,3.48-.19,.68-.29,1.4-.29,2.13v13.86c0,2.86,1.52,5.5,4,6.93l12,6.92c.64,.37,1.31,.64,2,.82,.65,.17,1.33,.26,2,.26s1.35-.09,2-.26c.69-.18,1.36-.45,2-.82l12-6.92c2.48-1.43,4-4.07,4-6.93v-13.86c0-.73-.1-1.45-.29-2.13ZM28,12.68c.61-.35,1.3-.54,2-.54s1.39,.19,2,.54l12,6.93-14,8.08-14-8.08,12-6.93Zm-12,27.71c-1.23-.71-2-2.04-2-3.46v-13.85l14,8.07v16.17l-12-6.93Zm30-3.46c0,1.42-.77,2.75-2,3.46l-12,6.93V31.15l14-8.08v13.86Z',
|
||||
),
|
||||
];
|
||||
|
||||
export enum IconType {
|
||||
Fill,
|
||||
Brush,
|
||||
Unity,
|
||||
Object,
|
||||
}
|
||||
|
||||
interface IconConfig extends ShapeConfig {
|
||||
@@ -47,6 +54,9 @@ export class Icon extends Shape {
|
||||
case IconType.Unity:
|
||||
this.paths = UNITY;
|
||||
break;
|
||||
case IconType.Object:
|
||||
this.paths = OBJECT;
|
||||
break;
|
||||
}
|
||||
|
||||
this._fillFunc = context => {
|
||||
|
||||
@@ -71,7 +71,10 @@ export class LinearLayout extends Group {
|
||||
const margin = child.getMargin();
|
||||
const scale = child.getAbsoluteScale(this);
|
||||
const offset = child.getOriginDelta(Origin.TopLeft);
|
||||
const parentOffset = getOriginOffset(this.contentSize, child.origin());
|
||||
const parentOffset = getOriginOffset(
|
||||
margin.shrink(this.contentSize),
|
||||
child.origin(),
|
||||
);
|
||||
if (direction === Center.Vertical) {
|
||||
child.position({
|
||||
x: parentOffset.x,
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface SurfaceConfig extends ContainerConfig {
|
||||
circleMask?: CircleMask;
|
||||
background?: string;
|
||||
child?: Node;
|
||||
rescaleChild?: boolean;
|
||||
shadow?: boolean;
|
||||
}
|
||||
|
||||
@KonvaNode()
|
||||
@@ -39,6 +41,10 @@ export class Surface extends Group {
|
||||
public background: GetSet<SurfaceConfig['background'], this>;
|
||||
@getset(null)
|
||||
public child: GetSet<SurfaceConfig['child'], this>;
|
||||
@getset(true)
|
||||
public rescaleChild: GetSet<SurfaceConfig['rescaleChild'], this>;
|
||||
@getset(false)
|
||||
public shadow: GetSet<SurfaceConfig['shadow'], this>;
|
||||
|
||||
private surfaceMask: SurfaceMask | null = null;
|
||||
private circleMask: CircleMask | null = null;
|
||||
@@ -130,8 +136,10 @@ export class Surface extends Group {
|
||||
);
|
||||
|
||||
this.surfaceMask = data;
|
||||
child.scaleX(scale);
|
||||
child.scaleY(scale);
|
||||
if (this.rescaleChild()) {
|
||||
child.scaleX(scale);
|
||||
child.scaleY(scale);
|
||||
}
|
||||
child.position(getOriginDelta(data, Origin.Middle, child.getOrigin()));
|
||||
this.markDirty();
|
||||
}
|
||||
@@ -204,19 +212,6 @@ export class Surface extends Group {
|
||||
context.restore();
|
||||
}
|
||||
|
||||
if (this.surfaceMask) {
|
||||
context._context.clip(
|
||||
CanvasHelper.roundRectPath(
|
||||
new Path2D(),
|
||||
-size.width / 2,
|
||||
-size.height / 2,
|
||||
size.width,
|
||||
size.height,
|
||||
this.surfaceMask.radius,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.circleMask) {
|
||||
context._context.beginPath();
|
||||
context._context.arc(
|
||||
@@ -233,6 +228,11 @@ export class Surface extends Group {
|
||||
context.save();
|
||||
context._context.fillStyle = this.surfaceMask?.color ?? this.background();
|
||||
context._context.globalAlpha = opacity;
|
||||
if (this.shadow()) {
|
||||
context._context.shadowColor = 'rgba(0, 0, 0, 0.32)';
|
||||
context._context.shadowOffsetY = 10;
|
||||
context._context.shadowBlur = 40;
|
||||
}
|
||||
CanvasHelper.roundRect(
|
||||
context._context,
|
||||
-size.width / 2,
|
||||
@@ -244,6 +244,19 @@ export class Surface extends Group {
|
||||
context._context.fill();
|
||||
context.restore();
|
||||
|
||||
if (this.surfaceMask) {
|
||||
context._context.clip(
|
||||
CanvasHelper.roundRectPath(
|
||||
new Path2D(),
|
||||
-size.width / 2,
|
||||
-size.height / 2,
|
||||
size.width,
|
||||
size.height,
|
||||
this.surfaceMask.radius,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
m = transform.copy().invert().getMatrix();
|
||||
context.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ export class Video extends Shape {
|
||||
|
||||
@threadable('videoRunner')
|
||||
private *playRunner(): ThreadGenerator {
|
||||
this.frame(0);
|
||||
while (this.task !== null) {
|
||||
if (this.playing()) {
|
||||
this.frame(this.frame() + 1);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import {getset, KonvaNode, threadable} from '../../decorators';
|
||||
import {cached, getset, KonvaNode, threadable} from '../../decorators';
|
||||
import {GetSet} from 'konva/lib/types';
|
||||
import PrismJS from 'prismjs';
|
||||
import {Context} from 'konva/lib/Context';
|
||||
import {Text, TextConfig} from 'konva/lib/shapes/Text';
|
||||
import {Util} from 'konva/lib/Util';
|
||||
import {easeOutExpo, tween} from '../../tweening';
|
||||
import {CodeTheme} from './CodeTheme';
|
||||
import {easeInExpo, easeOutExpo, tween} from '../../tweening';
|
||||
import {CodeTheme, CodeTokens} from './CodeTheme';
|
||||
import {JS_CODE_THEME} from '../../themes';
|
||||
import {ThreadGenerator} from '../../threading';
|
||||
import {useScene} from '../../utils';
|
||||
import {Node} from 'konva/lib/Node';
|
||||
|
||||
type CodePoint = [number, number];
|
||||
type CodeRange = [CodePoint, CodePoint];
|
||||
@@ -14,14 +17,22 @@ type CodeRange = [CodePoint, CodePoint];
|
||||
interface CodeConfig extends TextConfig {
|
||||
selection?: CodeRange[];
|
||||
theme?: CodeTheme;
|
||||
numbers?: boolean;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
const FALLBACK_COLOR = '#FF00FF';
|
||||
|
||||
@KonvaNode({centroid: false})
|
||||
export class Code extends Text {
|
||||
@getset([])
|
||||
public selection: GetSet<CodeConfig['selection'], this>;
|
||||
@getset(JS_CODE_THEME)
|
||||
public theme: GetSet<CodeConfig['theme'], this>;
|
||||
@getset(false)
|
||||
public numbers: GetSet<CodeConfig['numbers'], this>;
|
||||
@getset('js')
|
||||
public language: GetSet<CodeConfig['language'], this>;
|
||||
|
||||
private readonly textCanvas: HTMLCanvasElement;
|
||||
private readonly textCtx: CanvasRenderingContext2D;
|
||||
@@ -50,27 +61,72 @@ export class Code extends Text {
|
||||
context.translate(padding.left, padding.right);
|
||||
this.drawSelection(context._context);
|
||||
this.drawText(context._context);
|
||||
if (this.numbers()) {
|
||||
this.drawLineNumbers(context._context);
|
||||
}
|
||||
}
|
||||
|
||||
private drawSelection(context: CanvasRenderingContext2D) {
|
||||
const lines = this.text().split('\n');
|
||||
const letterWidth = this.measureSize(' ').width;
|
||||
const lineHeight = this.fontSize() * this.lineHeight();
|
||||
const selection = [...this.selection()];
|
||||
const outline = this.outline;
|
||||
public setText(text: any): this {
|
||||
super.setText(text);
|
||||
this.markDirty();
|
||||
|
||||
context.beginPath();
|
||||
this._clearCache(this.getLines);
|
||||
this._clearCache(this.getTokens);
|
||||
this._clearCache(this.getNormalizedSelection);
|
||||
return this;
|
||||
}
|
||||
|
||||
public setLanguage(langauge: string): this {
|
||||
this.attrs.language = langauge;
|
||||
this._clearCache(this.getTokens);
|
||||
return this;
|
||||
}
|
||||
|
||||
public setSelection(value: CodeRange[]): this {
|
||||
this.attrs.selection = value;
|
||||
this._clearCache(this.getNormalizedSelection);
|
||||
return this;
|
||||
}
|
||||
|
||||
@cached('Code.lines')
|
||||
private getLines(): string[] {
|
||||
return this.text().split('\n');
|
||||
}
|
||||
|
||||
@cached('Code.tokens')
|
||||
private getTokens(): (PrismJS.Token | string)[] {
|
||||
const language = this.language();
|
||||
if (language in PrismJS.languages) {
|
||||
return PrismJS.tokenize(this.text(), PrismJS.languages[language]);
|
||||
} else {
|
||||
console.warn(
|
||||
`Missing language: ${language}.`,
|
||||
`Make sure that 'prismjs/components/prism-${language}' has been imported.`,
|
||||
);
|
||||
return PrismJS.tokenize(this.text(), PrismJS.languages.plain);
|
||||
}
|
||||
}
|
||||
|
||||
@cached('Code.selection')
|
||||
private getNormalizedSelection(): CodeRange[] {
|
||||
const lines = this.getLines();
|
||||
const normalized: CodeRange[] = [];
|
||||
const selection = [...this.selection()];
|
||||
for (const range of selection) {
|
||||
let [[startLine, startColumn], [endLine, endColumn]] = range;
|
||||
if (startLine > endLine) {
|
||||
[startLine, endLine] = [endLine, startLine];
|
||||
}
|
||||
if (startLine === endLine && startColumn > endColumn) {
|
||||
[startColumn, endColumn] = [endColumn, startColumn];
|
||||
}
|
||||
|
||||
if (startLine >= lines.length) {
|
||||
startLine = lines.length - 1;
|
||||
}
|
||||
|
||||
if (endLine >= lines.length) {
|
||||
endLine = lines.length - 1;
|
||||
}
|
||||
|
||||
if (endColumn >= lines[endLine].length) {
|
||||
endColumn = Math.max(lines[endLine].length, 1);
|
||||
}
|
||||
@@ -82,7 +138,7 @@ export class Code extends Text {
|
||||
: Math.max(1, lines[startLine + 1].length);
|
||||
|
||||
if (nextLineOffset <= startColumn) {
|
||||
selection.push([
|
||||
normalized.push([
|
||||
[startLine + 1, 0],
|
||||
[endLine, endColumn],
|
||||
]);
|
||||
@@ -91,6 +147,25 @@ export class Code extends Text {
|
||||
}
|
||||
}
|
||||
|
||||
normalized.push([
|
||||
[startLine, startColumn],
|
||||
[endLine, endColumn],
|
||||
]);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private drawSelection(context: CanvasRenderingContext2D) {
|
||||
const letterWidth = this.measureSize(' ').width;
|
||||
const lineHeight = this.fontSize() * this.lineHeight();
|
||||
const selection = this.getNormalizedSelection();
|
||||
const outline = this.outline;
|
||||
const lines = this.getLines();
|
||||
|
||||
context.beginPath();
|
||||
for (const range of selection) {
|
||||
let [[startLine, startColumn], [endLine, endColumn]] = range;
|
||||
let offset =
|
||||
startLine === endLine
|
||||
? endColumn * letterWidth
|
||||
@@ -176,13 +251,13 @@ export class Code extends Text {
|
||||
|
||||
context.closePath();
|
||||
context.fillStyle = '#242424';
|
||||
context.globalAlpha = this.getAbsoluteOpacity();
|
||||
context.fill();
|
||||
}
|
||||
|
||||
private drawText(context: CanvasRenderingContext2D) {
|
||||
const letterWidth = this.measureSize(' ').width;
|
||||
const lineHeight = this.fontSize() * this.lineHeight();
|
||||
const tokens = PrismJS.tokenize(this.text(), PrismJS.languages.javascript);
|
||||
const theme = this.theme();
|
||||
|
||||
context.font = this._getContextFont();
|
||||
@@ -190,45 +265,70 @@ export class Code extends Text {
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
for (const token of tokens) {
|
||||
const draw = (token: string | PrismJS.Token, colors: CodeTokens) => {
|
||||
if (typeof token === 'string') {
|
||||
context.fillStyle = theme.punctuation ?? theme.fallback;
|
||||
if (token.includes('\n')) {
|
||||
const words = token.split('\n');
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
if (i > 0) {
|
||||
x = 0;
|
||||
y++;
|
||||
}
|
||||
|
||||
context.globalAlpha = this.getOpacity(x, y);
|
||||
context.fillText(words[i], x * letterWidth, (y + 0.5) * lineHeight);
|
||||
x += words[i].length;
|
||||
context.fillStyle = colors.punctuation ?? FALLBACK_COLOR;
|
||||
const lines = token.split('\n');
|
||||
let isFirst = true;
|
||||
for (const line of lines) {
|
||||
if (!isFirst) {
|
||||
x = 0;
|
||||
y++;
|
||||
}
|
||||
} else {
|
||||
context.globalAlpha = this.getOpacity(x, y);
|
||||
context.fillText(
|
||||
token as string,
|
||||
x * letterWidth,
|
||||
(y + 0.5) * lineHeight,
|
||||
);
|
||||
x += token.length;
|
||||
isFirst = false;
|
||||
|
||||
const trim = line.length - line.trimStart().length;
|
||||
context.globalAlpha = this.getOpacityAtPoint(x + trim, y);
|
||||
context.fillText(line, x * letterWidth, (y + 0.5) * lineHeight);
|
||||
x += line.length;
|
||||
}
|
||||
} else {
|
||||
context.fillStyle = theme[token.type] ?? theme.fallback;
|
||||
context.globalAlpha = this.getOpacity(x, y);
|
||||
} else if (typeof token.content === 'string') {
|
||||
if (!(token.type in colors)) {
|
||||
console.warn(`Unstyled token type:`, token.type);
|
||||
}
|
||||
context.fillStyle = colors[token.type] ?? FALLBACK_COLOR;
|
||||
context.globalAlpha = this.getOpacityAtPoint(x, y);
|
||||
context.fillText(
|
||||
token.content as string,
|
||||
token.content,
|
||||
x * letterWidth,
|
||||
(y + 0.5) * lineHeight,
|
||||
);
|
||||
x += token.length;
|
||||
} else if (Array.isArray(token.content)) {
|
||||
const subTheme = theme[token.type] ?? colors;
|
||||
for (const subToken of token.content) {
|
||||
draw(subToken, subTheme);
|
||||
}
|
||||
} else {
|
||||
const subTheme = theme[token.type] ?? colors;
|
||||
draw(token.content, subTheme);
|
||||
}
|
||||
};
|
||||
|
||||
for (const token of this.getTokens()) {
|
||||
draw(token, theme.default);
|
||||
}
|
||||
}
|
||||
|
||||
private getOpacity(x: number, y: number): number {
|
||||
return this.isSelected(x, y) ? 1 : this.unselectedOpacity;
|
||||
private drawLineNumbers(context: CanvasRenderingContext2D) {
|
||||
const theme = this.theme();
|
||||
const lines = this.getLines();
|
||||
const lineHeight = this.fontSize() * this.lineHeight();
|
||||
|
||||
context.save();
|
||||
context.fillStyle = theme.default.comment ?? FALLBACK_COLOR;
|
||||
context.globalAlpha = this.getAbsoluteOpacity();
|
||||
context.textAlign = 'right';
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
context.fillText(i.toString(), -20, (i + 0.5) * lineHeight);
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
private getOpacityAtPoint(x: number, y: number): number {
|
||||
return this.isSelected(x, y)
|
||||
? this.getAbsoluteOpacity()
|
||||
: this.getAbsoluteOpacity() * this.unselectedOpacity;
|
||||
}
|
||||
|
||||
private isSelected(x: number, y: number): boolean {
|
||||
@@ -256,11 +356,11 @@ export class Code extends Text {
|
||||
return this;
|
||||
}
|
||||
|
||||
public selectWord(line: number, from: number, to?: number): this {
|
||||
public selectWord(line: number, from: number, length?: number): this {
|
||||
this.selection([
|
||||
[
|
||||
[line, from],
|
||||
[line, to ?? Infinity],
|
||||
[line, from + (length ?? Infinity)],
|
||||
],
|
||||
]);
|
||||
return this;
|
||||
@@ -296,21 +396,29 @@ export class Code extends Text {
|
||||
}
|
||||
|
||||
@threadable('animateCode')
|
||||
public *animate() {
|
||||
public *animate(): ThreadGenerator {
|
||||
const hasSelection = this.hasSelection();
|
||||
const currentOpacity = this.unselectedOpacity;
|
||||
|
||||
yield* tween(
|
||||
0.5,
|
||||
value => {
|
||||
this.outline = easeOutExpo(value, -8, 0);
|
||||
this.unselectedOpacity = easeOutExpo(
|
||||
value,
|
||||
currentOpacity,
|
||||
hasSelection ? 0.32 : 1,
|
||||
);
|
||||
},
|
||||
() => this.apply(),
|
||||
);
|
||||
yield* tween(0.5, value => {
|
||||
this.outline = easeOutExpo(value, -8, 0);
|
||||
this.unselectedOpacity = easeOutExpo(
|
||||
value,
|
||||
currentOpacity,
|
||||
hasSelection ? 0.32 : 1,
|
||||
);
|
||||
});
|
||||
this.apply();
|
||||
}
|
||||
|
||||
@threadable()
|
||||
public *animateClearSelection() {
|
||||
const currentOpacity = this.unselectedOpacity;
|
||||
yield* tween(0.5, value => {
|
||||
this.outline = easeInExpo(value, 0, -8);
|
||||
this.unselectedOpacity = easeInExpo(value, currentOpacity, 1);
|
||||
});
|
||||
this.clearSelection();
|
||||
this.apply();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
export type CodeTheme = Record<string, string> & {fallback: string};
|
||||
export type CodeTokens = Record<string, string>;
|
||||
|
||||
export interface JSCodeTheme {
|
||||
export type CodeTheme<T extends CodeTokens = CodeTokens> = {
|
||||
[Key: string]: T;
|
||||
default: T;
|
||||
};
|
||||
|
||||
export type JSCodeTokens = CodeTokens & {
|
||||
boolean?: string;
|
||||
'class-name'?: string;
|
||||
comment?: string;
|
||||
@@ -18,5 +23,4 @@ export interface JSCodeTheme {
|
||||
string?: string;
|
||||
'string-property'?: string;
|
||||
'template-string'?: string;
|
||||
fallback: string;
|
||||
}
|
||||
};
|
||||
|
||||
12
src/decorators/cached.ts
Normal file
12
src/decorators/cached.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type {Node} from 'konva/lib/Node';
|
||||
|
||||
export function cached(key: string): MethodDecorator {
|
||||
return function (target, propertyKey, descriptor: PropertyDescriptor) {
|
||||
const original = descriptor.value;
|
||||
descriptor.value = function (this: Node) {
|
||||
return this._getCache(key, original);
|
||||
};
|
||||
descriptor.value.prototype.cachedKey = key;
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './decorate';
|
||||
export * from './threadable';
|
||||
export * from './getset';
|
||||
export * from './cached';
|
||||
export * from './KonvaNode';
|
||||
5
src/global.d.ts
vendored
5
src/global.d.ts
vendored
@@ -23,6 +23,11 @@ declare module '*.wav' {
|
||||
export = value;
|
||||
}
|
||||
|
||||
declare module '*.csv' {
|
||||
const value: any;
|
||||
export = value;
|
||||
}
|
||||
|
||||
declare module '*.wav?meta' {
|
||||
const value: import('./types/Waveform').Waveform;
|
||||
export = value;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type {Node} from 'konva/lib/Node';
|
||||
import {Factory} from 'konva/lib/Factory';
|
||||
import {ANIMATE} from '../symbols';
|
||||
import {Animator, TweenProvider} from '../tweening';
|
||||
import {
|
||||
Animator,
|
||||
InterpolationFunction,
|
||||
TweenFunction,
|
||||
TweenProvider,
|
||||
} from '../tweening';
|
||||
import {ThreadGenerator} from '../threading';
|
||||
import {Vector2d} from 'konva/lib/types';
|
||||
|
||||
@@ -10,7 +15,13 @@ declare module 'konva/lib/types' {
|
||||
(): Type;
|
||||
(value: Type): This;
|
||||
(value: typeof ANIMATE): Animator<Type, This>;
|
||||
(value: Type, time: number): ThreadGenerator;
|
||||
<Rest extends any[]>(
|
||||
value: Type,
|
||||
time: number,
|
||||
interpolation?: InterpolationFunction,
|
||||
mapper?: TweenFunction<Type, Rest>,
|
||||
...rest: Rest
|
||||
): ThreadGenerator;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,15 +34,20 @@ Factory.addOverloadedGetterSetter = function addOverloadedGetterSetter(
|
||||
const setter = 'set' + capitalizedAttr;
|
||||
const getter = 'get' + capitalizedAttr;
|
||||
|
||||
constructor.prototype[attr] = function (
|
||||
constructor.prototype[attr] = function <Rest extends any[]>(
|
||||
value?: Vector2d | typeof ANIMATE,
|
||||
time?: number,
|
||||
interpolation?: InterpolationFunction,
|
||||
mapper?: TweenFunction<any, Rest>,
|
||||
...rest: Rest
|
||||
) {
|
||||
if (value === ANIMATE) {
|
||||
return new Animator<any, Node>(this, attr, tween);
|
||||
}
|
||||
if (time !== undefined) {
|
||||
return new Animator<any, Node>(this, attr, tween).key(value, time).run();
|
||||
return new Animator<any, Node>(this, attr, tween)
|
||||
.key(value, time, interpolation, mapper, ...rest)
|
||||
.run();
|
||||
}
|
||||
return value === undefined ? this[getter]() : this[setter](value);
|
||||
};
|
||||
|
||||
@@ -69,6 +69,8 @@ declare module 'konva/lib/Node' {
|
||||
wasDirty(): boolean;
|
||||
|
||||
subscribe(event: string, handler: () => void): () => void;
|
||||
|
||||
_clearCache(attr?: string | Function): void;
|
||||
}
|
||||
|
||||
export interface NodeConfig {
|
||||
@@ -249,6 +251,17 @@ Node.prototype.setAttrs = function (this: Node, config: any) {
|
||||
return super_setAttrs.call(this, config);
|
||||
};
|
||||
|
||||
const super__clearCache = Node.prototype._clearCache;
|
||||
Node.prototype._clearCache = function (this: Node, attr?: string | Function) {
|
||||
if (typeof attr === 'function') {
|
||||
if (attr.prototype?.cachedKey) {
|
||||
this._cache.delete(attr.prototype.cachedKey);
|
||||
}
|
||||
} else {
|
||||
super__clearCache.call(this, attr);
|
||||
}
|
||||
};
|
||||
|
||||
Factory.addGetterSetter(Node, 'padd', new Spacing());
|
||||
Factory.addGetterSetter(Node, 'margin', new Spacing());
|
||||
Factory.addGetterSetter(Node, 'origin', Origin.Middle);
|
||||
|
||||
@@ -36,7 +36,7 @@ interface PlayerCommands {
|
||||
|
||||
export interface PlayerRenderEvent {
|
||||
frame: number;
|
||||
data: string;
|
||||
data: Blob;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'player-state';
|
||||
@@ -147,6 +147,7 @@ export class Player {
|
||||
public readonly audio?: {
|
||||
src: string;
|
||||
meta: Waveform;
|
||||
offset: number;
|
||||
},
|
||||
) {
|
||||
this.startTime = performance.now();
|
||||
@@ -271,7 +272,7 @@ export class Player {
|
||||
this.project.draw();
|
||||
await this.renderChanged.dispatchAsync({
|
||||
frame: this.project.frame,
|
||||
data: this.project.master.getNativeCanvasElement().toDataURL(),
|
||||
data: await this.project.getBlob(),
|
||||
});
|
||||
if (state.finished || this.project.frame >= state.endFrame) {
|
||||
this.toggleRendering(false);
|
||||
@@ -299,7 +300,7 @@ export class Player {
|
||||
state.paused ||
|
||||
(state.speed === 1 &&
|
||||
this.hasAudio() &&
|
||||
this.audioElement.currentTime < this.project.time)
|
||||
this.audioTime() < this.project.time)
|
||||
) {
|
||||
this.request();
|
||||
return;
|
||||
@@ -308,11 +309,9 @@ export class Player {
|
||||
else if (
|
||||
this.hasAudio() &&
|
||||
state.speed === 1 &&
|
||||
this.project.time < this.audioElement.currentTime - MAX_AUDIO_DESYNC
|
||||
this.project.time < this.audioTime() - MAX_AUDIO_DESYNC
|
||||
) {
|
||||
const seekFrame = this.project.secondsToFrames(
|
||||
this.audioElement.currentTime,
|
||||
);
|
||||
const seekFrame = this.project.secondsToFrames(this.audioTime());
|
||||
state.finished = await this.project.seek(seekFrame, state.speed);
|
||||
}
|
||||
// Simply move forward one frame
|
||||
@@ -364,10 +363,15 @@ export class Player {
|
||||
|
||||
this.audioElement.currentTime = Math.max(
|
||||
0,
|
||||
this.project.framesToSeconds(this.project.frame + frameOffset),
|
||||
this.project.framesToSeconds(this.project.frame + frameOffset) +
|
||||
this.audio.offset,
|
||||
);
|
||||
}
|
||||
|
||||
private audioTime(): number {
|
||||
return (this.audioElement?.currentTime ?? 0) - this.audio.offset;
|
||||
}
|
||||
|
||||
private request() {
|
||||
this.requestId = requestAnimationFrame(async time => {
|
||||
if (time - this.renderTime >= 990 / this.project.framesPerSeconds) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {JSCodeTheme} from '../components/code';
|
||||
import {CodeTheme, JSCodeTokens} from '../components/code';
|
||||
|
||||
const KEYWORD = '#ff6470';
|
||||
const TEXT = '#ACB3BF';
|
||||
@@ -6,25 +6,30 @@ const FUNCTION = '#ffc66d';
|
||||
const STRING = '#99C47A';
|
||||
const NUMBER = '#68ABDF';
|
||||
const PROPERTY = '#AC7BB5';
|
||||
const COMMENT = '#a9b7c6';
|
||||
const COMMENT = '#5c5e60';
|
||||
|
||||
export const JS_CODE_THEME: JSCodeTheme = {
|
||||
boolean: KEYWORD,
|
||||
constant: KEYWORD,
|
||||
keyword: KEYWORD,
|
||||
'class-name': TEXT,
|
||||
operator: TEXT,
|
||||
punctuation: TEXT,
|
||||
function: FUNCTION,
|
||||
string: STRING,
|
||||
number: NUMBER,
|
||||
'literal-property': PROPERTY,
|
||||
comment: COMMENT,
|
||||
'function-variable': '#ff00ff',
|
||||
hashbang: '#ff00ff',
|
||||
parameter: '#ff00ff',
|
||||
regex: '#ff00ff',
|
||||
'string-property': '#ff00ff',
|
||||
'template-string': '#ff00ff',
|
||||
fallback: '#ff00ff',
|
||||
export const JS_CODE_THEME: CodeTheme<JSCodeTokens> = {
|
||||
default: {
|
||||
boolean: KEYWORD,
|
||||
constant: KEYWORD,
|
||||
keyword: KEYWORD,
|
||||
'class-name': NUMBER,
|
||||
operator: TEXT,
|
||||
punctuation: TEXT,
|
||||
'script-punctuation': TEXT,
|
||||
function: FUNCTION,
|
||||
string: STRING,
|
||||
number: NUMBER,
|
||||
'literal-property': PROPERTY,
|
||||
comment: COMMENT,
|
||||
'function-variable': FUNCTION,
|
||||
},
|
||||
parameter: {
|
||||
'literal-property': TEXT,
|
||||
operator: TEXT,
|
||||
punctuation: NUMBER,
|
||||
},
|
||||
'attr-name': {
|
||||
punctuation: FUNCTION,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -132,8 +132,8 @@ export class Animator<Type, This extends Node> {
|
||||
return this;
|
||||
}
|
||||
|
||||
public waitUntil(event: string): this
|
||||
public waitUntil(time: number): this
|
||||
public waitUntil(event: string): this;
|
||||
public waitUntil(time: number): this;
|
||||
public waitUntil(time: number | string): this {
|
||||
// @ts-ignore
|
||||
this.keys.push(() => waitUntil(time));
|
||||
@@ -172,22 +172,30 @@ export class Animator<Type, This extends Node> {
|
||||
|
||||
private inferProperties() {
|
||||
this.valueFrom ??= this.getValueFrom();
|
||||
this.mapper = Animator.inferMapper(this.valueFrom);
|
||||
}
|
||||
|
||||
if (typeof this.valueFrom === 'string') {
|
||||
if (this.valueFrom.startsWith('#') || this.valueFrom.startsWith('rgb')) {
|
||||
this.mapper = colorTween;
|
||||
public static inferMapper<T>(value: T): TweenFunction<T> {
|
||||
let tween: TweenFunction<any> = map;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (value.startsWith('#') || value.startsWith('rgb')) {
|
||||
tween = colorTween;
|
||||
} else {
|
||||
this.mapper = textTween;
|
||||
tween = textTween;
|
||||
}
|
||||
} else if (this.valueFrom && typeof this.valueFrom === 'object') {
|
||||
if ('x' in this.valueFrom) {
|
||||
if ('width' in this.valueFrom) {
|
||||
this.mapper = rectArcTween;
|
||||
} else if (value && typeof value === 'object') {
|
||||
if ('x' in value) {
|
||||
if ('width' in value) {
|
||||
tween = rectArcTween;
|
||||
} else {
|
||||
tween = vector2dTween;
|
||||
}
|
||||
this.mapper = vector2dTween;
|
||||
} else if ('left' in this.valueFrom) {
|
||||
this.mapper = spacingTween;
|
||||
} else if ('left' in value) {
|
||||
tween = spacingTween;
|
||||
}
|
||||
}
|
||||
|
||||
return tween as TweenFunction<T>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,15 @@ const compiler = webpack({
|
||||
test: /\.mp4/i,
|
||||
type: 'asset',
|
||||
},
|
||||
{
|
||||
test: /\.csv$/,
|
||||
loader: 'csv-loader',
|
||||
options: {
|
||||
dynamicTyping: true,
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.label$/i,
|
||||
use: [
|
||||
@@ -143,6 +152,12 @@ const compiler = webpack({
|
||||
experiments: {
|
||||
topLevelAwait: true,
|
||||
},
|
||||
plugins: [
|
||||
new webpack.ProvidePlugin({
|
||||
// Required to load additional languages for Prism
|
||||
Prism: 'prismjs',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const server = new WebpackDevServer(
|
||||
@@ -160,7 +175,6 @@ const server = new WebpackDevServer(
|
||||
path.join(renderOutput, req.params.name),
|
||||
{encoding: 'base64'},
|
||||
);
|
||||
req.setEncoding('utf8');
|
||||
req.pipe(stream);
|
||||
req.on('end', () => res.end());
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user