import React from 'react';
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring } from 'remotion';
interface DiffLine {
content: string;
type: 'added' | 'removed' | 'unchanged' | 'context';
lineNumber?: number;
heatLevel?: number;
}
interface CodeDiffProps {
startFrame: number;
durationInFrames: number;
lines: DiffLine[] | string;
mode: 'unified' | 'split';
language: string;
showLineNumbers: boolean;
showHeatmap: boolean;
title: string;
leftLabel: string;
rightLabel: string;
theme: 'dark' | 'light' | 'github' | 'monokai';
width: number;
height: number;
position: string;
animateLines: boolean;
}
export const CodeDiff: React.FC<CodeDiffProps> = ({
startFrame,
durationInFrames,
lines = [],
mode = 'unified',
language = 'typescript',
showLineNumbers = true,
showHeatmap = false,
title = 'Code Comparison',
leftLabel = 'Before',
rightLabel = 'After',
theme = 'dark',
width = 1400,
height = 800,
position = 'center',
animateLines = true,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Visibility check
if (frame < startFrame || frame >= startFrame + durationInFrames) {
return null;
}
const relativeFrame = frame - startFrame;
// Parse lines if it's a string
const parsedLines: DiffLine[] = typeof lines === 'string' ? JSON.parse(lines) : lines;
// Theme configurations using design tokens
const themeStyles = {
dark: {
bg: '[[ colors.background.darker ]]',
chrome: 'rgba(45, 45, 45, 0.95)',
text: '[[ colors.text.on_dark ]]',
lineNumber: '[[ colors.text.muted ]]',
added: '[[ colors.semantic.success ]]20',
addedText: '[[ colors.semantic.success ]]',
removed: '[[ colors.semantic.error ]]20',
removedText: '[[ colors.semantic.error ]]',
unchanged: 'transparent',
border: '[[ colors.border.light ]]',
},
light: {
bg: '[[ colors.background.light ]]',
chrome: 'rgba(246, 246, 246, 0.95)',
text: '[[ colors.text.on_light ]]',
lineNumber: '[[ colors.text.muted ]]',
added: '[[ colors.semantic.success ]]15',
addedText: '[[ colors.semantic.success ]]',
removed: '[[ colors.semantic.error ]]15',
removedText: '[[ colors.semantic.error ]]',
unchanged: 'transparent',
border: '[[ colors.border.medium ]]',
},
github: {
bg: '[[ colors.background.light ]]',
chrome: 'rgba(246, 248, 250, 0.95)',
text: '[[ colors.text.on_light ]]',
lineNumber: '[[ colors.text.muted ]]',
added: '[[ colors.semantic.success ]]12',
addedText: '[[ colors.semantic.success ]]',
removed: '[[ colors.semantic.error ]]12',
removedText: '[[ colors.semantic.error ]]',
unchanged: 'transparent',
border: '[[ colors.border.light ]]',
},
monokai: {
bg: '[[ colors.background.dark ]]',
chrome: 'rgba(62, 61, 50, 0.95)',
text: '[[ colors.text.on_dark ]]',
lineNumber: '[[ colors.text.muted ]]',
added: '[[ colors.semantic.success ]]25',
addedText: '[[ colors.semantic.success ]]',
removed: '[[ colors.semantic.error ]]25',
removedText: '[[ colors.semantic.error ]]',
unchanged: 'transparent',
border: '[[ colors.border.subtle ]]',
},
};
const currentTheme = themeStyles[theme];
// Position mappings
const positionMap: Record<string, { top?: string; left?: string; right?: string; bottom?: string; transform?: string }> = {
center: {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
},
'top-left': { top: '[[ spacing.spacing.xl ]]', left: '[[ spacing.spacing.xl ]]' },
'top-center': { top: '[[ spacing.spacing.xl ]]', left: '50%', transform: 'translateX(-50%)' },
'top-right': { top: '[[ spacing.spacing.xl ]]', right: '[[ spacing.spacing.xl ]]' },
'center-left': { top: '50%', left: '[[ spacing.spacing.xl ]]', transform: 'translateY(-50%)' },
'center-right': { top: '50%', right: '[[ spacing.spacing.xl ]]', transform: 'translateY(-50%)' },
'bottom-left': { bottom: '[[ spacing.spacing.xl ]]', left: '[[ spacing.spacing.xl ]]' },
'bottom-center': { bottom: '[[ spacing.spacing.xl ]]', left: '50%', transform: 'translateX(-50%)' },
'bottom-right': { bottom: '[[ spacing.spacing.xl ]]', right: '[[ spacing.spacing.xl ]]' },
};
// Entrance animation
const entrance = spring({
frame: relativeFrame,
fps: fps,
config: {
damping: [[ motion.default_spring.config.damping ]],
stiffness: [[ motion.default_spring.config.stiffness ]],
mass: [[ motion.default_spring.config.mass ]],
},
});
const opacity = interpolate(relativeFrame, [0, 15], [0, 1], { extrapolateRight: 'clamp' });
const scaleAnimation = interpolate(entrance, [0, 1], [0.95, 1]);
// Line animation
const getLineOpacity = (index: number): number => {
if (!animateLines) return 1;
const lineStartFrame = 20 + index * 2;
return interpolate(relativeFrame, [lineStartFrame, lineStartFrame + 10], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
};
// Heatmap color
const getHeatmapColor = (heatLevel: number | undefined): string => {
if (!showHeatmap || heatLevel === undefined) return 'transparent';
const intensity = heatLevel / 100;
return `[[ colors.semantic.error ]]${Math.round(intensity * 0.3 * 255).toString(16).padStart(2, '0')}`;
};
// Diff symbol
const getDiffSymbol = (type: string): string => {
if (type === 'added') return '+';
if (type === 'removed') return '-';
return ' ';
};
// Render unified diff
const renderUnifiedDiff = () => (
<div style={{ flex: 1, overflow: 'auto', padding: parseInt('[[ spacing.spacing.md ]]') }}>
{parsedLines.map((line, index) => {
const lineOpacity = getLineOpacity(index);
const bgColor =
line.type === 'added'
? currentTheme.added
: line.type === 'removed'
? currentTheme.removed
: currentTheme.unchanged;
const textColor =
line.type === 'added'
? currentTheme.addedText
: line.type === 'removed'
? currentTheme.removedText
: currentTheme.text;
return (
<div
key={index}
style={{
display: 'flex',
alignItems: 'flex-start',
background: showHeatmap ? getHeatmapColor(line.heatLevel) : bgColor,
borderLeft: line.type !== 'unchanged' ? `3px solid ${textColor}` : 'none',
opacity: lineOpacity,
minHeight: parseInt('[[ spacing.spacing.lg ]]'),
}}
>
{showLineNumbers && (
<span
style={{
color: currentTheme.lineNumber,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].sm ]]'),
fontFamily: "'[[ "', '".join(typography.code_font.fonts) ]]'",
minWidth: parseInt('[[ spacing.spacing["3xl"] ]]'),
textAlign: 'right',
padding: `[[ spacing.spacing.xxs ]] [[ spacing.spacing.sm ]]`,
userSelect: 'none',
}}
>
{line.lineNumber || ''}
</span>
)}
<span
style={{
color: textColor,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].sm ]]'),
fontFamily: "'[[ "', '".join(typography.code_font.fonts) ]]'",
padding: `[[ spacing.spacing.xxs ]] [[ spacing.spacing.xs ]]`,
userSelect: 'none',
width: parseInt('[[ spacing.spacing.lg ]]'),
}}
>
{getDiffSymbol(line.type)}
</span>
<pre
style={{
margin: 0,
color: textColor,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].base ]]'),
fontFamily: "'[[ "', '".join(typography.code_font.fonts) ]]'",
padding: `[[ spacing.spacing.xxs ]] [[ spacing.spacing.sm ]] [[ spacing.spacing.xxs ]] 0`,
flex: 1,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{line.content}
</pre>
</div>
);
})}
</div>
);
// Render split diff
const renderSplitDiff = () => {
const leftLines = parsedLines.filter((l) => l.type === 'removed' || l.type === 'unchanged');
const rightLines = parsedLines.filter((l) => l.type === 'added' || l.type === 'unchanged');
return (
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Left side (before) */}
<div style={{ flex: 1, borderRight: `1px solid ${currentTheme.border}`, overflow: 'auto' }}>
<div
style={{
background: currentTheme.chrome,
padding: `[[ spacing.spacing.xs ]] [[ spacing.spacing.md ]]`,
borderBottom: `1px solid ${currentTheme.border}`,
color: currentTheme.text,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].base ]]'),
fontFamily: "'[[ "', '".join(typography.body_font.fonts) ]]'",
fontWeight: parseInt('[[ typography.font_weights.semibold ]]'),
}}
>
{leftLabel}
</div>
<div style={{ padding: parseInt('[[ spacing.spacing.md ]]') }}>
{leftLines.map((line, index) => {
const lineOpacity = getLineOpacity(index);
return (
<div
key={index}
style={{
display: 'flex',
alignItems: 'flex-start',
background: line.type === 'removed' ? currentTheme.removed : currentTheme.unchanged,
opacity: lineOpacity,
minHeight: parseInt('[[ spacing.spacing.lg ]]'),
}}
>
{showLineNumbers && (
<span
style={{
color: currentTheme.lineNumber,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].sm ]]'),
fontFamily: "'[[ "', '".join(typography.code_font.fonts) ]]'",
minWidth: parseInt('[[ spacing.spacing["3xl"] ]]'),
textAlign: 'right',
padding: `[[ spacing.spacing.xxs ]] [[ spacing.spacing.sm ]]`,
userSelect: 'none',
}}
>
{line.lineNumber || ''}
</span>
)}
<pre
style={{
margin: 0,
color: line.type === 'removed' ? currentTheme.removedText : currentTheme.text,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].base ]]'),
fontFamily: "'[[ "', '".join(typography.code_font.fonts) ]]'",
padding: `[[ spacing.spacing.xxs ]] [[ spacing.spacing.sm ]]`,
flex: 1,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{line.content}
</pre>
</div>
);
})}
</div>
</div>
{/* Right side (after) */}
<div style={{ flex: 1, overflow: 'auto' }}>
<div
style={{
background: currentTheme.chrome,
padding: `[[ spacing.spacing.xs ]] [[ spacing.spacing.md ]]`,
borderBottom: `1px solid ${currentTheme.border}`,
color: currentTheme.text,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].base ]]'),
fontFamily: "'[[ "', '".join(typography.body_font.fonts) ]]'",
fontWeight: parseInt('[[ typography.font_weights.semibold ]]'),
}}
>
{rightLabel}
</div>
<div style={{ padding: parseInt('[[ spacing.spacing.md ]]') }}>
{rightLines.map((line, index) => {
const lineOpacity = getLineOpacity(index);
return (
<div
key={index}
style={{
display: 'flex',
alignItems: 'flex-start',
background: line.type === 'added' ? currentTheme.added : currentTheme.unchanged,
opacity: lineOpacity,
minHeight: parseInt('[[ spacing.spacing.lg ]]'),
}}
>
{showLineNumbers && (
<span
style={{
color: currentTheme.lineNumber,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].sm ]]'),
fontFamily: "'[[ "', '".join(typography.code_font.fonts) ]]'",
minWidth: parseInt('[[ spacing.spacing["3xl"] ]]'),
textAlign: 'right',
padding: `[[ spacing.spacing.xxs ]] [[ spacing.spacing.sm ]]`,
userSelect: 'none',
}}
>
{line.lineNumber || ''}
</span>
)}
<pre
style={{
margin: 0,
color: line.type === 'added' ? currentTheme.addedText : currentTheme.text,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].base ]]'),
fontFamily: "'[[ "', '".join(typography.code_font.fonts) ]]'",
padding: `[[ spacing.spacing.xxs ]] [[ spacing.spacing.sm ]]`,
flex: 1,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{line.content}
</pre>
</div>
);
})}
</div>
</div>
</div>
);
};
return (
<AbsoluteFill
style={{
...positionMap[position],
width,
height,
opacity,
transform: `${positionMap[position].transform || ''} scale(${scaleAnimation})`,
}}
>
<div
style={{
width: '100%',
height: '100%',
background: currentTheme.bg,
borderRadius: parseInt('[[ spacing.border_radius.lg ]]'),
overflow: 'hidden',
filter: `drop-shadow(0 10px 40px [[ colors.shadow.dark ]])`,
display: 'flex',
flexDirection: 'column',
border: `1px solid ${currentTheme.border}`,
}}
>
{/* Header */}
<div
style={{
background: currentTheme.chrome,
padding: parseInt('[[ spacing.spacing.md ]]'),
borderBottom: `1px solid ${currentTheme.border}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div
style={{
color: currentTheme.text,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].lg ]]'),
fontFamily: "'[[ "', '".join(typography.body_font.fonts) ]]'",
fontWeight: parseInt('[[ typography.font_weights.semibold ]]'),
}}
>
{title}
</div>
<div
style={{
color: currentTheme.lineNumber,
fontSize: parseInt('[[ typography.font_sizes[typography.default_resolution].base ]]'),
fontFamily: "'[[ "', '".join(typography.body_font.fonts) ]]'",
}}
>
{language}
</div>
</div>
{/* Content */}
{mode === 'unified' ? renderUnifiedDiff() : renderSplitDiff()}
</div>
</AbsoluteFill>
);
};