diff --git a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts index e6cd9942..e88ae593 100644 --- a/src/components/video-editor/hooks/useAnnotationRegionCommands.ts +++ b/src/components/video-editor/hooks/useAnnotationRegionCommands.ts @@ -10,6 +10,7 @@ import { } from "../types"; interface UseAnnotationRegionCommandsParams { + onSelectAnnotation: (id: string | null) => void; setAnnotationRegions: Dispatch>; selectedAnnotationId: string | null; setSelectedAnnotationId: Dispatch>; @@ -19,6 +20,7 @@ interface UseAnnotationRegionCommandsParams { } export function useAnnotationRegionCommands({ + onSelectAnnotation, setAnnotationRegions, selectedAnnotationId, setSelectedAnnotationId, @@ -42,14 +44,14 @@ export function useAnnotationRegionCommands({ trackIndex, }; setAnnotationRegions((current) => [...current, newRegion]); - setSelectedAnnotationId(id); + onSelectAnnotation(id); setSelectedZoomId(null); }, [ + onSelectAnnotation, nextAnnotationIdRef, nextAnnotationZIndexRef, setAnnotationRegions, - setSelectedAnnotationId, setSelectedZoomId, ], ); diff --git a/src/components/video-editor/hooks/useAudioRegionCommands.ts b/src/components/video-editor/hooks/useAudioRegionCommands.ts index c02a8f6d..0d40bbc7 100644 --- a/src/components/video-editor/hooks/useAudioRegionCommands.ts +++ b/src/components/video-editor/hooks/useAudioRegionCommands.ts @@ -3,6 +3,7 @@ import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback import type { AudioRegion, EditorEffectSection } from "../types"; interface UseAudioRegionCommandsParams { + setSelectedClipId: Dispatch>; setAudioRegions: Dispatch>; selectedAudioId: string | null; setSelectedAudioId: Dispatch>; @@ -14,6 +15,7 @@ interface UseAudioRegionCommandsParams { } export function useAudioRegionCommands({ + setSelectedClipId, setAudioRegions, selectedAudioId, setSelectedAudioId, @@ -29,6 +31,7 @@ export function useAudioRegionCommands({ if (id) { setSelectedZoomId(null); setSelectedAnnotationId(null); + setSelectedClipId(null); setSelectedCaptionId(null); setActiveEffectSection("audio"); } @@ -36,6 +39,7 @@ export function useAudioRegionCommands({ [ setActiveEffectSection, setSelectedAnnotationId, + setSelectedClipId, setSelectedAudioId, setSelectedCaptionId, setSelectedZoomId, @@ -58,6 +62,7 @@ export function useAudioRegionCommands({ setSelectedAudioId(id); setSelectedZoomId(null); setSelectedAnnotationId(null); + setSelectedClipId(null); setSelectedCaptionId(null); setActiveEffectSection("audio"); }, @@ -66,6 +71,7 @@ export function useAudioRegionCommands({ setActiveEffectSection, setAudioRegions, setSelectedAnnotationId, + setSelectedClipId, setSelectedAudioId, setSelectedCaptionId, setSelectedZoomId, diff --git a/src/components/video-editor/hooks/useClipRegionCommands.ts b/src/components/video-editor/hooks/useClipRegionCommands.ts index 4774edfd..b754bb63 100644 --- a/src/components/video-editor/hooks/useClipRegionCommands.ts +++ b/src/components/video-editor/hooks/useClipRegionCommands.ts @@ -1,8 +1,14 @@ -import type { Span } from "dnd-timeline"; +import type { ClipSequenceSpan } from "../timeline/core/timelineTypes"; import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback } from "react"; import { toast } from "@/components/ui/toast"; import { changeClipSpan } from "../clipSpanChange"; -import { planClipSpeedChange } from "../clipSpeedChange"; +import { + packClipSequence, + reorderClipSequence, + rippleRegionAnchors, + rippleRegions, +} from "../clipSequence"; +import { getClipSourceStartMs, type AnnotationRegion, type AudioRegion } from "../types"; import { planClipSplit } from "../clipSplit"; import type { ClipRegion, EditorEffectSection, ZoomRegion } from "../types"; import { supportsPreviewPlaybackRate } from "../videoPlayback/playbackRate"; @@ -14,6 +20,8 @@ type Translator = ( ) => string; interface UseClipRegionCommandsParams { + setAnnotationRegions: Dispatch>; + setAudioRegions: Dispatch>; sourceDurationMs: number; clipRegions: ClipRegion[]; setClipRegions: Dispatch>; @@ -31,10 +39,11 @@ interface UseClipRegionCommandsParams { } export function useClipRegionCommands({ + setAnnotationRegions, + setAudioRegions, sourceDurationMs, clipRegions, setClipRegions, - zoomRegions, setZoomRegions, selectedClipId, setSelectedClipId, @@ -46,6 +55,17 @@ export function useClipRegionCommands({ nextClipIdRef, t, }: UseClipRegionCommandsParams) { + const applySequence = useCallback( + (edited: ClipRegion[]) => { + const next = packClipSequence(edited); + setClipRegions(next); + setZoomRegions((current) => rippleRegions(current, clipRegions, next)); + setAnnotationRegions((current) => rippleRegions(current, clipRegions, next)); + setAudioRegions((current) => rippleRegionAnchors(current, clipRegions, next)); + }, + [clipRegions, setClipRegions, setZoomRegions, setAnnotationRegions, setAudioRegions], + ); + const handleSelectClip = useCallback( (id: string | null) => { setSelectedClipId(id); @@ -88,37 +108,25 @@ export function useClipRegionCommands({ ); const handleClipSpanChange = useCallback( - (id: string, span: Span) => { + (id: string, span: ClipSequenceSpan) => { const oldClip = clipRegions.find((clip) => clip.id === id); const newStart = Math.round(span.start); const newEnd = Math.round(span.end); - if (oldClip) { - const startDelta = newStart - oldClip.startMs; - const endDelta = newEnd - oldClip.endMs; - if (Math.abs(startDelta - endDelta) < 1 && Math.abs(startDelta) > 0) { - setZoomRegions((current) => - current.map((zoom) => - zoom.startMs < oldClip.endMs && zoom.endMs > oldClip.startMs - ? { - ...zoom, - startMs: zoom.startMs + startDelta, - endMs: zoom.endMs + startDelta, - } - : zoom, - ), - ); - } + if (!oldClip) return; + if (span.sequenceIndex !== undefined) { + applySequence(reorderClipSequence(clipRegions, id, span.sequenceIndex)); + return; } - - setClipRegions((current) => - current.map((clip) => { - if (clip.id !== id) return clip; - return changeClipSpan(clip, newStart, newEnd, sourceDurationMs); - }), + applySequence( + clipRegions.map((clip) => + clip.id === id + ? changeClipSpan(clip, newStart, newEnd, sourceDurationMs) + : clip, + ), ); }, - [clipRegions, setClipRegions, setZoomRegions, sourceDurationMs], + [clipRegions, applySequence, sourceDurationMs], ); const handleClipSpeedChange = useCallback( @@ -133,26 +141,28 @@ export function useClipRegionCommands({ ); return; } - const plan = planClipSpeedChange({ clipRegions, zoomRegions, selectedClipId, speed }); - if (!plan) return; - if ("blockedReason" in plan) { - toast.warning( - plan.blockedReason === "clip-overlap" - ? t( - "editor.timeline.speedClipOverlap", - "Speed change would overlap the next clip. Move or split clips before slowing this section.", - ) - : t( - "editor.timeline.speedZoomOverlap", - "Speed change would overlap another zoom. Move or delete the overlapping zoom first.", - ), - ); - return; - } - setClipRegions(plan.clipRegions); - setZoomRegions(plan.zoomRegions); + + applySequence( + clipRegions.map((clip) => + clip.id === selectedClipId + ? { + ...clip, + sourceStartMs: getClipSourceStartMs(clip), + speed, + endMs: + clip.startMs + + Math.max( + 1, + Math.round( + ((clip.endMs - clip.startMs) * clip.speed) / speed, + ), + ), + } + : clip, + ), + ); }, - [clipRegions, selectedClipId, setClipRegions, setZoomRegions, t, zoomRegions], + [clipRegions, selectedClipId, applySequence, t], ); const handleClipMutedChange = useCallback( @@ -178,11 +188,10 @@ export function useClipRegionCommands({ const handleClipDelete = useCallback( (id: string) => { - // Other tracks have their own timeline positions; deleting footage is not a ripple edit. - setClipRegions((current) => current.filter((clip) => clip.id !== id)); + applySequence(clipRegions.filter((clip) => clip.id !== id)); if (selectedClipId === id) setSelectedClipId(null); }, - [selectedClipId, setClipRegions, setSelectedClipId], + [clipRegions, selectedClipId, applySequence, setSelectedClipId], ); return { diff --git a/src/components/video-editor/hooks/useEditorGlobalInteractions.test.ts b/src/components/video-editor/hooks/useEditorGlobalInteractions.test.ts new file mode 100644 index 00000000..e02b7dcd --- /dev/null +++ b/src/components/video-editor/hooks/useEditorGlobalInteractions.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SHORTCUTS } from "@/lib/shortcuts"; +import { useEditorGlobalInteractions } from "./useEditorGlobalInteractions"; + +vi.mock("react", () => ({ + useEffect: (effect: () => void) => effect(), + useRef: (current: unknown) => ({ current }), +})); +class Element { + isContentEditable = false; +} +class Input extends Element {} +class Textarea extends Element {} +class Select extends Element {} +afterEach(() => vi.unstubAllGlobals()); + +function setup(binding = DEFAULT_SHORTCUTS.playPause) { + vi.stubGlobal("HTMLInputElement", Input); + vi.stubGlobal("HTMLTextAreaElement", Textarea); + vi.stubGlobal("HTMLSelectElement", Select); + const handlers = new Map void>(); + vi.stubGlobal("window", { + addEventListener: (name: string, handler: (event: KeyboardEvent) => void) => + handlers.set(name, handler), + removeEventListener: vi.fn(), + }); + const playback = { + video: {}, + isPlaying: false, + pause: vi.fn(() => { + playback.isPlaying = false; + }), + }; + const startPlayback = vi.fn(() => { + playback.isPlaying = true; + }); + useEditorGlobalInteractions({ + timeline: {}, + videoPlaybackRef: { current: playback }, + shortcuts: { ...DEFAULT_SHORTCUTS, playPause: binding }, + isMac: true, + startPlayback, + handleUndo: vi.fn(), + handleRedo: vi.fn(), + } as unknown as Parameters[0]); + const send = (type = "keydown", options: Record = {}) => { + const event = { + key: " ", + code: "Space", + target: new Element(), + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + repeat: false, + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn(), + ...options, + }; + handlers.get(type)!(event as unknown as KeyboardEvent); + return event; + }; + return { send, playback, startPlayback }; +} + +describe("editor playback shortcut", () => { + it("consumes keydown and keyup and toggles once per physical press", () => { + const { send, playback, startPlayback } = setup(); + const down = send(); + expect(down.preventDefault).toHaveBeenCalledOnce(); + expect(down.stopImmediatePropagation).toHaveBeenCalledOnce(); + send("keydown", { repeat: true }); + send(); // Even duplicate keydowns without the repeat flag belong to the held key. + expect(startPlayback).toHaveBeenCalledOnce(); + expect(playback.pause).not.toHaveBeenCalled(); + const up = send("keyup"); + expect(up.preventDefault).toHaveBeenCalledOnce(); + expect(up.stopImmediatePropagation).toHaveBeenCalledOnce(); + send(); + expect(playback.pause).toHaveBeenCalledOnce(); + send("keyup"); + send(); + expect(startPlayback).toHaveBeenCalledTimes(2); + }); + it("ignores repeat-only events and recovers after losing window focus", () => { + const { send, startPlayback, playback } = setup(); + send("keydown", { repeat: true }); + expect(startPlayback).not.toHaveBeenCalled(); + send(); + send("blur"); + send(); + expect(playback.pause).toHaveBeenCalledOnce(); + }); + it.each([ + new Input(), + new Textarea(), + new Select(), + Object.assign(new Element(), { isContentEditable: true }), + ])("leaves editable controls alone", (target) => { + const { send, startPlayback } = setup(); + expect(send("keydown", { target }).preventDefault).not.toHaveBeenCalled(); + expect(send("keyup", { target }).preventDefault).not.toHaveBeenCalled(); + expect(startPlayback).not.toHaveBeenCalled(); + }); + it("leaves composition and already handled events alone", () => { + const { send, startPlayback } = setup(); + expect(send("keydown", { isComposing: true }).preventDefault).not.toHaveBeenCalled(); + expect(send("keydown", { defaultPrevented: true }).preventDefault).not.toHaveBeenCalled(); + expect(startPlayback).not.toHaveBeenCalled(); + }); + it("supports customized shortcuts and releases even if modifiers change", () => { + const { send, startPlayback, playback } = setup({ key: "k", ctrl: true }); + expect(send().preventDefault).not.toHaveBeenCalled(); + send("keydown", { key: "k", code: "KeyK", metaKey: true }); + expect(startPlayback).toHaveBeenCalledOnce(); + send("keyup", { key: "k", code: "KeyK", metaKey: false }); + send("keydown", { key: "k", code: "KeyK", metaKey: true }); + expect(playback.pause).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/video-editor/hooks/useEditorGlobalInteractions.ts b/src/components/video-editor/hooks/useEditorGlobalInteractions.ts index 4f47427f..1e5b0dd4 100644 --- a/src/components/video-editor/hooks/useEditorGlobalInteractions.ts +++ b/src/components/video-editor/hooks/useEditorGlobalInteractions.ts @@ -1,4 +1,4 @@ -import { type RefObject, useEffect } from "react"; +import { type RefObject, useEffect, useRef } from "react"; import type { useShortcuts } from "@/contexts/ShortcutsContext"; import { matchesShortcut } from "@/lib/shortcuts"; import type { useTimelineState } from "../state/useTimelineState"; @@ -23,12 +23,36 @@ export function useEditorGlobalInteractions({ handleRedo, startPlayback, }: Input) { + const heldPlaybackKey = useRef(null); + useEffect(() => { + const consumePlaybackKey = (event: KeyboardEvent) => { + event.preventDefault(); + // React Aria buttons also handle Space. Own both halves of this shortcut + // so their press handler cannot toggle playback a second time. + event.stopImmediatePropagation(); + }; + const keyIdentity = (event: KeyboardEvent) => event.code || event.key.toLowerCase(); + const handleKeyUp = (event: KeyboardEvent) => { + if (heldPlaybackKey.current !== keyIdentity(event)) return; + heldPlaybackKey.current = null; + consumePlaybackKey(event); + }; + const releasePlaybackKey = () => { + heldPlaybackKey.current = null; + }; const handleKeyDown = (event: KeyboardEvent) => { + if (heldPlaybackKey.current === keyIdentity(event)) { + consumePlaybackKey(event); + return; + } + if (event.defaultPrevented || event.isComposing) return; const target = event.target as HTMLElement | null; + if (target?.closest?.("[data-recording-library]")) return; const editable = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || target?.isContentEditable; const primaryModifier = isMac ? event.metaKey : event.ctrlKey; const key = event.key.toLowerCase(); @@ -49,14 +73,22 @@ export function useEditorGlobalInteractions({ return; } if (!matchesShortcut(event, shortcuts.playPause, isMac) || editable) return; - event.preventDefault(); + consumePlaybackKey(event); + if (event.repeat) return; + heldPlaybackKey.current = keyIdentity(event); const playback = videoPlaybackRef.current; if (!playback?.video) return; if (!playback.isPlaying) startPlayback(); else playback.pause(); }; window.addEventListener("keydown", handleKeyDown, { capture: true }); - return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); + window.addEventListener("keyup", handleKeyUp, { capture: true }); + window.addEventListener("blur", releasePlaybackKey); + return () => { + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + window.removeEventListener("keyup", handleKeyUp, { capture: true }); + window.removeEventListener("blur", releasePlaybackKey); + }; }, [shortcuts, isMac, handleUndo, handleRedo, startPlayback, videoPlaybackRef]); useEffect(() => { diff --git a/src/components/video-editor/hooks/useTimelineEditingController.ts b/src/components/video-editor/hooks/useTimelineEditingController.ts index 8b470473..ffaa6938 100644 --- a/src/components/video-editor/hooks/useTimelineEditingController.ts +++ b/src/components/video-editor/hooks/useTimelineEditingController.ts @@ -124,6 +124,7 @@ export function useTimelineEditingController(input: Input) { handleSeek: playback.handleSeek, }); const zoomCommands = useZoomRegionCommands({ + setSelectedClipId: timeline.setSelectedClipId, videoPath: input.videoPath, setZoomRegions: timeline.setZoomRegions, selectedZoomId: timeline.selectedZoomId, @@ -140,6 +141,7 @@ export function useTimelineEditingController(input: Input) { (id: string | null) => { timeline.setSelectedAnnotationId(id); if (id) { + timeline.setSelectedClipId(null); timeline.setSelectedZoomId(null); timeline.setSelectedAudioId(null); timeline.setSelectedCaptionId(null); @@ -147,6 +149,7 @@ export function useTimelineEditingController(input: Input) { }, [ timeline.setSelectedAnnotationId, + timeline.setSelectedClipId, timeline.setSelectedZoomId, timeline.setSelectedAudioId, timeline.setSelectedCaptionId, @@ -172,6 +175,8 @@ export function useTimelineEditingController(input: Input) { input.pendingFreshRecordingAutoSuggestTelemetryCountRef, }); const clipCommands = useClipRegionCommands({ + setAnnotationRegions: timeline.setAnnotationRegions, + setAudioRegions: timeline.setAudioRegions, sourceDurationMs: input.duration * 1000, clipRegions: timeline.clipRegions, setClipRegions: timeline.setClipRegions, @@ -188,6 +193,7 @@ export function useTimelineEditingController(input: Input) { t: input.t, }); const audioCommands = useAudioRegionCommands({ + setSelectedClipId: timeline.setSelectedClipId, setAudioRegions: timeline.setAudioRegions, selectedAudioId: timeline.selectedAudioId, setSelectedAudioId: timeline.setSelectedAudioId, @@ -198,6 +204,7 @@ export function useTimelineEditingController(input: Input) { nextAudioIdRef: input.nextAudioIdRef, }); const annotationCommands = useAnnotationRegionCommands({ + onSelectAnnotation: handleSelectAnnotation, setAnnotationRegions: timeline.setAnnotationRegions, selectedAnnotationId: timeline.selectedAnnotationId, setSelectedAnnotationId: timeline.setSelectedAnnotationId, diff --git a/src/components/video-editor/hooks/useTimelineProjection.ts b/src/components/video-editor/hooks/useTimelineProjection.ts index 7d88ffa7..f4d0bd09 100644 --- a/src/components/video-editor/hooks/useTimelineProjection.ts +++ b/src/components/video-editor/hooks/useTimelineProjection.ts @@ -1,5 +1,6 @@ /* biome-ignore-all lint/correctness/useExhaustiveDependencies: mutable timeline bootstrap refs intentionally do not trigger effects. */ import { type MutableRefObject, useCallback, useEffect, useMemo } from "react"; +import { closeClipGaps, rippleRegionAnchors, rippleRegions } from "../clipSequence"; import { projectCaptionCues } from "../captionTimeline"; import { deriveNextId } from "../projectPersistence"; import type { useTimelineState } from "../state/useTimelineState"; @@ -57,7 +58,19 @@ export function useTimelineProjection({ nextRegions.map(({ id }) => id), ); } - timeline.setClipRegions(nextRegions); + const sequence = closeClipGaps(nextRegions); + timeline.setClipRegions(sequence); + if (trimRegions.length > 0) { + timeline.setZoomRegions((current) => + rippleRegions(current, nextRegions, sequence), + ); + timeline.setAnnotationRegions((current) => + rippleRegions(current, nextRegions, sequence), + ); + timeline.setAudioRegions((current) => + rippleRegionAnchors(current, nextRegions, sequence), + ); + } } initializedRef.current = true; return; @@ -96,7 +109,8 @@ export function useTimelineProjection({ ); const timelinePlayheadTime = currentTime; const timelineDuration = useMemo( - () => getTimelineDurationMs(clipRegions, duration * 1000) / 1000, + () => + getTimelineDurationMs(clipRegions, initializedRef.current ? 0 : duration * 1000) / 1000, [clipRegions, duration], ); const effectiveSpeedRegions = useMemo(() => { diff --git a/src/components/video-editor/hooks/useZoomRegionCommands.ts b/src/components/video-editor/hooks/useZoomRegionCommands.ts index 0fadb578..513653c7 100644 --- a/src/components/video-editor/hooks/useZoomRegionCommands.ts +++ b/src/components/video-editor/hooks/useZoomRegionCommands.ts @@ -11,6 +11,7 @@ import { } from "../types"; interface UseZoomRegionCommandsParams { + setSelectedClipId: Dispatch>; videoPath: string | null; setZoomRegions: Dispatch>; selectedZoomId: string | null; @@ -25,6 +26,7 @@ interface UseZoomRegionCommandsParams { } export function useZoomRegionCommands({ + setSelectedClipId, videoPath, setZoomRegions, selectedZoomId, @@ -43,6 +45,7 @@ export function useZoomRegionCommands({ if (id) { setActiveEffectSection("zoom"); setSelectedAnnotationId(null); + setSelectedClipId(null); setSelectedAudioId(null); setSelectedCaptionId(null); } else { @@ -52,6 +55,7 @@ export function useZoomRegionCommands({ [ setActiveEffectSection, setSelectedAnnotationId, + setSelectedClipId, setSelectedAudioId, setSelectedCaptionId, setSelectedZoomId, @@ -82,12 +86,14 @@ export function useZoomRegionCommands({ setZoomRegions((current) => [...current, newRegion]); setSelectedZoomId(id); setSelectedAnnotationId(null); + setSelectedClipId(null); setSelectedCaptionId(null); }, [ markFreshRecordingSuggestion, nextZoomIdRef, setSelectedAnnotationId, + setSelectedClipId, setSelectedCaptionId, setSelectedZoomId, setZoomRegions, diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 11e0e524..c8790fd2 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,24 +1,34 @@ import { Gauge, + MagnifyingGlassPlus as ZoomIn, ChatCircle as MessageSquare, MusicNotes as Music, Scissors, SpeakerX, - MagnifyingGlassPlus as ZoomIn, } from "@phosphor-icons/react"; import { ClipFilmstrip } from "./components/filmstrip/ClipFilmstrip"; -import type { Span } from "dnd-timeline"; -import { useItem } from "dnd-timeline"; -import { useMemo } from "react"; +import type { Span, GetSpanFromDragEvent, GetSpanFromResizeEvent } from "dnd-timeline"; +import { useItem, useTimelineContext } from "dnd-timeline"; +import { useCallback, useMemo, useRef } from "react"; +import { useDndMonitor } from "@dnd-kit/core"; +import { useTimelinePresentation } from "./core/TimelinePresentation"; +import { getRegionDisplaySpan, snapRegionSpan } from "./core/clipPresentation"; +import { resolveDragEnd, resolveResizeEnd } from "./dnd/engine"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; import { formatClipSpeedLabel } from "../clipSpeedChange"; +import { getTimeAtClipSeam, type ClipPresentation } from "./core/clipPresentation"; import { formatPlayheadTime } from "./core/time"; import AudioWaveform from "./components/waveform/AudioWaveform"; import type { AudioPeaksData } from "./core/timelineTypes"; import glassStyles from "./ItemGlass.module.css"; interface ItemProps { + clipPresentation?: ClipPresentation[]; + sharedLeftGrip?: boolean; + sharedRightGrip?: boolean; + displaySpan?: Span; + embedded?: boolean; videoPath?: string | null; sourceSpan?: Span; id: string; @@ -28,6 +38,7 @@ interface ItemProps { children: React.ReactNode; isSelected?: boolean; onSelect?: () => void; + onDoubleClick?: () => void; onSelectId?: (id: string) => void; zoomDepth?: number; zoomMode?: "auto" | "manual"; @@ -53,14 +64,20 @@ const ZOOM_LABELS: Record = { }; export default function Item({ + clipPresentation: suppliedPresentation, id, + sharedLeftGrip = false, + sharedRightGrip = false, + embedded = false, videoPath, sourceSpan, span, + displaySpan: suppliedDisplaySpan, rowId, disabled = false, isSelected = false, onSelect, + onDoubleClick, onSelectId, zoomDepth = 1, zoomMode = "auto", @@ -75,13 +92,106 @@ export default function Item({ loadingLabel, children, }: ItemProps) { + const timeline = useTimelineContext(); + const presentation = useTimelinePresentation(); + const clipPresentation = + variant === "clip" ? undefined : (suppliedPresentation ?? presentation.clips); + const displaySpan = clipPresentation?.length + ? getRegionDisplaySpan(span, clipPresentation) + : (suppliedDisplaySpan ?? span); + const nodeRef = useRef(null); + const targets = useMemo( + () => [ + ...new Set( + presentation.regions + .filter((region) => region.id !== id) + .flatMap((region) => [region.start, region.end]), + ), + ], + [presentation.regions, id], + ); + const snap = (next: Span, edge?: "start" | "end") => + snapRegionSpan(next, targets, presentation.clips, timeline.pixelsToValue(1), edge); + const getMediaSpanFromDrag: GetSpanFromDragEvent = (event) => { + if (!("delta" in event)) return span; + const dragged = timeline.getSpanFromDragEvent(event); + if (!dragged) return null; + if (clipPresentation) { + const start = getTimeAtClipSeam(dragged.start, clipPresentation); + return snap({ start, end: start + span.end - span.start }); + } + const visualOffset = displaySpan.start - span.start; + return { start: dragged.start - visualOffset, end: dragged.end - visualOffset }; + }; + const getMediaSpanFromResize: GetSpanFromResizeEvent = (event) => { + const delta = timeline.pixelsToValue(event.delta.x); + const edge = event.direction; + if (clipPresentation) + return snap( + { + ...span, + [edge]: getTimeAtClipSeam(displaySpan[edge] + delta, clipPresentation), + }, + edge, + ); + const scale = (span.end - span.start) / (displaySpan.end - displaySpan.start); + return { ...span, [edge]: span[edge] + delta * scale }; + }; + + const paintPreview = (preview: Span, deltaY = 0) => { + const node = nodeRef.current; + if (!node || !clipPresentation) return; + const display = getRegionDisplaySpan(preview, clipPresentation); + const side = timeline.direction === "rtl" ? "right" : "left"; + node.style[side] = `${timeline.valueToPixels(display.start - timeline.range.start)}px`; + node.style.width = `${timeline.valueToPixels(display.end - display.start)}px`; + node.style.transform = deltaY ? `translateY(${deltaY}px)` : "none"; + }; + const { previewConfig } = presentation; + useDndMonitor({ + onDragMove(event) { + if (event.active.id !== id || !clipPresentation) return; + const next = getMediaSpanFromDrag(event); + if (next) { + const resolved = resolveDragEnd(id, next, rowId, previewConfig); + paintPreview(resolved?.span ?? span, event.delta.y); + } + }, + onDragEnd(event) { + if (event.active.id === id) paintPreview(span); + }, + onDragCancel(event) { + if (event.active.id === id) paintPreview(span); + }, + }); const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ id, - span, + span: displaySpan, disabled: disabled || isLoading, - data: { rowId }, + data: { + rowId, + span, + getSpanFromDragEvent: getMediaSpanFromDrag, + getSpanFromResizeEvent: getMediaSpanFromResize, + }, + resizeHandleWidth: variant === "clip" ? 12 : undefined, + onResizeMove(event) { + if (!clipPresentation) return; + const next = getMediaSpanFromResize(event); + if (next) { + const resolved = resolveResizeEnd(id, next, previewConfig); + if (resolved) paintPreview(resolved); + } + }, }); + const attachRef = useCallback( + (node: HTMLDivElement | null) => { + nodeRef.current = node; + setNodeRef(node); + }, + [setNodeRef], + ); const timeLabel = useMemo( () => `${formatPlayheadTime(span.start)} – ${formatPlayheadTime(span.end)}`, [span.start, span.end], @@ -90,7 +200,7 @@ export default function Item({ if (isLoading) { return (
event.stopPropagation()} onClickCapture={(event) => event.stopPropagation()} > @@ -146,29 +258,38 @@ export default function Item({ ...itemStyle, minWidth: MIN_ITEM_PX, height: "100%", - overflow: "hidden", + overflow: isClip ? "visible" : "hidden", + pointerEvents: "auto" as const, }; return (
{ + if (!onDoubleClick) return; + event.stopPropagation(); + onDoubleClick(); + }} className="group h-full" >
{ event.stopPropagation(); @@ -204,7 +327,11 @@ export default function Item({ glassStyles.left, isClip && glassStyles.clipHandle, )} - style={{ cursor: "col-resize", pointerEvents: "auto" }} + style={{ + cursor: "col-resize", + pointerEvents: isClip ? "none" : "auto", + display: isClip && sharedLeftGrip ? "none" : undefined, + }} title="Resize left" />
{showAudioWaveform && waveformPeaks && ( @@ -240,21 +371,27 @@ export default function Item({ "relative z-10 flex max-w-full items-center justify-center gap-1 px-1 text-[11px] font-medium text-black/70 dark:text-white/90 select-none overflow-hidden", isClip && "rounded bg-black/65 px-2 py-1 text-white dark:text-white", - isZoom && "text-white dark:text-white", + (isZoom || embedded) && "text-white dark:text-white", + embedded && "w-full justify-start px-2", )} > {isClip ? ( clipSpeedLabel ) : isZoom ? ( <> - +