framecode 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/framecode.schema.json +154 -0
- package/package.json +63 -0
- package/src/BrandOverlay.tsx +129 -0
- package/src/CascadeTransition.tsx +88 -0
- package/src/CodeTransition.tsx +103 -0
- package/src/Main.tsx +192 -0
- package/src/ProgressBar.tsx +112 -0
- package/src/ReloadOnCodeChange.tsx +40 -0
- package/src/Root.tsx +89 -0
- package/src/TypewriterTransition.tsx +167 -0
- package/src/annotations/Callout.tsx +78 -0
- package/src/annotations/Error.tsx +76 -0
- package/src/annotations/Focus.tsx +56 -0
- package/src/annotations/InlineToken.tsx +8 -0
- package/src/annotations/Mark.tsx +94 -0
- package/src/calculate-metadata/calculate-metadata.tsx +119 -0
- package/src/calculate-metadata/get-files.ts +18 -0
- package/src/calculate-metadata/process-snippet.ts +21 -0
- package/src/calculate-metadata/schema.ts +47 -0
- package/src/calculate-metadata/theme.tsx +354 -0
- package/src/cli/commands/init.ts +28 -0
- package/src/cli/commands/render.ts +379 -0
- package/src/cli/commands/themes.ts +46 -0
- package/src/cli/index.ts +18 -0
- package/src/cli/utils/config.ts +83 -0
- package/src/cli/utils/files.ts +156 -0
- package/src/cli/utils/logger.ts +36 -0
- package/src/cli/utils/process-code.ts +196 -0
- package/src/cli/utils/prompts.ts +338 -0
- package/src/font.ts +13 -0
- package/src/index.css +26 -0
- package/src/index.ts +4 -0
- package/src/shared/calculations.ts +58 -0
- package/src/utils.ts +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# FrameCode
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/framecode)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://github.com/esau-morais/framecode)
|
|
6
|
+
|
|
7
|
+
CLI-first video generator that turns code snippets into social-ready videos with Remotion + Shiki.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- 5 built-in themes: `vercel-dark`, `story-gradients`, `retro-terminal`, `github-dark`, `synthwave-84`
|
|
12
|
+
- Animations: `morph`, `typewriter`, `cascade` (with per-file overrides)
|
|
13
|
+
- Presets: `post` (9:16), `tutorial` (16:9), `square` (1:1)
|
|
14
|
+
- Optional brand kit overlay: logo, Twitter/X handle, website, accent color
|
|
15
|
+
- Interactive render wizard or fully scripted CLI flow
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bunx framecode init --local
|
|
21
|
+
framecode render "~/projects/demo/src/**/*.ts" -t vercel-dark -a cascade -p tutorial
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bun add -g framecode
|
|
28
|
+
framecode --help
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Example 1: Single file
|
|
35
|
+
framecode render "~/projects/demo/src/intro.ts" -t story-gradients -a typewriter
|
|
36
|
+
|
|
37
|
+
# Example 2: Multi-file with per-step overrides in config
|
|
38
|
+
framecode render "~/projects/demo/src/**/*.ts" "!**/*.test.ts" -c framecode.json
|
|
39
|
+
|
|
40
|
+
# Example 3: Add brand kit on render
|
|
41
|
+
framecode render "~/projects/demo/src/main.ts" \
|
|
42
|
+
--logo "~/projects/demo/public/logo.png" \
|
|
43
|
+
--twitter framecodehq \
|
|
44
|
+
--website framecode.dev \
|
|
45
|
+
--accent "#ff8800"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Config
|
|
49
|
+
|
|
50
|
+
Create config with:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
framecode init --local
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Minimal `framecode.json`:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"$schema": "https://raw.githubusercontent.com/esau-morais/framecode/main/framecode.schema.json",
|
|
61
|
+
"theme": "vercel-dark",
|
|
62
|
+
"preset": "tutorial",
|
|
63
|
+
"animation": "cascade",
|
|
64
|
+
"stepConfigs": [{ "file": "intro.ts", "animation": "typewriter", "charsPerSecond": 42 }],
|
|
65
|
+
"brand": { "twitter": "@framecodehq", "accent": "#ff8800" }
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Themes
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
framecode themes
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Shows built-in themes first, then the full Shiki theme catalog.
|
|
76
|
+
|
|
77
|
+
## Animations
|
|
78
|
+
|
|
79
|
+
- `morph`: smooth transition between snapshots
|
|
80
|
+
- `typewriter`: character reveal with cursor
|
|
81
|
+
- `cascade`: line-by-line reveal with stagger
|
|
82
|
+
|
|
83
|
+
## Roadmap
|
|
84
|
+
|
|
85
|
+
Current focus: Phase 1 MVP CLI completion (themes, brand kit, docs polish), then monorepo + web in Phase 2.
|
|
86
|
+
|
|
87
|
+
## Contributing
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
bun install
|
|
91
|
+
bun run lint
|
|
92
|
+
bun run typecheck
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
MIT
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft-07/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"definitions": {
|
|
5
|
+
"animation": {
|
|
6
|
+
"type": "string",
|
|
7
|
+
"enum": [
|
|
8
|
+
"morph",
|
|
9
|
+
"typewriter",
|
|
10
|
+
"cascade"
|
|
11
|
+
],
|
|
12
|
+
"default": "morph"
|
|
13
|
+
},
|
|
14
|
+
"stepConfig": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"file": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Filename to match (exact match)"
|
|
20
|
+
},
|
|
21
|
+
"animation": {
|
|
22
|
+
"$ref": "#/definitions/animation",
|
|
23
|
+
"description": "Animation style override for this step"
|
|
24
|
+
},
|
|
25
|
+
"charsPerSecond": {
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"minimum": 1,
|
|
28
|
+
"description": "Characters per second for typewriter animation"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"required": [
|
|
32
|
+
"file"
|
|
33
|
+
],
|
|
34
|
+
"additionalProperties": false
|
|
35
|
+
},
|
|
36
|
+
"brand": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"logo": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Logo source URL, data URL, or local file path"
|
|
42
|
+
},
|
|
43
|
+
"twitter": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Twitter/X handle watermark"
|
|
46
|
+
},
|
|
47
|
+
"website": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "Website watermark"
|
|
50
|
+
},
|
|
51
|
+
"accent": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$",
|
|
54
|
+
"description": "Brand accent color (#RGB or #RRGGBB)"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"additionalProperties": false
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"properties": {
|
|
61
|
+
"theme": {
|
|
62
|
+
"type": "string",
|
|
63
|
+
"enum": [
|
|
64
|
+
"vercel-dark",
|
|
65
|
+
"story-gradients",
|
|
66
|
+
"retro-terminal",
|
|
67
|
+
"github-dark",
|
|
68
|
+
"github-light",
|
|
69
|
+
"nord",
|
|
70
|
+
"dracula",
|
|
71
|
+
"monokai",
|
|
72
|
+
"tokyo-night",
|
|
73
|
+
"catppuccin-mocha",
|
|
74
|
+
"rose-pine",
|
|
75
|
+
"synthwave-84",
|
|
76
|
+
"material-theme-darker",
|
|
77
|
+
"one-dark-pro",
|
|
78
|
+
"vitesse-dark",
|
|
79
|
+
"vitesse-light",
|
|
80
|
+
"min-dark",
|
|
81
|
+
"min-light",
|
|
82
|
+
"night-owl",
|
|
83
|
+
"ayu-dark",
|
|
84
|
+
"gruvbox-dark-medium",
|
|
85
|
+
"gruvbox-light-medium",
|
|
86
|
+
"everforest-dark",
|
|
87
|
+
"everforest-light",
|
|
88
|
+
"kanagawa-wave",
|
|
89
|
+
"rose-pine-moon",
|
|
90
|
+
"rose-pine-dawn",
|
|
91
|
+
"catppuccin-latte",
|
|
92
|
+
"catppuccin-frappe",
|
|
93
|
+
"catppuccin-macchiato",
|
|
94
|
+
"github-dark-dimmed",
|
|
95
|
+
"github-dark-high-contrast",
|
|
96
|
+
"github-light-high-contrast",
|
|
97
|
+
"dark-plus",
|
|
98
|
+
"light-plus",
|
|
99
|
+
"poimandres",
|
|
100
|
+
"slack-dark",
|
|
101
|
+
"one-light",
|
|
102
|
+
"material-theme-lighter",
|
|
103
|
+
"material-theme-ocean",
|
|
104
|
+
"material-theme-palenight",
|
|
105
|
+
"dracula-soft",
|
|
106
|
+
"houston"
|
|
107
|
+
],
|
|
108
|
+
"default": "vercel-dark"
|
|
109
|
+
},
|
|
110
|
+
"preset": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"enum": [
|
|
113
|
+
"post",
|
|
114
|
+
"tutorial",
|
|
115
|
+
"square"
|
|
116
|
+
],
|
|
117
|
+
"default": "tutorial"
|
|
118
|
+
},
|
|
119
|
+
"animation": {
|
|
120
|
+
"$ref": "#/definitions/animation"
|
|
121
|
+
},
|
|
122
|
+
"charsPerSecond": {
|
|
123
|
+
"type": "integer",
|
|
124
|
+
"minimum": 1,
|
|
125
|
+
"default": 30,
|
|
126
|
+
"description": "Global characters per second for typewriter animation"
|
|
127
|
+
},
|
|
128
|
+
"fps": {
|
|
129
|
+
"type": "integer",
|
|
130
|
+
"minimum": 1,
|
|
131
|
+
"maximum": 120,
|
|
132
|
+
"default": 30
|
|
133
|
+
},
|
|
134
|
+
"width": {
|
|
135
|
+
"type": "integer",
|
|
136
|
+
"minimum": 1
|
|
137
|
+
},
|
|
138
|
+
"height": {
|
|
139
|
+
"type": "integer",
|
|
140
|
+
"minimum": 1
|
|
141
|
+
},
|
|
142
|
+
"brand": {
|
|
143
|
+
"$ref": "#/definitions/brand"
|
|
144
|
+
},
|
|
145
|
+
"stepConfigs": {
|
|
146
|
+
"type": "array",
|
|
147
|
+
"items": {
|
|
148
|
+
"$ref": "#/definitions/stepConfig"
|
|
149
|
+
},
|
|
150
|
+
"description": "Per-step animation configuration overrides"
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"additionalProperties": false
|
|
154
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "framecode",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "CLI-first video generator that turns code snippets into videos",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Esau Morais <esaumorais7@gmail.com> (https://emots.dev)",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/esau-morais/framecode.git"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"framecode": "src/cli/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"framecode.schema.json",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"module": "src/index.ts",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "remotion studio",
|
|
26
|
+
"build": "remotion bundle",
|
|
27
|
+
"upgrade": "remotion upgrade",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"lint": "oxlint",
|
|
30
|
+
"lint:fix": "oxfmt",
|
|
31
|
+
"prepublishOnly": "bun run typecheck && bun run lint && bun run build"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@clack/prompts": "^1.0.0-alpha.9",
|
|
35
|
+
"@remotion/bundler": "4.0.399",
|
|
36
|
+
"@remotion/cli": "4.0.399",
|
|
37
|
+
"@remotion/google-fonts": "4.0.399",
|
|
38
|
+
"@remotion/layout-utils": "4.0.399",
|
|
39
|
+
"@remotion/renderer": "4.0.399",
|
|
40
|
+
"@remotion/studio": "4.0.399",
|
|
41
|
+
"@remotion/tailwind-v4": "^4.0.399",
|
|
42
|
+
"chalk": "^5.6.2",
|
|
43
|
+
"codehike": "1.0.7",
|
|
44
|
+
"commander": "^14.0.2",
|
|
45
|
+
"polished": "4.3.1",
|
|
46
|
+
"react": "19.2.3",
|
|
47
|
+
"react-dom": "19.2.3",
|
|
48
|
+
"remotion": "4.0.399",
|
|
49
|
+
"shiki": "^3.20.0",
|
|
50
|
+
"tailwindcss": "^4.1.18",
|
|
51
|
+
"twoslash": "^0.3.6",
|
|
52
|
+
"zod": "3.22.3"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@remotion/eslint-plugin": "^4.0.399",
|
|
56
|
+
"@types/bun": "^1.3.5",
|
|
57
|
+
"@types/react": "19.2.7",
|
|
58
|
+
"@types/web": "0.0.312",
|
|
59
|
+
"oxfmt": "^0.22.0",
|
|
60
|
+
"oxlint": "^1.36.0",
|
|
61
|
+
"typescript": "5.9.3"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { Img, staticFile } from "remotion";
|
|
3
|
+
import { horizontalPadding } from "./font";
|
|
4
|
+
import { useThemeColors } from "./calculate-metadata/theme";
|
|
5
|
+
import type { BrandConfig } from "./calculate-metadata/schema";
|
|
6
|
+
|
|
7
|
+
function normalizeHandle(handle?: string): string | null {
|
|
8
|
+
if (!handle) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const trimmed = handle.trim();
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return trimmed.startsWith("@") ? trimmed : `@${trimmed}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeWebsite(website?: string): string | null {
|
|
21
|
+
if (!website) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const trimmed = website.trim();
|
|
26
|
+
if (!trimmed) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return trimmed.replace(/^https?:\/\//i, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveLogoSource(logo: string): string {
|
|
34
|
+
if (/^(https?:)?\/\//i.test(logo) || logo.startsWith("data:")) {
|
|
35
|
+
return logo;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const normalizedPath = logo.replaceAll("\\", "/").replace(/^\//, "");
|
|
39
|
+
return staticFile(normalizedPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function BrandOverlay({ brand }: { readonly brand?: BrandConfig }) {
|
|
43
|
+
const themeColors = useThemeColors();
|
|
44
|
+
|
|
45
|
+
const handle = normalizeHandle(brand?.twitter);
|
|
46
|
+
const website = normalizeWebsite(brand?.website);
|
|
47
|
+
const logo = brand?.logo?.trim() ? resolveLogoSource(brand.logo) : null;
|
|
48
|
+
|
|
49
|
+
const hasTopLeft = Boolean(logo || website);
|
|
50
|
+
const hasBottomRight = Boolean(handle);
|
|
51
|
+
|
|
52
|
+
if (!hasTopLeft && !hasBottomRight) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const accent = brand?.accent ?? themeColors.icon.foreground;
|
|
57
|
+
|
|
58
|
+
const chipStyle = useMemo<React.CSSProperties>(
|
|
59
|
+
() => ({
|
|
60
|
+
border: `1px solid ${accent}`,
|
|
61
|
+
backgroundColor: "rgba(0, 0, 0, 0.58)",
|
|
62
|
+
color: themeColors.foreground,
|
|
63
|
+
borderRadius: 999,
|
|
64
|
+
padding: "8px 14px",
|
|
65
|
+
fontSize: 26,
|
|
66
|
+
lineHeight: 1,
|
|
67
|
+
letterSpacing: 0.2,
|
|
68
|
+
fontWeight: 600,
|
|
69
|
+
textShadow: "0 2px 8px rgba(0, 0, 0, 0.5)",
|
|
70
|
+
}),
|
|
71
|
+
[accent, themeColors.foreground],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<>
|
|
76
|
+
{hasTopLeft ? (
|
|
77
|
+
<div
|
|
78
|
+
style={{
|
|
79
|
+
position: "absolute",
|
|
80
|
+
top: 32,
|
|
81
|
+
left: horizontalPadding,
|
|
82
|
+
display: "flex",
|
|
83
|
+
alignItems: "center",
|
|
84
|
+
gap: 12,
|
|
85
|
+
pointerEvents: "none",
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
{logo ? (
|
|
89
|
+
<div
|
|
90
|
+
style={{
|
|
91
|
+
width: 52,
|
|
92
|
+
height: 52,
|
|
93
|
+
borderRadius: 12,
|
|
94
|
+
border: `1px solid ${accent}`,
|
|
95
|
+
backgroundColor: "rgba(0, 0, 0, 0.58)",
|
|
96
|
+
display: "flex",
|
|
97
|
+
alignItems: "center",
|
|
98
|
+
justifyContent: "center",
|
|
99
|
+
overflow: "hidden",
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<Img
|
|
103
|
+
src={logo}
|
|
104
|
+
style={{
|
|
105
|
+
width: "80%",
|
|
106
|
+
height: "80%",
|
|
107
|
+
objectFit: "contain",
|
|
108
|
+
}}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
) : null}
|
|
112
|
+
{website ? <span style={chipStyle}>{website}</span> : null}
|
|
113
|
+
</div>
|
|
114
|
+
) : null}
|
|
115
|
+
{hasBottomRight ? (
|
|
116
|
+
<div
|
|
117
|
+
style={{
|
|
118
|
+
position: "absolute",
|
|
119
|
+
right: horizontalPadding,
|
|
120
|
+
bottom: 32,
|
|
121
|
+
pointerEvents: "none",
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<span style={chipStyle}>{handle}</span>
|
|
125
|
+
</div>
|
|
126
|
+
) : null}
|
|
127
|
+
</>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useMemo, useCallback } from "react";
|
|
2
|
+
import { interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import {
|
|
4
|
+
Pre,
|
|
5
|
+
HighlightedCode,
|
|
6
|
+
AnnotationHandler,
|
|
7
|
+
InnerLine,
|
|
8
|
+
} from "codehike/code";
|
|
9
|
+
import { fontFamily, fontSize as baseFontSize, tabSize } from "./font";
|
|
10
|
+
import { useMarkHandler } from "./annotations/Mark";
|
|
11
|
+
import { useFocusHandler } from "./annotations/Focus";
|
|
12
|
+
|
|
13
|
+
type TimingMode = "spring" | "linear";
|
|
14
|
+
|
|
15
|
+
export function CascadeTransition({
|
|
16
|
+
code,
|
|
17
|
+
staggerDelay = 3,
|
|
18
|
+
timing = "spring",
|
|
19
|
+
fontSize,
|
|
20
|
+
}: {
|
|
21
|
+
readonly code: HighlightedCode;
|
|
22
|
+
readonly staggerDelay?: number;
|
|
23
|
+
readonly timing?: TimingMode;
|
|
24
|
+
readonly fontSize?: number;
|
|
25
|
+
}) {
|
|
26
|
+
const frame = useCurrentFrame();
|
|
27
|
+
const { fps } = useVideoConfig();
|
|
28
|
+
|
|
29
|
+
const getLineProgress = useCallback(
|
|
30
|
+
(lineNum: number) => {
|
|
31
|
+
const delay = (lineNum - 1) * staggerDelay;
|
|
32
|
+
return timing === "spring"
|
|
33
|
+
? spring({ frame, fps, delay, config: { damping: 20, stiffness: 100 } })
|
|
34
|
+
: interpolate(frame, [delay, delay + 15], [0, 1], {
|
|
35
|
+
extrapolateLeft: "clamp",
|
|
36
|
+
extrapolateRight: "clamp",
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
[frame, fps, staggerDelay, timing],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const cascadeHandler: AnnotationHandler = useMemo(
|
|
43
|
+
() => ({
|
|
44
|
+
name: "cascade",
|
|
45
|
+
Line: (props) => {
|
|
46
|
+
const progress = getLineProgress(props.lineNumber);
|
|
47
|
+
const translateY = interpolate(progress, [0, 1], [10, 0]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<InnerLine
|
|
51
|
+
merge={props}
|
|
52
|
+
style={{
|
|
53
|
+
opacity: progress,
|
|
54
|
+
transform: `translate3d(0, ${translateY}px, 0)`,
|
|
55
|
+
display: "block",
|
|
56
|
+
willChange: "transform, opacity",
|
|
57
|
+
backfaceVisibility: "hidden",
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
[getLineProgress],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const markHandler = useMarkHandler(getLineProgress);
|
|
67
|
+
|
|
68
|
+
const focusHandler = useFocusHandler(code);
|
|
69
|
+
|
|
70
|
+
const handlers = useMemo(
|
|
71
|
+
() => [cascadeHandler, markHandler, focusHandler],
|
|
72
|
+
[cascadeHandler, markHandler, focusHandler],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const style: React.CSSProperties = useMemo(
|
|
76
|
+
() => ({
|
|
77
|
+
fontSize: fontSize ?? baseFontSize,
|
|
78
|
+
lineHeight: 1.5,
|
|
79
|
+
fontFamily,
|
|
80
|
+
tabSize,
|
|
81
|
+
margin: 0,
|
|
82
|
+
whiteSpace: "pre",
|
|
83
|
+
}),
|
|
84
|
+
[fontSize],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return <Pre code={code} handlers={handlers} style={style} />;
|
|
88
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Easing, interpolate, useDelayRender, useCurrentFrame } from "remotion";
|
|
2
|
+
import { Pre, HighlightedCode, AnnotationHandler } from "codehike/code";
|
|
3
|
+
import React, { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
calculateTransitions,
|
|
6
|
+
getStartingSnapshot,
|
|
7
|
+
TokenTransitionsSnapshot,
|
|
8
|
+
} from "codehike/utils/token-transitions";
|
|
9
|
+
import { applyStyle } from "./utils";
|
|
10
|
+
import { callout } from "./annotations/Callout";
|
|
11
|
+
import { useMorphMarkHandler } from "./annotations/Mark";
|
|
12
|
+
import { useFocusHandler } from "./annotations/Focus";
|
|
13
|
+
import { tokenTransitions } from "./annotations/InlineToken";
|
|
14
|
+
import { errorInline, errorMessage } from "./annotations/Error";
|
|
15
|
+
import { fontFamily, fontSize as baseFontSize, tabSize } from "./font";
|
|
16
|
+
|
|
17
|
+
export function CodeTransition({
|
|
18
|
+
oldCode,
|
|
19
|
+
newCode,
|
|
20
|
+
durationInFrames = 30,
|
|
21
|
+
fontSize,
|
|
22
|
+
}: {
|
|
23
|
+
readonly oldCode: HighlightedCode | null;
|
|
24
|
+
readonly newCode: HighlightedCode;
|
|
25
|
+
readonly durationInFrames?: number;
|
|
26
|
+
readonly fontSize?: number;
|
|
27
|
+
}) {
|
|
28
|
+
const frame = useCurrentFrame();
|
|
29
|
+
|
|
30
|
+
const ref = React.useRef<HTMLPreElement>(null);
|
|
31
|
+
const [oldSnapshot, setOldSnapshot] =
|
|
32
|
+
useState<TokenTransitionsSnapshot | null>(null);
|
|
33
|
+
const { delayRender, continueRender } = useDelayRender();
|
|
34
|
+
const [handle] = React.useState(() => delayRender());
|
|
35
|
+
|
|
36
|
+
const prevCode: HighlightedCode = useMemo(() => {
|
|
37
|
+
return oldCode || { ...newCode, tokens: [], annotations: [] };
|
|
38
|
+
}, [newCode, oldCode]);
|
|
39
|
+
|
|
40
|
+
const code = useMemo(() => {
|
|
41
|
+
return oldSnapshot ? newCode : prevCode;
|
|
42
|
+
}, [newCode, prevCode, oldSnapshot]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!oldSnapshot) {
|
|
46
|
+
setOldSnapshot(getStartingSnapshot(ref.current!));
|
|
47
|
+
}
|
|
48
|
+
}, [oldSnapshot]);
|
|
49
|
+
|
|
50
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
|
+
useLayoutEffect(() => {
|
|
52
|
+
if (!oldSnapshot) {
|
|
53
|
+
setOldSnapshot(getStartingSnapshot(ref.current!));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const transitions = calculateTransitions(ref.current!, oldSnapshot);
|
|
57
|
+
transitions.forEach(({ element, keyframes, options }) => {
|
|
58
|
+
const delay = durationInFrames * options.delay;
|
|
59
|
+
const duration = durationInFrames * options.duration;
|
|
60
|
+
const linearProgress = interpolate(
|
|
61
|
+
frame,
|
|
62
|
+
[delay, delay + duration],
|
|
63
|
+
[0, 1],
|
|
64
|
+
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
|
65
|
+
);
|
|
66
|
+
const progress = interpolate(linearProgress, [0, 1], [0, 1], {
|
|
67
|
+
easing: Easing.bezier(0.17, 0.67, 0.76, 0.91),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
applyStyle({ element, keyframes, progress, linearProgress });
|
|
71
|
+
});
|
|
72
|
+
continueRender(handle);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const markHandler = useMorphMarkHandler();
|
|
76
|
+
|
|
77
|
+
const focusHandler = useFocusHandler(newCode);
|
|
78
|
+
|
|
79
|
+
const handlers: AnnotationHandler[] = useMemo(
|
|
80
|
+
() => [
|
|
81
|
+
tokenTransitions,
|
|
82
|
+
markHandler,
|
|
83
|
+
focusHandler,
|
|
84
|
+
callout,
|
|
85
|
+
errorInline,
|
|
86
|
+
errorMessage,
|
|
87
|
+
],
|
|
88
|
+
[markHandler, focusHandler],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const style: React.CSSProperties = useMemo(
|
|
92
|
+
() => ({
|
|
93
|
+
position: "relative",
|
|
94
|
+
fontSize: fontSize ?? baseFontSize,
|
|
95
|
+
lineHeight: 1.5,
|
|
96
|
+
fontFamily,
|
|
97
|
+
tabSize,
|
|
98
|
+
}),
|
|
99
|
+
[fontSize],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return <Pre ref={ref} code={code} handlers={handlers} style={style} />;
|
|
103
|
+
}
|