feat: implement video editor timeline components with interactive zoom, trim, and speed region controls.

This commit is contained in:
moncef
2026-04-07 00:30:23 +01:00
parent 24928164ca
commit 112f02fe03
9 changed files with 230 additions and 15 deletions
+98 -1
View File
@@ -1,5 +1,5 @@
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
import { useItem, useTimelineContext } from "dnd-timeline";
import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
@@ -13,8 +13,11 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
speedValue?: number;
variant?: "zoom" | "trim" | "annotation" | "speed";
onZoomDurationChange?: (id: string, zoomIn: number, zoomOut: number) => void;
}
// Map zoom depth to multiplier labels
@@ -44,10 +47,14 @@ export default function Item({
isSelected = false,
onSelect,
zoomDepth = 1,
zoomInDurationMs,
zoomOutDurationMs,
speedValue,
variant = "zoom",
children,
onZoomDurationChange,
}: ItemProps) {
const { pixelsToValue } = useTimelineContext();
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
@@ -101,6 +108,96 @@ export default function Item({
onSelect?.();
}}
>
{isZoom && (
<>
{/* Transition In Marker */}
<div
className="absolute top-0 bottom-0 left-0 bg-white/10 border-r border-white/20 pointer-events-none"
style={{
width: `${((zoomInDurationMs ?? 1522.575) / (span.end - span.start)) * 100}%`,
}}
/>
{/* Draggable handle for Transition In */}
<div
className="absolute top-0 bottom-0 w-2 cursor-col-resize z-20 group-hover:bg-white/5 transition-colors"
style={{
left: `${((zoomInDurationMs ?? 1522.575) / (span.end - span.start)) * 100}%`,
transform: "translateX(-50%)",
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
const onPointerMove = (moveEvent: PointerEvent) => {
const deltaPx = moveEvent.clientX - e.clientX;
const deltaMs = pixelsToValue(deltaPx);
const newDuration = Math.max(
0,
Math.min(
(zoomInDurationMs ?? 1522.575) + deltaMs,
span.end - span.start - (zoomOutDurationMs ?? 1015.05),
),
);
onZoomDurationChange?.(id, newDuration, zoomOutDurationMs ?? 1015.05);
};
const onPointerUp = () => {
target.releasePointerCapture(e.pointerId);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}}
/>
{/* Transition Out Marker */}
<div
className="absolute top-0 bottom-0 right-0 bg-white/10 border-l border-white/20 pointer-events-none"
style={{
width: `${((zoomOutDurationMs ?? 1015.05) / (span.end - span.start)) * 100}%`,
}}
/>
{/* Draggable handle for Transition Out */}
<div
className="absolute top-0 bottom-0 w-2 cursor-col-resize z-20 group-hover:bg-white/5 transition-colors"
style={{
right: `${((zoomOutDurationMs ?? 1015.05) / (span.end - span.start)) * 100}%`,
transform: "translateX(50%)",
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget;
target.setPointerCapture(e.pointerId);
const onPointerMove = (moveEvent: PointerEvent) => {
const deltaPx = e.clientX - moveEvent.clientX; // Inverted because right-anchored
const deltaMs = pixelsToValue(deltaPx);
const newDuration = Math.max(
0,
Math.min(
(zoomOutDurationMs ?? 1015.05) + deltaMs,
span.end - span.start - (zoomInDurationMs ?? 1522.575),
),
);
onZoomDurationChange?.(id, zoomInDurationMs ?? 1522.575, newDuration);
};
const onPointerUp = () => {
target.releasePointerCapture(e.pointerId);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}}
/>
</>
)}
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{
@@ -58,6 +58,7 @@ interface TimelineEditorProps {
onZoomAdded: (span: Span) => void;
onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
onZoomSpanChange: (id: string, span: Span) => void;
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
onZoomDelete: (id: string) => void;
selectedZoomId: string | null;
onSelectZoom: (id: string | null) => void;
@@ -96,6 +97,8 @@ interface TimelineRenderItem {
label: string;
zoomDepth?: number;
speedValue?: number;
zoomInDurationMs?: number;
zoomOutDurationMs?: number;
variant: "zoom" | "trim" | "annotation" | "speed";
}
@@ -530,6 +533,7 @@ function Timeline({
selectedTrimId,
selectedAnnotationId,
selectedSpeedId,
onZoomDurationChange,
keyframes = [],
}: {
items: TimelineRenderItem[];
@@ -545,6 +549,7 @@ function Timeline({
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
selectedSpeedId?: string | null;
onZoomDurationChange: (id: string, zoomIn: number, zoomOut: number) => void;
keyframes?: { id: string; time: number }[];
}) {
const t = useScopedT("timeline");
@@ -668,6 +673,9 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
zoomInDurationMs={item.zoomInDurationMs}
zoomOutDurationMs={item.zoomOutDurationMs}
onZoomDurationChange={onZoomDurationChange}
variant="zoom"
>
{item.label}
@@ -740,6 +748,7 @@ export default function TimelineEditor({
onZoomAdded,
onZoomSuggested,
onZoomSpanChange,
onZoomDurationChange,
onZoomDelete,
selectedZoomId,
onSelectZoom,
@@ -1271,6 +1280,8 @@ export default function TimelineEditor({
span: { start: region.startMs, end: region.endMs },
label: t("labels.zoomItem", { index: String(index + 1) }),
zoomDepth: region.depth,
zoomInDurationMs: region.zoomInDurationMs,
zoomOutDurationMs: region.zoomOutDurationMs,
variant: "zoom",
}));
@@ -1494,6 +1505,7 @@ export default function TimelineEditor({
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
selectedSpeedId={selectedSpeedId}
onZoomDurationChange={onZoomDurationChange}
keyframes={keyframes}
/>
</TimelineWrapper>