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/src/Main.tsx
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { AbsoluteFill, Series } from "remotion";
|
|
2
|
+
import { ProgressBar } from "./ProgressBar";
|
|
3
|
+
import { CodeTransition } from "./CodeTransition";
|
|
4
|
+
import { TypewriterTransition } from "./TypewriterTransition";
|
|
5
|
+
import { CascadeTransition } from "./CascadeTransition";
|
|
6
|
+
import { HighlightedCode } from "codehike/code";
|
|
7
|
+
import {
|
|
8
|
+
ThemeColors,
|
|
9
|
+
ThemeProvider,
|
|
10
|
+
getThemeVisuals,
|
|
11
|
+
} from "./calculate-metadata/theme";
|
|
12
|
+
import { useMemo } from "react";
|
|
13
|
+
import { RefreshOnCodeChange } from "./ReloadOnCodeChange";
|
|
14
|
+
import { verticalPadding, horizontalPadding } from "./font";
|
|
15
|
+
import { BrandOverlay } from "./BrandOverlay";
|
|
16
|
+
|
|
17
|
+
import type { animationSchema, schema } from "./calculate-metadata/schema";
|
|
18
|
+
import type { z } from "zod";
|
|
19
|
+
|
|
20
|
+
export type Animation = z.infer<typeof animationSchema>;
|
|
21
|
+
|
|
22
|
+
export type CodeStep = {
|
|
23
|
+
code: HighlightedCode;
|
|
24
|
+
fontSize: number;
|
|
25
|
+
durationInFrames: number;
|
|
26
|
+
animation: Animation;
|
|
27
|
+
charsPerSecond: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type Props = {
|
|
31
|
+
steps: CodeStep[] | null;
|
|
32
|
+
themeColors: ThemeColors | null;
|
|
33
|
+
codeWidth: number | null;
|
|
34
|
+
} & Omit<z.infer<typeof schema>, "steps">;
|
|
35
|
+
|
|
36
|
+
export const Main: React.FC<Props> = ({
|
|
37
|
+
steps,
|
|
38
|
+
theme,
|
|
39
|
+
themeColors,
|
|
40
|
+
codeWidth,
|
|
41
|
+
brand,
|
|
42
|
+
}) => {
|
|
43
|
+
if (!steps) {
|
|
44
|
+
throw new Error("Steps are not defined");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const transitionDuration = 30;
|
|
48
|
+
|
|
49
|
+
if (!themeColors) {
|
|
50
|
+
throw new Error("Theme colors are not defined");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const themeVisuals = useMemo(() => {
|
|
54
|
+
return getThemeVisuals(theme, themeColors);
|
|
55
|
+
}, [theme, themeColors]);
|
|
56
|
+
|
|
57
|
+
const backgroundStyle: React.CSSProperties = useMemo(() => {
|
|
58
|
+
return {
|
|
59
|
+
backgroundColor: themeVisuals.canvasBackground,
|
|
60
|
+
backgroundImage: themeVisuals.canvasBackgroundImage,
|
|
61
|
+
overflow: "hidden",
|
|
62
|
+
};
|
|
63
|
+
}, [themeVisuals]);
|
|
64
|
+
|
|
65
|
+
const overlayStyle: React.CSSProperties | null = useMemo(() => {
|
|
66
|
+
if (themeVisuals.overlayKind === "none") {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (themeVisuals.overlayKind === "grid") {
|
|
71
|
+
return {
|
|
72
|
+
pointerEvents: "none",
|
|
73
|
+
opacity: themeVisuals.overlayOpacity,
|
|
74
|
+
backgroundImage:
|
|
75
|
+
"linear-gradient(to right, rgba(255, 255, 255, 0.35) 1px, transparent 1px), linear-gradient(to bottom, rgba(255, 255, 255, 0.35) 1px, transparent 1px)",
|
|
76
|
+
backgroundSize: "28px 28px",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (themeVisuals.overlayKind === "guides") {
|
|
81
|
+
return {
|
|
82
|
+
pointerEvents: "none",
|
|
83
|
+
opacity: themeVisuals.overlayOpacity,
|
|
84
|
+
backgroundImage:
|
|
85
|
+
"linear-gradient(to right, transparent 11.9%, rgba(255, 255, 255, 0.14) 11.9%, rgba(255, 255, 255, 0.14) 12.1%, transparent 12.1%, transparent 49.9%, rgba(255, 255, 255, 0.18) 49.9%, rgba(255, 255, 255, 0.18) 50.1%, transparent 50.1%, transparent 87.9%, rgba(255, 255, 255, 0.14) 87.9%, rgba(255, 255, 255, 0.14) 88.1%, transparent 88.1%), linear-gradient(to bottom, transparent 17.9%, rgba(255, 255, 255, 0.12) 17.9%, rgba(255, 255, 255, 0.12) 18.1%, transparent 18.1%, transparent 49.9%, rgba(255, 255, 255, 0.15) 49.9%, rgba(255, 255, 255, 0.15) 50.1%, transparent 50.1%, transparent 81.9%, rgba(255, 255, 255, 0.12) 81.9%, rgba(255, 255, 255, 0.12) 82.1%, transparent 82.1%), radial-gradient(circle at 0% 50%, rgba(255, 255, 255, 0.95) 0 3px, transparent 3.5px), radial-gradient(circle at 100% 50%, rgba(255, 255, 255, 0.95) 0 3px, transparent 3.5px)",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
pointerEvents: "none",
|
|
91
|
+
opacity: themeVisuals.overlayOpacity,
|
|
92
|
+
backgroundImage:
|
|
93
|
+
"repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0) 2px, rgba(158, 255, 182, 0.25) 3px, rgba(0, 0, 0, 0) 4px)",
|
|
94
|
+
};
|
|
95
|
+
}, [themeVisuals]);
|
|
96
|
+
|
|
97
|
+
const codeWindowStyle: React.CSSProperties = useMemo(() => {
|
|
98
|
+
return {
|
|
99
|
+
width: codeWidth || "100%",
|
|
100
|
+
maxWidth: "100%",
|
|
101
|
+
padding: `${verticalPadding}px ${horizontalPadding}px`,
|
|
102
|
+
backgroundColor: themeVisuals.codeSurface,
|
|
103
|
+
border: `1px solid ${themeVisuals.codeBorder}`,
|
|
104
|
+
borderRadius: themeVisuals.codeRadius,
|
|
105
|
+
boxShadow: themeVisuals.codeShadow,
|
|
106
|
+
overflow: "hidden",
|
|
107
|
+
};
|
|
108
|
+
}, [codeWidth, themeVisuals]);
|
|
109
|
+
|
|
110
|
+
const sequenceStyle: React.CSSProperties = useMemo(() => {
|
|
111
|
+
return {
|
|
112
|
+
display: "flex",
|
|
113
|
+
flexDirection: "column",
|
|
114
|
+
gap: themeVisuals.showFilename ? 24 : 0,
|
|
115
|
+
height: "100%",
|
|
116
|
+
};
|
|
117
|
+
}, [themeVisuals.showFilename]);
|
|
118
|
+
|
|
119
|
+
const filenameStyle: React.CSSProperties = useMemo(() => {
|
|
120
|
+
return {
|
|
121
|
+
color: themeVisuals.filenameColor,
|
|
122
|
+
fontSize: 22,
|
|
123
|
+
fontWeight: 600,
|
|
124
|
+
letterSpacing: 0.4,
|
|
125
|
+
textTransform: "lowercase",
|
|
126
|
+
opacity: 0.9,
|
|
127
|
+
};
|
|
128
|
+
}, [themeVisuals.filenameColor]);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<ThemeProvider themeColors={themeColors}>
|
|
132
|
+
<AbsoluteFill style={backgroundStyle}>
|
|
133
|
+
{overlayStyle ? <AbsoluteFill style={overlayStyle} /> : null}
|
|
134
|
+
<ProgressBar
|
|
135
|
+
steps={steps}
|
|
136
|
+
trackColor={themeVisuals.progressTrack}
|
|
137
|
+
fillColor={brand?.accent ?? themeVisuals.accent}
|
|
138
|
+
top={themeVisuals.progressTop}
|
|
139
|
+
height={themeVisuals.progressHeight}
|
|
140
|
+
gap={themeVisuals.progressGap}
|
|
141
|
+
/>
|
|
142
|
+
<AbsoluteFill
|
|
143
|
+
style={{
|
|
144
|
+
justifyContent: "center",
|
|
145
|
+
alignItems: "center",
|
|
146
|
+
padding: `${verticalPadding}px ${horizontalPadding}px`,
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
<div style={codeWindowStyle}>
|
|
150
|
+
<Series>
|
|
151
|
+
{steps.map((step, index) => (
|
|
152
|
+
<Series.Sequence
|
|
153
|
+
key={index}
|
|
154
|
+
layout="none"
|
|
155
|
+
durationInFrames={step.durationInFrames}
|
|
156
|
+
name={step.code.meta}
|
|
157
|
+
>
|
|
158
|
+
<div style={sequenceStyle}>
|
|
159
|
+
{themeVisuals.showFilename ? (
|
|
160
|
+
<div style={filenameStyle}>{step.code.meta}</div>
|
|
161
|
+
) : null}
|
|
162
|
+
{step.animation === "typewriter" ? (
|
|
163
|
+
<TypewriterTransition
|
|
164
|
+
code={step.code}
|
|
165
|
+
charsPerSecond={step.charsPerSecond}
|
|
166
|
+
fontSize={step.fontSize}
|
|
167
|
+
/>
|
|
168
|
+
) : step.animation === "cascade" ? (
|
|
169
|
+
<CascadeTransition
|
|
170
|
+
code={step.code}
|
|
171
|
+
fontSize={step.fontSize}
|
|
172
|
+
/>
|
|
173
|
+
) : (
|
|
174
|
+
<CodeTransition
|
|
175
|
+
oldCode={steps[index - 1]?.code ?? null}
|
|
176
|
+
newCode={step.code}
|
|
177
|
+
durationInFrames={transitionDuration}
|
|
178
|
+
fontSize={step.fontSize}
|
|
179
|
+
/>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
</Series.Sequence>
|
|
183
|
+
))}
|
|
184
|
+
</Series>
|
|
185
|
+
</div>
|
|
186
|
+
</AbsoluteFill>
|
|
187
|
+
<BrandOverlay brand={brand} />
|
|
188
|
+
</AbsoluteFill>
|
|
189
|
+
<RefreshOnCodeChange />
|
|
190
|
+
</ThemeProvider>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useCurrentFrame } from "remotion";
|
|
3
|
+
import { useThemeColors } from "./calculate-metadata/theme";
|
|
4
|
+
import { horizontalPadding } from "./font";
|
|
5
|
+
import { CodeStep } from "./Main";
|
|
6
|
+
import React from "react";
|
|
7
|
+
|
|
8
|
+
const Step: React.FC<{
|
|
9
|
+
readonly index: number;
|
|
10
|
+
readonly currentStep: number;
|
|
11
|
+
readonly currentStepProgress: number;
|
|
12
|
+
readonly trackColor?: string;
|
|
13
|
+
readonly fillColor?: string;
|
|
14
|
+
}> = ({
|
|
15
|
+
index,
|
|
16
|
+
currentStep,
|
|
17
|
+
currentStepProgress,
|
|
18
|
+
trackColor,
|
|
19
|
+
fillColor,
|
|
20
|
+
}) => {
|
|
21
|
+
const themeColors = useThemeColors();
|
|
22
|
+
|
|
23
|
+
const outer: React.CSSProperties = useMemo(() => {
|
|
24
|
+
return {
|
|
25
|
+
backgroundColor:
|
|
26
|
+
trackColor ??
|
|
27
|
+
themeColors.editor.lineHighlightBackground ??
|
|
28
|
+
themeColors.editor.rangeHighlightBackground,
|
|
29
|
+
borderRadius: 6,
|
|
30
|
+
overflow: "hidden",
|
|
31
|
+
height: "100%",
|
|
32
|
+
flex: 1,
|
|
33
|
+
};
|
|
34
|
+
}, [themeColors, trackColor]);
|
|
35
|
+
|
|
36
|
+
const inner: React.CSSProperties = useMemo(() => {
|
|
37
|
+
return {
|
|
38
|
+
height: "100%",
|
|
39
|
+
backgroundColor: fillColor ?? themeColors.icon.foreground,
|
|
40
|
+
width:
|
|
41
|
+
index > currentStep
|
|
42
|
+
? 0
|
|
43
|
+
: index === currentStep
|
|
44
|
+
? currentStepProgress * 100 + "%"
|
|
45
|
+
: "100%",
|
|
46
|
+
};
|
|
47
|
+
}, [fillColor, themeColors.icon.foreground, index, currentStep, currentStepProgress]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div style={outer}>
|
|
51
|
+
<div style={inner} />
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function ProgressBar({
|
|
57
|
+
steps,
|
|
58
|
+
trackColor,
|
|
59
|
+
fillColor,
|
|
60
|
+
top = 48,
|
|
61
|
+
height = 6,
|
|
62
|
+
gap = 12,
|
|
63
|
+
}: {
|
|
64
|
+
readonly steps: CodeStep[];
|
|
65
|
+
readonly trackColor?: string;
|
|
66
|
+
readonly fillColor?: string;
|
|
67
|
+
readonly top?: number;
|
|
68
|
+
readonly height?: number;
|
|
69
|
+
readonly gap?: number;
|
|
70
|
+
}) {
|
|
71
|
+
const frame = useCurrentFrame();
|
|
72
|
+
|
|
73
|
+
const { currentStep, currentStepProgress } = useMemo(() => {
|
|
74
|
+
let accumulated = 0;
|
|
75
|
+
for (let i = 0; i < steps.length; i++) {
|
|
76
|
+
const stepEnd = accumulated + steps[i].durationInFrames;
|
|
77
|
+
if (frame < stepEnd) {
|
|
78
|
+
const progress = (frame - accumulated) / steps[i].durationInFrames;
|
|
79
|
+
return { currentStep: i, currentStepProgress: progress };
|
|
80
|
+
}
|
|
81
|
+
accumulated = stepEnd;
|
|
82
|
+
}
|
|
83
|
+
return { currentStep: steps.length - 1, currentStepProgress: 1 };
|
|
84
|
+
}, [frame, steps]);
|
|
85
|
+
|
|
86
|
+
const container: React.CSSProperties = useMemo(() => {
|
|
87
|
+
return {
|
|
88
|
+
position: "absolute",
|
|
89
|
+
top,
|
|
90
|
+
height,
|
|
91
|
+
left: horizontalPadding,
|
|
92
|
+
right: horizontalPadding,
|
|
93
|
+
display: "flex",
|
|
94
|
+
gap,
|
|
95
|
+
};
|
|
96
|
+
}, [gap, height, top]);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div style={container}>
|
|
100
|
+
{steps.map((_, index) => (
|
|
101
|
+
<Step
|
|
102
|
+
key={index}
|
|
103
|
+
trackColor={trackColor}
|
|
104
|
+
fillColor={fillColor}
|
|
105
|
+
currentStep={currentStep}
|
|
106
|
+
currentStepProgress={currentStepProgress}
|
|
107
|
+
index={index}
|
|
108
|
+
/>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getStaticFiles, reevaluateComposition } from "@remotion/studio";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import React, { useEffect } from "react";
|
|
4
|
+
import { watchPublicFolder } from "@remotion/studio";
|
|
5
|
+
import { useRemotionEnvironment } from "remotion";
|
|
6
|
+
|
|
7
|
+
const getCurrentHash = () => {
|
|
8
|
+
const files = getStaticFiles();
|
|
9
|
+
const codeFiles = files.filter((file) => file.name.startsWith("code"));
|
|
10
|
+
const contents = codeFiles.map((file) => file.src + file.lastModified);
|
|
11
|
+
return contents.join("");
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const RefreshOnCodeChange: React.FC = () => {
|
|
15
|
+
const [files, setFiles] = useState(getCurrentHash());
|
|
16
|
+
const env = useRemotionEnvironment();
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (env.isReadOnlyStudio) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (!env.isStudio) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const { cancel } = watchPublicFolder(() => {
|
|
26
|
+
const hash = getCurrentHash();
|
|
27
|
+
if (hash !== files) {
|
|
28
|
+
setFiles(hash);
|
|
29
|
+
reevaluateComposition();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return () => {
|
|
34
|
+
cancel();
|
|
35
|
+
};
|
|
36
|
+
// oxlint-disable-next-line
|
|
37
|
+
}, [files, env.isReadOnlyStudio]);
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
};
|
package/src/Root.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Composition, Folder } from "remotion";
|
|
2
|
+
import { Main } from "./Main";
|
|
3
|
+
import "./index.css";
|
|
4
|
+
|
|
5
|
+
import { calculateMetadata } from "./calculate-metadata/calculate-metadata";
|
|
6
|
+
import { schema } from "./calculate-metadata/schema";
|
|
7
|
+
|
|
8
|
+
export const RemotionRoot = () => {
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<Composition
|
|
12
|
+
id="Main"
|
|
13
|
+
component={Main}
|
|
14
|
+
defaultProps={{
|
|
15
|
+
steps: null,
|
|
16
|
+
themeColors: null,
|
|
17
|
+
codeWidth: null,
|
|
18
|
+
theme: "vercel-dark" as const,
|
|
19
|
+
preset: "tutorial" as const,
|
|
20
|
+
animation: "morph" as const,
|
|
21
|
+
charsPerSecond: 30,
|
|
22
|
+
}}
|
|
23
|
+
fps={30}
|
|
24
|
+
height={1080}
|
|
25
|
+
calculateMetadata={calculateMetadata}
|
|
26
|
+
schema={schema}
|
|
27
|
+
/>
|
|
28
|
+
<Folder name="Presets">
|
|
29
|
+
<Composition
|
|
30
|
+
id="Preset-Post"
|
|
31
|
+
component={Main}
|
|
32
|
+
width={720}
|
|
33
|
+
height={1280}
|
|
34
|
+
fps={30}
|
|
35
|
+
durationInFrames={150}
|
|
36
|
+
defaultProps={{
|
|
37
|
+
steps: null,
|
|
38
|
+
themeColors: null,
|
|
39
|
+
codeWidth: null,
|
|
40
|
+
theme: "vercel-dark" as const,
|
|
41
|
+
preset: "post" as const,
|
|
42
|
+
animation: "morph" as const,
|
|
43
|
+
charsPerSecond: 30,
|
|
44
|
+
}}
|
|
45
|
+
calculateMetadata={calculateMetadata}
|
|
46
|
+
schema={schema}
|
|
47
|
+
/>
|
|
48
|
+
<Composition
|
|
49
|
+
id="Preset-Tutorial"
|
|
50
|
+
component={Main}
|
|
51
|
+
width={1920}
|
|
52
|
+
height={1080}
|
|
53
|
+
fps={30}
|
|
54
|
+
durationInFrames={300}
|
|
55
|
+
defaultProps={{
|
|
56
|
+
steps: null,
|
|
57
|
+
themeColors: null,
|
|
58
|
+
codeWidth: null,
|
|
59
|
+
theme: "vercel-dark" as const,
|
|
60
|
+
preset: "tutorial" as const,
|
|
61
|
+
animation: "morph" as const,
|
|
62
|
+
charsPerSecond: 30,
|
|
63
|
+
}}
|
|
64
|
+
calculateMetadata={calculateMetadata}
|
|
65
|
+
schema={schema}
|
|
66
|
+
/>
|
|
67
|
+
<Composition
|
|
68
|
+
id="Preset-Square"
|
|
69
|
+
component={Main}
|
|
70
|
+
width={1080}
|
|
71
|
+
height={1080}
|
|
72
|
+
fps={30}
|
|
73
|
+
durationInFrames={150}
|
|
74
|
+
defaultProps={{
|
|
75
|
+
steps: null,
|
|
76
|
+
themeColors: null,
|
|
77
|
+
codeWidth: null,
|
|
78
|
+
theme: "vercel-dark" as const,
|
|
79
|
+
preset: "square" as const,
|
|
80
|
+
animation: "morph" as const,
|
|
81
|
+
charsPerSecond: 30,
|
|
82
|
+
}}
|
|
83
|
+
calculateMetadata={calculateMetadata}
|
|
84
|
+
schema={schema}
|
|
85
|
+
/>
|
|
86
|
+
</Folder>
|
|
87
|
+
</>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
|
3
|
+
import { HighlightedCode } from "codehike/code";
|
|
4
|
+
import { fontFamily, fontSize as baseFontSize, tabSize } from "./font";
|
|
5
|
+
import { useThemeColors } from "./calculate-metadata/theme";
|
|
6
|
+
|
|
7
|
+
interface CharInfo {
|
|
8
|
+
char: string;
|
|
9
|
+
style: React.CSSProperties;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function flattenCode(code: HighlightedCode): CharInfo[] {
|
|
13
|
+
const chars: CharInfo[] = [];
|
|
14
|
+
|
|
15
|
+
for (const token of code.tokens) {
|
|
16
|
+
if (typeof token === "string") {
|
|
17
|
+
for (const char of token) {
|
|
18
|
+
chars.push({ char, style: {} });
|
|
19
|
+
}
|
|
20
|
+
} else {
|
|
21
|
+
const [content, color, style] = token;
|
|
22
|
+
const tokenStyle: React.CSSProperties = {
|
|
23
|
+
...style,
|
|
24
|
+
color: color ?? style?.color,
|
|
25
|
+
};
|
|
26
|
+
for (const char of content) {
|
|
27
|
+
chars.push({ char, style: tokenStyle });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
while (chars.length > 0 && chars[chars.length - 1].char === "\n") {
|
|
33
|
+
chars.pop();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return chars;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Segment {
|
|
40
|
+
text: string;
|
|
41
|
+
style: React.CSSProperties;
|
|
42
|
+
startIndex: number;
|
|
43
|
+
isNewline: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function groupCharsIntoSegments(chars: CharInfo[]): Segment[] {
|
|
47
|
+
const segments: Segment[] = [];
|
|
48
|
+
let i = 0;
|
|
49
|
+
|
|
50
|
+
while (i < chars.length) {
|
|
51
|
+
const { char, style } = chars[i];
|
|
52
|
+
|
|
53
|
+
if (char === "\n") {
|
|
54
|
+
segments.push({ text: "\n", style: {}, startIndex: i, isNewline: true });
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let text = char;
|
|
60
|
+
const startIndex = i;
|
|
61
|
+
i++;
|
|
62
|
+
|
|
63
|
+
while (
|
|
64
|
+
i < chars.length &&
|
|
65
|
+
chars[i].char !== "\n" &&
|
|
66
|
+
chars[i].style.color === style.color
|
|
67
|
+
) {
|
|
68
|
+
text += chars[i].char;
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
segments.push({ text, style, startIndex, isNewline: false });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return segments;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function TypewriterTransition({
|
|
79
|
+
code,
|
|
80
|
+
charsPerSecond = 30,
|
|
81
|
+
fontSize,
|
|
82
|
+
}: {
|
|
83
|
+
readonly code: HighlightedCode;
|
|
84
|
+
readonly charsPerSecond?: number;
|
|
85
|
+
readonly fontSize?: number;
|
|
86
|
+
}) {
|
|
87
|
+
const frame = useCurrentFrame();
|
|
88
|
+
const { fps } = useVideoConfig();
|
|
89
|
+
const themeColors = useThemeColors();
|
|
90
|
+
|
|
91
|
+
const chars = useMemo(() => flattenCode(code), [code]);
|
|
92
|
+
const segments = useMemo(() => groupCharsIntoSegments(chars), [chars]);
|
|
93
|
+
const totalChars = chars.length;
|
|
94
|
+
|
|
95
|
+
const typingDuration = (totalChars / charsPerSecond) * fps;
|
|
96
|
+
|
|
97
|
+
const charsShown = Math.floor(
|
|
98
|
+
interpolate(frame, [0, typingDuration], [0, totalChars], {
|
|
99
|
+
extrapolateRight: "clamp",
|
|
100
|
+
extrapolateLeft: "clamp",
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const isComplete = charsShown >= totalChars;
|
|
105
|
+
const blinkInterval = Math.round(fps / 2);
|
|
106
|
+
const cursorVisible = isComplete
|
|
107
|
+
? Math.floor(frame / blinkInterval) % 2 === 0
|
|
108
|
+
: true;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<pre
|
|
112
|
+
style={{
|
|
113
|
+
fontSize: fontSize ?? baseFontSize,
|
|
114
|
+
lineHeight: 1.5,
|
|
115
|
+
fontFamily,
|
|
116
|
+
tabSize,
|
|
117
|
+
margin: 0,
|
|
118
|
+
whiteSpace: "pre",
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<code>
|
|
122
|
+
{segments.map((segment, idx) => {
|
|
123
|
+
const segmentEnd = segment.startIndex + segment.text.length;
|
|
124
|
+
|
|
125
|
+
if (segment.startIndex >= charsShown) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (segment.isNewline) {
|
|
130
|
+
return <br key={idx} />;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const visibleLength = Math.min(
|
|
134
|
+
segment.text.length,
|
|
135
|
+
charsShown - segment.startIndex,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (segmentEnd <= charsShown) {
|
|
139
|
+
return (
|
|
140
|
+
<span key={idx} style={segment.style}>
|
|
141
|
+
{segment.text}
|
|
142
|
+
</span>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<span key={idx} style={segment.style}>
|
|
148
|
+
{segment.text.slice(0, visibleLength)}
|
|
149
|
+
</span>
|
|
150
|
+
);
|
|
151
|
+
})}
|
|
152
|
+
<span
|
|
153
|
+
key="cursor"
|
|
154
|
+
style={{
|
|
155
|
+
display: "inline-block",
|
|
156
|
+
width: "0.6em",
|
|
157
|
+
height: "1.2em",
|
|
158
|
+
backgroundColor: themeColors?.foreground ?? "currentColor",
|
|
159
|
+
opacity: cursorVisible ? 0.8 : 0,
|
|
160
|
+
verticalAlign: "text-bottom",
|
|
161
|
+
marginLeft: 2,
|
|
162
|
+
}}
|
|
163
|
+
/>
|
|
164
|
+
</code>
|
|
165
|
+
</pre>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InlineAnnotation,
|
|
3
|
+
AnnotationHandler,
|
|
4
|
+
InnerLine,
|
|
5
|
+
Pre,
|
|
6
|
+
} from "codehike/code";
|
|
7
|
+
import { interpolate, useCurrentFrame } from "remotion";
|
|
8
|
+
import { useThemeColors } from "../calculate-metadata/theme";
|
|
9
|
+
import { mix, readableColor } from "polished";
|
|
10
|
+
|
|
11
|
+
export const callout: AnnotationHandler = {
|
|
12
|
+
name: "callout",
|
|
13
|
+
transform: (annotation: InlineAnnotation) => {
|
|
14
|
+
const { name, query, lineNumber, fromColumn, toColumn, data } = annotation;
|
|
15
|
+
return {
|
|
16
|
+
name,
|
|
17
|
+
query,
|
|
18
|
+
fromLineNumber: lineNumber,
|
|
19
|
+
toLineNumber: lineNumber,
|
|
20
|
+
data: { ...data, column: (fromColumn + toColumn) / 2 },
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
AnnotatedLine: ({ annotation, ...props }) => {
|
|
24
|
+
const { column, codeblock } = annotation.data;
|
|
25
|
+
const { indentation } = props;
|
|
26
|
+
const frame = useCurrentFrame();
|
|
27
|
+
|
|
28
|
+
const opacity = interpolate(frame, [25, 35], [0, 1], {
|
|
29
|
+
extrapolateLeft: "clamp",
|
|
30
|
+
extrapolateRight: "clamp",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const themeColors = useThemeColors();
|
|
34
|
+
|
|
35
|
+
const color = readableColor(themeColors.background);
|
|
36
|
+
const calloutColor = mix(0.08, color, themeColors.background);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
<InnerLine merge={props} />
|
|
41
|
+
<div
|
|
42
|
+
style={{
|
|
43
|
+
opacity,
|
|
44
|
+
minWidth: `${column + 4}ch`,
|
|
45
|
+
marginLeft: `${indentation}ch`,
|
|
46
|
+
width: "fit-content",
|
|
47
|
+
backgroundColor: calloutColor,
|
|
48
|
+
padding: "1rem 2rem",
|
|
49
|
+
position: "relative",
|
|
50
|
+
marginTop: "0.25rem",
|
|
51
|
+
whiteSpace: "pre-wrap",
|
|
52
|
+
color: themeColors.editor.foreground,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<div
|
|
56
|
+
style={{
|
|
57
|
+
left: `${column - indentation - 1}ch`,
|
|
58
|
+
position: "absolute",
|
|
59
|
+
width: "1rem",
|
|
60
|
+
height: "1rem",
|
|
61
|
+
transform: "rotate(45deg) translateY(-50%)",
|
|
62
|
+
top: "-2px",
|
|
63
|
+
backgroundColor: calloutColor,
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
{codeblock ? (
|
|
67
|
+
<Pre
|
|
68
|
+
code={codeblock}
|
|
69
|
+
style={{ margin: 0, fontFamily: "inherit" }}
|
|
70
|
+
/>
|
|
71
|
+
) : (
|
|
72
|
+
annotation.data.children || annotation.query
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
};
|