diff --git a/package-lock.json b/package-lock.json
index 0cc70cb..5813422 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 17563dd..0a49b64 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/ui/item-content.tsx b/src/components/ui/item-content.tsx
index 9d60271..ebaae9d 100644
--- a/src/components/ui/item-content.tsx
+++ b/src/components/ui/item-content.tsx
@@ -7,7 +7,8 @@ interface ItemContentProps extends PropsWithChildren {
function ItemContent({ children, classes }: ItemContentProps) {
return (
{children}
diff --git a/src/components/video-editor/timeline/Item.module.css b/src/components/video-editor/timeline/Item.module.css
index ac79e29..e69de29 100644
--- a/src/components/video-editor/timeline/Item.module.css
+++ b/src/components/video-editor/timeline/Item.module.css
@@ -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;
-}
diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx
index cfab696..66c10cb 100644
--- a/src/components/video-editor/timeline/Item.tsx
+++ b/src/components/video-editor/timeline/Item.tsx
@@ -42,19 +42,26 @@ export default function Item({ id, span, rowId, isSelected = false, onSelect, zo
{
event.stopPropagation();
onSelect?.();
}}
>
-
-
-
+
+
{/* Content */}
diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css
index 303a347..adef673 100644
--- a/src/components/video-editor/timeline/ItemGlass.module.css
+++ b/src/components/video-editor/timeline/ItemGlass.module.css
@@ -28,7 +28,6 @@
position: absolute;
top: 0;
bottom: 0;
- background: #34B27B;
width: 4px;
pointer-events: none;
z-index: 2;
diff --git a/src/components/video-editor/timeline/KeyframeMarkers.tsx b/src/components/video-editor/timeline/KeyframeMarkers.tsx
new file mode 100644
index 0000000..ce4973d
--- /dev/null
+++ b/src/components/video-editor/timeline/KeyframeMarkers.tsx
@@ -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
= ({ 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 (
+ {
+ e.stopPropagation();
+ setSelectedKeyframeId(kf.id);
+ }}
+ title={`Keyframe @ ${kf.time}ms`}
+ >
+
+
+ );
+ })}
+ >
+ );
+};
+
+export default KeyframeMarkers;
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 8b62801..6599889 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -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(() => createInitialRange(totalMs));
+ const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
+ const [selectedKeyframeId, setSelectedKeyframeId] = useState(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({
- ⇧ + ⌘ + Scroll
+ ⇧ + ⌘ + Scroll
Pan
- ⌘ + Scroll
+ ⌘ + Scroll
Zoom
+
+ F
+ Add Keyframe
+
-
+
setSelectedKeyframeId(null)}
+ >
+