keyframes

This commit is contained in:
Siddharth
2025-11-25 18:45:37 -07:00
parent 060a7bab92
commit 98d6acaa6a
8 changed files with 132 additions and 22 deletions
+8
View File
@@ -35,6 +35,7 @@
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"@vitejs/plugin-react": "^4.2.1",
@@ -3516,6 +3517,13 @@
"@types/node": "*"
}
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/verror": {
"version": "1.10.11",
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz",
+1
View File
@@ -39,6 +39,7 @@
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"@vitejs/plugin-react": "^4.2.1",
+2 -1
View File
@@ -7,7 +7,8 @@ interface ItemContentProps extends PropsWithChildren {
function ItemContent({ children, classes }: ItemContentProps) {
return (
<div
className={`border-2 rounded-sm shadow-md w-full overflow-hidden flex flex-row pl-3 h-items-center ${classes}`}
className={`bg-white/5 border border-white/10 rounded-md shadow-sm w-full flex flex-row items-center px-3 py-1 gap-2 transition-all duration-150 hover:bg-[#34B27B]/10 hover:shadow-lg ${classes}`}
style={{ minHeight: 40 }}
>
{children}
</div>
@@ -1,12 +0,0 @@
.itemDark {
background: #23232a;
border: 1px solid #34B27B;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.18);
color: #e5e7eb;
transition: box-shadow 0.2s, border 0.2s, background 0.2s;
}
.squircle {
border-radius: 12px;
-corner-smoothing: antialiased;
}
+12 -5
View File
@@ -42,19 +42,26 @@ export default function Item({ id, span, rowId, isSelected = false, onSelect, zo
<div style={itemContentStyle}>
<div
className={cn(
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
glassStyles.glassGreen,
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected
)}
style={{ height: 48 }}
style={{ height: 48, color: '#fff' }}
onClick={(event) => {
event.stopPropagation();
onSelect?.();
}}
>
<div className={cn(glassStyles.zoomEndCap, glassStyles.left)} />
<div className={cn(glassStyles.zoomEndCap, glassStyles.right)} />
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: '#21916A' }}
title="Resize left"
/>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: '#21916A' }}
title="Resize right"
/>
{/* Content */}
<div className="relative z-10 flex items-center gap-1.5 text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none">
<ZoomIn className="w-3.5 h-3.5" />
@@ -28,7 +28,6 @@
position: absolute;
top: 0;
bottom: 0;
background: #34B27B;
width: 4px;
pointer-events: none;
z-index: 2;
@@ -0,0 +1,50 @@
import React from "react";
import { useTimelineContext } from "dnd-timeline";
interface Keyframe {
id: string;
time: number;
}
interface KeyframeMarkersProps {
keyframes: Keyframe[];
selectedKeyframeId: string | null;
setSelectedKeyframeId: (id: string | null) => void;
}
const KeyframeMarkers: React.FC<KeyframeMarkersProps> = ({ keyframes, selectedKeyframeId, setSelectedKeyframeId }) => {
const { sidebarWidth, range, valueToPixels } = useTimelineContext();
return (
<>
{keyframes.map(kf => {
const offset = valueToPixels(kf.time - range.start);
const isSelected = kf.id === selectedKeyframeId;
return (
<div
key={kf.id}
className={`absolute top-8 cursor-pointer ${isSelected ? 'ring-2 ring-[#34B27B]' : ''}`}
style={{ left: `${sidebarWidth + offset - 8}px`, zIndex: 40 }}
onClick={e => {
e.stopPropagation();
setSelectedKeyframeId(kf.id);
}}
title={`Keyframe @ ${kf.time}ms`}
>
<div style={{
width: '10px',
height: '10px',
background: '#ffe100ff',
transform: 'rotate(45deg)',
border: 'none',
opacity: isSelected ? 1 : 0.6,
transition: 'opacity 0.15s',
}} />
</div>
);
})}
</>
);
};
export default KeyframeMarkers;
@@ -7,8 +7,10 @@ import { cn } from "@/lib/utils";
import TimelineWrapper from "./TimelineWrapper";
import Row from "./Row";
import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
import type { Range, Span } from "dnd-timeline";
import type { ZoomRegion } from "../types";
import { v4 as uuidv4 } from 'uuid';
const ROW_ID = "row-1";
const FALLBACK_RANGE_MS = 1000;
@@ -361,6 +363,7 @@ export default function TimelineEditor({
zoomRegions,
onZoomAdded,
onZoomSpanChange,
onZoomDelete,
selectedZoomId,
onSelectZoom,
}: TimelineEditorProps) {
@@ -373,6 +376,48 @@ export default function TimelineEditor({
);
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
// Add keyframe at current playhead position
const addKeyframe = useCallback(() => {
if (totalMs === 0) return;
const time = Math.max(0, Math.min(currentTimeMs, totalMs));
if (keyframes.some(kf => Math.abs(kf.time - time) < 1)) return;
setKeyframes(prev => [...prev, { id: uuidv4(), time }]);
}, [currentTimeMs, totalMs, keyframes]);
// Delete selected keyframe
const deleteSelectedKeyframe = useCallback(() => {
if (!selectedKeyframeId) return;
setKeyframes(prev => prev.filter(kf => kf.id !== selectedKeyframeId));
setSelectedKeyframeId(null);
}, [selectedKeyframeId]);
// Delete selected zoom item
const deleteSelectedZoom = useCallback(() => {
if (!selectedZoomId) return;
onZoomDelete(selectedZoomId);
onSelectZoom(null);
}, [selectedZoomId, onZoomDelete, onSelectZoom]);
// Listen for F key to add keyframe, Ctrl+D to remove selected keyframe or zoom item
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'f' || e.key === 'F') {
addKeyframe();
}
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) {
if (selectedKeyframeId) {
deleteSelectedKeyframe();
} else if (selectedZoomId) {
deleteSelectedZoom();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [addKeyframe, deleteSelectedKeyframe, deleteSelectedZoom, selectedKeyframeId, selectedZoomId]);
useEffect(() => {
setRange(createInitialRange(totalMs));
@@ -491,16 +536,22 @@ export default function TimelineEditor({
<div className="flex-1" />
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-slate-400 font-sans"> + + Scroll</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans"> + + Scroll</kbd>
<span>Pan</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-slate-400 font-sans"> + Scroll</kbd>
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans"> + Scroll</kbd>
<span>Zoom</span>
</span>
<span className="flex items-center gap-1.5">
<kbd className="px-1.5 py-0.5 bg-white/5 border border-white/10 rounded text-[#34B27B] font-sans">F</kbd>
<span>Add Keyframe</span>
</span>
</div>
</div>
<div className="flex-1 overflow-hidden bg-[#09090b] relative">
<div className="flex-1 overflow-hidden bg-[#09090b] relative"
onClick={() => setSelectedKeyframeId(null)}
>
<TimelineWrapper
range={clampedRange}
videoDuration={videoDuration}
@@ -511,6 +562,11 @@ export default function TimelineEditor({
gridSizeMs={timelineScale.gridMs}
onItemSpanChange={onZoomSpanChange}
>
<KeyframeMarkers
keyframes={keyframes}
selectedKeyframeId={selectedKeyframeId}
setSelectedKeyframeId={setSelectedKeyframeId}
/>
<Timeline
items={timelineItems}
videoDurationMs={totalMs}