keyframes
This commit is contained in:
Generated
+8
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user