Files
LTX-DeskWEB/frontend/views/editor/useTimelineDrag.ts
T

1122 lines
44 KiB
TypeScript

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<string, { startTime: number; trackIndex: number }>
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<React.SetStateAction<TimelineClip[]>>
tracks: Track[]
selectedClipIds: Set<string>
setSelectedClipIds: React.Dispatch<React.SetStateAction<Set<string>>>
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<HTMLDivElement>
trackContainerRef: React.RefObject<HTMLDivElement>
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<DraggingClipState | null>(null)
const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null)
const [slipSlideClip, setSlipSlideClip] = useState<SlipSlideClipState | null>(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<Record<string, DragPreviewPosition> | null>(null)
const dragPreviewNodesRef = useRef<Map<string, DragPreviewNode>>(new Map())
const dragPreviewRafRef = useRef<number | null>(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<string>): Set<string> => {
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<string, { startTime: number; trackIndex: number }> = {}
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<string>
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<string, { startTime: number; trackIndex: number }> = {}
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<string, string>()
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<string, { startTime: number; trackIndex: number }> = {}
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<string, DragPreviewPosition> = {}
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<string>()
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<string>(clip.linkedClipIds || [])
const adjLinkedIds = new Set<string>(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<string>(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<string>(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<string>(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,
}
}