├── test
├── public
│ └── .gitkeep
├── motion-canvas.d.ts
├── project.ts
├── scenes
│ ├── example.meta
│ └── example.tsx
└── project.meta
├── .eslintignore
├── .prettierignore
├── src
├── highlightstyle
│ ├── index.ts
│ ├── MaterialPaleNight.ts
│ └── Catppuccin.ts
├── index.ts
├── components
│ ├── Layout.tsx
│ ├── index.ts
│ ├── CodeLineNumbers.tsx
│ ├── Glow.tsx
│ ├── ImgWindow.tsx
│ ├── LinePlot.tsx
│ ├── DistortedCurve.tsx
│ ├── Body.tsx
│ ├── WindowsButton.tsx
│ ├── ErrorBox.tsx
│ ├── Table.tsx
│ ├── ScatterPlot.tsx
│ ├── MotionCanvasLogo.tsx
│ ├── FileTree.tsx
│ ├── Terminal.tsx
│ ├── Window.tsx
│ ├── Plot.tsx
│ └── Scrollable.tsx
├── Slide.ts
├── app
│ ├── app.ts
│ └── rendering.test.ts
├── Util.ts
└── Colors.ts
├── .husky
└── pre-commit
├── .editorconfig
├── .prettierrc
├── .npmignore
├── .gitignore
├── vite.config.ts
├── .github
└── workflows
│ ├── publish.yml
│ ├── check.yml
│ └── release-gif.yml
├── .eslintrc.json
├── tsconfig.json
├── rollup.config.mjs
├── LICENSE
├── package.json
└── README.md
/test/public/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | lib
2 | node_modules
3 | dist
4 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | lib
2 | node_modules
3 | dist
4 |
--------------------------------------------------------------------------------
/test/motion-canvas.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/highlightstyle/index.ts:
--------------------------------------------------------------------------------
1 | export * from './MaterialPaleNight';
2 | export * from './Catppuccin';
3 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npm run lint-staged
5 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Colors';
2 | export * from './Slide';
3 | export * from './Util';
4 | export * from './highlightstyle';
5 | export * from './components';
6 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 |
7 | [*.{js,ts,json}]
8 | indent_style = space
9 | indent_size = 2
10 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "bracketSpacing": false,
3 | "singleQuote": true,
4 | "printWidth": 80,
5 | "trailingComma": "all",
6 | "arrowParens": "avoid",
7 | "proseWrap": "always"
8 | }
9 |
--------------------------------------------------------------------------------
/test/project.ts:
--------------------------------------------------------------------------------
1 | import {makeProject} from '@motion-canvas/core';
2 |
3 | import example from './scenes/example?scene';
4 |
5 | export default makeProject({
6 | scenes: [example],
7 | });
8 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .github
2 | .husky
3 | .editorconfig
4 | .eslintignore
5 | .eslintrc.json
6 | .prettierignore
7 | .prettierrc
8 | tsconfig.json
9 | vite.config.ts
10 | test
11 | *.mp4
12 | output
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated files
2 | node_modules
3 | output
4 | dist
5 | lib
6 |
7 | # Editor directories and files
8 | .vscode/*
9 | !.vscode/extensions.json
10 | .idea
11 | .DS_Store
12 | *.suo
13 | *.ntvs*
14 | *.njsproj
15 | *.sln
16 | *.sw?
17 |
--------------------------------------------------------------------------------
/src/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import {Layout, LayoutProps} from '@motion-canvas/2d';
2 |
3 | export const Row = (props: LayoutProps) => (
4 |
5 | );
6 |
7 | export const Column = (props: LayoutProps) => (
8 |
9 | );
10 |
--------------------------------------------------------------------------------
/test/scenes/example.meta:
--------------------------------------------------------------------------------
1 | {
2 | "version": 0,
3 | "timeEvents": [
4 | {
5 | "name": "spline follow",
6 | "targetTime": 0
7 | },
8 | {
9 | "name": "zoomed out",
10 | "targetTime": 6
11 | },
12 | {
13 | "name": "zoomed in",
14 | "targetTime": 19
15 | }
16 | ],
17 | "seed": 2637074891
18 | }
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig} from 'vite';
2 | import motionCanvas from '@motion-canvas/vite-plugin';
3 | import tsConfigPaths from 'vite-tsconfig-paths';
4 | import ffmpeg from '@motion-canvas/ffmpeg';
5 |
6 | export default defineConfig({
7 | plugins: [
8 | tsConfigPaths(),
9 | motionCanvas({
10 | project: './test/project.ts',
11 | }),
12 | ffmpeg(),
13 | ],
14 | });
15 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Body';
2 | export * from './CodeLineNumbers';
3 | export * from './ErrorBox';
4 | export * from './FileTree';
5 | export * from './Glow';
6 | export * from './ImgWindow';
7 | export * from './Layout';
8 | export * from './LinePlot';
9 | export * from './MotionCanvasLogo';
10 | export * from './Plot';
11 | export * from './ScatterPlot';
12 | export * from './Scrollable';
13 | export * from './Table';
14 | export * from './Terminal';
15 | export * from './Window';
16 | export * from './WindowsButton';
17 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 | on:
3 | push:
4 | branches: main
5 |
6 | jobs:
7 | publish:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 | - uses: actions/setup-node@v3
12 | with:
13 | node-version: '20'
14 | - run: npm ci
15 | - name: Format Check
16 | run: |
17 | npm run lint
18 | - name: Build Succeeds
19 | run: |
20 | npm run build
21 | - uses: JS-DevTools/npm-publish@v3
22 | with:
23 | token: ${{ secrets.NPM_TOKEN }}
24 |
--------------------------------------------------------------------------------
/test/project.meta:
--------------------------------------------------------------------------------
1 | {
2 | "version": 0,
3 | "shared": {
4 | "background": "rgb(0,0,0)",
5 | "range": [
6 | 0,
7 | null
8 | ],
9 | "size": {
10 | "x": 1920,
11 | "y": 1080
12 | },
13 | "audioOffset": 0
14 | },
15 | "preview": {
16 | "fps": 30,
17 | "resolutionScale": 1
18 | },
19 | "rendering": {
20 | "fps": 60,
21 | "resolutionScale": 1,
22 | "colorSpace": "srgb",
23 | "exporter": {
24 | "name": "@motion-canvas/ffmpeg",
25 | "options": {
26 | "fastStart": true,
27 | "includeAudio": true
28 | }
29 | }
30 | }
31 | }
--------------------------------------------------------------------------------
/.github/workflows/check.yml:
--------------------------------------------------------------------------------
1 | name: Checks
2 | on:
3 | pull_request:
4 | types: [opened, reopened, synchronize, edited]
5 | branches-ignore:
6 | - 'nobuild**'
7 | push:
8 | branches:
9 | - main
10 | tags:
11 | - 'v*.*.*'
12 |
13 | jobs:
14 | UnitTests:
15 | name: 'Tests and Builds'
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: actions/checkout@v2
19 | - uses: actions/setup-node@v1
20 | with:
21 | node-version: 18
22 | - name: Install
23 | run: |
24 | npm ci
25 | - name: Build Succeeds
26 | run: |
27 | npm run build
28 | - name: Format Check
29 | run: |
30 | npm run lint
31 |
--------------------------------------------------------------------------------
/src/Slide.ts:
--------------------------------------------------------------------------------
1 | import {
2 | LoopCallback,
3 | PlaybackState,
4 | ThreadGenerator,
5 | beginSlide,
6 | cancel,
7 | loop,
8 | usePlayback,
9 | } from '@motion-canvas/core';
10 |
11 | export function* loopSlide(
12 | name: string,
13 | setup: undefined | (() => ThreadGenerator),
14 | frame: (() => ThreadGenerator) | LoopCallback,
15 | cleanup: undefined | (() => ThreadGenerator),
16 | ): ThreadGenerator {
17 | if (usePlayback().state !== PlaybackState.Presenting) {
18 | if (setup) yield* setup();
19 | // Run the loop once if it's in preview mode
20 | // @ts-ignore
21 | yield* frame(0);
22 | yield* beginSlide(name);
23 | if (cleanup) yield* cleanup();
24 | return;
25 | }
26 | if (setup) yield* setup();
27 | const task = yield loop(Infinity, frame);
28 | yield* beginSlide(name);
29 | if (cleanup) yield* cleanup();
30 | cancel(task);
31 | }
32 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "ignorePatterns": [
7 | "**/*.js",
8 | "**/*.d.ts",
9 | "packages/template",
10 | "packages/create/template-*"
11 | ],
12 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
13 | "parser": "@typescript-eslint/parser",
14 | "parserOptions": {
15 | "ecmaVersion": "latest",
16 | "sourceType": "module"
17 | },
18 | "plugins": ["@typescript-eslint", "eslint-plugin-tsdoc"],
19 | "rules": {
20 | "require-yield": "off",
21 | "@typescript-eslint/explicit-member-accessibility": "error",
22 | "@typescript-eslint/no-explicit-any": "off",
23 | "@typescript-eslint/ban-ts-comment": "off",
24 | "@typescript-eslint/no-non-null-assertion": "off",
25 | "@typescript-eslint/no-namespace": "off",
26 | "tsdoc/syntax": "warn"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@motion-canvas/2d/tsconfig.project.json",
3 | "compilerOptions": {
4 | "experimentalDecorators": true,
5 | "noImplicitAny": true,
6 | "module": "esnext",
7 | "target": "esnext",
8 | "moduleResolution": "node",
9 | "resolveJsonModule": true,
10 | "isolatedModules": true,
11 | "noEmit": true,
12 | "skipLibCheck": true,
13 | "esModuleInterop": false,
14 | "useDefineForClassFields": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "allowSyntheticDefaultImports": true,
17 | "jsx": "react-jsx",
18 | "jsxImportSource": "@motion-canvas/2d/lib",
19 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
20 | "paths": {
21 | "@components/*": ["./src/components/*"],
22 | "@*": ["./src/*"]
23 | },
24 | "outDir": "lib/tsc/"
25 | },
26 | "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"]
27 | }
28 |
--------------------------------------------------------------------------------
/rollup.config.mjs:
--------------------------------------------------------------------------------
1 | import typescript from '@rollup/plugin-typescript';
2 | import dts from 'rollup-plugin-dts';
3 | import externals from 'rollup-plugin-node-externals';
4 | import terser from '@rollup/plugin-terser';
5 |
6 | /** @type {import('rollup').RollupOptions} */
7 | const config = [
8 | {
9 | input: 'src/index.ts',
10 | output: {
11 | file: 'lib/index.min.js',
12 | format: 'es',
13 | },
14 | plugins: [externals(), typescript(), terser()],
15 | external: [/^@motion-canvas\/core/, /^@motion-canvas\/2d/],
16 | },
17 | {
18 | input: 'src/index.ts',
19 | output: {
20 | file: 'lib/index.js',
21 | format: 'es',
22 | },
23 | plugins: [externals(), typescript()],
24 | external: [/^@motion-canvas\/core/, /^@motion-canvas\/2d/],
25 | },
26 | {
27 | input: 'src/index.ts',
28 | output: {
29 | file: 'lib/index.d.ts',
30 | format: 'es',
31 | },
32 | plugins: [dts()],
33 | },
34 | ];
35 |
36 | export default config;
37 |
--------------------------------------------------------------------------------
/src/components/CodeLineNumbers.tsx:
--------------------------------------------------------------------------------
1 | import {Code, Layout, LayoutProps, Txt, TxtProps} from '@motion-canvas/2d';
2 | import {Reference, range} from '@motion-canvas/core';
3 |
4 | export interface CodeLineNumbersProps extends LayoutProps {
5 | code: Reference;
6 | numberProps?: TxtProps;
7 | rootLayoutProps?: LayoutProps;
8 | columnLayoutProps?: LayoutProps;
9 | }
10 |
11 | export class CodeLineNumbers extends Layout {
12 | public constructor(props: CodeLineNumbersProps) {
13 | super({
14 | ...props,
15 | layout: true,
16 | justifyContent: 'space-evenly',
17 | direction: 'column',
18 | });
19 | this.children(() =>
20 | range(props.code().parsed().split('\n').length).map(i => (
21 |
28 | )),
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/Glow.tsx:
--------------------------------------------------------------------------------
1 | import {Layout, LayoutProps, initial, signal} from '@motion-canvas/2d';
2 | import {SignalValue, SimpleSignal, clamp} from '@motion-canvas/core';
3 |
4 | export interface GlowProps extends LayoutProps {
5 | amount?: SignalValue;
6 | copyOpacity?: SignalValue;
7 | }
8 |
9 | export class Glow extends Layout {
10 | @initial(10)
11 | @signal()
12 | public declare readonly amount: SimpleSignal;
13 |
14 | @initial(1)
15 | @signal()
16 | public declare readonly copyOpacity: SimpleSignal;
17 |
18 | public constructor(props: GlowProps) {
19 | super({...props});
20 | }
21 |
22 | protected draw(context: CanvasRenderingContext2D): void {
23 | super.draw(context);
24 |
25 | context.save();
26 | context.globalAlpha = clamp(0, 1, this.copyOpacity());
27 | context.filter = `blur(${this.amount()}px)`;
28 | context.globalCompositeOperation = 'overlay';
29 | this.children().forEach(child => child.render(context));
30 | context.restore();
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/components/ImgWindow.tsx:
--------------------------------------------------------------------------------
1 | import {Rect, Img} from '@motion-canvas/2d';
2 | import {
3 | Reference,
4 | SignalValue,
5 | PossibleVector2,
6 | createComputed,
7 | Vector2,
8 | unwrap,
9 | } from '@motion-canvas/core';
10 | import {Window, WindowProps, WindowStyle} from './Window';
11 | import {Colors} from '../Colors';
12 |
13 | export const ImgWindow = (
14 | props: Omit & {
15 | src: string;
16 | ref: Reference;
17 | padding?: number;
18 | size?: SignalValue>;
19 | },
20 | ) => {
21 | const sz = createComputed(() => new Vector2(unwrap(props.size)));
22 | return (
23 | sz().addY(50)}
31 | >
32 |
33 |
sz().sub(props.padding)}>
34 |
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/src/components/LinePlot.tsx:
--------------------------------------------------------------------------------
1 | import {signal, Line, LineProps} from '@motion-canvas/2d';
2 | import {BBox, SimpleSignal, Vector2, useLogger} from '@motion-canvas/core';
3 | import {Plot} from './Plot';
4 |
5 | export interface LinePlotProps extends LineProps {
6 | data?: [number, number][];
7 | points?: never;
8 | }
9 |
10 | export class LinePlot extends Line {
11 | @signal()
12 | public declare readonly data: SimpleSignal<[number, number][], this>;
13 |
14 | public constructor(props?: LinePlotProps) {
15 | super({
16 | ...props,
17 | points: props.data,
18 | });
19 | }
20 |
21 | public override parsedPoints(): Vector2[] {
22 | const parent = this.parent();
23 | if (!(parent instanceof Plot)) {
24 | useLogger().warn(
25 | 'Using a LinePlot outside of a Plot is the same as a Line',
26 | );
27 | return super.parsedPoints();
28 | }
29 | const data = this.data().map(point => parent.getPointFromPlotSpace(point));
30 | return data;
31 | }
32 |
33 | protected childrenBBox(): BBox {
34 | return BBox.fromPoints(...this.parsedPoints());
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Hunter Henrichsen
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/release-gif.yml:
--------------------------------------------------------------------------------
1 | name: 'Generate Preview GIF'
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'main'
7 |
8 | jobs:
9 | pre-release:
10 | name: 'Generate Preview GIF'
11 | runs-on: 'ubuntu-latest'
12 |
13 | steps:
14 | - uses: actions/checkout@v3
15 | - uses: actions/setup-node@v3
16 | id: setup-node
17 | with:
18 | node-version: 20
19 | cache: 'npm'
20 | - uses: browser-actions/setup-chrome@v1
21 | id: setup-chrome
22 | - uses: FedericoCarboni/setup-ffmpeg@v2
23 | id: setup-ffmpeg
24 | - run: npm ci
25 | - run: npm test
26 | - if: always()
27 | run:
28 | ffmpeg -framerate 15 -i output/project/%06d.png -vf
29 | "fps=15,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse"
30 | output-big.gif
31 | - uses: 'marvinpinto/action-automatic-releases@latest'
32 | with:
33 | repo_token: '${{ secrets.GITHUB_TOKEN }}'
34 | automatic_release_tag: 'latest'
35 | prerelease: true
36 | title: 'Development Build'
37 | files: |
38 | output-big.gif
39 |
--------------------------------------------------------------------------------
/src/components/DistortedCurve.tsx:
--------------------------------------------------------------------------------
1 | import {Curve, Line, LineProps} from '@motion-canvas/2d';
2 | import {
3 | SignalValue,
4 | createComputed,
5 | range,
6 | unwrap,
7 | useRandom,
8 | } from '@motion-canvas/core';
9 |
10 | export function DistortedCurve(props: {
11 | curve: SignalValue;
12 | displacement?: number;
13 | count?: number;
14 | samples?: number;
15 | lineProps: LineProps;
16 | }) {
17 | const c = unwrap(props.curve);
18 | const points = createComputed(() =>
19 | range((props.samples ?? 100) + 1).map(
20 | i => c.getPointAtPercentage(i / props.samples).position,
21 | ),
22 | );
23 | const dpl = props.displacement ?? 10;
24 | const displacementMaps: [number, number][][] = range(props.count ?? 1).map(
25 | () =>
26 | range(1 + (props.samples ?? 100)).map(() => [
27 | useRandom().nextFloat(-dpl, dpl),
28 | useRandom().nextFloat(-dpl, dpl),
29 | ]),
30 | );
31 | return (
32 | <>
33 | {range(props.count ?? 1).map(ci => {
34 | const displaced = createComputed(() =>
35 | points().map((p, pi) => p.add(displacementMaps[ci][pi])),
36 | );
37 | return ;
38 | })}
39 | >
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/app.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 | import puppeteer, {Page} from 'puppeteer';
3 | import {fileURLToPath} from 'url';
4 | import {createServer} from 'vite';
5 |
6 | const Root = fileURLToPath(new URL('.', import.meta.url));
7 |
8 | export interface App {
9 | page: Page;
10 | stop: () => Promise;
11 | }
12 |
13 | export async function start(): Promise {
14 | const [browser, server] = await Promise.all([
15 | puppeteer.launch({
16 | headless: true,
17 | protocolTimeout: 15 * 60 * 1000,
18 | }),
19 | createServer({
20 | root: path.resolve(Root, '../../'),
21 | configFile: path.resolve(Root, '../../vite.config.ts'),
22 | server: {
23 | port: 9000,
24 | },
25 | }),
26 | ]);
27 |
28 | const portPromise = new Promise(resolve => {
29 | server.httpServer.once('listening', async () => {
30 | const port = (server.httpServer.address() as any).port;
31 | resolve(port);
32 | });
33 | });
34 | await server.listen();
35 | const port = await portPromise;
36 | const page = await browser.newPage();
37 | await page.goto(`http://localhost:${port}`, {
38 | waitUntil: 'networkidle0',
39 | });
40 |
41 | return {
42 | page,
43 | async stop() {
44 | await Promise.all([browser.close(), server.close()]);
45 | },
46 | };
47 | }
48 |
--------------------------------------------------------------------------------
/src/highlightstyle/MaterialPaleNight.ts:
--------------------------------------------------------------------------------
1 | /*
2 | Credits for color palette:
3 |
4 | Author: Mattia Astorino (http://github.com/equinusocio)
5 | Website: https://material-theme.site/
6 | */
7 |
8 | import {HighlightStyle} from '@codemirror/language';
9 | import {tags as t} from '@lezer/highlight';
10 |
11 | const stone = '#7d8799', // Brightened compared to original to increase contrast
12 | invalid = '#ffffff';
13 |
14 | /// The highlighting style for code in the Material Palenight theme.
15 | export const MaterialPalenightHighlightStyle = HighlightStyle.define([
16 | {tag: t.keyword, color: '#c792ea'},
17 | {tag: t.operator, color: '#89ddff'},
18 | {tag: t.special(t.variableName), color: '#eeffff'},
19 | {tag: t.typeName, color: '#f07178'},
20 | {tag: t.atom, color: '#f78c6c'},
21 | {tag: t.number, color: '#ff5370'},
22 | {tag: t.definition(t.variableName), color: '#82aaff'},
23 | {tag: t.string, color: '#c3e88d'},
24 | {tag: t.special(t.string), color: '#f07178'},
25 | {tag: t.comment, color: stone},
26 | {tag: t.variableName, color: '#f07178'},
27 | {tag: t.tagName, color: '#ff5370'},
28 | {tag: t.bracket, color: '#a2a1a4'},
29 | {tag: t.meta, color: '#ffcb6b'},
30 | {tag: t.attributeName, color: '#c792ea'},
31 | {tag: t.propertyName, color: '#c792ea'},
32 | {tag: t.className, color: '#decb6b'},
33 | {tag: t.invalid, color: invalid},
34 | ]);
35 |
--------------------------------------------------------------------------------
/src/components/Body.tsx:
--------------------------------------------------------------------------------
1 | import {Layout, LayoutProps, Txt, TxtProps} from '@motion-canvas/2d';
2 | import {Colors} from '../Colors';
3 | import {SignalValue, createComputed, unwrap} from '@motion-canvas/core';
4 |
5 | export const Text = {
6 | fontFamily: 'Montserrat',
7 | fill: Colors.Tailwind.Slate['100'],
8 | fontSize: 36,
9 | };
10 |
11 | export const Title: TxtProps = {
12 | ...Text,
13 | fontSize: 64,
14 | fontWeight: 700,
15 | };
16 |
17 | export const Bold = (props: TxtProps) => ;
18 |
19 | export const Em = (props: TxtProps) => ;
20 |
21 | export const Body = (
22 | props: LayoutProps & {
23 | text: string;
24 | wrapAt?: SignalValue;
25 | txtProps?: TxtProps;
26 | },
27 | ) => {
28 | const wrapAt = props.wrapAt ?? 20;
29 | const lines = createComputed(() =>
30 | props.text.split(' ').reduce((acc, word) => {
31 | if (acc.length === 0) {
32 | return [word];
33 | }
34 | if (acc[acc.length - 1].length + word.length > unwrap(wrapAt)) {
35 | return [...acc, word];
36 | }
37 | return [...acc.slice(0, -1), `${acc[acc.length - 1]} ${word}`];
38 | }, []),
39 | );
40 |
41 | const children = createComputed(() =>
42 | lines().map(line => (
43 |
44 | {line}
45 |
46 | )),
47 | );
48 |
49 | return (
50 |
51 | {children()}
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/src/highlightstyle/Catppuccin.ts:
--------------------------------------------------------------------------------
1 | import {Colors} from '@Colors';
2 | import {HighlightStyle} from '@codemirror/language';
3 | import {tags as t} from '@lezer/highlight';
4 |
5 | export const CatppuccinMochaHighlightStyle: HighlightStyle =
6 | HighlightStyle.define([
7 | {tag: t.keyword, color: Colors.Catppuccin.Mocha.Mauve},
8 | {tag: t.operator, color: Colors.Catppuccin.Mocha.Sky},
9 | {tag: t.special(t.variableName), color: Colors.Catppuccin.Mocha.Red},
10 | {tag: t.typeName, color: Colors.Catppuccin.Mocha.Yellow},
11 | {tag: t.atom, color: Colors.Catppuccin.Mocha.Red},
12 | {tag: t.number, color: Colors.Catppuccin.Mocha.Peach},
13 | {tag: t.definition(t.variableName), color: Colors.Catppuccin.Mocha.Text},
14 | {tag: t.string, color: Colors.Catppuccin.Mocha.Green},
15 | {tag: t.special(t.string), color: Colors.Catppuccin.Mocha.Green},
16 | {tag: t.comment, color: Colors.Catppuccin.Mocha.Overlay2},
17 | {tag: t.variableName, color: Colors.Catppuccin.Mocha.Text},
18 | {tag: t.tagName, color: Colors.Catppuccin.Mocha.Red},
19 | {tag: t.bracket, color: Colors.Catppuccin.Mocha.Overlay2},
20 | {tag: t.meta, color: Colors.Catppuccin.Mocha.Overlay2},
21 | {tag: t.punctuation, color: Colors.Catppuccin.Mocha.Overlay2},
22 | {tag: t.attributeName, color: Colors.Catppuccin.Mocha.Red},
23 | {tag: t.propertyName, color: Colors.Catppuccin.Mocha.Blue},
24 | {tag: t.className, color: Colors.Catppuccin.Mocha.Yellow},
25 | {tag: t.invalid, color: Colors.Catppuccin.Mocha.Red},
26 | {
27 | tag: t.function(t.variableName),
28 | color: Colors.Catppuccin.Mocha.Blue,
29 | },
30 | {
31 | tag: t.function(t.propertyName),
32 | color: Colors.Catppuccin.Mocha.Blue,
33 | },
34 | {
35 | tag: t.definition(t.function(t.variableName)),
36 | color: Colors.Catppuccin.Mocha.Blue,
37 | },
38 | ]);
39 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@hhenrichsen/canvas-commons",
3 | "description": "Common utilities for working with Motion Canvas",
4 | "repository": "https://github.com/hhenrichsen/canvas-commons",
5 | "version": "0.10.2",
6 | "main": "lib/index.js",
7 | "types": "lib/index.d.ts",
8 | "scripts": {
9 | "prepare": "husky install",
10 | "build:dev": "rollup -c rollup.config.mjs",
11 | "watch": "rollup -c rollup.config.mjs -w",
12 | "build": "rollup -c rollup.config.mjs",
13 | "prebuild": "rimraf ./lib",
14 | "lint-staged": "lint-staged",
15 | "lint": "npm run eslint && npm run prettier",
16 | "format": "npm run eslint:fix && npm run prettier:fix",
17 | "eslint": "eslint \"**/*.ts?(x)\"",
18 | "eslint:fix": "eslint --fix \"**/*.ts?(x)\"",
19 | "prettier": "prettier --check .",
20 | "prettier:fix": "prettier --write .",
21 | "serve": "vite",
22 | "test": "vitest"
23 | },
24 | "dependencies": {
25 | "@codemirror/language": "^6.10.1",
26 | "@lezer/highlight": "^1.2.0",
27 | "@motion-canvas/2d": "^3.17.1",
28 | "@motion-canvas/core": "^3.17.0",
29 | "@motion-canvas/ffmpeg": "^3.17.0"
30 | },
31 | "devDependencies": {
32 | "@lezer/javascript": "^1.4.16",
33 | "@motion-canvas/ui": "^3.17.0",
34 | "@motion-canvas/vite-plugin": "^3.17.0",
35 | "@rollup/plugin-terser": "^0.4.4",
36 | "@rollup/plugin-typescript": "^11.1.6",
37 | "@typescript-eslint/eslint-plugin": "^7.2.0",
38 | "@typescript-eslint/parser": "^7.2.0",
39 | "cross-env": "^7.0.3",
40 | "eslint": "^8.57.0",
41 | "eslint-plugin-tsdoc": "^0.2.17",
42 | "husky": "^9.0.11",
43 | "lint-staged": "^15.2.2",
44 | "prettier": "^3.2.5",
45 | "puppeteer": "^22.11.0",
46 | "rimraf": "^5.0.5",
47 | "rollup": "^4.13.0",
48 | "rollup-plugin-dts": "^6.1.0",
49 | "rollup-plugin-node-externals": "^7.0.1",
50 | "tslib": "^2.6.2",
51 | "typescript": "^5.4.2",
52 | "vite": "^4.0.0",
53 | "vite-tsconfig-paths": "^4.3.1",
54 | "vitest": "^1.6.0"
55 | },
56 | "lint-staged": {
57 | "*.{ts,tsx}": "eslint --fix",
58 | "*.{js,jsx,ts,tsx,md,scss,json,mjs}": "prettier --write"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/WindowsButton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Rect,
3 | ComponentChildren,
4 | RectProps,
5 | Line,
6 | PossibleCanvasStyle,
7 | } from '@motion-canvas/2d';
8 | import {Colors} from '../Colors';
9 | import {
10 | SignalValue,
11 | createComputed,
12 | createRef,
13 | createSignal,
14 | unwrap,
15 | } from '@motion-canvas/core';
16 |
17 | export const Windows98Button = ({
18 | lightColor = Colors.Tailwind.Slate['950'],
19 | darkColor = Colors.Tailwind.Slate['400'],
20 | ...props
21 | }: RectProps & {
22 | children?: SignalValue;
23 | borderSize?: SignalValue;
24 | lightColor?: SignalValue;
25 | darkColor?: SignalValue;
26 | }) => {
27 | const borderSize = createComputed(() => unwrap(props.borderSize) ?? 4);
28 | const content = createRef();
29 | const container = createSignal();
30 | const nonChildProps = {...props};
31 | delete nonChildProps.children;
32 | return (
33 |
44 | content()?.size().add(borderSize()) ?? 0}
48 | x={() => borderSize() / 2}
49 | y={() => borderSize() / 2}
50 | />
51 | {
54 | const tr = content()?.topRight();
55 | return tr
56 | ? [tr, tr.addX(borderSize()), tr.add([borderSize(), -borderSize()])]
57 | : [];
58 | }}
59 | fill={darkColor}
60 | >
61 | {
64 | const bl = content()?.bottomLeft();
65 | return bl
66 | ? [bl, bl.add([-borderSize(), borderSize()]), bl.addY(borderSize())]
67 | : [];
68 | }}
69 | fill={darkColor}
70 | >
71 |
80 | {props.children}
81 |
82 |
83 | );
84 | };
85 |
--------------------------------------------------------------------------------
/src/app/rendering.test.ts:
--------------------------------------------------------------------------------
1 | import {afterAll, beforeAll, describe, expect, test} from 'vitest';
2 | import {App, start} from './app';
3 |
4 | describe('Rendering', () => {
5 | let app: App;
6 |
7 | beforeAll(async () => {
8 | app = await start();
9 | });
10 |
11 | afterAll(async () => {
12 | await app.stop();
13 | });
14 |
15 | test(
16 | 'Animation renders correctly',
17 | {
18 | timeout: 15 * 60 * 1000,
19 | },
20 | async () => {
21 | await app.page.evaluateHandle('document.fonts.ready');
22 | await new Promise(resolve => setTimeout(resolve, 5_000));
23 | await app.page.screenshot();
24 | const rendering = await app.page.waitForSelector(
25 | "::-p-xpath(//div[contains(text(), 'Video Settings')])",
26 | );
27 | if (rendering) {
28 | const tab = await app.page.evaluateHandle(
29 | el => el.parentElement,
30 | rendering,
31 | );
32 | await tab.click();
33 | }
34 | await new Promise(resolve => setTimeout(resolve, 1_000));
35 |
36 | const frameRateLabel = await app.page.waitForSelector(
37 | "::-p-xpath(//div[contains(text(), 'Rendering')]/parent::div//label[contains(text(), 'frame rate')]/parent::div//input)",
38 | );
39 | expect(frameRateLabel).toBeDefined();
40 | expect(frameRateLabel).toBeDefined();
41 | await frameRateLabel.click({clickCount: 3});
42 | await frameRateLabel.type('15');
43 |
44 | const scaleLabel = await app.page.waitForSelector(
45 | "::-p-xpath(//div[contains(text(), 'Rendering')]/parent::div//label[contains(text(), 'scale')])",
46 | );
47 | expect(scaleLabel).toBeDefined();
48 | const scale = await app.page.evaluateHandle(
49 | el => el.parentElement.children[1],
50 | scaleLabel,
51 | );
52 |
53 | await app.page.select(
54 | "::-p-xpath(//div[contains(text(), 'Rendering')]/parent::div//label[contains(text(), 'exporter')]/parent::div//select)",
55 | 'Image sequence',
56 | );
57 |
58 | await scale.select('1');
59 |
60 | const render = await app.page.waitForSelector('#render');
61 | await render.click();
62 | await app.page.waitForSelector('#render[data-rendering="true"]', {
63 | timeout: 2 * 1000,
64 | });
65 | await app.page.waitForSelector('#render:not([data-rendering="true"])', {
66 | timeout: 15 * 60 * 1000,
67 | });
68 |
69 | expect(true).toBe(true);
70 | },
71 | );
72 | });
73 |
--------------------------------------------------------------------------------
/src/Util.ts:
--------------------------------------------------------------------------------
1 | import {Curve, Layout, PossibleCanvasStyle, View2D} from '@motion-canvas/2d';
2 | import {
3 | Reference,
4 | SignalValue,
5 | SimpleSignal,
6 | Vector2,
7 | all,
8 | createSignal,
9 | delay,
10 | unwrap,
11 | } from '@motion-canvas/core';
12 |
13 | export function belowScreenPosition(
14 | view: View2D,
15 | node: SignalValue,
16 | ): Vector2 {
17 | const n = unwrap(node);
18 | return new Vector2({
19 | x: n.position().x,
20 | y: view.size().y / 2 + n.height() / 2,
21 | });
22 | }
23 |
24 | export function signalRef(): {ref: Reference; signal: SimpleSignal} {
25 | const s = createSignal();
26 | // @ts-ignore
27 | return {ref: s, signal: s};
28 | }
29 |
30 | export function signum(value: number): number {
31 | return value > 0 ? 1 : value < 0 ? -1 : 0;
32 | }
33 |
34 | export function* drawIn(
35 | nodeOrRef: SignalValue,
36 | stroke: PossibleCanvasStyle,
37 | fill: PossibleCanvasStyle,
38 | duration: number,
39 | restoreStroke: boolean = false,
40 | defaultStrokeWidth: number = 4,
41 | ) {
42 | const node = unwrap(nodeOrRef);
43 | const prevStroke = node.stroke();
44 | const oldStrokeWidth = node.lineWidth();
45 | const strokeWidth =
46 | node.lineWidth() > 0 ? node.lineWidth() : defaultStrokeWidth;
47 | node.end(0);
48 | node.lineWidth(strokeWidth);
49 | node.stroke(stroke);
50 | yield* node.end(1, duration * 0.7);
51 | yield* node.fill(fill, duration * 0.3);
52 | if (restoreStroke) {
53 | yield delay(
54 | duration * 0.1,
55 | all(
56 | node.lineWidth(oldStrokeWidth, duration * 0.7),
57 | node.stroke(prevStroke, duration * 0.7),
58 | ),
59 | );
60 | }
61 | }
62 |
63 | function getLinkPoints(node: Layout) {
64 | return [node.left(), node.right(), node.top(), node.bottom()];
65 | }
66 |
67 | export function getClosestLinkPoints(
68 | a: SignalValue,
69 | b: SignalValue,
70 | ): [Vector2, Vector2] {
71 | const aPoints = getLinkPoints(unwrap(a));
72 | const bPoints = getLinkPoints(unwrap(b));
73 |
74 | const aClosest = aPoints.map(aPoint => {
75 | return bPoints.map(bPoint => {
76 | return {
77 | aPoint,
78 | bPoint,
79 | distanceSq:
80 | Math.pow(aPoint.x - bPoint.x, 2) + Math.pow(aPoint.y - bPoint.y, 2),
81 | };
82 | });
83 | });
84 |
85 | const min = aClosest.reduce(
86 | (a, b) => {
87 | const bMin = b.reduce((a, b) => {
88 | return a.distanceSq < b.distanceSq ? a : b;
89 | });
90 | return a.distanceSq < bMin.distanceSq ? a : bMin;
91 | },
92 | {aPoint: {x: 0, y: 0}, bPoint: {x: 0, y: 0}, distanceSq: Infinity},
93 | );
94 | delete min.distanceSq;
95 | return [new Vector2(min.aPoint), new Vector2(min.bPoint)];
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/ErrorBox.tsx:
--------------------------------------------------------------------------------
1 | import {Layout, Txt, View2D} from '@motion-canvas/2d';
2 | import {Windows98Button} from './WindowsButton';
3 | import {Window, WindowProps, WindowStyle} from './Window';
4 | import {
5 | PossibleVector2,
6 | Reference,
7 | SignalValue,
8 | Vector2,
9 | all,
10 | beginSlide,
11 | chain,
12 | createComputed,
13 | createRef,
14 | sequence,
15 | unwrap,
16 | useRandom,
17 | } from '@motion-canvas/core';
18 | import {Colors} from '../Colors';
19 | import {Body} from './Body';
20 | import {belowScreenPosition} from '../Util';
21 |
22 | export const ErrorBox = (
23 | props: Omit & {
24 | error: string;
25 | size?: SignalValue>;
26 | wrapAt?: SignalValue;
27 | },
28 | ) => {
29 | const sz = createComputed(
30 | () => new Vector2(unwrap(props.size ?? [500, 300])),
31 | );
32 | return (
33 | sz().addY(50)}
39 | >
40 | <>
41 |
42 |
49 |
50 |
51 | OK
52 |
53 |
54 |
55 | >
56 |
57 | );
58 | };
59 |
60 | export function* errorBoxes(messages: string[], view: View2D, prefix: string) {
61 | const refs = messages.map(message => [message, createRef()]) as [
62 | string,
63 | Reference,
64 | ][];
65 | const random = useRandom();
66 | yield* chain(
67 | ...refs.map(([message, ref], i) => {
68 | view.add(
69 | ,
79 | );
80 | const p = ref().position();
81 | ref().position(belowScreenPosition(view, ref));
82 | return all(
83 | ref().position(p, 1),
84 | ref().scale(1, 1),
85 | beginSlide(`${prefix}-${i}`),
86 | );
87 | }),
88 | );
89 | return {
90 | refs,
91 | closeAll: function* () {
92 | yield* sequence(
93 | 0.2,
94 | ...refs.reverse().map(([, ref]) => ref().close(view, 1)),
95 | );
96 | },
97 | };
98 | }
99 |
--------------------------------------------------------------------------------
/src/components/Table.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Layout,
3 | LayoutProps,
4 | computed,
5 | Node,
6 | RectProps,
7 | Rect,
8 | PossibleCanvasStyle,
9 | CanvasStyleSignal,
10 | canvasStyleSignal,
11 | initial,
12 | signal,
13 | } from '@motion-canvas/2d';
14 | import {
15 | SignalValue,
16 | SimpleSignal,
17 | createEffect,
18 | useLogger,
19 | } from '@motion-canvas/core';
20 |
21 | export interface TableProps extends LayoutProps {
22 | stroke?: SignalValue;
23 | lineWidth?: SignalValue;
24 | }
25 |
26 | export class Table extends Layout {
27 | @initial('white')
28 | @canvasStyleSignal()
29 | public declare readonly stroke: CanvasStyleSignal;
30 |
31 | @initial(1)
32 | @signal()
33 | public declare readonly lineWidth: SimpleSignal;
34 |
35 | public constructor(props: TableProps) {
36 | super({...props, layout: true, direction: 'column'});
37 |
38 | createEffect(() => {
39 | this.rowChildren().forEach(row => {
40 | row.dataChildren().forEach((data, idx) => {
41 | data.width(this.columnSizes()[idx] ?? 0);
42 | data.stroke(this.stroke());
43 | data.lineWidth(this.lineWidth());
44 | });
45 | });
46 | });
47 | }
48 |
49 | @computed()
50 | public rowChildren() {
51 | return this.children().filter(
52 | (child): child is TableRow => child instanceof TableRow,
53 | );
54 | }
55 |
56 | @computed()
57 | public columnSizes() {
58 | return this.children()
59 | .filter((child): child is TableRow => child instanceof TableRow)
60 | .reduce((sizes: number[], row: TableRow) => {
61 | row
62 | .children()
63 | .filter((child): child is TableData => child instanceof TableData)
64 | .forEach((data, i) => {
65 | if (sizes[i] === undefined) {
66 | sizes[i] = 0;
67 | }
68 | sizes[i] = Math.max(data.size().x, sizes[i]);
69 | });
70 | return sizes;
71 | }, []);
72 | }
73 | }
74 |
75 | export class TableRow extends Layout {
76 | public constructor(props: LayoutProps) {
77 | super({...props, layout: true, direction: 'row'});
78 |
79 | this.children().forEach(child => {
80 | child.parent(this);
81 | });
82 |
83 | if (this.children().some(child => !(child instanceof TableData))) {
84 | useLogger().warn(
85 | 'Table rows must only contain TableData; other nodes are undefined behavior',
86 | );
87 | }
88 | }
89 |
90 | @computed()
91 | public dataChildren() {
92 | return this.children().filter(
93 | (child): child is TableData => child instanceof TableData,
94 | );
95 | }
96 | }
97 |
98 | export interface TableDataProps extends RectProps {
99 | children?: Node[] | Node;
100 | }
101 |
102 | export class TableData extends Rect {
103 | public constructor(props: TableDataProps) {
104 | super({
105 | padding: 8,
106 | lineWidth: 2,
107 | ...props,
108 | });
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/components/ScatterPlot.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | initial,
3 | signal,
4 | resolveCanvasStyle,
5 | canvasStyleSignal,
6 | CanvasStyleSignal,
7 | PossibleCanvasStyle,
8 | computed,
9 | Layout,
10 | LayoutProps,
11 | parser,
12 | } from '@motion-canvas/2d';
13 | import {
14 | PossibleVector2,
15 | SignalValue,
16 | SimpleSignal,
17 | clamp,
18 | useLogger,
19 | } from '@motion-canvas/core';
20 | import {Plot} from './Plot';
21 |
22 | export interface ScatterPlotProps extends LayoutProps {
23 | pointRadius?: number;
24 | pointColor?: PossibleCanvasStyle;
25 | data?: SignalValue;
26 | start?: SignalValue;
27 | end?: SignalValue;
28 | }
29 |
30 | export class ScatterPlot extends Layout {
31 | @initial(5)
32 | @signal()
33 | public declare readonly pointRadius: SimpleSignal;
34 |
35 | @initial('white')
36 | @canvasStyleSignal()
37 | public declare readonly pointColor: CanvasStyleSignal;
38 |
39 | @signal()
40 | public declare readonly data: SimpleSignal<[number, number][], this>;
41 |
42 | @initial(0)
43 | @parser((value: number) => clamp(0, 1, value))
44 | @signal()
45 | public declare readonly start: SimpleSignal;
46 |
47 | @initial(1)
48 | @parser((value: number) => clamp(0, 1, value))
49 | @signal()
50 | public declare readonly end: SimpleSignal;
51 |
52 | @computed()
53 | private firstIndex() {
54 | return Math.ceil(this.data().length * this.start() + 1);
55 | }
56 |
57 | @computed()
58 | private firstPointProgress() {
59 | return this.firstIndex() - this.start() * this.data().length;
60 | }
61 |
62 | @computed()
63 | private lastIndex() {
64 | return Math.floor(this.data().length * this.end() - 1);
65 | }
66 |
67 | @computed()
68 | private pointProgress() {
69 | return this.end() * this.data().length - this.lastIndex();
70 | }
71 |
72 | public constructor(props?: ScatterPlotProps) {
73 | super({
74 | ...props,
75 | });
76 | }
77 |
78 | protected draw(context: CanvasRenderingContext2D): void {
79 | context.save();
80 | context.fillStyle = resolveCanvasStyle(this.pointColor(), context);
81 |
82 | const parent = this.parent();
83 | if (!(parent instanceof Plot)) {
84 | useLogger().warn('Using a ScatterPlot outside of a Plot does nothing');
85 | return;
86 | }
87 |
88 | if (this.firstIndex() < this.lastIndex()) {
89 | const firstPoint = this.data()[this.firstIndex() - 1];
90 |
91 | const coord = parent.getPointFromPlotSpace(firstPoint);
92 |
93 | context.beginPath();
94 | context.arc(
95 | coord.x,
96 | coord.y,
97 | this.pointRadius() * this.firstPointProgress(),
98 | 0,
99 | Math.PI * 2,
100 | );
101 | context.fill();
102 | }
103 |
104 | const data = this.data();
105 | data.slice(this.firstIndex(), this.lastIndex()).forEach(point => {
106 | const coord = parent.getPointFromPlotSpace(point);
107 |
108 | context.beginPath();
109 | context.arc(coord.x, coord.y, this.pointRadius(), 0, Math.PI * 2);
110 | context.fill();
111 | });
112 |
113 | if (this.lastIndex() > this.firstIndex()) {
114 | const lastPoint = data[this.lastIndex()];
115 |
116 | const lastCoord = parent.getPointFromPlotSpace(lastPoint);
117 |
118 | context.beginPath();
119 | context.arc(
120 | lastCoord.x,
121 | lastCoord.y,
122 | this.pointRadius() * this.pointProgress(),
123 | 0,
124 | Math.PI * 2,
125 | );
126 | context.fill();
127 | }
128 |
129 | context.restore();
130 | }
131 | }
132 |
--------------------------------------------------------------------------------
/src/components/MotionCanvasLogo.tsx:
--------------------------------------------------------------------------------
1 | import {Layout, LayoutProps, Node, Rect} from '@motion-canvas/2d';
2 | import {
3 | Reference,
4 | range,
5 | all,
6 | linear,
7 | loop,
8 | createRef,
9 | unwrap,
10 | Vector2,
11 | chain,
12 | } from '@motion-canvas/core';
13 |
14 | const YELLOW = '#FFC66D';
15 | const RED = '#FF6470';
16 | const GREEN = '#99C47A';
17 | const BLUE = '#68ABDF';
18 |
19 | const Trail = (props: LayoutProps) => (
20 |
21 | );
22 |
23 | export class MotionCanvasLogo extends Layout {
24 | private readonly star: Reference;
25 | private readonly trail1: Reference;
26 | private readonly trail2: Reference;
27 | private readonly trail3: Reference;
28 | private readonly dot: Reference;
29 |
30 | public constructor(props: LayoutProps) {
31 | super({
32 | ...props,
33 | size: () => (new Vector2(unwrap(props.scale)).x ?? 1) * 300,
34 | });
35 | this.star = createRef();
36 | this.trail1 = createRef();
37 | this.trail2 = createRef();
38 | this.trail3 = createRef();
39 | this.dot = createRef();
40 |
41 | this.add(
42 |
43 |
44 |
45 |
46 | {range(3).map(() => (
47 |
48 | ))}
49 |
50 |
58 |
59 |
60 |
61 | {range(3).map(() => (
62 |
63 | ))}
64 |
65 |
73 |
74 |
75 |
76 | {range(4).map(i => (
77 |
85 | ))}
86 |
87 |
96 |
97 |
98 | {range(5).map(i => (
99 |
108 | ))}
109 | {range(5).map(i => (
110 |
118 | ))}
119 |
120 |
121 | ,
122 | );
123 | }
124 |
125 | public animate() {
126 | // eslint-disable-next-line @typescript-eslint/no-this-alias -- need this for generator functions to work
127 | const that = this;
128 | return loop(() =>
129 | all(
130 | chain(that.star().rotation(360, 4, linear), that.star().rotation(0, 0)),
131 | loop(4, function* () {
132 | yield* that.trail1().position.y(-150, 1, linear);
133 | that.trail1().position.y(0);
134 | }),
135 | loop(2, function* () {
136 | yield* that.trail2().position.y(-150, 2, linear);
137 | that.trail2().position.y(0);
138 | }),
139 | loop(2, function* () {
140 | yield* all(
141 | that.trail3().position.y(-130, 2, linear),
142 | that.dot().fill(GREEN, 2, linear),
143 | );
144 | that.dot().fill(BLUE);
145 | that.trail3().position.y(0);
146 | }),
147 | ),
148 | );
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/src/components/FileTree.tsx:
--------------------------------------------------------------------------------
1 | import {Colors} from '@Colors';
2 | import {
3 | Icon,
4 | Layout,
5 | Rect,
6 | RectProps,
7 | Txt,
8 | colorSignal,
9 | initial,
10 | signal,
11 | } from '@motion-canvas/2d';
12 | import {
13 | Color,
14 | ColorSignal,
15 | DEFAULT,
16 | PossibleColor,
17 | SignalValue,
18 | SimpleSignal,
19 | makeRef,
20 | } from '@motion-canvas/core';
21 |
22 | export enum FileType {
23 | File = 'File',
24 | Folder = 'Folder',
25 | Asset = 'Asset',
26 | }
27 |
28 | export interface FileStructure {
29 | name: SignalValue;
30 | type: FileType;
31 | id?: string;
32 | children?: FileStructure[];
33 | }
34 |
35 | export interface FileTreeProps extends RectProps {
36 | structure?: FileStructure;
37 | folderColor?: SignalValue;
38 | fileColor?: SignalValue;
39 | assetColor?: SignalValue;
40 | labelColor?: SignalValue;
41 | indentAmount?: SignalValue;
42 | rowSize?: SignalValue;
43 | }
44 |
45 | export class FileTree extends Rect {
46 | public declare readonly structure: FileStructure;
47 |
48 | @initial(Colors.Tailwind.Amber['500'])
49 | @colorSignal()
50 | public declare readonly folderColor: ColorSignal;
51 |
52 | @initial(Colors.Tailwind.Purple['500'])
53 | @colorSignal()
54 | public declare readonly assetColor: ColorSignal;
55 |
56 | @initial(Colors.Tailwind.Slate['500'])
57 | @colorSignal()
58 | public declare readonly fileColor: ColorSignal;
59 |
60 | @initial(Colors.Tailwind.Slate['100'])
61 | @colorSignal()
62 | public declare readonly labelColor: ColorSignal;
63 |
64 | @initial(40)
65 | @signal()
66 | public declare readonly rowSize: SimpleSignal;
67 |
68 | @signal()
69 | public declare readonly indentAmount: SimpleSignal;
70 |
71 | private readonly refs: Record<
72 | string,
73 | {
74 | txt: Txt;
75 | icon: Icon;
76 | container: Rect;
77 | }
78 | > = {};
79 |
80 | public constructor(props: FileTreeProps) {
81 | super({indentAmount: props.rowSize ?? 40, ...props});
82 | this.structure = props.structure || {name: '/', type: FileType.Folder};
83 | this.add(this.createRow(this.structure));
84 | }
85 |
86 | private getIconProps(structure: FileStructure) {
87 | switch (structure.type) {
88 | case FileType.Folder:
89 | return {icon: 'ic:baseline-folder', color: this.folderColor()};
90 | case FileType.File:
91 | return {icon: 'ic:round-insert-drive-file', color: this.fileColor()};
92 | case FileType.Asset:
93 | return {icon: 'ic:baseline-image', color: this.assetColor()};
94 | }
95 | }
96 |
97 | public getRef(id: string) {
98 | return this.refs[id];
99 | }
100 |
101 | private createRow(structure: FileStructure, depth: number = 0) {
102 | if (structure.id) {
103 | this.refs[structure.id] = {
104 | icon: null as Icon,
105 | txt: null as Txt,
106 | container: null as Rect,
107 | };
108 | }
109 | return (
110 |
111 | this.rowSize() * 0.1}
117 | ref={
118 | structure.id
119 | ? makeRef(this.refs[structure.id], 'container')
120 | : undefined
121 | }
122 | >
123 | {depth ? : null}
124 | this.rowSize() * 0.8}
132 | marginRight={() => this.rowSize() / 2}
133 | />
134 | this.rowSize() * 0.6}
136 | fill={this.labelColor}
137 | ref={
138 | structure.id ? makeRef(this.refs[structure.id], 'txt') : undefined
139 | }
140 | text={structure.name}
141 | />
142 |
143 | {structure.children?.map(child => this.createRow(child, depth + 1))}
144 |
145 | );
146 | }
147 |
148 | public *emphasize(id: string, duration: number, modifier = 1.3) {
149 | const dbRefs = this.getRef(id);
150 | yield dbRefs.icon.size(() => this.rowSize() * 0.8 * modifier, duration);
151 | yield dbRefs.txt.fill(this.folderColor, duration);
152 | yield dbRefs.container.fill(
153 | new Color(Colors.Tailwind.Slate['500']).alpha(0.5),
154 | 1,
155 | );
156 | yield* dbRefs.txt.fontSize(() => this.rowSize() * 0.6 * modifier, duration);
157 | }
158 |
159 | public *reset(id: string, duration: number) {
160 | const dbRefs = this.getRef(id);
161 | yield dbRefs.icon.size(() => this.rowSize() * 0.8, duration);
162 | yield dbRefs.txt.fill(this.labelColor, duration);
163 | yield dbRefs.container.fill(DEFAULT, 1);
164 | yield* dbRefs.txt.fontSize(() => this.rowSize() * 0.6, duration);
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/src/components/Terminal.tsx:
--------------------------------------------------------------------------------
1 | import {Colors} from '@Colors';
2 | import {
3 | Layout,
4 | Rect,
5 | Txt,
6 | TxtProps,
7 | computed,
8 | initial,
9 | signal,
10 | Node,
11 | LayoutProps,
12 | } from '@motion-canvas/2d';
13 | import {
14 | SignalValue,
15 | SimpleSignal,
16 | TimingFunction,
17 | createSignal,
18 | linear,
19 | unwrap,
20 | } from '@motion-canvas/core';
21 |
22 | export interface TerminalProps extends LayoutProps {
23 | prefix?: SignalValue;
24 | defaultTxtProps?: TxtProps;
25 | wpm?: SignalValue;
26 | }
27 |
28 | export class Terminal extends Layout {
29 | private internalCanvas: CanvasRenderingContext2D = document
30 | .createElement('canvas')
31 | .getContext('2d');
32 |
33 | @initial('❯ ')
34 | @signal()
35 | public declare readonly prefix: SimpleSignal;
36 |
37 | @initial({
38 | fill: Colors.Catppuccin.Mocha.Text,
39 | fontFamily: 'monospace',
40 | fontSize: 40,
41 | })
42 | @signal()
43 | public declare readonly defaultTxtProps: SimpleSignal;
44 |
45 | @initial(100)
46 | @signal()
47 | public declare readonly wpm: SimpleSignal;
48 |
49 | private lines: SimpleSignal;
50 | private cachedLines: SimpleSignal;
51 |
52 | @computed()
53 | private getLines(): Node[] {
54 | return this.lines()
55 | .slice(this.cachedLines().length)
56 | .map(fragments => {
57 | return (
58 |
59 | {fragments.length ? (
60 | fragments.map(fragment => {
61 | const parentedDefaults = {
62 | ...this.defaultTxtProps(),
63 | ...fragment,
64 | };
65 | this.internalCanvas.font = `${unwrap(parentedDefaults.fontWeight) || 400} ${unwrap(parentedDefaults.fontSize)}px ${unwrap(parentedDefaults.fontFamily)}`;
66 | const spc = this.internalCanvas.measureText(' ');
67 | return unwrap(fragment.text)
68 | .split(/( )/g)
69 | .filter(Boolean)
70 | .map(spaceOrText => {
71 | if (spaceOrText == ' ') {
72 | return (
73 |
80 | );
81 | }
82 | return (
83 |
88 | );
89 | });
90 | })
91 | ) : (
92 |
93 | )}
94 |
95 | );
96 | });
97 | }
98 |
99 | public constructor(props: TerminalProps) {
100 | super({
101 | ...props,
102 | });
103 | this.cachedLines = createSignal([]);
104 | this.lines = createSignal([]);
105 | this.layout(true);
106 | this.direction('column');
107 | this.children(() => [...this.cachedLines(), ...this.getLines()]);
108 | }
109 |
110 | public lineAppear(line: string | TxtProps | TxtProps[]) {
111 | this.cachedLines([...this.cachedLines(), ...this.getLines()]);
112 | this.lines([...this.lines(), !line ? [] : this.makeProps(line)]);
113 | }
114 |
115 | public *typeLine(
116 | line: string | TxtProps,
117 | duration: number,
118 | timingFunction?: TimingFunction,
119 | ) {
120 | this.cachedLines([...this.cachedLines(), ...this.getLines()]);
121 | const l = createSignal('');
122 | const t = typeof line == 'string' ? line : line.text;
123 | const p = this.prefix();
124 | const props: TxtProps[] = [
125 | typeof p == 'string' ? {text: p} : p,
126 | typeof line == 'string'
127 | ? {
128 | text: l,
129 | }
130 | : {
131 | text: l,
132 | ...line,
133 | },
134 | ];
135 | const fixedProps = [
136 | typeof p == 'string' ? {text: p} : p,
137 | {
138 | ...props[0],
139 | text: t,
140 | },
141 | ];
142 | this.lines([...this.lines(), props]);
143 | yield* l(l() + t, duration, timingFunction);
144 | this.lines([...this.lines().slice(0, -1), fixedProps]);
145 | }
146 |
147 | public *typeAfterLine(
148 | line: string | TxtProps,
149 | duration?: number,
150 | timingFunction: TimingFunction = linear,
151 | ) {
152 | this.cachedLines(this.cachedLines().slice(0, -1));
153 | const t = typeof line == 'string' ? line : line.text;
154 | const calcDuration = duration ?? (t.length / (this.wpm() * 5)) * 60;
155 | const l = createSignal('');
156 | const lastLine = this.lines()[this.lines().length - 1];
157 |
158 | const props: TxtProps =
159 | typeof line == 'string'
160 | ? {
161 | text: l,
162 | }
163 | : {
164 | text: l,
165 | ...line,
166 | };
167 | const fixedProps = {
168 | ...props,
169 | text: t,
170 | };
171 | lastLine.push(props);
172 | this.lines([...this.lines().slice(0, -1), lastLine]);
173 | yield* l(t, calcDuration, timingFunction);
174 | lastLine.pop();
175 | lastLine.push(fixedProps);
176 | this.lines([...this.lines().slice(0, -1), lastLine]);
177 | }
178 |
179 | public appearAfterLine(line: string | TxtProps) {
180 | this.cachedLines(this.cachedLines().slice(0, -1));
181 | const lastLine = this.lines()[this.lines().length - 1];
182 |
183 | lastLine.push(...this.makeProps(line));
184 | this.lines([...this.lines().slice(0, -1), lastLine]);
185 | }
186 |
187 | public replaceLine(newLine: string | TxtProps | TxtProps[]) {
188 | this.cachedLines(this.cachedLines().slice(0, -1));
189 | this.lines([...this.lines().slice(0, -1), this.makeProps(newLine)]);
190 | }
191 |
192 | public deleteLine() {
193 | this.cachedLines(this.cachedLines().slice(0, -1));
194 | this.lines([...this.lines().slice(0, -1)]);
195 | }
196 |
197 | private makeProps(line: string | TxtProps | TxtProps[]) {
198 | return Array.isArray(line)
199 | ? line
200 | : [typeof line == 'string' ? {text: line} : line];
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Canvas Commons
2 |
3 | [](https://www.npmjs.com/package/@hhenrichsen/canvas-commons)
4 | [ ](https://github.com/hhenrichsen/canvas-commons)
5 | [](https://ko-fi.com/hhenrichsen)
6 |
7 |
8 | > **Warning**
9 | ⚠️ This library is still under construction. Breaking changes are possible until I release version 1.0.0. Update versions with caution and only after reading the commit log. ⚠️
10 |
11 | If you use this in your videos, I would appreciate credit via a link to this
12 | repo, or a mention by name. I would also love to see them; feel free to show me
13 | on the motion canvas discord (I'm `@Hunter` on there).
14 |
15 | If you want to support the development of this and other libraries, feel free to
16 | donate on [Ko-fi](https://ko-fi.com/hhenrichsen).
17 |
18 | ## Preview
19 |
20 | 
21 |
22 | Code for this GIF can be found
23 | [here](https://github.com/hhenrichsen/canvas-commons/blob/main/test/src/scenes/example.tsx)
24 |
25 | ## Using this library
26 |
27 | ### From git
28 |
29 | 1. Clone this repo.
30 | 1. Run `npm install ` in your motion canvas project
31 |
32 | ### From npm
33 |
34 | 1. Run `npm install @hhenrichsen/canvas-commons`
35 |
36 | ## Components
37 |
38 | ### Scrollable
39 |
40 | The `Scrollable` node is a custom component designed to allow for scrolling
41 | within a container. Its size represents the viewports size, and it can be
42 | scrolled to any position within its content.
43 |
44 | #### Props
45 |
46 | - `activeOpacity` - the opacity of the scrollbars when they are active
47 | - `handleFadeoutDuration` - how long it takes for the scrollbars to fade out
48 | - `handleFadeoutHang` - how long the scrollbars stay visible after the last
49 | scroll event
50 | - `handleInset` - the amount to inset the scrollbar handles
51 | - `handleProps` - the props to pass to the scrollbar handles
52 | - `handleWidth` - the width of the scrollbar handles
53 | - `inactiveOpacity` - the opacity of the scrollbars when they are inactive
54 | - `scrollOffset` - the initial offset to use for the scrollable
55 | - `scrollPadding` - the amount of extra space to add when scrolling to preset
56 | positions
57 | - `zoom` - the zoom level of the scrollable
58 |
59 | #### Example
60 |
61 | ```tsx
62 | import {Scrollable} from '@hhenrichsen/canvas-commons';
63 | import {makeScene2D, Rect} from '@motion-canvas/2d';
64 | import {createRef, waitFor} from '@motion-canvas/core';
65 |
66 | export default makeScene2D(function* (view) {
67 | const scrollable = createRef();
68 | const rect = createRef();
69 | view.add(
70 |
71 |
72 | ,
73 | );
74 |
75 | yield* scrollable().scrollTo([150, 150], 2);
76 | yield* scrollable().scrollToLeft(1);
77 | yield* scrollable().scrollToTop(1);
78 | yield* scrollable().scrollTo(0, 1);
79 | yield* waitFor(1);
80 |
81 | yield rect().fill('seagreen', 1);
82 | yield* rect().size(600, 2);
83 | yield* waitFor(1);
84 |
85 | yield* scrollable().scrollToBottom(1);
86 | yield* scrollable().scrollToRight(1);
87 | yield* scrollable().scrollBy(-100, 1);
88 | yield* waitFor(5);
89 | });
90 | ```
91 |
92 | ### Window
93 |
94 | The `Window` node is custom component designed to look like a window on either a
95 | MacOS system or a Windows 98 system.
96 |
97 | #### Props
98 |
99 | - `bodyColor` - the color of the body
100 | - `headerColor` - the color of the header
101 | - `titleProps` - the props to pass to the title's `` node
102 | - `title` - the title of the window
103 | - `windowStyle` - the style of the window, either `WindowStyle.Windows98` or
104 | `WindowStyle.MacOS`
105 |
106 | #### Example
107 |
108 | ```tsx
109 | import {Window, Scrollable, WindowStyle} from '@hhenrichsen/canvas-commons';
110 | import {makeScene2D, Rect} from '@motion-canvas/2d';
111 | import {createRef, waitFor} from '@motion-canvas/core';
112 |
113 | export default makeScene2D(function* (view) {
114 | const window = createRef();
115 | const rect = createRef();
116 | view.add(
117 | <>
118 |
119 |
120 |
121 | >,
122 | );
123 |
124 | yield* window.open(view, 1);
125 | yield* waitFor(1);
126 | });
127 | ```
128 |
129 | ### FileTree
130 |
131 | The `FileTree` node is a custom component designed to look like a file tree. It
132 | supports highlighting and selection of files and folders.
133 |
134 | #### Props
135 |
136 | - `assetColor` - the color of the asset icon
137 | - `fileColor` - the color of the file icon
138 | - `folderColor` - the color of the folder icon
139 | - `indentAmount` - the amount to indent each level of the tree
140 | - `labelColor` - the color of the label
141 | - `rowSize` - the size of each row in the tree
142 | - `structure` - the structure of the file tree
143 |
144 | #### Example
145 |
146 | ```tsx
147 | import {FileTree, FileType} from '@hhenrichsen/canvas-commons';
148 | import {makeScene2D} from '@motion-canvas/2d';
149 | import {createRef, waitFor} from '@motion-canvas/core';
150 |
151 | export default makeScene2D(function* (view) {
152 | const fileStructure = createRef();
153 | view.add(
154 | <>
155 |
226 | >,
227 | );
228 |
229 | yield* fileStructure().emphasize('db', 1);
230 | });
231 | ```
232 |
233 | ### Functional Components
234 |
235 | I also have a collection of functional components that I use to automate using
236 | some of these components:
237 |
238 | - `ImgWindow` - a window that contains an image
239 | - `Body` - a `Txt` component that wraps text
240 | - `Title` - a `Txt` component that is bold and large
241 | - `Em` - a `Txt` component that is emphasized
242 | - `Bold` - a `Txt` component that is bold
243 | - `ErrorBox` - a Windows 98-style error message
244 | - `Windows98Button` - a button with a bevel, like in Windows 98
245 |
--------------------------------------------------------------------------------
/src/Colors.ts:
--------------------------------------------------------------------------------
1 | export const Colors = {
2 | Tailwind: {
3 | Slate: {
4 | '50': '#f8fafc',
5 | '100': '#f1f5f9',
6 | '200': '#e2e8f0',
7 | '300': '#cbd5e1',
8 | '400': '#94a3b8',
9 | '500': '#64748b',
10 | '600': '#475569',
11 | '700': '#334155',
12 | '800': '#1e293b',
13 | '900': '#0f172a',
14 | '950': '#020617',
15 | },
16 | Gray: {
17 | '50': '#f9fafb',
18 | '100': '#f3f4f6',
19 | '200': '#e5e7eb',
20 | '300': '#d1d5db',
21 | '400': '#9ca3af',
22 | '500': '#6b7280',
23 | '600': '#4b5563',
24 | '700': '#374151',
25 | '800': '#1f2937',
26 | '900': '#111827',
27 | '950': '#030712',
28 | },
29 | Zinc: {
30 | '50': '#fafafa',
31 | '100': '#f4f4f5',
32 | '200': '#e4e4e7',
33 | '300': '#d4d4d8',
34 | '400': '#a1a1aa',
35 | '500': '#71717a',
36 | '600': '#52525b',
37 | '700': '#3f3f46',
38 | '800': '#27272a',
39 | '900': '#18181b',
40 | '950': '#09090b',
41 | },
42 | Neutral: {
43 | '50': '#fafafa',
44 | '100': '#f5f5f5',
45 | '200': '#e5e5e5',
46 | '300': '#d4d4d4',
47 | '400': '#a3a3a3',
48 | '500': '#737373',
49 | '600': '#525252',
50 | '700': '#404040',
51 | '800': '#262626',
52 | '900': '#171717',
53 | '950': '#0a0a0a',
54 | },
55 | Stone: {
56 | '50': '#fafaf9',
57 | '100': '#f5f5f4',
58 | '200': '#e7e5e4',
59 | '300': '#d6d3d1',
60 | '400': '#a8a29e',
61 | '500': '#78716c',
62 | '600': '#57534e',
63 | '700': '#44403c',
64 | '800': '#292524',
65 | '900': '#1c1917',
66 | '950': '#0c0a09',
67 | },
68 | Red: {
69 | '50': '#fef2f2',
70 | '100': '#fee2e2',
71 | '200': '#fecaca',
72 | '300': '#fca5a5',
73 | '400': '#f87171',
74 | '500': '#ef4444',
75 | '600': '#dc2626',
76 | '700': '#b91c1c',
77 | '800': '#991b1b',
78 | '900': '#7f1d1d',
79 | '950': '#450a0a',
80 | },
81 | Orange: {
82 | '50': '#fff7ed',
83 | '100': '#ffedd5',
84 | '200': '#fed7aa',
85 | '300': '#fdba74',
86 | '400': '#fb923c',
87 | '500': '#f97316',
88 | '600': '#ea580c',
89 | '700': '#c2410c',
90 | '800': '#9a3412',
91 | '900': '#7c2d12',
92 | '950': '#431407',
93 | },
94 | Amber: {
95 | '50': '#fffbeb',
96 | '100': '#fef3c7',
97 | '200': '#fde68a',
98 | '300': '#fcd34d',
99 | '400': '#fbbf24',
100 | '500': '#f59e0b',
101 | '600': '#d97706',
102 | '700': '#b45309',
103 | '800': '#92400e',
104 | '900': '#78350f',
105 | '950': '#451a03',
106 | },
107 |
108 | Yellow: {
109 | '50': '#fefce8',
110 | '100': '#fef9c3',
111 | '200': '#fef08a',
112 | '300': '#fde047',
113 | '400': '#facc15',
114 | '500': '#eab308',
115 | '600': '#ca8a04',
116 | '700': '#a16207',
117 | '800': '#854d0e',
118 | '900': '#713f12',
119 | '950': '#422006',
120 | },
121 | Lime: {
122 | '50': '#f7fee7',
123 | '100': '#ecfccb',
124 | '200': '#d9f99d',
125 | '300': '#bef264',
126 | '400': '#a3e635',
127 | '500': '#84cc16',
128 | '600': '#65a30d',
129 | '700': '#4d7c0f',
130 | '800': '#3f6212',
131 | '900': '#365314',
132 | '950': '#1a2e05',
133 | },
134 | Green: {
135 | '50': '#f0fdf4',
136 | '100': '#dcfce7',
137 | '200': '#bbf7d0',
138 | '300': '#86efac',
139 | '400': '#4ade80',
140 | '500': '#22c55e',
141 | '600': '#16a34a',
142 | '700': '#15803d',
143 | '800': '#166534',
144 | '900': '#14532d',
145 | '950': '#052e16',
146 | },
147 | Emerald: {
148 | '50': '#ecfdf5',
149 | '100': '#d1fae5',
150 | '200': '#a7f3d0',
151 | '300': '#6ee7b7',
152 | '400': '#34d399',
153 | '500': '#10b981',
154 | '600': '#059669',
155 | '700': '#047857',
156 | '800': '#065f46',
157 | '900': '#064e3b',
158 | '950': '#022c22',
159 | },
160 | Teal: {
161 | '50': '#f0fdfa',
162 | '100': '#ccfbf1',
163 | '200': '#99f6e4',
164 | '300': '#5eead4',
165 | '400': '#2dd4bf',
166 | '500': '#14b8a6',
167 | '600': '#0d9488',
168 | '700': '#0f766e',
169 | '800': '#115e59',
170 | '900': '#134e4a',
171 | '950': '#042f2e',
172 | },
173 | Cyan: {
174 | '50': '#ecfeff',
175 | '100': '#cffafe',
176 | '200': '#a5f3fc',
177 | '300': '#67e8f9',
178 | '400': '#22d3ee',
179 | '500': '#06b6d4',
180 | '600': '#0891b2',
181 | '700': '#0e7490',
182 | '800': '#155e75',
183 | '900': '#164e63',
184 | '950': '#083344',
185 | },
186 | Sky: {
187 | '50': '#f0f9ff',
188 | '100': '#e0f2fe',
189 | '200': '#bae6fd',
190 | '300': '#7dd3fc',
191 | '400': '#38bdf8',
192 | '500': '#0ea5e9',
193 | '600': '#0284c7',
194 | '700': '#0369a1',
195 | '800': '#075985',
196 | '900': '#0c4a6e',
197 | '950': '#082f49',
198 | },
199 | Blue: {
200 | '50': '#eff6ff',
201 | '100': '#dbeafe',
202 | '200': '#bfdbfe',
203 | '300': '#93c5fd',
204 | '400': '#60a5fa',
205 | '500': '#3b82f6',
206 | '600': '#2563eb',
207 | '700': '#1d4ed8',
208 | '800': '#1e40af',
209 | '900': '#1e3a8a',
210 | '950': '#172554',
211 | },
212 | Indigo: {
213 | '50': '#eef2ff',
214 | '100': '#e0e7ff',
215 | '200': '#c7d2fe',
216 | '300': '#a5b4fc',
217 | '400': '#818cf8',
218 | '500': '#6366f1',
219 | '600': '#4f46e5',
220 | '700': '#4338ca',
221 | '800': '#3730a3',
222 | '900': '#312e81',
223 | '950': '#1e1b4b',
224 | },
225 | Violet: {
226 | '50': '#f5f3ff',
227 | '100': '#ede9fe',
228 | '200': '#ddd6fe',
229 | '300': '#c4b5fd',
230 | '400': '#a78bfa',
231 | '500': '#8b5cf6',
232 | '600': '#7c3aed',
233 | '700': '#6d28d9',
234 | '800': '#5b21b6',
235 | '900': '#4c1d95',
236 | '950': '#2e1065',
237 | },
238 | Purple: {
239 | '50': '#faf5ff',
240 | '100': '#f3e8ff',
241 | '200': '#e9d5ff',
242 | '300': '#d8b4fe',
243 | '400': '#c084fc',
244 | '500': '#a855f7',
245 | '600': '#9333ea',
246 | '700': '#7e22ce',
247 | '800': '#6b21a8',
248 | '900': '#581c87',
249 | '950': '#3b0764',
250 | },
251 | Fuchsia: {
252 | '50': '#fdf4ff',
253 | '100': '#fae8ff',
254 | '200': '#f5d0fe',
255 | '300': '#f0abfc',
256 | '400': '#e879f9',
257 | '500': '#d946ef',
258 | '600': '#c026d3',
259 | '700': '#a21caf',
260 | '800': '#86198f',
261 | '900': '#701a75',
262 | '950': '#4a044e',
263 | },
264 | Pink: {
265 | '50': '#fdf2f8',
266 | '100': '#fce7f3',
267 | '200': '#fbcfe8',
268 | '300': '#f9a8d4',
269 | '400': '#f472b6',
270 | '500': '#ec4899',
271 | '600': '#db2777',
272 | '700': '#be185d',
273 | '800': '#9d174d',
274 | '900': '#831843',
275 | '950': '#500724',
276 | },
277 | Rose: {
278 | '50': '#fff1f2',
279 | '100': '#ffe4e6',
280 | '200': '#fecdd3',
281 | '300': '#fda4af',
282 | '400': '#fb7185',
283 | '500': '#f43f5e',
284 | '600': '#e11d48',
285 | '700': '#be123c',
286 | '800': '#9f1239',
287 | '900': '#881337',
288 | '950': '#4c0519',
289 | },
290 | },
291 | Nord: {
292 | PolarNight: {
293 | '1': '#2e3440',
294 | '2': '#3b4252',
295 | '3': '#434c5e',
296 | '4': '#4c566a',
297 | },
298 | SnowStorm: {
299 | '1': '#d8dee9',
300 | '2': '#e5e9f0',
301 | '3': '#eceff4',
302 | },
303 | Frost: {
304 | '1': '#8fbcbb',
305 | '2': '#88c0d0',
306 | '3': '#81a1c1',
307 | '4': '#5e81ac',
308 | },
309 | Aurora: {
310 | '1': '#bf616a',
311 | '2': '#d08770',
312 | '3': '#ebcb8b',
313 | '4': '#a3be8c',
314 | '5': '#b48ead',
315 | },
316 | },
317 | Catppuccin: {
318 | Mocha: {
319 | Rosewater: '#f5e0dc',
320 | Flamingo: '#f2cdcd',
321 | Pink: '#f5c2e7',
322 | Mauve: '#cba6f7',
323 | Red: '#f38ba8',
324 | Maroon: '#eba0ac',
325 | Peach: '#fab387',
326 | Yellow: '#f9e2af',
327 | Green: '#a6e3a1',
328 | Teal: '#94e2d5',
329 | Sky: '#89dceb',
330 | Sapphire: '#74c7ec',
331 | Blue: '#89b4fa',
332 | Lavender: '#b4befe',
333 | Text: '#cdd6f4',
334 | Subtext1: '#bac2de',
335 | Subtext0: '#a6adc8',
336 | Overlay2: '#9399b2',
337 | Overlay1: '#7f849c',
338 | Overlay0: '#6c7086',
339 | Surface2: '#585b70',
340 | Surface1: '#45475a',
341 | Surface0: '#313244',
342 | Base: '#1e1e2e',
343 | Mantle: '#181825',
344 | Crust: '#11111b',
345 | },
346 | },
347 | };
348 |
--------------------------------------------------------------------------------
/src/components/Window.tsx:
--------------------------------------------------------------------------------
1 | import {Colors} from '@Colors';
2 | import {belowScreenPosition} from '@Util';
3 | import {
4 | Rect,
5 | Circle,
6 | Txt,
7 | TxtProps,
8 | Icon,
9 | Gradient,
10 | initial,
11 | PossibleCanvasStyle,
12 | CanvasStyleSignal,
13 | canvasStyleSignal,
14 | nodeName,
15 | View2D,
16 | signal,
17 | withDefaults,
18 | colorSignal,
19 | IconProps,
20 | LayoutProps,
21 | DesiredLength,
22 | } from '@motion-canvas/2d';
23 | import {
24 | Color,
25 | ColorSignal,
26 | PossibleColor,
27 | PossibleVector2,
28 | Reference,
29 | SerializedVector2,
30 | SignalValue,
31 | SimpleSignal,
32 | Vector2,
33 | } from '@motion-canvas/core';
34 | import {Scrollable} from './Scrollable';
35 | import {Windows98Button} from './WindowsButton';
36 |
37 | export enum WindowStyle {
38 | MacOS,
39 | Windows98,
40 | }
41 |
42 | export interface WindowProps extends LayoutProps {
43 | title?: SignalValue;
44 | icon?: SignalValue;
45 | iconColor?: SignalValue;
46 | titleProps?: TxtProps;
47 | headerColor?: SignalValue;
48 | bodyColor?: SignalValue;
49 | windowStyle?: WindowStyle;
50 | scrollOffset?: SignalValue;
51 | buttonColors?: SignalValue<
52 | [PossibleCanvasStyle, PossibleCanvasStyle, PossibleCanvasStyle]
53 | >;
54 | buttonIconColors?: SignalValue<
55 | [PossibleCanvasStyle, PossibleCanvasStyle, PossibleCanvasStyle]
56 | >;
57 | buttonLightColor?: SignalValue;
58 | buttonDarkColor?: SignalValue;
59 | }
60 |
61 | /**
62 | * Like an Icon, but doesn't explode if the icon is null or empty.
63 | */
64 | class ShortCircuitIcon extends Icon {
65 | public constructor(props: IconProps) {
66 | super(props);
67 | }
68 |
69 | protected desiredSize(): SerializedVector2 {
70 | if (this.icon()) {
71 | return super.desiredSize();
72 | }
73 | return new Vector2(0, 0);
74 | }
75 |
76 | protected getSrc(): string {
77 | if (this.icon()) {
78 | return super.getSrc();
79 | }
80 | return null;
81 | }
82 | }
83 |
84 | @nodeName('Window')
85 | export class Window extends Rect {
86 | @signal()
87 | public declare readonly title: SimpleSignal;
88 |
89 | @signal()
90 | public declare readonly icon: SimpleSignal;
91 |
92 | @initial(Colors.Tailwind.Slate['50'])
93 | @colorSignal()
94 | public declare readonly iconColor: ColorSignal;
95 |
96 | @signal()
97 | public declare readonly titleProps: SimpleSignal;
98 |
99 | @initial(Colors.Tailwind.Slate['700'])
100 | @canvasStyleSignal()
101 | public declare readonly headerColor: CanvasStyleSignal;
102 |
103 | @initial(Colors.Tailwind.Slate['800'])
104 | @canvasStyleSignal()
105 | public declare readonly bodyColor: CanvasStyleSignal;
106 |
107 | @signal()
108 | public declare readonly buttonColors: SimpleSignal<
109 | [PossibleCanvasStyle, PossibleCanvasStyle, PossibleCanvasStyle],
110 | this
111 | >;
112 |
113 | @initial(['black', 'black', 'black'])
114 | @signal()
115 | public declare readonly buttonIconColors: SimpleSignal<
116 | [PossibleColor, PossibleColor, PossibleColor],
117 | this
118 | >;
119 |
120 | public declare readonly windowStyle: WindowStyle;
121 |
122 | @initial('white')
123 | @canvasStyleSignal()
124 | public declare readonly buttonLightColor: CanvasStyleSignal;
125 |
126 | @initial(Colors.Tailwind.Slate['950'])
127 | @canvasStyleSignal()
128 | public declare readonly buttonDarkColor: CanvasStyleSignal;
129 |
130 | public readonly scrollable: Reference;
131 |
132 | public constructor(props: WindowProps) {
133 | super({
134 | size: 400,
135 | stroke: 'white',
136 | ...props,
137 | });
138 | this.windowStyle = props.windowStyle ?? WindowStyle.MacOS;
139 | if (!props.buttonColors) {
140 | this.buttonColors(
141 | this.windowStyle == WindowStyle.MacOS
142 | ? [
143 | Colors.Tailwind.Red['500'],
144 | Colors.Tailwind.Yellow['500'],
145 | Colors.Tailwind.Green['500'],
146 | ]
147 | : [
148 | Colors.Tailwind.Slate['400'],
149 | Colors.Tailwind.Slate['400'],
150 | Colors.Tailwind.Slate['400'],
151 | ],
152 | );
153 | }
154 | if (!props.headerColor && this.windowStyle == WindowStyle.Windows98) {
155 | this.headerColor(
156 | () =>
157 | new Gradient({
158 | stops: [
159 | {color: '#111179', offset: 0},
160 | {color: '#0481CF', offset: 1},
161 | ],
162 | type: 'linear',
163 | from: {x: 0, y: 0},
164 | to: {
165 | x: this.size.x(),
166 | y: 0,
167 | },
168 | }),
169 | );
170 | }
171 |
172 | this.add(
173 |
187 | {props.children}
188 |
202 |
203 |
208 |
214 |
215 | {this.windowStyle == WindowStyle.MacOS ? (
216 |
217 | this.buttonColors()[0]}>
218 | this.buttonColors()[1]}>
219 | this.buttonColors()[2]}>
220 |
221 | ) : null}
222 | {this.windowStyle == WindowStyle.Windows98 ? (
223 |
224 | this.buttonColors()[0]}
229 | >
230 | this.buttonIconColors()[0]}
233 | icon={'material-symbols:minimize'}
234 | />
235 |
236 | this.buttonColors()[1]}
242 | >
243 | this.buttonIconColors()[1]}
246 | icon={'material-symbols:chrome-maximize-outline-sharp'}
247 | />
248 |
249 | this.buttonColors()[2]}
254 | >
255 | this.buttonIconColors()[2]}
258 | icon={'material-symbols:close'}
259 | />
260 |
261 |
262 | ) : null}
263 |
264 | ,
265 | );
266 | }
267 |
268 | public *close(view: View2D, duration: number) {
269 | yield this.scale(0, duration);
270 | yield* this.position(belowScreenPosition(view, this), duration);
271 | }
272 |
273 | public *open(view: View2D, duration: number) {
274 | const oldPosition = this.position();
275 | const oldScale = this.scale();
276 | this.position(belowScreenPosition(view, this));
277 | this.scale(0);
278 | yield this.scale(oldScale, duration);
279 | yield* this.position(oldPosition, duration);
280 | }
281 | }
282 |
283 | export const Windows98Window = withDefaults(Window, {
284 | windowStyle: WindowStyle.Windows98,
285 | });
286 |
287 | export const MacOSWindow = withDefaults(Window, {
288 | windowStyle: WindowStyle.MacOS,
289 | });
290 |
--------------------------------------------------------------------------------
/src/components/Plot.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | CanvasStyleSignal,
3 | Layout,
4 | LayoutProps,
5 | canvasStyleSignal,
6 | computed,
7 | drawRect,
8 | initial,
9 | resolveCanvasStyle,
10 | signal,
11 | vector2Signal,
12 | } from '@motion-canvas/2d';
13 | import {
14 | BBox,
15 | PossibleColor,
16 | PossibleVector2,
17 | SignalValue,
18 | SimpleSignal,
19 | Vector2,
20 | Vector2Signal,
21 | range,
22 | } from '@motion-canvas/core';
23 |
24 | export interface PlotProps extends LayoutProps {
25 | minX?: SignalValue;
26 | minY?: SignalValue;
27 | min?: SignalValue;
28 |
29 | maxX?: SignalValue;
30 | maxY?: SignalValue;
31 | max?: SignalValue;
32 |
33 | ticksX?: SignalValue;
34 | ticksY?: SignalValue;
35 | ticks?: SignalValue;
36 |
37 | labelSizeX?: SignalValue;
38 | labelSizeY?: SignalValue;
39 | labelSize?: SignalValue;
40 |
41 | labelPaddingX?: SignalValue;
42 | labelPaddingY?: SignalValue;
43 | labelPadding?: SignalValue;
44 |
45 | tickPaddingX?: SignalValue;
46 | tickPaddingY?: SignalValue;
47 | tickPadding?: SignalValue;
48 |
49 | tickLabelSizeX?: SignalValue;
50 | tickLabelSizeY?: SignalValue;
51 | tickLabelSize?: SignalValue;
52 |
53 | tickOverflowX?: SignalValue;
54 | tickOverflowY?: SignalValue;
55 | tickOverflow?: SignalValue;
56 |
57 | gridStrokeWidth?: SignalValue;
58 | axisStrokeWidth?: SignalValue;
59 |
60 | labelX?: SignalValue;
61 | axisColorX?: SignalValue;
62 | axisTextColorX?: SignalValue;
63 |
64 | labelY?: SignalValue;
65 | axisColorY?: SignalValue;
66 | axisTextColorY?: SignalValue;
67 |
68 | labelFormatterX?: (x: number) => string;
69 | labelFormatterY?: (y: number) => string;
70 | }
71 |
72 | export class Plot extends Layout {
73 | @initial(Vector2.zero)
74 | @vector2Signal('min')
75 | public declare readonly min: Vector2Signal;
76 |
77 | @initial(Vector2.one.mul(100))
78 | @vector2Signal('max')
79 | public declare readonly max: Vector2Signal;
80 |
81 | @initial(Vector2.one.mul(10))
82 | @vector2Signal('ticks')
83 | public declare readonly ticks: Vector2Signal;
84 |
85 | @initial(Vector2.one.mul(30))
86 | @vector2Signal('labelSize')
87 | public declare readonly labelSize: Vector2Signal;
88 |
89 | @initial(Vector2.one.mul(5))
90 | @vector2Signal('labelPadding')
91 | public declare readonly labelPadding: Vector2Signal;
92 |
93 | @initial(Vector2.one.mul(10))
94 | @vector2Signal('tickLabelSize')
95 | public declare readonly tickLabelSize: Vector2Signal;
96 |
97 | @initial(Vector2.one.mul(5))
98 | @vector2Signal('tickOverflow')
99 | public declare readonly tickOverflow: Vector2Signal;
100 |
101 | @initial(Vector2.one.mul(6))
102 | @vector2Signal('tickPadding')
103 | public declare readonly tickPadding: Vector2Signal;
104 |
105 | @initial(Vector2.one.mul(1))
106 | @vector2Signal('gridStrokeWidth')
107 | public declare readonly gridStrokeWidth: Vector2Signal;
108 |
109 | @initial(Vector2.one.mul(2))
110 | @vector2Signal('axisStrokeWidth')
111 | public declare readonly axisStrokeWidth: Vector2Signal;
112 |
113 | @initial('white')
114 | @canvasStyleSignal()
115 | public declare readonly axisColorX: CanvasStyleSignal;
116 |
117 | @initial('white')
118 | @canvasStyleSignal()
119 | public declare readonly axisTextColorX: CanvasStyleSignal;
120 |
121 | @initial('')
122 | @signal()
123 | public declare readonly labelX: SimpleSignal;
124 |
125 | @initial('white')
126 | @canvasStyleSignal()
127 | public declare readonly axisColorY: CanvasStyleSignal;
128 |
129 | @initial('white')
130 | @canvasStyleSignal()
131 | public declare readonly axisTextColorY: CanvasStyleSignal;
132 |
133 | @initial('')
134 | @signal()
135 | public declare readonly labelY: SimpleSignal;
136 |
137 | public readonly labelFormatterX: (x: number) => string;
138 | public readonly labelFormatterY: (y: number) => string;
139 |
140 | @computed()
141 | private edgePadding() {
142 | return this.labelSize()
143 | .add(this.labelPadding())
144 | .add(this.tickLabelSize().mul([Math.log10(this.max().y) + 1, 2]))
145 | .add(this.tickOverflow())
146 | .add(this.axisStrokeWidth());
147 | }
148 |
149 | public constructor(props?: PlotProps) {
150 | super(props);
151 | this.labelFormatterX = props.labelFormatterX ?? (x => x.toFixed(0));
152 | this.labelFormatterY = props.labelFormatterY ?? (y => y.toFixed(0));
153 | }
154 |
155 | public cacheBBox(): BBox {
156 | return BBox.fromSizeCentered(this.size().add(this.edgePadding().mul(2)));
157 | }
158 |
159 | protected draw(context: CanvasRenderingContext2D): void {
160 | const halfSize = this.computedSize().mul(-0.5);
161 |
162 | for (let i = 0; i <= this.ticks().floored.x; i++) {
163 | const startPosition = halfSize.add(
164 | this.computedSize().mul([i / this.ticks().x, 1]),
165 | );
166 |
167 | context.beginPath();
168 | context.moveTo(
169 | startPosition.x,
170 | startPosition.y +
171 | this.tickOverflow().x +
172 | this.axisStrokeWidth().x / 2 +
173 | this.axisStrokeWidth().x / 2,
174 | );
175 | context.lineTo(startPosition.x, halfSize.y);
176 | context.strokeStyle = resolveCanvasStyle(this.axisColorX(), context);
177 | context.lineWidth = this.gridStrokeWidth().x;
178 | context.stroke();
179 |
180 | context.fillStyle = resolveCanvasStyle(this.axisTextColorX(), context);
181 | context.font = `${this.tickLabelSize().y}px sans-serif`;
182 | context.textAlign = 'center';
183 | context.textBaseline = 'top';
184 | context.fillText(
185 | `${this.labelFormatterX(this.mapToX(i / this.ticks().x))}`,
186 | startPosition.x,
187 | startPosition.y +
188 | this.axisStrokeWidth().x +
189 | this.tickOverflow().x +
190 | Math.floor(this.tickPadding().x / 2),
191 | );
192 | }
193 |
194 | for (let i = 0; i <= this.ticks().floored.y; i++) {
195 | const startPosition = halfSize.add(
196 | this.computedSize().mul([1, 1 - i / this.ticks().y]),
197 | );
198 |
199 | context.beginPath();
200 | context.moveTo(startPosition.x, startPosition.y);
201 | context.lineTo(halfSize.x - this.tickOverflow().y, startPosition.y);
202 | context.strokeStyle = resolveCanvasStyle(this.axisColorY(), context);
203 | context.lineWidth = this.gridStrokeWidth().y;
204 | context.stroke();
205 |
206 | context.fillStyle = resolveCanvasStyle(this.axisTextColorY(), context);
207 | context.font = `${this.tickLabelSize().y}px ${this.fontFamily()}`;
208 | context.textAlign = 'right';
209 | context.textBaseline = 'middle';
210 | context.fillText(
211 | `${this.labelFormatterY(this.mapToY(i / this.ticks().y))}`,
212 | halfSize.x -
213 | this.axisStrokeWidth().y -
214 | this.tickOverflow().y -
215 | Math.floor(this.tickPadding().y / 2),
216 | startPosition.y,
217 | );
218 | }
219 |
220 | context.beginPath();
221 | const yAxisStartPoint = this.getPointFromPlotSpace([0, this.min().y]);
222 | const yAxisEndPoint = this.getPointFromPlotSpace([0, this.max().y]);
223 | context.moveTo(
224 | yAxisStartPoint.x - this.gridStrokeWidth().y / 2,
225 | yAxisStartPoint.y - this.gridStrokeWidth().y / 2,
226 | );
227 | context.lineTo(
228 | yAxisEndPoint.x - this.gridStrokeWidth().y / 2,
229 | yAxisEndPoint.y + this.gridStrokeWidth().y / 2,
230 | );
231 | context.strokeStyle = resolveCanvasStyle(this.axisColorX(), context);
232 | context.lineWidth = this.axisStrokeWidth().x;
233 | context.stroke();
234 |
235 | context.beginPath();
236 | const xAxisStartPoint = this.getPointFromPlotSpace([this.min().x, 0]);
237 | const xAxisEndPoint = this.getPointFromPlotSpace([this.max().x, 0]);
238 | context.moveTo(
239 | xAxisStartPoint.x - this.gridStrokeWidth().x / 2,
240 | xAxisStartPoint.y + this.gridStrokeWidth().x / 2,
241 | );
242 | context.lineTo(
243 | xAxisEndPoint.x + this.gridStrokeWidth().x / 2,
244 | xAxisEndPoint.y + this.gridStrokeWidth().x / 2,
245 | );
246 | context.strokeStyle = resolveCanvasStyle(this.axisColorY(), context);
247 | context.lineWidth = this.axisStrokeWidth().y;
248 | context.stroke();
249 |
250 | // Draw X axis label
251 | context.fillStyle = resolveCanvasStyle(this.axisTextColorX(), context);
252 | context.font = `${this.labelSize().y}px ${this.fontFamily()}`;
253 | context.textAlign = 'center';
254 | context.textBaseline = 'alphabetic';
255 | context.fillText(
256 | this.labelX(),
257 | 0,
258 | -halfSize.y +
259 | this.axisStrokeWidth().x +
260 | this.tickOverflow().x +
261 | this.tickLabelSize().x +
262 | this.tickPadding().x +
263 | Math.floor(this.labelPadding().x) +
264 | this.labelSize().x,
265 | );
266 |
267 | // Draw rotated Y axis label
268 | context.fillStyle = resolveCanvasStyle(this.axisTextColorY(), context);
269 | context.font = `${this.labelSize().y}px ${this.fontFamily()}`;
270 | context.textAlign = 'center';
271 | context.textBaseline = 'alphabetic';
272 | context.save();
273 | context.translate(
274 | halfSize.x -
275 | this.axisStrokeWidth().y -
276 | this.tickOverflow().y -
277 | this.tickLabelSize().y -
278 | this.tickPadding().y -
279 | Math.floor(this.labelPadding().y / 2) -
280 | this.labelSize().y,
281 | 0,
282 | );
283 | context.rotate(-Math.PI / 2);
284 | context.fillText(this.labelY(), 0, 0);
285 | context.restore();
286 |
287 | if (this.clip()) {
288 | context.clip(this.getPath());
289 | }
290 | this.drawChildren(context);
291 | }
292 |
293 | public getPath(): Path2D {
294 | const path = new Path2D();
295 | const box = BBox.fromSizeCentered(this.size());
296 | drawRect(path, box);
297 |
298 | return path;
299 | }
300 |
301 | public getPointFromPlotSpace(point: PossibleVector2) {
302 | const bottomLeft = this.computedSize().mul([-0.5, 0.5]);
303 |
304 | return this.toRelativeGridSize(point)
305 | .mul([1, -1])
306 | .mul(this.computedSize())
307 | .add(bottomLeft);
308 | }
309 |
310 | private mapToX(value: number) {
311 | return this.min().x + value * (this.max().x - this.min().x);
312 | }
313 |
314 | private mapToY(value: number) {
315 | return this.min().y + value * (this.max().y - this.min().y);
316 | }
317 |
318 | private toRelativeGridSize(p: PossibleVector2) {
319 | return new Vector2(p).sub(this.min()).div(this.max().sub(this.min()));
320 | }
321 |
322 | public makeGraphData(
323 | resolution: number,
324 | f: (x: number) => number,
325 | ): [number, number][] {
326 | return range(this.min().x, this.max().x + resolution, resolution).map(x => [
327 | x,
328 | f(x),
329 | ]);
330 | }
331 | }
332 |
--------------------------------------------------------------------------------
/test/scenes/example.tsx:
--------------------------------------------------------------------------------
1 | import {makeScene2D} from '@motion-canvas/2d/lib/scenes';
2 | import {waitFor, waitUntil} from '@motion-canvas/core/lib/flow';
3 | import {
4 | Circle,
5 | Code,
6 | Gradient,
7 | Knot,
8 | Layout,
9 | LezerHighlighter,
10 | Rect,
11 | Spline,
12 | Txt,
13 | } from '@motion-canvas/2d';
14 | import {createRef, linear, range, useRandom} from '@motion-canvas/core';
15 | import {Scrollable} from '@components/Scrollable';
16 | import {WindowStyle, Window} from '@components/Window';
17 | import {Colors} from '@Colors';
18 | import {DistortedCurve} from '@components/DistortedCurve';
19 | import {drawIn} from '@Util';
20 | import {parser as javascript} from '@lezer/javascript';
21 | import {CatppuccinMochaHighlightStyle} from '@highlightstyle/Catppuccin';
22 | import {CodeLineNumbers} from '@components/CodeLineNumbers';
23 | import {Terminal} from '@components/Terminal';
24 | import {Table, TableData, TableRow} from '@components/Table';
25 | import {Plot, LinePlot, ScatterPlot} from '@index';
26 |
27 | export default makeScene2D(function* (view) {
28 | const code = createRef();
29 | const codeContainer = createRef();
30 | view.add(
31 |
32 |
38 | {
52 | if (count < 10) {
53 | count++;
54 | render();
55 | }
56 | });
57 |
58 | class Cat {
59 | constructor(name) {
60 | this.name = name ?? 'Mochi';
61 | }
62 |
63 | meow() {
64 | console.log(\`Meow! I'm \${this.name}\`);
65 | }
66 | }`}
67 | fontSize={30}
68 | />
69 | ,
70 | );
71 | yield* codeContainer().opacity(1, 1);
72 | yield* waitFor(1);
73 | yield* code().code.append('\n// This is a comment', 1);
74 | yield* waitFor(1);
75 | yield* codeContainer().opacity(0, 1);
76 | code().remove();
77 |
78 | const draw = createRef();
79 | view.add(
80 | ,
87 | );
88 |
89 | yield* drawIn(draw, 'white', 'white', 1, true);
90 |
91 | yield* waitFor(1);
92 | yield* draw().opacity(0, 1);
93 | yield* waitFor(1);
94 |
95 | const scrollable = createRef();
96 | const r = createRef();
97 | const spl = createRef();
98 | const win = createRef();
99 | view.add(
100 |
101 |
102 |
103 |
142 |
148 |
154 |
160 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 | ,
175 | );
176 | yield* win().open(view, 1);
177 |
178 | yield* waitUntil('spline follow');
179 | yield* scrollable().scrollTo(spl().getPointAtPercentage(0).position, 1);
180 | yield* scrollable().followCurve(spl(), 5);
181 |
182 | yield* waitUntil('zoomed out');
183 | yield* waitFor(1);
184 | yield* scrollable().zoom(0.5, 1);
185 | yield* waitFor(1);
186 | yield* scrollable().scrollToRightCenter(1);
187 | yield* waitFor(1);
188 | yield* scrollable().scrollToBottomCenter(1);
189 | yield* waitFor(1);
190 | yield* scrollable().scrollToLeftCenter(1);
191 | yield* waitFor(1);
192 | yield* scrollable().scrollToTopCenter(1);
193 | yield* waitFor(1);
194 | yield* scrollable().scrollToCenter(1);
195 | yield* waitFor(1);
196 |
197 | yield* waitUntil('zoomed in');
198 |
199 | yield* scrollable().zoom(2, 1);
200 | yield* waitFor(1);
201 | yield* scrollable().scrollToRightCenter(1);
202 | yield* waitFor(1);
203 | yield* scrollable().scrollToBottomCenter(1);
204 | yield* waitFor(1);
205 | yield* scrollable().scrollToLeftCenter(1);
206 | yield* waitFor(1);
207 | yield* scrollable().scrollToTopCenter(1);
208 | yield* waitFor(1);
209 | yield* scrollable().scrollToCenter(1);
210 | yield* waitFor(1);
211 |
212 | yield* win().close(view, 1);
213 |
214 | const terminal = createRef();
215 | const terminalWindow = createRef();
216 | yield view.add(
217 |
230 |
235 | ,
236 | ,
237 | );
238 | scrollable().fill(Colors.Catppuccin.Mocha.Mantle);
239 | yield* terminalWindow().open(view, 1);
240 |
241 | yield* terminal().typeLine('npm init @motion-canvas@latest', 2);
242 | yield* waitFor(1);
243 | terminal().lineAppear('');
244 | terminal().lineAppear('Need to install the following packages:');
245 | terminal().lineAppear(' @motion-canvas/create');
246 | terminal().lineAppear('Ok to proceed? (y)');
247 | yield* waitFor(1);
248 | yield* terminal().typeAfterLine(' y', 1);
249 | terminal().lineAppear([
250 | {text: '? Project name '},
251 | {text: '»', fill: Colors.Catppuccin.Mocha.Surface2},
252 | ]);
253 | yield* waitFor(1);
254 | yield* terminal().typeAfterLine(' my-animation');
255 | yield* waitFor(1);
256 | terminal().replaceLine([
257 | {text: '√', fill: Colors.Catppuccin.Mocha.Green},
258 | {text: ' Project name '},
259 | {text: '...', fill: Colors.Catppuccin.Mocha.Surface2},
260 | {text: ' my-animation'},
261 | ]);
262 | terminal().lineAppear([
263 | {text: '? Project path '},
264 | {text: '»', fill: Colors.Catppuccin.Mocha.Surface2},
265 | ]);
266 | yield* terminal().typeAfterLine(' my-animation');
267 | yield* waitFor(1);
268 | terminal().replaceLine([
269 | {text: '√', fill: Colors.Catppuccin.Mocha.Green},
270 | {text: ' Project path '},
271 | {text: '...', fill: Colors.Catppuccin.Mocha.Surface2},
272 | {text: ' my-animation'},
273 | ]);
274 | terminal().lineAppear('? Language');
275 | terminal().appearAfterLine({
276 | text: ' » - Use arrow-keys. Return to submit.',
277 | fill: Colors.Catppuccin.Mocha.Surface2,
278 | });
279 | terminal().lineAppear({
280 | text: '> TypeScript (Recommended)',
281 | fill: Colors.Catppuccin.Mocha.Sky,
282 | });
283 | terminal().lineAppear(' JavaScript');
284 | yield* waitFor(3);
285 |
286 | terminal().deleteLine();
287 | terminal().deleteLine();
288 | terminal().replaceLine([
289 | {text: '√', fill: Colors.Catppuccin.Mocha.Green},
290 | {text: ' Language '},
291 | {text: '...', fill: Colors.Catppuccin.Mocha.Surface2},
292 | {text: 'TypeScript (Recommended)'},
293 | ]);
294 | terminal().lineAppear('');
295 |
296 | terminal().lineAppear({
297 | text: '√ Scaffolding complete. You can now run:',
298 | fill: Colors.Catppuccin.Mocha.Green,
299 | });
300 | terminal().lineAppear({
301 | text: ' cd my-animation',
302 | });
303 | terminal().lineAppear({
304 | text: ' npm install',
305 | });
306 | terminal().lineAppear({
307 | text: ' npm start',
308 | });
309 |
310 | yield* waitFor(2);
311 | yield* terminalWindow().close(view, 1);
312 |
313 | const table = createRef();
314 | view.add(
315 |
316 |
317 |
318 |
319 | 1
320 |
321 |
322 |
323 |
324 | 2
325 |
326 |
327 |
328 |
329 | 3
330 |
331 |
332 |
333 |
334 |
335 |
336 | asdfghjkl;
337 |
338 |
339 |
340 |
341 | 2
342 |
343 |
344 |
345 |
346 |
347 |
348 |
,
349 | );
350 |
351 | yield* table().opacity(1, 1);
352 | yield* waitFor(1);
353 | yield* table().opacity(0, 1);
354 |
355 | const random = useRandom();
356 |
357 | const plot = createRef();
358 | view.add(
359 |
367 | [i * 4, random.nextInt(0, 100)])}
371 | />
372 | ,
373 | ,
374 | );
375 |
376 | yield* plot().opacity(1, 2);
377 | yield* waitFor(2);
378 |
379 | yield* plot().ticks(20, 3);
380 | yield* plot().tickLabelSize(20, 2);
381 | yield* plot().size(800, 2);
382 | yield* plot().labelSize(30, 2);
383 | yield* plot().min(-100, 2);
384 | yield* plot().opacity(0, 2);
385 | plot().remove();
386 |
387 | const plot2 = createRef();
388 | const line2 = createRef();
389 | view.add(
390 | `${Math.round(x / Math.PI)}π`}
398 | ticks={[4, 4]}
399 | opacity={0}
400 | >
401 |
402 | ,
403 | );
404 |
405 | line2().data(plot2().makeGraphData(0.1, x => Math.sin(x)));
406 |
407 | yield* plot2().opacity(1, 2);
408 | yield* waitFor(2);
409 | yield* line2().end(1, 1);
410 | yield* waitFor(3);
411 |
412 | yield* plot2().opacity(0, 2);
413 |
414 | const plot3 = createRef();
415 | const scatter3 = createRef();
416 | view.add(
417 |
425 | [i * 4, random.nextInt(0, 100)])}
432 | />
433 | ,
434 | );
435 |
436 | yield* plot3().opacity(1, 2);
437 | yield* waitFor(2);
438 | yield scatter3().end(1, 3, linear);
439 | yield* waitFor(0.1);
440 | yield* scatter3().start(0, 3, linear);
441 | yield* waitFor(2);
442 | yield* plot3().opacity(0, 2);
443 |
444 | const plot4 = createRef();
445 | const line4 = createRef();
446 | view.add(
447 |
460 |
461 | ,
462 | );
463 |
464 | line4().data(plot4().makeGraphData(0.1, x => Math.pow(x, 2)));
465 | yield* plot4().opacity(1, 2);
466 | yield* waitFor(2);
467 | yield* line4().end(1, 1);
468 |
469 | yield* waitFor(5);
470 | });
471 |
--------------------------------------------------------------------------------
/src/components/Scrollable.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Rect,
3 | RectProps,
4 | Node,
5 | Layout,
6 | computed,
7 | vector2Signal,
8 | initial,
9 | nodeName,
10 | signal,
11 | Curve,
12 | } from '@motion-canvas/2d';
13 | import {
14 | BBox,
15 | InterpolationFunction,
16 | PossibleVector2,
17 | SignalValue,
18 | SimpleSignal,
19 | TimingFunction,
20 | Vector2,
21 | Vector2Signal,
22 | all,
23 | clampRemap,
24 | createRef,
25 | createSignal,
26 | delay,
27 | easeInOutCubic,
28 | map,
29 | unwrap,
30 | } from '@motion-canvas/core';
31 | import {Colors} from '../Colors';
32 | import {signum} from '@Util';
33 |
34 | export interface ScrollableProps extends RectProps {
35 | activeOpacity?: SignalValue;
36 | inactiveOpacity?: SignalValue;
37 | handleProps?: RectProps;
38 | scrollHandleDelay?: SignalValue;
39 | scrollHandleDuration?: SignalValue;
40 | scrollOffset?: SignalValue;
41 | scrollPadding?: SignalValue;
42 | handleWidth?: SignalValue;
43 | handleInset?: SignalValue;
44 | zoom?: SignalValue;
45 | }
46 |
47 | @nodeName('Scrollable')
48 | export class Scrollable extends Rect {
49 | @initial(0)
50 | @vector2Signal('scrollOffset')
51 | public declare readonly scrollOffset: Vector2Signal;
52 |
53 | @initial(4)
54 | @vector2Signal('scrollPadding')
55 | public declare readonly scrollPadding: Vector2Signal;
56 |
57 | @initial(0.5)
58 | @vector2Signal('inactiveOpacity')
59 | public declare readonly inactiveOpacity: Vector2Signal;
60 |
61 | @initial(1)
62 | @vector2Signal('activeOpacity')
63 | public declare readonly activeOpacity: Vector2Signal;
64 |
65 | @initial(8)
66 | @signal()
67 | public declare readonly handleWidth: SimpleSignal;
68 |
69 | @initial(16)
70 | @signal()
71 | public declare readonly handleInset: SimpleSignal;
72 |
73 | @initial(1)
74 | @signal()
75 | public declare readonly zoom: SimpleSignal;
76 |
77 | @initial(-0.2)
78 | @signal()
79 | public declare readonly scrollHandleDelay: SimpleSignal;
80 |
81 | @signal()
82 | public declare readonly scrollHandleDuration: SimpleSignal;
83 |
84 | private readonly scrollOpacity = Vector2.createSignal();
85 |
86 | @signal()
87 | private readonly pathProgress = createSignal(0);
88 |
89 | @signal()
90 | private readonly path = createSignal();
91 |
92 | @computed()
93 | private inverseZoom() {
94 | return 1 / this.zoom();
95 | }
96 |
97 | @computed()
98 | public contentsBox() {
99 | return this.contents()
100 | .childrenAs()
101 | .reduce(
102 | (b, child) => {
103 | if (!(child instanceof Layout)) {
104 | return b;
105 | }
106 | const combinedLowX = Math.min(
107 | b.x,
108 | child.position().x - child.size().x / 2,
109 | );
110 | const combinedLowY = Math.min(
111 | b.y,
112 | child.position().y - child.size().y / 2,
113 | );
114 | const combinedHighX = Math.max(
115 | b.x + b.width,
116 | child.position().x + child.size().x / 2,
117 | );
118 | const combinedHighY = Math.max(
119 | b.y + b.height,
120 | child.position().y + child.size().y / 2,
121 | );
122 |
123 | return new BBox(
124 | combinedLowX,
125 | combinedLowY,
126 | combinedHighX - combinedLowX,
127 | combinedHighY - combinedLowY,
128 | );
129 | },
130 | // Always start with the scrollable's size.
131 | new BBox(
132 | -this.size().x / 2,
133 | -this.size().y / 2,
134 | this.size().x,
135 | this.size().y,
136 | ),
137 | );
138 | }
139 |
140 | @computed()
141 | private contentsSize() {
142 | return this.contentsBox().size;
143 | }
144 |
145 | @computed()
146 | private contentsProportion() {
147 | return this.size()
148 | .mul(this.inverseZoom())
149 | .div([this.contentsSize().width, this.contentsSize().height]);
150 | }
151 |
152 | @computed()
153 | private scrollOpacityY() {
154 | if (this.contentsProportion().y > 1.05) {
155 | return 0;
156 | }
157 | if (this.contentsProportion().y > 1) {
158 | return (1.05 - this.contentsProportion().y) * 10;
159 | }
160 | return this.scrollOpacity().y;
161 | }
162 |
163 | @computed()
164 | private scrollOpacityX() {
165 | if (this.contentsProportion().x > 1) {
166 | return 0;
167 | }
168 | if (this.contentsProportion().x > 0.95) {
169 | return (0.95 - this.contentsProportion().x) * 10;
170 | }
171 | return this.scrollOpacity().x;
172 | }
173 |
174 | @computed()
175 | private handleSize() {
176 | return this.contentsProportion()
177 | .mul(this.size())
178 | .sub(this.handleInset() + this.handleWidth() / 2);
179 | }
180 |
181 | @computed()
182 | private handlePosition() {
183 | const halfHandleSize = this.handleSize().div(2);
184 | // Map the contents box to the scrollable's size, ensuring that they don't
185 | // get clipped by going out of bounds.
186 | return new Vector2(
187 | clampRemap(
188 | this.contentsBox().x + this.size().x / 2,
189 | this.contentsBox().x + this.contentsBox().width - this.size().x / 2,
190 | -this.size().x / 2 + this.handleInset() / 2 + halfHandleSize.x,
191 | this.size().x / 2 -
192 | this.handleWidth() / 2 -
193 | this.handleInset() -
194 | halfHandleSize.x,
195 | this.scrollOffset().x,
196 | ),
197 | clampRemap(
198 | this.contentsBox().y + this.size().y / 2,
199 | this.contentsBox().y + this.contentsBox().height - this.size().y / 2,
200 | -this.size().y / 2 + this.handleInset() / 2 + halfHandleSize.y,
201 | this.size().y / 2 -
202 | this.handleWidth() / 2 -
203 | this.handleInset() -
204 | halfHandleSize.y,
205 | this.scrollOffset().y,
206 | ),
207 | );
208 | }
209 |
210 | public readonly contents;
211 |
212 | public constructor(props: ScrollableProps) {
213 | super({...props, clip: true});
214 | this.scrollOpacity(this.inactiveOpacity);
215 | this.scrollHandleDuration(
216 | props.scrollHandleDuration ??
217 | (props.scrollHandleDelay ? -props.scrollHandleDelay : 0.2),
218 | );
219 | this.contents = createRef();
220 |
221 | this.add(
222 |
223 | this.scrollOffset().mul(-1).mul(this.zoom())}
225 | scale={this.zoom}
226 | >
227 | {props.children}
228 |
229 | this.size().x / 2 - this.handleInset()}
234 | height={() => this.handleSize().y}
235 | y={() => this.handlePosition().y}
236 | width={this.handleWidth}
237 | opacity={this.scrollOpacityY}
238 | >
239 | this.size().y / 2 - this.handleInset()}
244 | height={this.handleWidth}
245 | width={() => this.handleSize().x}
246 | x={() => this.handlePosition().x}
247 | opacity={this.scrollOpacityX}
248 | >
249 | ,
250 | );
251 | this.scrollOffset(
252 | props.scrollOffset ??
253 | (() => this.contentsSize().mul(-0.5).add(this.size().mul(0.5))),
254 | );
255 | }
256 |
257 | public *tweenZoom(
258 | v: SignalValue,
259 | duration: number,
260 | timingFunction?: TimingFunction,
261 | interpolationFunction?: InterpolationFunction,
262 | ) {
263 | yield this.scrollOpacity(this.activeOpacity, 0.1);
264 | yield* all(
265 | delay(
266 | duration + this.scrollHandleDelay(),
267 | this.scrollOpacity(this.inactiveOpacity, this.scrollHandleDuration()),
268 | ),
269 | this.zoom.context.tweener(
270 | v,
271 | duration,
272 | timingFunction ?? easeInOutCubic,
273 | interpolationFunction ?? map,
274 | ),
275 | );
276 | }
277 |
278 | public *tweenScrollOffset(
279 | offset: SignalValue,
280 | duration: number,
281 | timingFunction?: TimingFunction,
282 | interpolationFunction?: InterpolationFunction,
283 | ) {
284 | const _offset = new Vector2(unwrap(offset));
285 | yield this.scrollOpacity(
286 | () => [
287 | _offset.x != this.scrollOffset().x
288 | ? this.activeOpacity().x
289 | : this.inactiveOpacity().x,
290 | _offset.y != this.scrollOffset().y
291 | ? this.activeOpacity().y
292 | : this.inactiveOpacity().y,
293 | ],
294 | 0.1,
295 | );
296 | yield* all(
297 | delay(
298 | duration + this.scrollHandleDelay(),
299 | this.scrollOpacity(this.inactiveOpacity, this.scrollHandleDuration()),
300 | ),
301 | this.scrollOffset.context.tweener(
302 | offset,
303 | duration,
304 | timingFunction,
305 | interpolationFunction,
306 | ),
307 | );
308 | }
309 |
310 | public *scrollTo(
311 | offset: PossibleVector2,
312 | duration: number,
313 | timingFunction?: TimingFunction,
314 | interpolationFunction?: InterpolationFunction,
315 | ) {
316 | yield* this.scrollOffset(
317 | offset,
318 | duration,
319 | timingFunction,
320 | interpolationFunction,
321 | );
322 | }
323 |
324 | public *scrollBy(
325 | offset: PossibleVector2,
326 | duration: number,
327 | timingFunction?: TimingFunction,
328 | interpolationFunction?: InterpolationFunction,
329 | ) {
330 | yield* this.scrollTo(
331 | this.scrollOffset().add(offset),
332 | duration,
333 | timingFunction,
334 | interpolationFunction,
335 | );
336 | }
337 |
338 | public *scrollToScaled(
339 | x: number | undefined,
340 | y: number | undefined,
341 | duration: number,
342 | timingFunction?: TimingFunction,
343 | interpolationFunction?: InterpolationFunction,
344 | ) {
345 | const xSign = signum(0.5 - x);
346 | const ySign = signum(0.5 - y);
347 | const viewOffsetX =
348 | xSign * (this.size().x / 2 - this.scrollPadding().x) * this.inverseZoom();
349 | const viewOffsetY =
350 | ySign * (this.size().y / 2 - this.scrollPadding().y) * this.inverseZoom();
351 | yield* this.scrollTo(
352 | {
353 | x:
354 | x != undefined
355 | ? this.contentsBox().x + x * this.contentsBox().width + viewOffsetX
356 | : this.scrollOffset().x,
357 | y:
358 | y != undefined
359 | ? this.contentsBox().y + y * this.contentsBox().height + viewOffsetY
360 | : this.scrollOffset().y,
361 | },
362 | duration,
363 | timingFunction,
364 | interpolationFunction,
365 | );
366 | }
367 |
368 | public *scrollToTop(
369 | duration: number,
370 | timingFunction?: TimingFunction,
371 | interpolationFunction?: InterpolationFunction,
372 | ) {
373 | yield* this.scrollToScaled(
374 | undefined,
375 | 0,
376 | duration,
377 | timingFunction,
378 | interpolationFunction,
379 | );
380 | }
381 |
382 | public *scrollToTopCenter(
383 | duration: number,
384 | timingFunction?: TimingFunction,
385 | interpolationFunction?: InterpolationFunction,
386 | ) {
387 | yield* this.scrollToScaled(
388 | 0.5,
389 | 0,
390 | duration,
391 | timingFunction,
392 | interpolationFunction,
393 | );
394 | }
395 |
396 | public *scrollToBottom(
397 | duration: number,
398 | timingFunction?: TimingFunction,
399 | interpolationFunction?: InterpolationFunction,
400 | ) {
401 | yield* this.scrollToScaled(
402 | undefined,
403 | 1,
404 | duration,
405 | timingFunction,
406 | interpolationFunction,
407 | );
408 | }
409 |
410 | public *scrollToBottomCenter(
411 | duration: number,
412 | timingFunction?: TimingFunction,
413 | interpolationFunction?: InterpolationFunction,
414 | ) {
415 | yield* this.scrollToScaled(
416 | 0.5,
417 | 1,
418 | duration,
419 | timingFunction,
420 | interpolationFunction,
421 | );
422 | }
423 |
424 | public *scrollToLeft(
425 | duration: number,
426 | timingFunction?: TimingFunction,
427 | interpolationFunction?: InterpolationFunction,
428 | ) {
429 | yield* this.scrollToScaled(
430 | 0,
431 | undefined,
432 | duration,
433 | timingFunction,
434 | interpolationFunction,
435 | );
436 | }
437 |
438 | public *scrollToLeftCenter(
439 | duration: number,
440 | timingFunction?: TimingFunction,
441 | interpolationFunction?: InterpolationFunction,
442 | ) {
443 | yield* this.scrollToScaled(
444 | 0,
445 | 0.5,
446 | duration,
447 | timingFunction,
448 | interpolationFunction,
449 | );
450 | }
451 |
452 | public *scrollToRight(
453 | duration: number,
454 | timingFunction?: TimingFunction,
455 | interpolationFunction?: InterpolationFunction,
456 | ) {
457 | yield* this.scrollToScaled(
458 | 1,
459 | undefined,
460 | duration,
461 | timingFunction,
462 | interpolationFunction,
463 | );
464 | }
465 |
466 | public *scrollToRightCenter(
467 | duration: number,
468 | timingFunction?: TimingFunction,
469 | interpolationFunction?: InterpolationFunction,
470 | ) {
471 | yield* this.scrollToScaled(
472 | 1,
473 | 0.5,
474 | duration,
475 | timingFunction,
476 | interpolationFunction,
477 | );
478 | }
479 |
480 | public *scrollToCenter(
481 | duration: number,
482 | timingFunction?: TimingFunction,
483 | interpolationFunction?: InterpolationFunction,
484 | ) {
485 | yield* this.scrollToScaled(
486 | 0.5,
487 | 0.5,
488 | duration,
489 | timingFunction,
490 | interpolationFunction,
491 | );
492 | }
493 |
494 | public *scrollToTopLeft(
495 | duration: number,
496 | timingFunction?: TimingFunction,
497 | interpolationFunction?: InterpolationFunction,
498 | ) {
499 | yield* this.scrollToScaled(
500 | 0,
501 | 0,
502 | duration,
503 | timingFunction,
504 | interpolationFunction,
505 | );
506 | }
507 |
508 | public *scrollToTopRight(
509 | duration: number,
510 | timingFunction?: TimingFunction,
511 | interpolationFunction?: InterpolationFunction,
512 | ) {
513 | yield* this.scrollToScaled(
514 | 1,
515 | 0,
516 | duration,
517 | timingFunction,
518 | interpolationFunction,
519 | );
520 | }
521 |
522 | public *scrollToBottomLeft(
523 | duration: number,
524 | timingFunction?: TimingFunction,
525 | interpolationFunction?: InterpolationFunction,
526 | ) {
527 | yield* this.scrollToScaled(
528 | 0,
529 | 1,
530 | duration,
531 | timingFunction,
532 | interpolationFunction,
533 | );
534 | }
535 |
536 | public *scrollToBottomRight(
537 | duration: number,
538 | timingFunction?: TimingFunction,
539 | interpolationFunction?: InterpolationFunction,
540 | ) {
541 | yield* this.scrollToScaled(
542 | 1,
543 | 1,
544 | duration,
545 | timingFunction,
546 | interpolationFunction,
547 | );
548 | }
549 |
550 | public *scrollDown(
551 | amount: number,
552 | duration: number,
553 | timingFunction?: TimingFunction,
554 | interpolationFunction?: InterpolationFunction,
555 | ) {
556 | yield* this.scrollBy(
557 | [0, amount],
558 | duration,
559 | timingFunction,
560 | interpolationFunction,
561 | );
562 | }
563 |
564 | public *scrollUp(
565 | amount: number,
566 | duration: number,
567 | timingFunction?: TimingFunction,
568 | interpolationFunction?: InterpolationFunction,
569 | ) {
570 | yield* this.scrollBy(
571 | [0, -amount],
572 | duration,
573 | timingFunction,
574 | interpolationFunction,
575 | );
576 | }
577 |
578 | public *scrollRight(
579 | amount: number,
580 | duration: number,
581 | timingFunction?: TimingFunction,
582 | interpolationFunction?: InterpolationFunction,
583 | ) {
584 | yield* this.scrollBy(
585 | [amount, 0],
586 | duration,
587 | timingFunction,
588 | interpolationFunction,
589 | );
590 | }
591 |
592 | public *scrollLeft(
593 | amount: number,
594 | duration: number,
595 | timingFunction?: TimingFunction,
596 | interpolationFunction?: InterpolationFunction,
597 | ) {
598 | yield* this.scrollBy(
599 | [-amount, 0],
600 | duration,
601 | timingFunction,
602 | interpolationFunction,
603 | );
604 | }
605 |
606 | public *tweenToAndFollowCurve(
607 | curve: SignalValue,
608 | navigationDuration: number,
609 | duration: number,
610 | timingFunction?: TimingFunction,
611 | interpolationFunction?: InterpolationFunction,
612 | ) {
613 | const c = unwrap(curve);
614 | yield* this.scrollOffset(
615 | () => c.getPointAtPercentage(0).position,
616 | navigationDuration,
617 | );
618 | yield* this.followCurve(
619 | curve,
620 | duration,
621 | timingFunction,
622 | interpolationFunction,
623 | );
624 | }
625 |
626 | public *followCurve(
627 | curve: SignalValue,
628 | duration: number,
629 | timingFunction?: TimingFunction,
630 | interpolationFunction?: InterpolationFunction,
631 | ) {
632 | yield this.scrollOpacity(this.activeOpacity, 0.1);
633 |
634 | this.path(curve);
635 | this.scrollOffset(() => {
636 | const progress = this.pathProgress();
637 | const p = this.path().getPointAtPercentage(progress).position;
638 | return p;
639 | });
640 | yield* all(
641 | delay(
642 | duration + this.scrollHandleDelay(),
643 | this.scrollOpacity(this.inactiveOpacity, this.scrollHandleDuration()),
644 | ),
645 | this.pathProgress(1, duration, timingFunction, interpolationFunction),
646 | );
647 | }
648 | }
649 |
--------------------------------------------------------------------------------