import { useState, useRef, useEffect, useCallback } from 'react' import type { Asset, TimelineClip, Track } from '../../types/project-model' import { resolveOverlaps, migrateClip, type ToolType } from './video-editor-utils' interface DragPreviewPosition { startTime: number trackIndex: number } interface DragPreviewNode { el: HTMLElement transform: string zIndex: string willChange: string } export interface DraggingClipState { clipId: string startX: number startY: number originalStartTime: number originalTrackIndex: number originalPositions: Record isDuplicate?: boolean altHeld?: boolean } export interface ResizingClipState { clipId: string edge: 'left' | 'right' startX: number originalStartTime: number originalDuration: number originalTrimStart: number originalTrimEnd: number tool: ToolType adjacentClipId?: string adjacentOrigDuration?: number adjacentOrigTrimStart?: number adjacentOrigTrimEnd?: number adjacentOrigStartTime?: number } export interface SlipSlideClipState { clipId: string tool: 'slip' | 'slide' startX: number originalTrimStart: number originalTrimEnd: number originalStartTime: number originalDuration: number prevClipId?: string prevOrigDuration?: number nextClipId?: string nextOrigStartTime?: number nextOrigDuration?: number nextOrigTrimStart?: number } interface UseTimelineDragParams { activeTool: ToolType setActiveTool: (tool: ToolType) => void lastTrimTool: ToolType setLastTrimTool: (tool: ToolType) => void pixelsPerSecond: number totalDuration: number clips: TimelineClip[] setClips: React.Dispatch> tracks: Track[] selectedClipIds: Set setSelectedClipIds: React.Dispatch>> currentTime: number setCurrentTime: (time: number) => void setIsPlaying: (playing: boolean) => void snapEnabled: boolean resolveClipPath: (clip: TimelineClip | null) => string getMaxClipDuration: (clip: TimelineClip) => number addClipToTimeline: (asset: Asset, trackIndex: number, startTime?: number) => void assets: Asset[] timelines: any[] activeTimeline: any currentProjectId: string | null timelineRef: React.RefObject trackContainerRef: React.RefObject orderedTracks: { track: Track; realIndex: number; displayRow: number }[] getTrackHeight: (trackIndex: number) => number trackTopPx: (realTrackIndex: number, padding?: number) => number cutPoints: any[] splitClipAtPlayhead: (clipId: string, atTime?: number, batchClipIds?: string[]) => void setSelectedSubtitleId: (id: string | null) => void setSelectedGap: (gap: any) => void audioTrackHeight: number videoTrackHeight: number subtitleTrackHeight: number } export function useTimelineDrag(params: UseTimelineDragParams) { const { activeTool, pixelsPerSecond, totalDuration, clips, setClips, tracks, selectedClipIds, setSelectedClipIds, currentTime, setCurrentTime, setIsPlaying, snapEnabled, getMaxClipDuration, addClipToTimeline, assets, timelines, activeTimeline, timelineRef, trackContainerRef, orderedTracks, getTrackHeight, trackTopPx, splitClipAtPlayhead, setSelectedSubtitleId, setSelectedGap, audioTrackHeight, videoTrackHeight, subtitleTrackHeight, } = params const [draggingClip, setDraggingClip] = useState(null) const [resizingClip, setResizingClip] = useState(null) const [slipSlideClip, setSlipSlideClip] = useState(null) const [lassoRect, setLassoRect] = useState<{ startX: number; startY: number; currentX: number; currentY: number } | null>(null) const lassoOriginRef = useRef<{ scrollLeft: number; containerLeft: number; containerTop: number } | null>(null) const dragPreviewRef = useRef | null>(null) const dragPreviewNodesRef = useRef>(new Map()) const dragPreviewRafRef = useRef(null) const clearDragPreviewDom = useCallback(() => { if (dragPreviewRafRef.current !== null) { cancelAnimationFrame(dragPreviewRafRef.current) dragPreviewRafRef.current = null } for (const node of dragPreviewNodesRef.current.values()) { node.el.style.transform = node.transform node.el.style.zIndex = node.zIndex node.el.style.willChange = node.willChange } dragPreviewNodesRef.current.clear() dragPreviewRef.current = null }, []) // --- Ruler scrub: click + drag to scrub playhead --- const isScrubbing = useRef(false) const scrubFromEvent = useCallback((clientX: number) => { if (!timelineRef.current) return const rect = timelineRef.current.getBoundingClientRect() // getBoundingClientRect already accounts for the parent's scroll offset, // so clientX - rect.left gives the correct position within the timeline. const x = clientX - rect.left const time = x / pixelsPerSecond setCurrentTime(Math.max(0, Math.min(time, totalDuration))) }, [pixelsPerSecond, totalDuration]) const handleRulerMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return // only left button e.preventDefault() // prevent text selection isScrubbing.current = true setIsPlaying(false) // pause playback while scrubbing scrubFromEvent(e.clientX) const onMove = (ev: MouseEvent) => { if (!isScrubbing.current) return ev.preventDefault() scrubFromEvent(ev.clientX) } const onUp = () => { isScrubbing.current = false window.removeEventListener('mousemove', onMove) window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }, [activeTool, scrubFromEvent]) // Helper: expand a set of clip IDs to include their linked counterparts (audio ↔ video) // Uses transitive expansion so all members of a linked group are found, // e.g. clicking A2 → finds Video → finds A1, selecting all three. const expandWithLinkedClips = useCallback((ids: Set): Set => { const expanded = new Set(ids) const queue = [...ids] while (queue.length > 0) { const id = queue.pop()! const c = clips.find(cl => cl.id === id) if (c?.linkedClipIds) { for (const lid of c.linkedClipIds) { if (!expanded.has(lid) && clips.some(cl => cl.id === lid)) { expanded.add(lid) queue.push(lid) } } } } return expanded }, [clips]) const handleClipMouseDown = (e: React.MouseEvent, clip: TimelineClip) => { e.stopPropagation() // Prevent all interactions on locked tracks (except selection) const clipTrack = tracks[clip.trackIndex] if (clipTrack?.locked) { // Still allow selecting the clip visually setSelectedClipIds(expandWithLinkedClips(new Set([clip.id]))) return } if (activeTool === 'blade') { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const clickX = e.clientX - rect.left const clickTime = clip.startTime + (clickX / rect.width) * clip.duration setCurrentTime(clickTime) if (e.shiftKey) { // Shift+blade: cut ALL clips at this time across all unlocked tracks const clipIds = clips .filter(c => clickTime > c.startTime + 0.1 && clickTime < c.startTime + c.duration - 0.1 && !tracks[c.trackIndex]?.locked ) .map(c => c.id) if (clipIds.length > 0) { splitClipAtPlayhead(clipIds[0], clickTime, clipIds) } } else { // Normal blade: cut only the clicked clip splitClipAtPlayhead(clip.id, clickTime) } return } // --- Slip tool: shift source content within clip --- if (activeTool === 'slip') { setSelectedClipIds(expandWithLinkedClips(new Set([clip.id]))) setSlipSlideClip({ clipId: clip.id, tool: 'slip', startX: e.clientX, originalTrimStart: clip.trimStart, originalTrimEnd: clip.trimEnd, originalStartTime: clip.startTime, originalDuration: clip.duration, }) return } // --- Slide tool: move clip, adjust neighbors --- if (activeTool === 'slide') { setSelectedClipIds(expandWithLinkedClips(new Set([clip.id]))) // Find the previous and next clips on the same track const sameTrack = clips .filter(c => c.trackIndex === clip.trackIndex && c.id !== clip.id) .sort((a, b) => a.startTime - b.startTime) const prevClip = sameTrack.filter(c => c.startTime + c.duration <= clip.startTime + 0.05).pop() const nextClip = sameTrack.find(c => c.startTime >= clip.startTime + clip.duration - 0.05) setSlipSlideClip({ clipId: clip.id, tool: 'slide', startX: e.clientX, originalTrimStart: clip.trimStart, originalTrimEnd: clip.trimEnd, originalStartTime: clip.startTime, originalDuration: clip.duration, prevClipId: prevClip?.id, prevOrigDuration: prevClip?.duration, nextClipId: nextClip?.id, nextOrigStartTime: nextClip?.startTime, nextOrigDuration: nextClip?.duration, nextOrigTrimStart: nextClip?.trimStart, }) return } // --- Track Select Forward: select this clip + all clips to the right --- if (activeTool === 'trackForward') { const forwardClips = clips.filter(c => { if (e.shiftKey) { // Shift held: select forward on SAME track only return c.trackIndex === clip.trackIndex && c.startTime >= clip.startTime } else { // Default: select forward on ALL tracks (like Premiere) return c.startTime >= clip.startTime } }) const forwardIds = expandWithLinkedClips(new Set(forwardClips.map(c => c.id))) setSelectedClipIds(forwardIds) setSelectedSubtitleId(null) setSelectedGap(null) // Start drag so the user can slide the whole forward selection const originalPositions: Record = {} clips.filter(c => forwardIds.has(c.id)).forEach(c => { originalPositions[c.id] = { startTime: c.startTime, trackIndex: c.trackIndex } }) setDraggingClip({ clipId: clip.id, startX: e.clientX, startY: e.clientY, originalStartTime: clip.startTime, originalTrackIndex: clip.trackIndex, originalPositions, }) return } if (activeTool === 'select' || activeTool === 'ripple' || activeTool === 'roll') { // Compute the effective selection BEFORE React processes the state update let effectiveSelection: Set if (e.shiftKey) { // Shift+click: toggle clip in/out of multi-selection (toggle linked group together) effectiveSelection = new Set(selectedClipIds) if (effectiveSelection.has(clip.id)) { effectiveSelection.delete(clip.id) if (!e.altKey && clip.linkedClipIds) clip.linkedClipIds.forEach(lid => effectiveSelection.delete(lid)) } else { effectiveSelection.add(clip.id) if (!e.altKey && clip.linkedClipIds) clip.linkedClipIds.forEach(lid => { if (clips.some(c => c.id === lid)) effectiveSelection.add(lid) }) } setSelectedClipIds(effectiveSelection) } else if (e.altKey) { // Alt+click: select ONLY this specific clip, ignoring linked clips (like Premiere) if (selectedClipIds.has(clip.id)) { effectiveSelection = selectedClipIds } else { effectiveSelection = new Set([clip.id]) setSelectedClipIds(effectiveSelection) } } else { // Normal click: select this clip + its linked clips effectiveSelection = expandWithLinkedClips(new Set([clip.id])) setSelectedClipIds(effectiveSelection) } // Only drag clips that are in the effective (visual) selection. // This allows moving just the video or audio part of a linked clip // when only that part is selected (e.g. via Alt+lasso). Links are preserved. const originalPositions: Record = {} for (const c of clips) { if (effectiveSelection.has(c.id)) { originalPositions[c.id] = { startTime: c.startTime, trackIndex: c.trackIndex } } } // Always ensure the clicked clip is in the group if (!originalPositions[clip.id]) { originalPositions[clip.id] = { startTime: clip.startTime, trackIndex: clip.trackIndex } } // Set up dragging. Alt+drag duplication is deferred to first mouseMove // so that Alt+click (no drag) only changes selection without creating duplicates. setDraggingClip({ clipId: clip.id, startX: e.clientX, startY: e.clientY, originalStartTime: clip.startTime, originalTrackIndex: clip.trackIndex, originalPositions, altHeld: e.altKey || undefined, }) } } const handleMouseMove = useCallback((e: MouseEvent) => { // Handle lasso dragging if (lassoRect) { setLassoRect(prev => prev ? { ...prev, currentX: e.clientX, currentY: e.clientY } : null) return } if (!draggingClip || !trackContainerRef.current) return // Alt+drag: create duplicates on first significant movement (deferred from mouseDown) if (draggingClip.altHeld && !draggingClip.isDuplicate) { const dx = e.clientX - draggingClip.startX const dy = e.clientY - draggingClip.startY if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return const idMap = new Map() const duplicateClips: TimelineClip[] = [] for (const c of clips) { if (!draggingClip.originalPositions[c.id]) continue const newId = `clip-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` idMap.set(c.id, newId) duplicateClips.push({ ...c, id: newId, isRegenerating: false }) } setClips(prev => [...prev, ...duplicateClips]) const dupOrigPositions: Record = {} for (const [oldId, pos] of Object.entries(draggingClip.originalPositions)) { const newId = idMap.get(oldId) if (newId) dupOrigPositions[newId] = { ...pos } } const newPrimaryId = idMap.get(draggingClip.clipId) || draggingClip.clipId setSelectedClipIds(new Set(Object.keys(dupOrigPositions))) setDraggingClip({ ...draggingClip, clipId: newPrimaryId, originalPositions: dupOrigPositions, isDuplicate: true, altHeld: false, }) return } const primaryClip = clips.find(c => c.id === draggingClip.clipId) if (!primaryClip) return const deltaX = e.clientX - draggingClip.startX const deltaY = e.clientY - draggingClip.startY // Compute the primary clip's new position let newStartTime = draggingClip.originalStartTime + deltaX / pixelsPerSecond newStartTime = Math.max(0, newStartTime) // Snap the primary clip (skip other clips in the drag group for snapping) const origPositions = draggingClip.originalPositions if (snapEnabled) { const snapThreshold = 0.2 for (const otherClip of clips) { if (origPositions[otherClip.id]) continue // skip clips in the drag group if (Math.abs(newStartTime - otherClip.startTime) < snapThreshold) { newStartTime = otherClip.startTime } const otherEnd = otherClip.startTime + otherClip.duration if (Math.abs(newStartTime - otherEnd) < snapThreshold) { newStartTime = otherEnd } const clipEnd = newStartTime + primaryClip.duration if (Math.abs(clipEnd - otherClip.startTime) < snapThreshold) { newStartTime = otherClip.startTime - primaryClip.duration } } if (Math.abs(newStartTime - currentTime) < snapThreshold) { newStartTime = currentTime } if (Math.abs(newStartTime + primaryClip.duration - currentTime) < snapThreshold) { newStartTime = currentTime - primaryClip.duration } } // Use average track height for drag delta computation const avgTrackH = orderedTracks.length > 0 ? orderedTracks.reduce((s, e) => s + (e.track.type === 'subtitle' ? subtitleTrackHeight : e.track.kind === 'audio' ? audioTrackHeight : videoTrackHeight), 0) / orderedTracks.length : 56 const rawDisplayDelta = Math.round(deltaY / avgTrackH) // Convert display-row delta to real-trackIndex delta for the primary clip. // orderedTracks maps displayRow → realIndex, so we find the primary clip's // current display row, offset it by the Y-delta, and look up the real index. const primaryRealIndex = draggingClip.originalTrackIndex // (primaryDisplayRow, targetDisplayRow, targetRealIndex used for debugging; trackIndexDelta removed — per-clip resolution below) // Compute raw deltas relative to primary clip's original position let timeDelta = newStartTime - draggingClip.originalStartTime // Clamp time delta so no clip goes before time 0 for (const orig of Object.values(origPositions)) { if (orig.startTime + timeDelta < 0) { timeDelta = -orig.startTime } } // Premiere-style linked clip movement (mirrored around divider): // // Display layout (orderedTracks): // row 0: V3 (top) ← away from divider // row 1: V2 // row 2: V1 ← nearest to divider // --- divider --- // row 3: A1 ← nearest to divider // row 4: A2 // row 5: A3 (bottom) ← away from divider // // The video and audio sections MIRROR around the divider. When you drag a // video clip "down" (toward divider), the linked audio should move "up" // (also toward divider). When you drag "up" (away from divider), audio // moves "down" (also away from divider). They move symmetrically relative // to the divider, not in the same screen direction. // // Implementation: the primary clip (being dragged) uses rawDisplayDelta. // Linked clips of the OPPOSITE kind get the delta INVERTED. // Each clip is independently clamped within its kind — if one hits its // boundary the other can still move. const primaryTrack = tracks[primaryRealIndex] const primaryKind = primaryTrack?.kind || 'video' // Build per-kind ordered lists (in display order) const videoDisplayRows = orderedTracks .filter(e => e.track.kind === 'video' && e.track.type !== 'subtitle') .map(e => ({ displayRow: e.displayRow, realIndex: e.realIndex })) const audioDisplayRows = orderedTracks .filter(e => e.track.kind === 'audio') .map(e => ({ displayRow: e.displayRow, realIndex: e.realIndex })) // Helper: resolve a clip's target track within its own kind const resolveTrackForClip = (origTrackIndex: number): number => { if (rawDisplayDelta === 0) return origTrackIndex const origTrack = tracks[origTrackIndex] const clipKind = origTrack?.kind || 'video' const kindRows = clipKind === 'audio' ? audioDisplayRows : videoDisplayRows // Find this clip's position within its kind's ordered list const posInKind = kindRows.findIndex(r => r.realIndex === origTrackIndex) if (posInKind === -1) return origTrackIndex // Primary kind follows the drag direction; opposite kind mirrors (inverted delta) const effectiveDelta = clipKind === primaryKind ? rawDisplayDelta : -rawDisplayDelta const newPosInKind = Math.max(0, Math.min(kindRows.length - 1, posInKind + effectiveDelta)) const newTrackIndex = kindRows[newPosInKind].realIndex // Check if target track is locked if (tracks[newTrackIndex]?.locked) return origTrackIndex return newTrackIndex } const previewById: Record = {} for (const [clipId, orig] of Object.entries(origPositions)) { previewById[clipId] = { startTime: orig.startTime + timeDelta, trackIndex: resolveTrackForClip(orig.trackIndex), } } dragPreviewRef.current = previewById if (dragPreviewRafRef.current === null) { dragPreviewRafRef.current = requestAnimationFrame(() => { dragPreviewRafRef.current = null if (!trackContainerRef.current) return const preview = dragPreviewRef.current if (!preview) return for (const [clipId, target] of Object.entries(preview)) { const original = origPositions[clipId] if (!original) continue let node = dragPreviewNodesRef.current.get(clipId) if (!node || !node.el.isConnected) { const el = trackContainerRef.current.querySelector(`[data-clip-id="${clipId}"]`) as HTMLElement | null if (!el) continue node = { el, transform: el.style.transform, zIndex: el.style.zIndex, willChange: el.style.willChange, } dragPreviewNodesRef.current.set(clipId, node) } const xPx = (target.startTime - original.startTime) * pixelsPerSecond const yPx = trackTopPx(target.trackIndex, 4) - trackTopPx(original.trackIndex, 4) node.el.style.transform = `translate3d(${xPx}px, ${yPx}px, 0)` node.el.style.zIndex = '40' node.el.style.willChange = 'transform' } }) } }, [draggingClip, clips, pixelsPerSecond, snapEnabled, tracks, currentTime, lassoRect, orderedTracks, trackContainerRef, trackTopPx]) const handleMouseUp = useCallback((e?: MouseEvent | Event) => { // Finalize lasso selection if (lassoRect && trackContainerRef.current) { const origin = lassoOriginRef.current if (origin) { const container = trackContainerRef.current const scrollLeft = container.scrollLeft const scrollTop = container.scrollTop // Compute lasso rectangle in timeline-local coordinates const lx1 = Math.min(lassoRect.startX, lassoRect.currentX) - origin.containerLeft + scrollLeft const lx2 = Math.max(lassoRect.startX, lassoRect.currentX) - origin.containerLeft + scrollLeft const ly1 = Math.min(lassoRect.startY, lassoRect.currentY) - origin.containerTop + scrollTop const ly2 = Math.max(lassoRect.startY, lassoRect.currentY) - origin.containerTop + scrollTop // Convert to time/track const timeStart = lx1 / pixelsPerSecond const timeEnd = lx2 / pixelsPerSecond const newSelection = new Set() for (const clip of clips) { const clipLeft = clip.startTime const clipRight = clip.startTime + clip.duration const th = getTrackHeight(clip.trackIndex) const clipTop = trackTopPx(clip.trackIndex) + 4 const clipBottom = clipTop + (th - 8) // clip height = trackHeight - 8px padding // Check overlap between lasso rect and clip rect if (clipRight > timeStart && clipLeft < timeEnd && clipBottom > ly1 && clipTop < ly2) { newSelection.add(clip.id) } } // Alt/Option held: select only what's in the lasso (skip linked clips) const altHeld = e instanceof MouseEvent && e.altKey setSelectedClipIds(altHeld ? newSelection : expandWithLinkedClips(newSelection)) } setLassoRect(null) lassoOriginRef.current = null } // Commit drag result once (positions + overlap resolution) so history records one drag step. if (draggingClip) { const preview = dragPreviewRef.current const originalPositions = draggingClip.originalPositions const movedIds = new Set(Object.keys(originalPositions)) setClips(prev => { const positioned = prev.map(clip => { const target = preview?.[clip.id] if (!target) return clip return { ...clip, startTime: target.startTime, trackIndex: target.trackIndex, } }) return resolveOverlaps(positioned, movedIds) }) clearDragPreviewDom() } if (resizingClip) { setClips(prev => resolveOverlaps(prev, new Set([resizingClip.clipId]))) } setDraggingClip(null) setResizingClip(null) }, [lassoRect, clips, pixelsPerSecond, draggingClip, resizingClip, setClips, getTrackHeight, trackTopPx, clearDragPreviewDom]) const handleResizeMove = useCallback((e: MouseEvent) => { if (!resizingClip) return const clip = clips.find(c => c.id === resizingClip.clipId) if (!clip) return const deltaX = e.clientX - resizingClip.startX const deltaTime = deltaX / pixelsPerSecond const tool = resizingClip.tool // --- ROLL TRIM: move the edit point between two adjacent clips --- if (tool === 'roll' && resizingClip.adjacentClipId) { const adjClip = clips.find(c => c.id === resizingClip.adjacentClipId) if (!adjClip) return const linkedIds = new Set(clip.linkedClipIds || []) const adjLinkedIds = new Set(adjClip.linkedClipIds || []) let dt = deltaTime if (resizingClip.edge === 'right') { const maxExtend = Math.min( getMaxClipDuration(clip) - resizingClip.originalDuration, (resizingClip.adjacentOrigDuration ?? adjClip.duration) - 0.5, ) const maxShrink = resizingClip.originalDuration - 0.5 dt = Math.max(-maxShrink, Math.min(maxExtend, dt)) const finalDur = Math.max(0.5, resizingClip.originalDuration + dt) setClips(prev => prev.map(c => { if (c.id === clip.id) { return { ...c, duration: finalDur } } if (linkedIds.has(c.id)) { return { ...c, duration: finalDur } } if (c.id === resizingClip.adjacentClipId) { const newStart = (resizingClip.adjacentOrigStartTime ?? c.startTime) + dt const newDur = (resizingClip.adjacentOrigDuration ?? c.duration) - dt const newTrimStart = (resizingClip.adjacentOrigTrimStart ?? c.trimStart) + dt * c.speed return { ...c, startTime: newStart, duration: Math.max(0.5, newDur), trimStart: Math.max(0, newTrimStart) } } if (adjLinkedIds.has(c.id)) { const newStart = (resizingClip.adjacentOrigStartTime ?? adjClip.startTime) + dt const newDur = (resizingClip.adjacentOrigDuration ?? adjClip.duration) - dt const newTrimStart = c.trimStart + dt * c.speed return { ...c, startTime: newStart, duration: Math.max(0.5, newDur), trimStart: Math.max(0, newTrimStart) } } return c })) } else { const maxExtend = Math.min( getMaxClipDuration(clip) - resizingClip.originalDuration, (resizingClip.adjacentOrigDuration ?? adjClip.duration) - 0.5, ) const maxShrink = resizingClip.originalDuration - 0.5 dt = Math.max(-maxExtend, Math.min(maxShrink, dt)) const newStart = resizingClip.originalStartTime + dt const newDur = Math.max(0.5, resizingClip.originalDuration - dt) const newTrimStart = resizingClip.originalTrimStart + dt * clip.speed const adjFinalDur = Math.max(0.5, (resizingClip.adjacentOrigDuration ?? adjClip.duration) + dt) setClips(prev => prev.map(c => { if (c.id === clip.id) { return { ...c, startTime: newStart, duration: newDur, trimStart: Math.max(0, newTrimStart) } } if (linkedIds.has(c.id)) { const linkedTrimStart = c.trimStart + dt * c.speed return { ...c, startTime: newStart, duration: newDur, trimStart: Math.max(0, linkedTrimStart) } } if (c.id === resizingClip.adjacentClipId) { return { ...c, duration: adjFinalDur } } if (adjLinkedIds.has(c.id)) { return { ...c, duration: adjFinalDur } } return c })) } return } // --- RIPPLE TRIM: trim edge and shift all subsequent clips --- if (tool === 'ripple') { const linkedIds = new Set(clip.linkedClipIds || []) if (resizingClip.edge === 'left') { let newStartTime = resizingClip.originalStartTime + deltaTime let newDuration = resizingClip.originalDuration - deltaTime if (newDuration < 0.5) { newDuration = 0.5; newStartTime = resizingClip.originalStartTime + resizingClip.originalDuration - 0.5 } if (newStartTime < 0) { newDuration = resizingClip.originalDuration + resizingClip.originalStartTime; newStartTime = 0 } const newTrimStart = resizingClip.originalTrimStart + (newStartTime - resizingClip.originalStartTime) const maxDur = getMaxClipDuration({ ...clip, trimStart: Math.max(0, newTrimStart) }) newDuration = Math.min(newDuration, maxDur) const rippleDelta = newStartTime - resizingClip.originalStartTime const finalDuration = Math.max(0.5, newDuration) const finalTrimStart = Math.max(0, newTrimStart) setClips(prev => prev.map(c => { if (c.id === clip.id) { return { ...c, startTime: newStartTime, duration: finalDuration, trimStart: finalTrimStart } } if (linkedIds.has(c.id)) { const linkedNewTrimStart = c.trimStart + (newStartTime - resizingClip.originalStartTime) return { ...c, startTime: newStartTime, duration: finalDuration, trimStart: Math.max(0, linkedNewTrimStart) } } if (c.trackIndex === clip.trackIndex && c.id !== clip.id && c.startTime < resizingClip.originalStartTime) { return { ...c, startTime: Math.max(0, c.startTime + rippleDelta) } } return c })) } else { let newDuration = resizingClip.originalDuration + deltaTime newDuration = Math.max(0.5, newDuration) const maxDur = getMaxClipDuration(clip) newDuration = Math.min(newDuration, maxDur) const originalEnd = resizingClip.originalStartTime + resizingClip.originalDuration const newEnd = resizingClip.originalStartTime + newDuration const rippleDelta = newEnd - originalEnd const finalDuration = Math.max(0.5, newDuration) setClips(prev => prev.map(c => { if (c.id === clip.id) { return { ...c, duration: finalDuration } } if (linkedIds.has(c.id)) { return { ...c, duration: finalDuration } } if (c.trackIndex === clip.trackIndex && c.id !== clip.id && c.startTime >= originalEnd - 0.01) { return { ...c, startTime: Math.max(0, c.startTime + rippleDelta) } } return c })) } return } // --- NORMAL TRIM (select tool or any tool without special handling) --- if (resizingClip.edge === 'left') { let newStartTime = resizingClip.originalStartTime + deltaTime let newDuration = resizingClip.originalDuration - deltaTime if (newDuration < 0.5) { newDuration = 0.5 newStartTime = resizingClip.originalStartTime + resizingClip.originalDuration - 0.5 } if (newStartTime < 0) { newDuration = resizingClip.originalDuration + resizingClip.originalStartTime newStartTime = 0 } if (snapEnabled) { const snapThreshold = 0.2 if (Math.abs(newStartTime - currentTime) < snapThreshold) { const adjustment = currentTime - newStartTime newStartTime = currentTime newDuration -= adjustment } for (const otherClip of clips) { if (otherClip.id === clip.id) continue const otherEnd = otherClip.startTime + otherClip.duration if (Math.abs(newStartTime - otherEnd) < snapThreshold) { const adjustment = otherEnd - newStartTime newStartTime = otherEnd newDuration -= adjustment } if (Math.abs(newStartTime - otherClip.startTime) < snapThreshold) { const adjustment = otherClip.startTime - newStartTime newStartTime = otherClip.startTime newDuration -= adjustment } } } const newTrimStart = resizingClip.originalTrimStart + (newStartTime - resizingClip.originalStartTime) const maxDur = getMaxClipDuration({ ...clip, trimStart: Math.max(0, newTrimStart) }) newDuration = Math.min(newDuration, maxDur) // Build set of linked clip IDs to also trim const linkedIds = new Set(clip.linkedClipIds || []) const finalDuration = Math.max(0.5, newDuration) const finalTrimStart = Math.max(0, newTrimStart) setClips(prev => prev.map(c => { if (c.id === clip.id) { return { ...c, startTime: newStartTime, duration: finalDuration, trimStart: finalTrimStart } } if (linkedIds.has(c.id)) { const linkedNewTrimStart = c.trimStart + (newStartTime - resizingClip.originalStartTime) return { ...c, startTime: newStartTime, duration: finalDuration, trimStart: Math.max(0, linkedNewTrimStart) } } return c })) } else { let newDuration = resizingClip.originalDuration + deltaTime newDuration = Math.max(0.5, newDuration) if (snapEnabled) { const snapThreshold = 0.2 const newEndTime = clip.startTime + newDuration if (Math.abs(newEndTime - currentTime) < snapThreshold) { newDuration = currentTime - clip.startTime } for (const otherClip of clips) { if (otherClip.id === clip.id) continue if (Math.abs(newEndTime - otherClip.startTime) < snapThreshold) { newDuration = otherClip.startTime - clip.startTime } const otherEnd = otherClip.startTime + otherClip.duration if (Math.abs(newEndTime - otherEnd) < snapThreshold) { newDuration = otherEnd - clip.startTime } } } const maxDur = getMaxClipDuration(clip) newDuration = Math.min(newDuration, maxDur) // Build set of linked clip IDs to also trim const linkedIds = new Set(clip.linkedClipIds || []) const finalDuration = Math.max(0.5, newDuration) setClips(prev => prev.map(c => { if (c.id === clip.id) return { ...c, duration: finalDuration } if (linkedIds.has(c.id)) return { ...c, duration: finalDuration } return c })) } }, [resizingClip, clips, pixelsPerSecond, snapEnabled, currentTime, getMaxClipDuration]) const handleResizeStart = (e: React.MouseEvent, clip: TimelineClip, edge: 'left' | 'right') => { e.stopPropagation() e.preventDefault() // Prevent resizing clips on locked tracks if (tracks[clip.trackIndex]?.locked) return setSelectedClipIds(expandWithLinkedClips(new Set([clip.id]))) // For Roll trim, find the adjacent clip at the edit point let adjacentClip: TimelineClip | undefined if (activeTool === 'roll') { const clipEnd = clip.startTime + clip.duration if (edge === 'right') { // Find clip that starts right at (or very near) this clip's end on the same track adjacentClip = clips.find(c => c.id !== clip.id && c.trackIndex === clip.trackIndex && Math.abs(c.startTime - clipEnd) < 0.05) } else { // Find clip that ends right at (or very near) this clip's start on the same track adjacentClip = clips.find(c => c.id !== clip.id && c.trackIndex === clip.trackIndex && Math.abs((c.startTime + c.duration) - clip.startTime) < 0.05) } } setResizingClip({ clipId: clip.id, edge, startX: e.clientX, originalStartTime: clip.startTime, originalDuration: clip.duration, originalTrimStart: clip.trimStart, originalTrimEnd: clip.trimEnd, tool: activeTool, adjacentClipId: adjacentClip?.id, adjacentOrigDuration: adjacentClip?.duration, adjacentOrigTrimStart: adjacentClip?.trimStart, adjacentOrigTrimEnd: adjacentClip?.trimEnd, adjacentOrigStartTime: adjacentClip?.startTime, }) } useEffect(() => { if (draggingClip || lassoRect) { window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) return () => { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) } } }, [draggingClip, lassoRect, handleMouseMove, handleMouseUp]) useEffect(() => { return () => { clearDragPreviewDom() } }, [clearDragPreviewDom]) useEffect(() => { if (resizingClip) { window.addEventListener('mousemove', handleResizeMove) window.addEventListener('mouseup', handleMouseUp) return () => { window.removeEventListener('mousemove', handleResizeMove) window.removeEventListener('mouseup', handleMouseUp) } } }, [resizingClip, handleResizeMove, handleMouseUp]) // --- Slip/Slide mouse move handler --- const handleSlipSlideMove = useCallback((e: MouseEvent) => { if (!slipSlideClip) return const clip = clips.find(c => c.id === slipSlideClip.clipId) if (!clip) return const deltaX = e.clientX - slipSlideClip.startX const deltaTime = deltaX / pixelsPerSecond if (slipSlideClip.tool === 'slip') { // SLIP: shift source content within the clip (change trimStart/trimEnd, keep position) // Moving right = shift source earlier = increase trimStart, decrease trimEnd if (clip.type !== 'video' || !clip.asset?.duration) return const mediaDuration = clip.asset.duration const shiftAmount = deltaTime * clip.speed // convert to media time let newTrimStart = slipSlideClip.originalTrimStart + shiftAmount let newTrimEnd = slipSlideClip.originalTrimEnd - shiftAmount // Clamp so neither goes negative if (newTrimStart < 0) { newTrimEnd += newTrimStart newTrimStart = 0 } if (newTrimEnd < 0) { newTrimStart += newTrimEnd newTrimEnd = 0 } // Ensure trimStart + trimEnd + visible media ≤ total media duration const visibleMedia = clip.duration * clip.speed if (newTrimStart + visibleMedia + newTrimEnd > mediaDuration) { return // Can't slip further } setClips(prev => prev.map(c => c.id === clip.id ? { ...c, trimStart: Math.max(0, newTrimStart), trimEnd: Math.max(0, newTrimEnd) } : c )) } else { // SLIDE: move clip in time, adjust neighbor durations to fill the space let newStartTime = slipSlideClip.originalStartTime + deltaTime // Clamp: can't go before prevClip's start (or 0) const minStart = slipSlideClip.prevClipId ? clips.find(c => c.id === slipSlideClip.prevClipId)?.startTime ?? 0 : 0 // Clamp: can't go past nextClip's end (or infinity) const nextEnd = slipSlideClip.nextClipId ? (slipSlideClip.nextOrigStartTime ?? 0) + (slipSlideClip.nextOrigDuration ?? 0) : Infinity newStartTime = Math.max(minStart, Math.min(nextEnd - clip.duration, newStartTime)) const actualDelta = newStartTime - slipSlideClip.originalStartTime setClips(prev => prev.map(c => { if (c.id === clip.id) { return { ...c, startTime: newStartTime } } // Adjust previous clip: extend its duration if (slipSlideClip.prevClipId && c.id === slipSlideClip.prevClipId) { const newDur = (slipSlideClip.prevOrigDuration ?? c.duration) + actualDelta return { ...c, duration: Math.max(0.5, newDur) } } // Adjust next clip: shift start and extend duration if (slipSlideClip.nextClipId && c.id === slipSlideClip.nextClipId) { const newStart = (slipSlideClip.nextOrigStartTime ?? c.startTime) + actualDelta const newDur = (slipSlideClip.nextOrigDuration ?? c.duration) - actualDelta const newTrimStart = (slipSlideClip.nextOrigTrimStart ?? c.trimStart) + actualDelta * c.speed return { ...c, startTime: newStart, duration: Math.max(0.5, newDur), trimStart: Math.max(0, newTrimStart) } } return c })) } }, [slipSlideClip, clips, pixelsPerSecond]) const handleSlipSlideUp = useCallback(() => { setSlipSlideClip(null) }, []) useEffect(() => { if (slipSlideClip) { window.addEventListener('mousemove', handleSlipSlideMove) window.addEventListener('mouseup', handleSlipSlideUp) return () => { window.removeEventListener('mousemove', handleSlipSlideMove) window.removeEventListener('mouseup', handleSlipSlideUp) } } }, [slipSlideClip, handleSlipSlideMove, handleSlipSlideUp]) const handleTrackDrop = (e: React.DragEvent, trackIndex: number) => { e.preventDefault() // Check if it's a timeline being dropped (flatten on drop) const timelineData = e.dataTransfer.getData('timeline') if (timelineData && trackContainerRef.current) { const droppedTimeline = JSON.parse(timelineData) as { id: string; name: string } const sourceTimeline = timelines.find(t => t.id === droppedTimeline.id) if (!sourceTimeline || sourceTimeline.id === activeTimeline?.id) return if (sourceTimeline.clips.length === 0) return const rect = trackContainerRef.current.getBoundingClientRect() const scrollLeft = trackContainerRef.current.scrollLeft const x = e.clientX - rect.left + scrollLeft const dropTime = Math.max(0, x / pixelsPerSecond) // Flatten: copy all clips from the source timeline, offset to drop position // Find the earliest clip start in the source to compute relative offsets const earliestStart = sourceTimeline.clips.reduce( (min: number, c: any) => Math.min(min, c.startTime), Infinity ) const newClips = sourceTimeline.clips.map((srcClip: any) => migrateClip({ ...srcClip, id: `clip-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, startTime: dropTime + (srcClip.startTime - earliestStart), // Remap trackIndex: offset by the drop track, but keep relative spacing trackIndex: Math.min(trackIndex + srcClip.trackIndex, tracks.length - 1), })) setClips(prev => [...prev, ...newClips]) return } // Multi-asset drop (from multi-select drag) — add sequentially using addClipToTimeline const assetIdsJson = e.dataTransfer.getData('assetIds') if (assetIdsJson && trackContainerRef.current) { try { const ids: string[] = JSON.parse(assetIdsJson) const droppedAssets = ids.map(id => assets.find(a => a.id === id)).filter(Boolean) as Asset[] if (droppedAssets.length > 0) { const rect = trackContainerRef.current.getBoundingClientRect() const scrollLeft = trackContainerRef.current.scrollLeft const x = e.clientX - rect.left + scrollLeft let nextStart = Math.max(0, x / pixelsPerSecond) for (const a of droppedAssets) { addClipToTimeline(a, trackIndex, nextStart) nextStart += a.duration || 5 } return } } catch { /* ignore parse errors */ } } // Single asset drop const assetId = e.dataTransfer.getData('assetId') const assetData = e.dataTransfer.getData('asset') let asset: Asset | undefined if (assetData) { asset = JSON.parse(assetData) } else if (assetId) { asset = assets.find(a => a.id === assetId) } if (asset && trackContainerRef.current) { const rect = trackContainerRef.current.getBoundingClientRect() const scrollLeft = trackContainerRef.current.scrollLeft const x = e.clientX - rect.left + scrollLeft const startTime = Math.max(0, x / pixelsPerSecond) addClipToTimeline(asset, trackIndex, startTime) } } return { draggingClip, setDraggingClip, resizingClip, setResizingClip, slipSlideClip, setSlipSlideClip, lassoRect, setLassoRect, isScrubbing, scrubFromEvent, handleRulerMouseDown, expandWithLinkedClips, handleClipMouseDown, handleMouseMove, handleMouseUp, handleResizeMove, handleResizeStart, handleSlipSlideMove, handleSlipSlideUp, handleTrackDrop, lassoOriginRef, } }