diff --git a/src/features/timeline/components/timeline-content.tsx b/src/features/timeline/components/timeline-content.tsx index 726bb5668..2a4fcfa88 100644 --- a/src/features/timeline/components/timeline-content.tsx +++ b/src/features/timeline/components/timeline-content.tsx @@ -1310,8 +1310,9 @@ export const TimelineContent = memo(function TimelineContent({ // Click empty space to deselect items and markers (but preserve track selection) const handleContainerClick = (e: React.MouseEvent) => { - // Don't deselect if marquee selection, drag, or scrubbing just finished - if (marqueeWasActiveRef.current || dragWasActiveRef.current || scrubWasActiveRef.current) { + // Item drags own their exact browser-generated click at document capture. + // Keep the local delayed guards only for marquee and scrub interactions. + if (marqueeWasActiveRef.current || scrubWasActiveRef.current) { return } diff --git a/src/features/timeline/components/timeline-item/index.tsx b/src/features/timeline/components/timeline-item/index.tsx index 9d883da6b..b8ad43ce2 100644 --- a/src/features/timeline/components/timeline-item/index.tsx +++ b/src/features/timeline/components/timeline-item/index.tsx @@ -342,7 +342,6 @@ export const TimelineItem = memo(function TimelineItem({ const { dragAffectsJoin, isAnyDragActiveRef, - dragWasActiveRef, isAltDrag, isPartOfDrag, isBeingDragged, @@ -602,7 +601,6 @@ export const TimelineItem = memo(function TimelineItem({ activeToolRef, smartTrimIntentRef, smartBodyIntent, - dragWasActiveRef, isTrimming, isStretching, isSlipSlideActive, diff --git a/src/features/timeline/components/timeline-item/post-drag-click-guard.test.ts b/src/features/timeline/components/timeline-item/post-drag-click-guard.test.ts index d4f805a3c..e2f089c5b 100644 --- a/src/features/timeline/components/timeline-item/post-drag-click-guard.test.ts +++ b/src/features/timeline/components/timeline-item/post-drag-click-guard.test.ts @@ -1,23 +1,51 @@ -// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { + resetPostTimelineGestureClickForTest, + suppressPostTimelineGestureClick, +} from './post-drag-click-guard' -import { describe, expect, it } from 'vite-plus/test' -import { shouldSuppressTimelineItemClickAfterDrag } from './post-drag-click-guard' +function dispatchMouseEvent(target: EventTarget, type: 'mousedown' | 'click', detail = 1) { + target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, detail })) +} -describe('shouldSuppressTimelineItemClickAfterDrag', () => { - it('suppresses post-drag clicks for selection tools', () => { - expect(shouldSuppressTimelineItemClickAfterDrag('select', true)).toBe(true) - expect(shouldSuppressTimelineItemClickAfterDrag('trim-edit', true)).toBe(true) +describe('post timeline gesture click ownership', () => { + afterEach(() => resetPostTimelineGestureClickForTest()) + + it('suppresses exactly one browser-generated click', () => { + const element = document.createElement('button') + const onClick = vi.fn() + element.addEventListener('click', onClick) + document.body.appendChild(element) + + suppressPostTimelineGestureClick() + dispatchMouseEvent(element, 'click') + dispatchMouseEvent(element, 'click') + + expect(onClick).toHaveBeenCalledTimes(1) }) - it('allows post-drag clicks for non-selection tools so razor and edit tools still work', () => { - expect(shouldSuppressTimelineItemClickAfterDrag('razor', true)).toBe(false) - expect(shouldSuppressTimelineItemClickAfterDrag('rate-stretch', true)).toBe(false) - expect(shouldSuppressTimelineItemClickAfterDrag('slip', true)).toBe(false) - expect(shouldSuppressTimelineItemClickAfterDrag('slide', true)).toBe(false) + it('releases ownership when a later independent mouse gesture starts', () => { + const element = document.createElement('button') + const onClick = vi.fn() + element.addEventListener('click', onClick) + document.body.appendChild(element) + + suppressPostTimelineGestureClick() + dispatchMouseEvent(element, 'mousedown') + dispatchMouseEvent(element, 'click') + + expect(onClick).toHaveBeenCalledTimes(1) }) - it('never suppresses when no drag just finished', () => { - expect(shouldSuppressTimelineItemClickAfterDrag('select', false)).toBe(false) - expect(shouldSuppressTimelineItemClickAfterDrag('razor', false)).toBe(false) + it('does not suppress keyboard or programmatic activation', () => { + const element = document.createElement('button') + const onClick = vi.fn() + element.addEventListener('click', onClick) + document.body.appendChild(element) + + suppressPostTimelineGestureClick() + dispatchMouseEvent(element, 'click', 0) + + expect(onClick).toHaveBeenCalledTimes(1) }) }) diff --git a/src/features/timeline/components/timeline-item/post-drag-click-guard.ts b/src/features/timeline/components/timeline-item/post-drag-click-guard.ts index c6e9549c9..afc7d7edf 100644 --- a/src/features/timeline/components/timeline-item/post-drag-click-guard.ts +++ b/src/features/timeline/components/timeline-item/post-drag-click-guard.ts @@ -1,12 +1,41 @@ -import type { SelectionState } from '@/shared/state/selection/types' - -export function shouldSuppressTimelineItemClickAfterDrag( - activeTool: SelectionState['activeTool'], - dragWasActive: boolean, -): boolean { - if (!dragWasActive) { - return false +let removePendingClickOwnership: (() => void) | null = null + +function clearPendingClickOwnership() { + removePendingClickOwnership?.() + removePendingClickOwnership = null +} + +/** + * Own the browser-generated click that immediately follows a completed mouse + * gesture. A later independent click always starts with another mousedown, + * which clears the ownership before that click can be dispatched. + */ +export function suppressPostTimelineGestureClick(): void { + clearPendingClickOwnership() + if (typeof document === 'undefined') return + + const handleIndependentMouseDown = () => { + clearPendingClickOwnership() } + const handleClick = (event: MouseEvent) => { + // Keyboard activation and HTMLElement.click() do not belong to the mouse + // gesture and must remain available. + if (event.detail === 0) return + + clearPendingClickOwnership() + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + } + + removePendingClickOwnership = () => { + document.removeEventListener('mousedown', handleIndependentMouseDown, true) + document.removeEventListener('click', handleClick, true) + } + document.addEventListener('mousedown', handleIndependentMouseDown, true) + document.addEventListener('click', handleClick, true) +} - return activeTool === 'select' || activeTool === 'trim-edit' +export function resetPostTimelineGestureClickForTest(): void { + clearPendingClickOwnership() } diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx index 0f35021e6..3b618064e 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx +++ b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.test.tsx @@ -89,7 +89,6 @@ function makeInput( activeToolRef: { current: activeTool }, smartTrimIntentRef: { current: null }, smartBodyIntent: null, - dragWasActiveRef: { current: false }, isTrimming: false, isStretching: false, isSlipSlideActive: false, diff --git a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts index 173d19663..cdeef51f1 100644 --- a/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts +++ b/src/features/timeline/components/timeline-item/use-timeline-item-pointer-handlers.ts @@ -31,14 +31,12 @@ import { } from '../../utils/smart-trim-zones' import { isRateStretchableItem } from '../../hooks/use-rate-stretch' import { getTimelineClipLabelRowHeightPx } from './hover-layout' -import { shouldSuppressTimelineItemClickAfterDrag } from './post-drag-click-guard' import { emitUiSound } from '@/shared/ui/ui-sound' import type { useTimelineDrag } from '../../hooks/use-timeline-drag' import type { useTimelineTrim } from '../../hooks/use-timeline-trim' import type { useRateStretch } from '../../hooks/use-rate-stretch' import type { useTimelineSlipSlide } from '../../hooks/use-timeline-slip-slide' import type { useSmartTrimHover } from './use-smart-trim-hover' -import type { useDragVisualState } from './use-drag-visual-state' export interface TimelineItemPointerHint { x: number @@ -54,7 +52,6 @@ export interface TimelineItemPointerHandlersInput { activeToolRef: RefObject smartTrimIntentRef: ReturnType['smartTrimIntentRef'] smartBodyIntent: SmartBodyIntent - dragWasActiveRef: ReturnType['dragWasActiveRef'] isTrimming: boolean isStretching: boolean isSlipSlideActive: boolean @@ -90,7 +87,6 @@ export function useTimelineItemPointerHandlers({ activeToolRef, smartTrimIntentRef, smartBodyIntent, - dragWasActiveRef, isTrimming, isStretching, isSlipSlideActive, @@ -109,9 +105,6 @@ export function useTimelineItemPointerHandlers({ emitUiSound('error') return } - if (shouldSuppressTimelineItemClickAfterDrag(activeToolRef.current, dragWasActiveRef.current)) - return - // Razor tool: split item at click position if (activeToolRef.current === 'razor') { const tracksContainer = e.currentTarget.closest('.timeline-tracks') as HTMLElement | null @@ -193,7 +186,7 @@ export function useTimelineItemPointerHandlers({ selectItems(targetIds) } }, - [activeToolRef, dragWasActiveRef, trackLocked, item.from, item.id, smartTrimIntentRef], + [activeToolRef, trackLocked, item.from, item.id, smartTrimIntentRef], ) // Double-click: open media in source monitor with clip's source range as I/O diff --git a/src/features/timeline/hooks/use-timeline-drag.dom.test.tsx b/src/features/timeline/hooks/use-timeline-drag.dom.test.tsx new file mode 100644 index 000000000..b01682bd4 --- /dev/null +++ b/src/features/timeline/hooks/use-timeline-drag.dom.test.tsx @@ -0,0 +1,711 @@ +import { useState } from 'react' +import { act, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import type { TimelineItem, TimelineTrack } from '@/types/timeline' +import { useEditorStore } from '@/shared/state/editor' +import { useSelectionStore } from '@/shared/state/selection' +import { + makeTimelineAudioItem, + makeTimelineTrack, + makeTimelineVideoItem, + resetTimelineCompositionTestState, +} from '../test-helpers' +import { useItemsStore } from '../stores/items-store' +import { useLinkedEditPreviewStore } from '../stores/linked-edit-preview-store' +import { useTimelineCommandStore } from '../stores/timeline-command-store' +import { useTimelineSettingsStore } from '../stores/timeline-settings-store' +import { useTimelineStore } from '../stores/timeline-store' +import { useTransitionsStore } from '../stores/transitions-store' +import { useZoomStore } from '../stores/zoom-store' +import { getLinkedItemIds } from '../utils/linked-items' +import { resetPostTimelineGestureClickForTest } from '../components/timeline-item/post-drag-click-guard' +import { useTimelineDrag } from './use-timeline-drag' + +const TIMELINE_DURATION = 600 +const TRACK_HEIGHT = 80 +let rafCallbacks = new Map() +let nextRafId = 1 + +function makeRect(top: number, bottom: number): DOMRect { + return { + x: 0, + y: top, + top, + left: 0, + right: 1000, + bottom, + width: 1000, + height: bottom - top, + toJSON: () => ({}), + } +} + +function setupStores(tracks: TimelineTrack[], items: TimelineItem[]) { + resetTimelineCompositionTestState() + useTimelineSettingsStore.setState({ fps: 30, isDirty: false, snapEnabled: false }) + useZoomStore.setState({ level: 0.3, pixelsPerSecond: 30 }) + useItemsStore.getState().setTracks(tracks) + useItemsStore.getState().setItems(items) + useTransitionsStore.getState().setTransitions([]) + useEditorStore.setState({ linkedSelectionEnabled: true }) + useSelectionStore.getState().clearSelection() + useSelectionStore.getState().setDragState(null) + useSelectionStore.getState().setActiveSnapTarget(null) + useSelectionStore.getState().setActiveLinkedDropTarget(null) + useLinkedEditPreviewStore.getState().clear() +} + +function captureSelectionMetadata() { + const state = useSelectionStore.getState() + return { + selectedItemIds: [...state.selectedItemIds], + selectedItemIdSet: new Set(state.selectedItemIdSet), + selectedMarkerId: state.selectedMarkerId, + selectedTransitionId: state.selectedTransitionId, + selectedTrackId: state.selectedTrackId, + selectedTrackIds: [...state.selectedTrackIds], + activeTrackId: state.activeTrackId, + selectionType: state.selectionType, + activeTool: state.activeTool, + activeSnapTarget: state.activeSnapTarget, + activeLinkedDropTarget: state.activeLinkedDropTarget, + dragState: state.dragState, + editKeyframePanelOpen: state.editKeyframePanelOpen, + expandedKeyframeLanes: new Set(state.expandedKeyframeLanes), + } +} + +function captureMutationState() { + const history = useTimelineCommandStore.getState() + return { + items: structuredClone(useItemsStore.getState().items), + tracks: structuredClone(useItemsStore.getState().tracks), + isDirty: useTimelineSettingsStore.getState().isDirty, + undoStack: structuredClone(history.undoStack), + redoStack: structuredClone(history.redoStack), + canUndo: history.canUndo, + canRedo: history.canRedo, + } +} + +function RenderedDragSurface({ + item, + tracks, + onClipClick, + onBackgroundClick, +}: { + item: TimelineItem + tracks: TimelineTrack[] + onClipClick?: () => void + onBackgroundClick?: () => void +}) { + const { handleDragStart } = useTimelineDrag(item, TIMELINE_DURATION) + const [, rerender] = useState(0) + + return ( +
{ + onBackgroundClick?.() + const selection = useSelectionStore.getState() + selection.clearItemSelection() + selection.selectMarker(null) + rerender((value) => value + 1) + }} + > +
+ {[...tracks] + .sort((left, right) => left.order - right.order) + .map((track) => ( +
+ {track.id === item.trackId && ( + + )} +
+ ))} +
+
+ ) +} + +function renderDragSurface(item: TimelineItem, tracks: TimelineTrack[]) { + const onClipClick = vi.fn() + const onBackgroundClick = vi.fn() + const view = render( + , + ) + const rows = Array.from(view.container.querySelectorAll('[data-track-id]')) + const centerYByTrackId = new Map() + rows.forEach((row, index) => { + const top = index * TRACK_HEIGHT + row.getBoundingClientRect = () => makeRect(top, top + TRACK_HEIGHT) + centerYByTrackId.set(row.dataset.trackId!, top + TRACK_HEIGHT / 2) + }) + const trackContainer = view.container.querySelector('.timeline-tracks')! + const timelineContainer = view.container.querySelector('.timeline-container')! + trackContainer.getBoundingClientRect = () => + makeRect(-TRACK_HEIGHT, rows.length * TRACK_HEIGHT + TRACK_HEIGHT) + timelineContainer.getBoundingClientRect = trackContainer.getBoundingClientRect + + return { + ...view, + anchor: view.getByTestId('drag-anchor'), + background: view.getByTestId('timeline-background'), + centerYByTrackId, + onClipClick, + onBackgroundClick, + } +} + +function flushAnimationFrames() { + const callbacks = Array.from(rafCallbacks.values()) + rafCallbacks.clear() + for (const callback of callbacks) callback(performance.now()) +} + +function dispatchClick(target: Element) { + target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1 })) +} + +function dragRendered(params: { + anchor: Element + startX?: number + startY: number + endX: number + endY: number + clickTarget: Element +}) { + const startX = params.startX ?? 0 + fireEvent.mouseDown(params.anchor, { button: 0, clientX: startX, clientY: params.startY }) + fireEvent.mouseMove(window, { clientX: startX + 4, clientY: params.startY }) + act(flushAnimationFrames) + fireEvent.mouseMove(window, { clientX: params.endX, clientY: params.endY }) + act(flushAnimationFrames) + fireEvent.mouseUp(window, { button: 0, clientX: params.endX, clientY: params.endY }) + act(() => dispatchClick(params.clickTarget)) +} + +function makeBasicLinkedCohort() { + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + return { video, audio } +} + +function makePreflightRejectionCase( + kind: 'nested' | 'deep' | 'implicit-nested' | 'missing' | 'cycle' | 'implicit-cycle', +) { + const { video, audio } = makeBasicLinkedCohort() + const baseTracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + + if (kind === 'nested' || kind === 'deep') { + const depth = kind === 'deep' ? 4 : 2 + const groups = Array.from({ length: depth }, (_, index) => + makeTimelineTrack({ + id: `source-group-${index}`, + name: `Source Group ${index}`, + order: 4 + index, + isGroup: true, + locked: index === 0, + parentTrackId: index === 0 ? undefined : `source-group-${index - 1}`, + }), + ) + const source = makeTimelineTrack({ + id: 'source-lane', + name: 'Source Lane', + kind: 'video', + order: 4 + depth, + parentTrackId: `source-group-${depth - 1}`, + }) + return { + tracks: [...baseTracks, ...groups, source], + items: [{ ...video, trackId: source.id }, audio], + anchor: { ...video, trackId: source.id }, + } + } + + if (kind === 'implicit-nested') { + const outer = makeTimelineTrack({ + id: 'audio-outer', + name: 'Audio Outer', + order: 4, + isGroup: true, + locked: true, + }) + const inner = makeTimelineTrack({ + id: 'audio-inner', + name: 'Audio Inner', + order: 5, + isGroup: true, + parentTrackId: outer.id, + }) + const companion = makeTimelineTrack({ + id: 'companion-lane', + name: 'Companion Lane', + kind: 'audio', + order: 6, + parentTrackId: inner.id, + }) + return { + tracks: [...baseTracks, outer, inner, companion], + items: [video, { ...audio, trackId: companion.id }], + anchor: video, + } + } + + if (kind === 'missing') { + const source = makeTimelineTrack({ + id: 'missing-source', + name: 'Missing Source', + kind: 'video', + order: 4, + parentTrackId: 'absent-parent', + }) + return { + tracks: [...baseTracks, source], + items: [{ ...video, trackId: source.id }, audio], + anchor: { ...video, trackId: source.id }, + } + } + + const cycleA = makeTimelineTrack({ + id: 'cycle-a', + name: 'Cycle A', + order: 4, + isGroup: true, + parentTrackId: 'cycle-b', + }) + const cycleB = makeTimelineTrack({ + id: 'cycle-b', + name: 'Cycle B', + order: 5, + isGroup: true, + parentTrackId: 'cycle-a', + }) + const cyclicLane = makeTimelineTrack({ + id: 'cyclic-lane', + name: 'Cyclic Lane', + kind: kind === 'implicit-cycle' ? 'audio' : 'video', + order: 6, + parentTrackId: cycleA.id, + }) + return kind === 'implicit-cycle' + ? { + tracks: [...baseTracks, cycleA, cycleB, cyclicLane], + items: [video, { ...audio, trackId: cyclicLane.id }], + anchor: video, + } + : { + tracks: [...baseTracks, cycleA, cycleB, cyclicLane], + items: [{ ...video, trackId: cyclicLane.id }, audio], + anchor: { ...video, trackId: cyclicLane.id }, + } +} + +describe('useTimelineDrag rendered click ownership', () => { + beforeEach(() => { + rafCallbacks = new Map() + nextRafId = 1 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextRafId++ + rafCallbacks.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => rafCallbacks.delete(id)) + }) + + afterEach(() => { + resetPostTimelineGestureClickForTest() + vi.unstubAllGlobals() + }) + + it.each([ + ['row 24 nested locked source', 'nested'], + ['row 25 four-level locked source', 'deep'], + ['row 26 nested locked implicit companion', 'implicit-nested'], + ['row 28 missing source parent', 'missing'], + ['row 30 cyclic source', 'cycle'], + ['row 32 cyclic implicit companion', 'implicit-cycle'], + ] as const)('%s rejects and owns its rendered post-mouseup click', (_name, kind) => { + const { tracks, items, anchor } = makePreflightRejectionCase(kind) + setupStores(tracks, items) + useSelectionStore.getState().selectTrack(anchor.trackId) + const beforeSelection = captureSelectionMetadata() + const beforeMutation = captureMutationState() + const view = renderDragSurface(anchor, tracks) + const startY = view.centerYByTrackId.get(anchor.trackId)! + + dragRendered({ + anchor: view.anchor, + startY, + endX: 30, + endY: startY, + clickTarget: view.background, + }) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeMutation) + }) + + it.each([ + ['row 34 selected cohort', true, false], + ['row 41 unselected cohort with history', false, true], + ] as const)( + '%s restores the complete selection and mutation state after locked-target rejection', + (_name, initiallySelected, seedHistory) => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0, locked: true }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const { video, audio } = makeBasicLinkedCohort() + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, prior]) + useSelectionStore + .getState() + .selectItems(initiallySelected ? [video.id, audio.id] : [prior.id]) + useSelectionStore.getState().setEditKeyframePanelOpen(true) + if (seedHistory) { + useTimelineStore.getState().moveItem(prior.id, prior.from + 1) + } else { + useTimelineSettingsStore.setState({ isDirty: true }) + } + const beforeSelection = captureSelectionMetadata() + const beforeMutation = captureMutationState() + const view = renderDragSurface(video, tracks) + + dragRendered({ + anchor: view.anchor, + startY: view.centerYByTrackId.get('v1')!, + endX: 30, + endY: view.centerYByTrackId.get('v2')!, + clickTarget: view.background, + }) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeMutation) + }, + ) + + it('row 35 restores prior item, track, and keyframe metadata below threshold', () => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const { video, audio } = makeBasicLinkedCohort() + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, prior]) + useSelectionStore.setState({ + selectedItemIds: [prior.id], + selectedItemIdSet: new Set([prior.id]), + selectedMarkerId: null, + selectedTransitionId: null, + selectedTrackId: 'v1', + selectedTrackIds: ['v1'], + activeTrackId: 'v1', + selectionType: 'item', + editKeyframePanelOpen: true, + expandedKeyframeLanes: new Set([prior.id]), + }) + const beforeSelection = captureSelectionMetadata() + const beforeMutation = captureMutationState() + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 10, clientY: startY }) + fireEvent.mouseMove(window, { clientX: 12, clientY: startY }) + fireEvent.mouseUp(window, { button: 0, clientX: 12, clientY: startY }) + act(() => dispatchClick(view.anchor)) + + expect(view.onClipClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeMutation) + }) + + it.each(['marker', 'transition'] as const)( + 'restores a prior %s selection after a rendered below-threshold cancellation', + (selectionType) => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const { video, audio } = makeBasicLinkedCohort() + setupStores(tracks, [video, audio]) + useSelectionStore.getState().selectTrack('v1') + if (selectionType === 'marker') { + useSelectionStore.getState().selectMarker('marker-1') + } else { + useSelectionStore.getState().selectTransition('transition-1') + } + const beforeSelection = captureSelectionMetadata() + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 10, clientY: startY }) + fireEvent.mouseMove(window, { clientX: 12, clientY: startY }) + fireEvent.mouseUp(window, { button: 0, clientX: 12, clientY: startY }) + act(() => dispatchClick(view.anchor)) + + expect(view.onClipClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + }, + ) + + it('row 42 preserves malformed-source full state and the prior keyframe target', () => { + const malformed = makePreflightRejectionCase('missing') + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(malformed.tracks, [...malformed.items, prior]) + useSelectionStore.getState().selectItems([prior.id]) + useSelectionStore.getState().setEditKeyframePanelOpen(true) + useTimelineSettingsStore.setState({ isDirty: true }) + const beforeSelection = captureSelectionMetadata() + const beforeMutation = captureMutationState() + const view = renderDragSurface(malformed.anchor, malformed.tracks) + const startY = view.centerYByTrackId.get(malformed.anchor.trackId)! + + dragRendered({ + anchor: view.anchor, + startY, + endX: 30, + endY: startY, + clickTarget: view.background, + }) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeMutation) + }) + + it.each(['Escape', 'pointercancel'] as const)( + 'restores exact state when an active gesture ends via %s', + (cancellation) => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const { video, audio } = makeBasicLinkedCohort() + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, prior]) + useSelectionStore.getState().selectItems([prior.id]) + const beforeSelection = captureSelectionMetadata() + const beforeMutation = captureMutationState() + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 0, clientY: startY }) + fireEvent.mouseMove(window, { clientX: 4, clientY: startY }) + act(flushAnimationFrames) + if (cancellation === 'Escape') { + fireEvent.keyDown(window, { key: 'Escape' }) + } else { + window.dispatchEvent(new Event('pointercancel', { bubbles: true })) + } + act(() => dispatchClick(view.background)) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeMutation) + }, + ) + + it.each(['source', 'destination'] as const)( + 'revalidates live %s lock drift and owns the rendered release click', + (lockDrift) => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const { video, audio } = makeBasicLinkedCohort() + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, prior]) + useSelectionStore.getState().selectItems([prior.id]) + const beforeSelection = captureSelectionMetadata() + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + const endY = view.centerYByTrackId.get('v2')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 0, clientY: startY }) + fireEvent.mouseMove(window, { clientX: 4, clientY: startY }) + act(flushAnimationFrames) + fireEvent.mouseMove(window, { clientX: 30, clientY: endY }) + act(flushAnimationFrames) + act(() => { + useItemsStore + .getState() + .setTracks( + tracks.map((track) => + track.id === (lockDrift === 'source' ? 'v1' : 'v2') + ? { ...track, locked: true } + : track, + ), + ) + }) + const beforeDropMutation = captureMutationState() + fireEvent.mouseUp(window, { button: 0, clientX: 30, clientY: endY }) + act(() => dispatchClick(view.background)) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureMutationState()).toEqual(beforeDropMutation) + }, + ) + + it('restores on unmount without swallowing the next independent click', () => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const { video, audio } = makeBasicLinkedCohort() + const prior = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, prior]) + useSelectionStore.getState().selectItems([prior.id]) + const beforeSelection = captureSelectionMetadata() + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 0, clientY: startY }) + fireEvent.mouseMove(window, { clientX: 2, clientY: startY }) + view.unmount() + + expect(captureSelectionMetadata()).toEqual(beforeSelection) + + const independent = document.createElement('button') + const onIndependentClick = vi.fn() + independent.addEventListener('click', onIndependentClick) + document.body.appendChild(independent) + fireEvent.mouseDown(independent) + fireEvent.mouseUp(independent) + dispatchClick(independent) + expect(onIndependentClick).toHaveBeenCalledTimes(1) + }) + + it('allows a no-move ordinary click and a Razor click', () => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const { video, audio } = makeBasicLinkedCohort() + setupStores(tracks, [video, audio]) + useSelectionStore.getState().selectTrack('v1') + const view = renderDragSurface(video, tracks) + const startY = view.centerYByTrackId.get('v1')! + + fireEvent.mouseDown(view.anchor, { button: 0, clientX: 10, clientY: startY }) + fireEvent.mouseUp(window, { button: 0, clientX: 10, clientY: startY }) + act(() => dispatchClick(view.anchor)) + + expect(view.onClipClick).toHaveBeenCalledTimes(1) + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set([video.id, audio.id]), + ) + + const razor = document.createElement('button') + const onRazorClick = vi.fn() + razor.addEventListener('click', onRazorClick) + document.body.appendChild(razor) + fireEvent.mouseDown(razor) + fireEvent.mouseUp(razor) + dispatchClick(razor) + expect(onRazorClick).toHaveBeenCalledTimes(1) + }) + + it('keeps successful linked-drop selection and allows the next independent click', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const { video, audio } = makeBasicLinkedCohort() + setupStores(tracks, [video, audio]) + const view = renderDragSurface(video, tracks) + + dragRendered({ + anchor: view.anchor, + startY: view.centerYByTrackId.get('v1')!, + endX: 30, + endY: view.centerYByTrackId.get('v2')!, + clickTarget: view.background, + }) + + expect(view.onBackgroundClick).not.toHaveBeenCalled() + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set([video.id, audio.id]), + ) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + + fireEvent.mouseDown(view.background) + fireEvent.mouseUp(view.background) + act(() => dispatchClick(view.background)) + expect(view.onBackgroundClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/features/timeline/hooks/use-timeline-drag.test.tsx b/src/features/timeline/hooks/use-timeline-drag.test.tsx new file mode 100644 index 000000000..cfb24c519 --- /dev/null +++ b/src/features/timeline/hooks/use-timeline-drag.test.tsx @@ -0,0 +1,902 @@ +import type React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' +import { act, renderHook } from '@testing-library/react' +import type { TextItem, TimelineItem, TimelineTrack } from '@/types/timeline' +import { useEditorStore } from '@/shared/state/editor' +import { useSelectionStore } from '@/shared/state/selection' +import { + makeTimelineAudioItem, + makeTimelineTrack, + makeTimelineVideoItem, + resetTimelineCompositionTestState, +} from '../test-helpers' +import { useItemsStore } from '../stores/items-store' +import { useLinkedEditPreviewStore } from '../stores/linked-edit-preview-store' +import { useTimelineCommandStore } from '../stores/timeline-command-store' +import { useTimelineSettingsStore } from '../stores/timeline-settings-store' +import { useTransitionsStore } from '../stores/transitions-store' +import { useZoomStore } from '../stores/zoom-store' +import { useTimelineDrag } from './use-timeline-drag' + +const TIMELINE_DURATION = 600 +const TRACK_HEIGHT = 80 +let rafCallbacks = new Map() +let nextRafId = 1 + +function makeRect(top: number, bottom: number): DOMRect { + return { + x: 0, + y: top, + top, + left: 0, + right: 1000, + bottom, + width: 1000, + height: bottom - top, + toJSON: () => ({}), + } +} + +function mountTimelineTracks(tracks: TimelineTrack[]): Map { + const container = document.createElement('div') + container.className = 'timeline-container' + const trackContainer = document.createElement('div') + trackContainer.className = 'timeline-tracks' + container.appendChild(trackContainer) + document.body.appendChild(container) + + const orderedTracks = [...tracks].sort((left, right) => left.order - right.order) + const centerYByTrackId = new Map() + orderedTracks.forEach((track, index) => { + const top = index * TRACK_HEIGHT + const row = document.createElement('div') + row.dataset.trackId = track.id + row.getBoundingClientRect = () => makeRect(top, top + TRACK_HEIGHT) + trackContainer.appendChild(row) + centerYByTrackId.set(track.id, top + TRACK_HEIGHT / 2) + }) + + trackContainer.getBoundingClientRect = () => + makeRect(-TRACK_HEIGHT, orderedTracks.length * TRACK_HEIGHT + TRACK_HEIGHT) + container.getBoundingClientRect = trackContainer.getBoundingClientRect + return centerYByTrackId +} + +function setupStores(tracks: TimelineTrack[], items: TimelineItem[]) { + resetTimelineCompositionTestState() + useTimelineSettingsStore.setState({ fps: 30, isDirty: false, snapEnabled: false }) + useZoomStore.setState({ level: 0.3, pixelsPerSecond: 30 }) + useItemsStore.getState().setTracks(tracks) + useItemsStore.getState().setItems(items) + useTransitionsStore.getState().setTransitions([]) + useEditorStore.setState({ linkedSelectionEnabled: true }) + useSelectionStore.getState().clearSelection() + useSelectionStore.getState().setDragState(null) + useSelectionStore.getState().setActiveSnapTarget(null) + useSelectionStore.getState().setActiveLinkedDropTarget(null) + useLinkedEditPreviewStore.getState().clear() +} + +function flushAnimationFrames() { + const callbacks = Array.from(rafCallbacks.values()) + rafCallbacks.clear() + for (const callback of callbacks) { + callback(performance.now()) + } +} + +function beginDrag( + result: { current: ReturnType }, + startX: number, + startY: number, +) { + startDragAttempt(result, startX, startY) + act(() => { + window.dispatchEvent(new MouseEvent('mousemove', { clientX: startX + 4, clientY: startY })) + }) +} + +function startDragAttempt( + result: { current: ReturnType }, + startX: number, + startY: number, +) { + const target = document.createElement('div') + const event = { + target, + clientX: startX, + clientY: startY, + ctrlKey: false, + metaKey: false, + stopPropagation: vi.fn(), + } as unknown as React.MouseEvent + + act(() => { + result.current.handleDragStart(event) + }) +} + +function moveDrag(clientX: number, clientY: number) { + act(() => { + window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY })) + flushAnimationFrames() + }) +} + +function releaseDrag() { + act(() => { + window.dispatchEvent(new MouseEvent('mouseup')) + }) +} + +function getItem(id: string): TimelineItem { + const item = useItemsStore.getState().itemById[id] + expect(item).toBeDefined() + return item as TimelineItem +} + +function captureSelectionMetadata() { + const state = useSelectionStore.getState() + return { + selectedItemIds: [...state.selectedItemIds], + selectedItemIdSet: new Set(state.selectedItemIdSet), + selectedMarkerId: state.selectedMarkerId, + selectedTransitionId: state.selectedTransitionId, + selectedTrackId: state.selectedTrackId, + selectedTrackIds: [...state.selectedTrackIds], + activeTrackId: state.activeTrackId, + selectionType: state.selectionType, + activeTool: state.activeTool, + activeSnapTarget: state.activeSnapTarget, + activeLinkedDropTarget: state.activeLinkedDropTarget, + dragState: state.dragState, + editKeyframePanelOpen: state.editKeyframePanelOpen, + expandedKeyframeLanes: new Set(state.expandedKeyframeLanes), + } +} + +function captureTimelineMutationState() { + const commandState = useTimelineCommandStore.getState() + return { + items: structuredClone(useItemsStore.getState().items), + tracks: structuredClone(useItemsStore.getState().tracks), + isDirty: useTimelineSettingsStore.getState().isDirty, + undoStack: structuredClone(commandState.undoStack), + redoStack: structuredClone(commandState.redoStack), + canUndo: commandState.canUndo, + canRedo: commandState.canRedo, + } +} + +function makeThreeSectionTracks(): TimelineTrack[] { + return [ + makeTimelineTrack({ id: 'v3', name: 'V3', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 2 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 3 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 4 }), + makeTimelineTrack({ id: 'a3', name: 'A3', kind: 'audio', order: 5 }), + ] +} + +describe('useTimelineDrag linked cohorts', () => { + beforeEach(() => { + rafCallbacks = new Map() + nextRafId = 1 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + const id = nextRafId + nextRafId += 1 + rafCallbacks.set(id, callback) + return id + }) + vi.stubGlobal('cancelAnimationFrame', (id: number) => { + rafCallbacks.delete(id) + }) + }) + + afterEach(() => { + document.body.innerHTML = '' + vi.unstubAllGlobals() + }) + + it('moves two linked pairs together and applies one collision correction to the cohort', () => { + const tracks = makeThreeSectionTracks() + const video1 = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const audio1 = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const video2 = makeTimelineVideoItem({ + id: 'video-2', + trackId: 'v2', + from: 40, + durationInFrames: 10, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + const audio2 = makeTimelineAudioItem({ + id: 'audio-2', + trackId: 'a2', + from: 40, + durationInFrames: 10, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + const blocker = makeTimelineVideoItem({ + id: 'blocker', + trackId: 'v2', + from: 20, + durationInFrames: 10, + mediaId: 'blocker-media', + }) + setupStores(tracks, [video1, audio1, video2, audio2, blocker]) + useSelectionStore.getState().selectItems(['video-1', 'video-2']) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video1, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(getItem('video-1')).toMatchObject({ trackId: 'v2', from: 10 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a2', from: 10 }) + expect(getItem('video-2')).toMatchObject({ trackId: 'v3', from: 50 }) + expect(getItem('audio-2')).toMatchObject({ trackId: 'a3', from: 50 }) + expect(getItem('video-2').from - getItem('video-1').from).toBe(40) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) + + it('finds one shared correction that stays clear across conflicting destination lanes', () => { + const tracks = makeThreeSectionTracks() + const video1 = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const audio1 = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const video2 = makeTimelineVideoItem({ + id: 'video-2', + trackId: 'v2', + from: 30, + durationInFrames: 10, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + const audio2 = makeTimelineAudioItem({ + id: 'audio-2', + trackId: 'a2', + from: 30, + durationInFrames: 10, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + const innerBlocker = makeTimelineVideoItem({ + id: 'inner-blocker', + trackId: 'v2', + from: 18, + durationInFrames: 8, + mediaId: 'inner-blocker-media', + }) + const outerBlocker = makeTimelineVideoItem({ + id: 'outer-blocker', + trackId: 'v3', + from: 60, + durationInFrames: 10, + mediaId: 'outer-blocker-media', + }) + setupStores(tracks, [video1, audio1, video2, audio2, innerBlocker, outerBlocker]) + useSelectionStore.getState().selectItems(['video-1', 'video-2']) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video1, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + // The inner blocker alone suggests +6, which would overlap the outer + // blocker. The nearest cohort-wide valid correction is instead -12. + expect(getItem('video-1')).toMatchObject({ trackId: 'v2', from: 8 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a2', from: 8 }) + expect(getItem('video-2')).toMatchObject({ trackId: 'v3', from: 38 }) + expect(getItem('audio-2')).toMatchObject({ trackId: 'a3', from: 38 }) + expect(getItem('video-2').from - getItem('video-1').from).toBe(30) + expect(getItem('video-1').from + getItem('video-1').durationInFrames).toBe(18) + expect(getItem('video-2').from + getItem('video-2').durationInFrames).toBeLessThan(60) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) + + it('moves an attached caption on its visual section without losing its frame offset', () => { + const tracks = makeThreeSectionTracks() + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + const caption: TextItem = { + id: 'caption-1', + type: 'text', + trackId: 'v2', + from: 5, + durationInFrames: 20, + label: 'Caption', + text: 'Caption', + textRole: 'caption', + captionSource: { type: 'transcript', clipId: 'video-1', mediaId: 'media-1' }, + color: '#ffffff', + } + setupStores(tracks, [video, audio, caption]) + useSelectionStore.getState().selectItems(['video-1']) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(10, yByTrackId.get('v2')!) + releaseDrag() + + expect(getItem('video-1')).toMatchObject({ trackId: 'v2', from: 10 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a2', from: 10 }) + expect(getItem('caption-1')).toMatchObject({ trackId: 'v3', from: 15 }) + }) + + it('creates corresponding outer lanes and undoes the whole cohort atomically', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const video1 = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio1 = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + const video2 = makeTimelineVideoItem({ + id: 'video-2', + trackId: 'v2', + from: 80, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + const audio2 = makeTimelineAudioItem({ + id: 'audio-2', + trackId: 'a2', + from: 80, + linkedGroupId: 'pair-2', + mediaId: 'media-2', + }) + setupStores(tracks, [video1, audio1, video2, audio2]) + useSelectionStore.getState().selectItems(['video-1', 'video-2']) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video1, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(10, -TRACK_HEIGHT / 2) + releaseDrag() + + const movedVideo2Track = useItemsStore + .getState() + .tracks.find((track) => track.id === getItem('video-2').trackId) + const movedAudio2Track = useItemsStore + .getState() + .tracks.find((track) => track.id === getItem('audio-2').trackId) + expect(getItem('video-1')).toMatchObject({ trackId: 'v2', from: 10 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a2', from: 10 }) + expect(movedVideo2Track).toMatchObject({ kind: 'video', name: 'V3' }) + expect(movedAudio2Track).toMatchObject({ kind: 'audio', name: 'A3' }) + expect(useItemsStore.getState().tracks).toHaveLength(6) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + + act(() => { + useTimelineCommandStore.getState().undo() + }) + + expect(useItemsStore.getState().tracks).toHaveLength(4) + expect(getItem('video-1')).toMatchObject({ trackId: 'v1', from: 0 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a1', from: 0 }) + expect(getItem('video-2')).toMatchObject({ trackId: 'v2', from: 80 }) + expect(getItem('audio-2')).toMatchObject({ trackId: 'a2', from: 80 }) + }) + + it('rejects the whole cohort when an implicitly linked companion is locked', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2, locked: true }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + setupStores(tracks, [video, audio]) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(result.current.isDragging).toBe(false) + expect(useSelectionStore.getState().dragState).toBeNull() + expect(getItem('video-1')).toMatchObject({ trackId: 'v1', from: 0 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a1', from: 0 }) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }) + + it('rejects an explicit source below an unlocked inner group and locked outer group', () => { + const tracks = [ + makeTimelineTrack({ + id: 'outer-locked-group', + name: 'Outer Locked Group', + order: 0, + isGroup: true, + locked: true, + }), + makeTimelineTrack({ + id: 'inner-unlocked-group', + name: 'Inner Unlocked Group', + order: 1, + isGroup: true, + parentTrackId: 'outer-locked-group', + }), + makeTimelineTrack({ + id: 'v1', + name: 'V1', + kind: 'video', + order: 2, + parentTrackId: 'inner-unlocked-group', + }), + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 3 }), + ] + const video = makeTimelineVideoItem({ id: 'video-1', trackId: 'v1' }) + setupStores(tracks, [video]) + const beforeItems = structuredClone(useItemsStore.getState().items) + const beforeTracks = structuredClone(useItemsStore.getState().tracks) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(result.current.isDragging).toBe(false) + expect(useItemsStore.getState().items).toEqual(beforeItems) + expect(useItemsStore.getState().tracks).toEqual(beforeTracks) + expect(useSelectionStore.getState().selectedItemIds).toEqual([]) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }) + + it('rejects an implicit companion below an unlocked inner group and locked outer group', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ + id: 'outer-locked-group', + name: 'Outer Locked Group', + order: 2, + isGroup: true, + locked: true, + }), + makeTimelineTrack({ + id: 'inner-unlocked-group', + name: 'Inner Unlocked Group', + order: 3, + isGroup: true, + parentTrackId: 'outer-locked-group', + }), + makeTimelineTrack({ + id: 'a1', + name: 'A1', + kind: 'audio', + order: 4, + parentTrackId: 'inner-unlocked-group', + }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 5 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + setupStores(tracks, [video, audio]) + const beforeItems = structuredClone(useItemsStore.getState().items) + const beforeTracks = structuredClone(useItemsStore.getState().tracks) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(result.current.isDragging).toBe(false) + expect(useItemsStore.getState().items).toEqual(beforeItems) + expect(useItemsStore.getState().tracks).toEqual(beforeTracks) + expect(useSelectionStore.getState().selectedItemIds).toEqual([]) + expect(useSelectionStore.getState().dragState).toBeNull() + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }) + + it('rejects a destination below an unlocked inner group and locked outer group', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ + id: 'outer-locked-group', + name: 'Outer Locked Group', + order: 3, + isGroup: true, + locked: true, + }), + makeTimelineTrack({ + id: 'inner-unlocked-group', + name: 'Inner Unlocked Group', + order: 4, + isGroup: true, + parentTrackId: 'outer-locked-group', + }), + makeTimelineTrack({ + id: 'a2', + name: 'A2', + kind: 'audio', + order: 5, + parentTrackId: 'inner-unlocked-group', + }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + setupStores(tracks, [video, audio]) + useSelectionStore.getState().selectItems(['video-1', 'audio-1']) + const beforeItems = structuredClone(useItemsStore.getState().items) + const beforeTracks = structuredClone(useItemsStore.getState().tracks) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(useItemsStore.getState().items).toEqual(beforeItems) + expect(useItemsStore.getState().tracks).toEqual(beforeTracks) + expect(useSelectionStore.getState().selectedItemIds).toEqual(['video-1', 'audio-1']) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }) + + it('restores exact prior selection metadata when an unselected cohort targets a locked lane', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0, locked: true }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + const priorSelection = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, priorSelection]) + useSelectionStore.getState().selectItems(['prior-selection']) + useSelectionStore.getState().setEditKeyframePanelOpen(true) + const beforeSelection = captureSelectionMetadata() + const beforeTimeline = captureTimelineMutationState() + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set(['video-1', 'audio-1']), + ) + moveDrag(20, yByTrackId.get('v2')!) + releaseDrag() + + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureTimelineMutationState()).toEqual(beforeTimeline) + }) + + it('rolls back selection and leaves timeline state untouched when released before threshold', () => { + const tracks = [ + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 0 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 1 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + const priorSelection = makeTimelineVideoItem({ + id: 'prior-selection', + trackId: 'v1', + from: 100, + mediaId: 'prior-media', + }) + setupStores(tracks, [video, audio, priorSelection]) + useSelectionStore.getState().selectItems(['prior-selection']) + useSelectionStore.getState().setEditKeyframePanelOpen(true) + const beforeSelection = captureSelectionMetadata() + const beforeTimeline = captureTimelineMutationState() + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + startDragAttempt(result, 0, yByTrackId.get('v1')!) + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set(['video-1', 'audio-1']), + ) + releaseDrag() + + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureTimelineMutationState()).toEqual(beforeTimeline) + }) + + it('rejects atomically when a source becomes effectively locked before drop', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTimelineTrack({ + id: 'outer-group', + name: 'Outer Group', + order: 1, + isGroup: true, + }), + makeTimelineTrack({ + id: 'inner-group', + name: 'Inner Group', + order: 2, + isGroup: true, + parentTrackId: 'outer-group', + }), + makeTimelineTrack({ + id: 'v1', + name: 'V1', + kind: 'video', + order: 3, + parentTrackId: 'inner-group', + }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 4 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 5 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + setupStores(tracks, [video, audio]) + const beforeSelection = captureSelectionMetadata() + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + act(() => { + useItemsStore + .getState() + .setTracks( + tracks.map((track) => (track.id === 'outer-group' ? { ...track, locked: true } : track)), + ) + }) + const beforeDrop = captureTimelineMutationState() + releaseDrag() + + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureTimelineMutationState()).toEqual(beforeDrop) + }) + + it('rejects atomically when a destination becomes effectively locked before drop', () => { + const tracks = [ + makeTimelineTrack({ + id: 'outer-group', + name: 'Outer Group', + order: 0, + isGroup: true, + }), + makeTimelineTrack({ + id: 'inner-group', + name: 'Inner Group', + order: 1, + isGroup: true, + parentTrackId: 'outer-group', + }), + makeTimelineTrack({ + id: 'v2', + name: 'V2', + kind: 'video', + order: 2, + parentTrackId: 'inner-group', + }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 3 }), + makeTimelineTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 4 }), + makeTimelineTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 5 }), + ] + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + linkedGroupId: 'pair-1', + }) + setupStores(tracks, [video, audio]) + const beforeSelection = captureSelectionMetadata() + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(20, yByTrackId.get('v2')!) + act(() => { + useItemsStore + .getState() + .setTracks( + tracks.map((track) => (track.id === 'outer-group' ? { ...track, locked: true } : track)), + ) + }) + const beforeDrop = captureTimelineMutationState() + releaseDrag() + + expect(captureSelectionMetadata()).toEqual(beforeSelection) + expect(captureTimelineMutationState()).toEqual(beforeDrop) + }) + + it.each(['appears', 'moves'] as const)( + 'uses live blocker state when a blocker %s between drag start and drop', + (blockerChange) => { + const tracks = makeThreeSectionTracks() + const video = makeTimelineVideoItem({ + id: 'video-1', + trackId: 'v1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const audio = makeTimelineAudioItem({ + id: 'audio-1', + trackId: 'a1', + from: 0, + durationInFrames: 10, + linkedGroupId: 'pair-1', + }) + const blocker = makeTimelineVideoItem({ + id: 'live-blocker', + trackId: 'v1', + from: blockerChange === 'moves' ? 100 : 8, + durationInFrames: 10, + mediaId: 'blocker-media', + }) + setupStores(tracks, blockerChange === 'moves' ? [video, audio, blocker] : [video, audio]) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(video, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(10, yByTrackId.get('v1')!) + act(() => { + if (blockerChange === 'appears') { + useItemsStore.getState().setItems([...useItemsStore.getState().items, blocker]) + } else { + useItemsStore + .getState() + .setItems( + useItemsStore + .getState() + .items.map((currentItem) => + currentItem.id === blocker.id ? { ...currentItem, from: 8 } : currentItem, + ), + ) + } + }) + releaseDrag() + + expect(getItem('video-1')).toMatchObject({ trackId: 'v1', from: 18 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a1', from: 18 }) + expect(getItem('live-blocker')).toMatchObject({ trackId: 'v1', from: 8 }) + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set(['video-1', 'audio-1']), + ) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + + act(() => { + useTimelineCommandStore.getState().undo() + }) + + expect(getItem('video-1')).toMatchObject({ trackId: 'v1', from: 0 }) + expect(getItem('audio-1')).toMatchObject({ trackId: 'a1', from: 0 }) + expect(getItem('live-blocker')).toMatchObject({ trackId: 'v1', from: 8 }) + expect(new Set(useSelectionStore.getState().selectedItemIds)).toEqual( + new Set(['video-1', 'audio-1']), + ) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(0) + }, + ) + + it('keeps unlinked multi-select lock filtering behavior unchanged', () => { + const tracks = [ + makeTimelineTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0, locked: true }), + makeTimelineTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + ] + const unlocked = makeTimelineVideoItem({ id: 'unlocked', trackId: 'v1', durationInFrames: 10 }) + const locked = makeTimelineVideoItem({ + id: 'locked', + trackId: 'v2', + from: 40, + durationInFrames: 10, + mediaId: 'media-2', + }) + setupStores(tracks, [unlocked, locked]) + useSelectionStore.getState().selectItems(['unlocked', 'locked']) + const yByTrackId = mountTimelineTracks(tracks) + const { result } = renderHook(() => useTimelineDrag(unlocked, TIMELINE_DURATION)) + + beginDrag(result, 0, yByTrackId.get('v1')!) + moveDrag(10, yByTrackId.get('v1')!) + releaseDrag() + + expect(getItem('unlocked')).toMatchObject({ trackId: 'v1', from: 10 }) + expect(getItem('locked')).toMatchObject({ trackId: 'v2', from: 40 }) + expect(useTimelineCommandStore.getState().undoStack).toHaveLength(1) + }) +}) diff --git a/src/features/timeline/hooks/use-timeline-drag.ts b/src/features/timeline/hooks/use-timeline-drag.ts index 6c9a31634..d450d8595 100644 --- a/src/features/timeline/hooks/use-timeline-drag.ts +++ b/src/features/timeline/hooks/use-timeline-drag.ts @@ -4,13 +4,16 @@ import type { TimelineItem, TimelineTrack } from '@/types/timeline' import type { DragState, UseTimelineDragReturn, SnapTarget } from '../types/drag' import { useTimelineStore } from '../stores/timeline-store' import { useEditorStore } from '@/shared/state/editor' -import { useSelectionStore } from '@/shared/state/selection' +import { useSelectionStore, type SelectionState } from '@/shared/state/selection' import { pixelsToFramePreciseNow, frameToPixelsNow, } from '@/features/timeline/utils/zoom-conversions' import { useSnapCalculator } from './use-snap-calculator' -import { findNearestAvailableSpace } from '../utils/collision-utils' +import { + findNearestAvailableSharedOffset, + findNearestAvailableSpace, +} from '../utils/collision-utils' import { getTrackKind } from '../utils/classic-tracks' import { expandItemIdsWithAttachedCaptions, @@ -22,13 +25,15 @@ import { import { findCompatibleTrackForItemType } from '../utils/track-item-compatibility' import { resolveCreateNewDragTrackTargets, - resolveLinkedDragTrackTargets, + resolveLinkedCohortDragTrackTargets, type LinkedDragDropZone, } from '../utils/linked-drag-targeting' import { useLinkedEditPreviewStore } from '../stores/linked-edit-preview-store' import { DRAG_THRESHOLD_PIXELS } from '../constants' import { createLogger } from '@/shared/logging/logger' import { createRafCoalescedCallback } from '../utils/raf-coalesced-callback' +import { resolveEffectiveTrackStates } from '../utils/group-utils' +import { suppressPostTimelineGestureClick } from '../components/timeline-item/post-drag-click-guard' const logger = createLogger('TimelineDrag') @@ -206,76 +211,139 @@ const DRAG_CURSOR_CLASSES = Object.values(DRAG_CURSOR_CLASS_BY_MODE) const TRACK_SECTION_DIVIDER_GAP = 0 const CROSS_TRACK_SNAP_THRESHOLD_PX = 18 -function getDraggedLinkedPair( - items: TimelineItem[], - draggedItemIds: string[], -): { visualItemId: string; audioItemId: string } | null { - if (draggedItemIds.length !== 2) { - return null - } +function isLinkedDragCohort(items: TimelineItem[], draggedItemIds: readonly string[]): boolean { + const draggedIdSet = new Set(draggedItemIds) - const draggedItems = draggedItemIds - .map((id) => items.find((item) => item.id === id)) - .filter((item): item is TimelineItem => item !== undefined) - if (draggedItems.length !== 2) { - return null - } + for (const itemId of draggedItemIds) { + if ( + getLinkedItemIds(items, itemId).some( + (linkedId) => linkedId !== itemId && draggedIdSet.has(linkedId), + ) + ) { + return true + } - // Any visual item (non-audio) paired with an audio item counts as a linked pair - const visualItem = draggedItems.find((draggedItem) => draggedItem.type !== 'audio') - const audioItem = draggedItems.find((draggedItem) => draggedItem.type === 'audio') - if (!visualItem || !audioItem) { - return null + const draggedItem = items.find((item) => item.id === itemId) + if ( + draggedItem?.type === 'text' && + draggedItem.captionSource && + draggedIdSet.has(draggedItem.captionSource.clipId) + ) { + return true + } } - const linkedIds = new Set(getLinkedItemIds(items, visualItem.id)) - if (!linkedIds.has(audioItem.id)) { - return null - } + return false +} - return { - visualItemId: visualItem.id, - audioItemId: audioItem.id, +function getDragAnchorRelatedItemIds(items: TimelineItem[], anchorItemId: string): string[] { + const relatedIds = new Set(getLinkedItemIds(items, anchorItemId)) + const anchorItem = items.find((item) => item.id === anchorItemId) + + if (anchorItem?.type === 'text' && anchorItem.captionSource) { + relatedIds.add(anchorItem.captionSource.clipId) + for (const linkedId of getLinkedItemIds(items, anchorItem.captionSource.clipId)) { + relatedIds.add(linkedId) + } } + + return Array.from(relatedIds) +} + +interface DraggedTrackTargets { + tracks: TimelineTrack[] + trackAssignments: Map +} + +function resolveMultiDragTrackId(params: { + draggedItem: { id: string; initialTrackId: string } + trackTargets: DraggedTrackTargets | null + isLinkedCohort: boolean + dropZone: LinkedDragDropZone | null + trackIndexById: ReadonlyMap + tracks: readonly TimelineTrack[] + anchorTrackId: string + targetAnchorTrackId: string +}): string | null { + const assignedTrackId = params.trackTargets?.trackAssignments.get(params.draggedItem.id) + if (assignedTrackId) return assignedTrackId + if (params.isLinkedCohort && params.dropZone) return null + + const anchorTrackIndex = params.trackIndexById.get(params.anchorTrackId) ?? -1 + const itemTrackIndex = params.trackIndexById.get(params.draggedItem.initialTrackId) ?? -1 + const targetAnchorTrackIndex = params.trackIndexById.get(params.targetAnchorTrackId) ?? -1 + const trackOffset = itemTrackIndex - anchorTrackIndex + const targetTrackIndex = Math.max( + 0, + Math.min(params.tracks.length - 1, targetAnchorTrackIndex + trackOffset), + ) + + return params.tracks[targetTrackIndex]?.id ?? params.draggedItem.initialTrackId } function resolveDraggedTrackTargets(params: { items: TimelineItem[] draggedItems: Array<{ id: string; initialTrackId: string }> + anchorItemId: string + isLinkedCohort: boolean tracks: TimelineTrack[] dropTarget: { trackId: string; zone: LinkedDragDropZone | null; createNew?: boolean } preferredTrackHeight: number -}): { tracks: TimelineTrack[]; trackAssignments: Map } | null { - const { items, draggedItems, tracks, dropTarget, preferredTrackHeight } = params +}): { trackTargets: DraggedTrackTargets | null; isLinkedCohort: boolean } { + const { + items, + draggedItems, + anchorItemId, + isLinkedCohort, + tracks, + dropTarget, + preferredTrackHeight, + } = params + if (!dropTarget.zone) { - return null + return { trackTargets: null, isLinkedCohort } } - const draggedItemIds = draggedItems.map((draggedItem) => draggedItem.id) - const linkedPair = getDraggedLinkedPair(items, draggedItemIds) - if (linkedPair) { - const linkedTrackTargets = resolveLinkedDragTrackTargets({ + if (isLinkedCohort) { + const sourceItemById = new Map(items.map((item) => [item.id, item])) + const linkedTrackTargets = resolveLinkedCohortDragTrackTargets({ tracks, + draggedItems: draggedItems + .map((draggedItem) => { + const sourceItem = sourceItemById.get(draggedItem.id) + return sourceItem + ? { + id: sourceItem.id, + initialTrackId: draggedItem.initialTrackId, + type: sourceItem.type, + } + : null + }) + .filter( + ( + draggedItem, + ): draggedItem is { + id: string + initialTrackId: string + type: TimelineItem['type'] + } => draggedItem !== null, + ), + anchorItemId, + anchorRelatedItemIds: getDragAnchorRelatedItemIds(items, anchorItemId), hoveredTrackId: dropTarget.trackId, zone: dropTarget.zone, createNew: dropTarget.createNew, preferredTrackHeight, }) - if (!linkedTrackTargets) { - return null - } return { - tracks: linkedTrackTargets.tracks, - trackAssignments: new Map([ - [linkedPair.visualItemId, linkedTrackTargets.videoTrackId], - [linkedPair.audioItemId, linkedTrackTargets.audioTrackId], - ]), + trackTargets: linkedTrackTargets, + isLinkedCohort, } } if (!dropTarget.createNew) { - return null + return { trackTargets: null, isLinkedCohort } } const createNewTrackTargets = resolveCreateNewDragTrackTargets({ @@ -302,12 +370,15 @@ function resolveDraggedTrackTargets(params: { }) if (!createNewTrackTargets) { - return null + return { trackTargets: null, isLinkedCohort } } return { - tracks: createNewTrackTargets.tracks, - trackAssignments: createNewTrackTargets.trackAssignments, + trackTargets: { + tracks: createNewTrackTargets.tracks, + trackAssignments: createNewTrackTargets.trackAssignments, + }, + isLinkedCohort, } } @@ -389,11 +460,68 @@ interface DraggedItemState { initialTrackId: string } +type DragSelectionSnapshot = Pick< + SelectionState, + | 'selectedItemIds' + | 'selectedItemIdSet' + | 'selectedMarkerId' + | 'selectedTransitionId' + | 'selectedTrackId' + | 'selectedTrackIds' + | 'activeTrackId' + | 'selectionType' + | 'editKeyframePanelOpen' + | 'expandedKeyframeLanes' +> + +function captureDragSelectionSnapshot(state: SelectionState): DragSelectionSnapshot { + return { + selectedItemIds: [...state.selectedItemIds], + selectedItemIdSet: new Set(state.selectedItemIdSet), + selectedMarkerId: state.selectedMarkerId, + selectedTransitionId: state.selectedTransitionId, + selectedTrackId: state.selectedTrackId, + selectedTrackIds: [...state.selectedTrackIds], + activeTrackId: state.activeTrackId, + selectionType: state.selectionType, + editKeyframePanelOpen: state.editKeyframePanelOpen, + expandedKeyframeLanes: new Set(state.expandedKeyframeLanes), + } +} + +function getEffectiveTrackStateById(tracks: TimelineTrack[]): ReadonlyMap { + return new Map(resolveEffectiveTrackStates(tracks).map((track) => [track.id, track])) +} + +function areItemSourceTracksUnlocked( + allItems: TimelineItem[], + tracks: TimelineTrack[], + itemIds: readonly string[], +): boolean { + const itemById = new Map(allItems.map((currentItem) => [currentItem.id, currentItem])) + const effectiveTrackById = getEffectiveTrackStateById(tracks) + + return itemIds.every((itemId) => { + const sourceItem = itemById.get(itemId) + const sourceTrack = sourceItem ? effectiveTrackById.get(sourceItem.trackId) : undefined + return sourceTrack?.locked === false + }) +} + +function areDestinationTracksUnlocked( + tracks: TimelineTrack[], + trackIds: readonly string[], +): boolean { + const effectiveTrackById = getEffectiveTrackStateById(tracks) + return trackIds.every((trackId) => effectiveTrackById.get(trackId)?.locked === false) +} + /** * Resolve the full set of items a drag should move and their initial positions: * expand the base selection (linked items when enabled, else the raw selection * or the just-clicked clip), attach captions, drop locked items, and snapshot - * each survivor's starting frame + track. + * each survivor's starting frame + track. Linked cohorts reject the entire + * gesture if any explicit or implicit member is on a locked track. */ function resolveDraggedItemStates( allItems: TimelineItem[], @@ -402,14 +530,28 @@ function resolveDraggedItemStates( isInSelection: boolean, linkedIds: string[], linkedSelectionEnabled: boolean, -): { baseItemsToDrag: string[]; draggableItemIds: string[]; draggedItems: DraggedItemState[] } { +): { + baseItemsToDrag: string[] + draggableItemIds: string[] + draggedItems: DraggedItemState[] + isLinkedCohort: boolean + isBlockedByLockedLinkedItem: boolean +} { const baseItemsToDrag = isInSelection ? linkedSelectionEnabled ? expandSelectionWithLinkedItems(allItems, currentSelectedIds) : currentSelectedIds : linkedIds const itemsToDrag = expandItemIdsWithAttachedCaptions(allItems, baseItemsToDrag) - const draggableItemIds = filterUnlockedItemIds(allItems, currentTracks, itemsToDrag) + const unlockedItemIds = filterUnlockedItemIds( + allItems, + resolveEffectiveTrackStates(currentTracks), + itemsToDrag, + ) + const isLinkedCohort = isLinkedDragCohort(allItems, itemsToDrag) + const isBlockedByLockedLinkedItem = + isLinkedCohort && unlockedItemIds.length !== itemsToDrag.length + const draggableItemIds = isBlockedByLockedLinkedItem ? [] : unlockedItemIds const draggedItems = draggableItemIds .map((id) => { const dragItem = allItems.find((i) => i.id === id) @@ -421,7 +563,13 @@ function resolveDraggedItemStates( } }) .filter((i): i is DraggedItemState => i !== null) - return { baseItemsToDrag, draggableItemIds, draggedItems } + return { + baseItemsToDrag, + draggableItemIds, + draggedItems, + isLinkedCohort, + isBlockedByLockedLinkedItem, + } } /** @@ -447,14 +595,19 @@ export function useTimelineDrag( const [isDragging, setIsDragging] = useState(false) const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) const dragStateRef = useRef(null) + const isLinkedCohortDragRef = useRef(false) const dragVisualTopByTrackIdRef = useRef>(new Map()) const linkedMovePreviewSignatureRef = useRef('') + const selectionRollbackRef = useRef(null) + const gestureMovementRef = useRef(0) + const removeDragThresholdListenersRef = useRef<(() => void) | null>(null) // Track Alt key state for duplication mode (dynamic toggle during drag) const isAltDragRef = useRef(false) // Track previous snap target to avoid unnecessary store updates const prevSnapTargetRef = useRef<{ frame: number; type: string } | null>(null) + const magneticSnapTargetsRef = useRef([]) // Get store actions with granular selectors const moveItem = useTimelineStore((s) => s.moveItem) @@ -506,6 +659,98 @@ export function useTimelineDrag( [], ) + const finishDragInteraction = useCallback( + ({ + rollbackSelection, + suppressPostGestureClick = false, + updateReactState = true, + }: { + rollbackSelection: boolean + suppressPostGestureClick?: boolean + updateReactState?: boolean + }) => { + const removeDragThresholdListeners = removeDragThresholdListenersRef.current + removeDragThresholdListenersRef.current = null + removeDragThresholdListeners?.() + + if (elementRef?.current) { + elementRef.current.style.transform = '' + } + dragOffsetRef.current = { x: 0, y: 0 } + dragVisualTopByTrackIdRef.current.clear() + dragPreviewOffsetByItemRef.current = {} + clearLargeAltDragCanvas() + clearLinkedMovePreview() + prevSnapTargetRef.current = null + magneticSnapTargetsRef.current = [] + dragStateRef.current = null + isLinkedCohortDragRef.current = false + isAltDragRef.current = false + gestureMovementRef.current = 0 + clearGlobalDragCursor() + document.body.style.userSelect = '' + + const selectionSnapshot = selectionRollbackRef.current + selectionRollbackRef.current = null + useSelectionStore.setState({ + ...(rollbackSelection && selectionSnapshot ? selectionSnapshot : {}), + dragState: null, + activeSnapTarget: null, + activeLinkedDropTarget: null, + }) + + if (suppressPostGestureClick) { + suppressPostTimelineGestureClick() + } + + if (updateReactState) { + setIsDragging(false) + setDragOffset({ x: 0, y: 0 }) + } + }, + [clearLinkedMovePreview, elementRef], + ) + + const trackRejectedDragAttempt = useCallback( + (startMouseX: number, startMouseY: number) => { + const handleMouseMove = (event: MouseEvent) => { + gestureMovementRef.current = Math.max( + gestureMovementRef.current, + Math.abs(event.clientX - startMouseX), + Math.abs(event.clientY - startMouseY), + ) + } + const handleMouseUp = () => { + finishDragInteraction({ + rollbackSelection: true, + suppressPostGestureClick: gestureMovementRef.current > 0, + }) + } + const handleCancellation = () => { + finishDragInteraction({ rollbackSelection: true, suppressPostGestureClick: true }) + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') handleCancellation() + } + const removeListeners = () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + window.removeEventListener('pointercancel', handleCancellation) + window.removeEventListener('keydown', handleKeyDown) + if (removeDragThresholdListenersRef.current === removeListeners) { + removeDragThresholdListenersRef.current = null + } + } + + removeDragThresholdListenersRef.current = removeListeners + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + window.addEventListener('pointercancel', handleCancellation) + window.addEventListener('keydown', handleKeyDown) + }, + [finishDragInteraction], + ) + // Get zoom utilities // Zoom conversions are read imperatively (via store.getState()) at call-time // to avoid subscribing every TimelineItem to the live zoom store. @@ -533,7 +778,6 @@ export function useTimelineDrag( // Helper to get items on-demand (avoids subscription that would cause all items to re-render) const getItems = useCallback(() => useTimelineStore.getState().items, []) // Update refs synchronously (not in useEffect) so they're always current - const magneticSnapTargetsRef = useRef([]) const getSnapThresholdFramesRef = useRef(getSnapThresholdFrames) getSnapThresholdFramesRef.current = getSnapThresholdFrames @@ -669,7 +913,7 @@ export function useTimelineDrag( (mouseY: number, startTrackId: string, itemType: TimelineItem['type']): string | null => { const hoveredTrackId = getTrackIdFromMouseY(mouseY, startTrackId) const compatibleTrack = findCompatibleTrackForItemType({ - tracks: tracksRef.current, + tracks: resolveEffectiveTrackStates(tracksRef.current), items: getItems(), itemType, preferredTrackId: hoveredTrackId, @@ -743,10 +987,7 @@ export function useTimelineDrag( */ const handleDragStart = useCallback( (e: React.MouseEvent) => { - // Don't allow dragging on locked tracks - if (trackLocked) { - return - } + if (dragStateRef.current || selectionRollbackRef.current) return // Prevent if clicking on resize handles const target = e.target as HTMLElement @@ -754,35 +995,54 @@ export function useTimelineDrag( return } + const currentSelectionState = useSelectionStore.getState() + selectionRollbackRef.current = captureDragSelectionSnapshot(currentSelectionState) + gestureMovementRef.current = 0 + + const allItems = getItems() + const currentTracks = useTimelineStore.getState().tracks + const anchorTrack = getEffectiveTrackStateById(currentTracks).get(item.trackId) + + // The caller supplies the rendered lock state, but re-read canonical + // effective state so a child lane cannot bypass a locked Layer Group. + if (trackLocked || !anchorTrack || anchorTrack.locked) { + trackRejectedDragAttempt(e.clientX, e.clientY) + return + } + e.stopPropagation() // Check if this item is in current selection - const currentSelectedIds = useSelectionStore.getState().selectedItemIds + const currentSelectedIds = currentSelectionState.selectedItemIds const isInSelection = currentSelectedIds.includes(item.id) - const allItems = getItems() - const currentTracks = tracksRef.current const linkedSelectionEnabled = useEditorStore.getState().linkedSelectionEnabled - // If not in selection, select it (multi-select handled by TimelineItem's onClick). - // Skip when a multi-select modifier is held: replacing the selection here - // would wipe the existing multi-selection, and the click handler's additive - // toggle would then read this clip as "already selected" and remove it again. - const isMultiSelectClick = e.ctrlKey || e.metaKey const linkedIds = linkedSelectionEnabled ? getLinkedItemIds(allItems, item.id) : [item.id] + + // Determine which items to drag and snapshot their initial positions + const { baseItemsToDrag, draggedItems, isLinkedCohort, isBlockedByLockedLinkedItem } = + resolveDraggedItemStates( + allItems, + currentTracks, + currentSelectedIds, + isInSelection, + linkedIds, + linkedSelectionEnabled, + ) + if (isBlockedByLockedLinkedItem || draggedItems.length === 0) { + isLinkedCohortDragRef.current = false + trackRejectedDragAttempt(e.clientX, e.clientY) + return + } + + // Only mutate selection after the complete cohort passes source-lock + // validation. A rejected linked gesture is otherwise not atomic. + const isMultiSelectClick = e.ctrlKey || e.metaKey if (!isInSelection && !isMultiSelectClick) { selectItems(linkedIds) } - // Determine which items to drag and snapshot their initial positions - const { baseItemsToDrag, draggedItems } = resolveDraggedItemStates( - allItems, - currentTracks, - currentSelectedIds, - isInSelection, - linkedIds, - linkedSelectionEnabled, - ) // Compare cohort *contents*, not just lengths: a same-size but // differently-composed drag cohort (e.g. linked items swapped in) must // still re-sync the selection. @@ -794,6 +1054,8 @@ export function useTimelineDrag( selectItems(baseItemsToDrag) } + isLinkedCohortDragRef.current = isLinkedCohort + // Initialize drag state dragStateRef.current = { itemId: item.id, // Anchor item @@ -818,6 +1080,11 @@ export function useTimelineDrag( const deltaX = e.clientX - dragStateRef.current.startMouseX const deltaY = e.clientY - dragStateRef.current.startMouseY + gestureMovementRef.current = Math.max( + gestureMovementRef.current, + Math.abs(deltaX), + Math.abs(deltaY), + ) // Check if we've moved enough to start dragging if (Math.abs(deltaX) > DRAG_THRESHOLD_PIXELS || Math.abs(deltaY) > DRAG_THRESHOLD_PIXELS) { @@ -843,29 +1110,46 @@ export function useTimelineDrag( setActiveLinkedDropTarget(null) clearLinkedMovePreview() - // Remove this listener - the main useEffect will handle it now - window.removeEventListener('mousemove', checkDragThreshold) - window.removeEventListener('mouseup', cancelDrag) + // Remove these listeners - the main useEffect will handle it now. + removeDragThresholdListeners() } } const cancelDrag = () => { - // Clean up if mouse released before threshold - dragStateRef.current = null - magneticSnapTargetsRef.current = [] - dragVisualTopByTrackIdRef.current.clear() - dragPreviewOffsetByItemRef.current = {} - clearLargeAltDragCanvas() - clearLinkedMovePreview() + // A click released before the threshold is a cancelled drag attempt. + finishDragInteraction({ + rollbackSelection: true, + suppressPostGestureClick: gestureMovementRef.current > 0, + }) + } + + const cancelDragExplicitly = () => { + finishDragInteraction({ rollbackSelection: true, suppressPostGestureClick: true }) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') cancelDragExplicitly() + } + + function removeDragThresholdListeners() { window.removeEventListener('mousemove', checkDragThreshold) window.removeEventListener('mouseup', cancelDrag) + window.removeEventListener('pointercancel', cancelDragExplicitly) + window.removeEventListener('keydown', handleKeyDown) + if (removeDragThresholdListenersRef.current === removeDragThresholdListeners) { + removeDragThresholdListenersRef.current = null + } } + removeDragThresholdListenersRef.current = removeDragThresholdListeners window.addEventListener('mousemove', checkDragThreshold) window.addEventListener('mouseup', cancelDrag) + window.addEventListener('pointercancel', cancelDragExplicitly) + window.addEventListener('keydown', handleKeyDown) }, [ clearLinkedMovePreview, + finishDragInteraction, item.id, item.from, item.trackId, @@ -876,6 +1160,7 @@ export function useTimelineDrag( setDragState, getItems, getMagneticSnapTargets, + trackRejectedDragAttempt, ], ) @@ -942,9 +1227,11 @@ export function useTimelineDrag( tracksRef.current.map((currentTrack, index) => [currentTrack.id, index]), ) const dropTarget = getTrackDropTarget(e.clientY, dragStateRef.current.startTrackId) - const previewTrackTargets = resolveDraggedTrackTargets({ + const previewTrackResolution = resolveDraggedTrackTargets({ items: currentItems, draggedItems: dragStateRef.current.draggedItems, + anchorItemId: dragStateRef.current.itemId, + isLinkedCohort: isLinkedCohortDragRef.current, tracks: tracksRef.current, dropTarget, preferredTrackHeight: @@ -953,20 +1240,25 @@ export function useTimelineDrag( ?.height ?? 64, }) + const previewTrackTargets = previewTrackResolution.trackTargets const hoveredCompatibleTrackId = getCompatibleTrackIdFromMouseY( e.clientY, dragStateRef.current.startTrackId, item.type, ) const hasInvalidExplicitDropTarget = - dropTarget.zone !== null && !previewTrackTargets && hoveredCompatibleTrackId === null + dropTarget.zone !== null && + !previewTrackTargets && + (previewTrackResolution.isLinkedCohort || hoveredCompatibleTrackId === null) const linkedDropTarget = dropTarget.zone && !hasInvalidExplicitDropTarget ? { trackId: dropTarget.trackId, zone: dropTarget.zone, createNew: dropTarget.createNew } : null const previewAnchorTrackId = previewTrackTargets?.trackAssignments.get(dragStateRef.current.itemId) ?? - hoveredCompatibleTrackId ?? + (previewTrackResolution.isLinkedCohort && dropTarget.zone + ? null + : hoveredCompatibleTrackId) ?? dragStateRef.current.startTrackId dragStateRef.current.currentMouseX = e.clientX dragStateRef.current.currentMouseY = e.clientY @@ -1039,19 +1331,17 @@ export function useTimelineDrag( const sourceItem = currentItemById.get(draggedItem.id) if (!sourceItem) return null - let itemNewTrackId = previewTrackTargets?.trackAssignments.get(draggedItem.id) - if (!itemNewTrackId) { - const anchorTrackIndex = trackIndexById.get(dragStateRef.current!.startTrackId) ?? -1 - const itemTrackIndex = trackIndexById.get(draggedItem.initialTrackId) ?? -1 - const newAnchorTrackIndex = trackIndexById.get(previewAnchorTrackId) ?? -1 - const trackOffset = itemTrackIndex - anchorTrackIndex - const newItemTrackIndex = Math.max( - 0, - Math.min(tracksRef.current.length - 1, newAnchorTrackIndex + trackOffset), - ) - itemNewTrackId = - tracksRef.current[newItemTrackIndex]?.id || draggedItem.initialTrackId - } + const itemNewTrackId = resolveMultiDragTrackId({ + draggedItem, + trackTargets: previewTrackTargets, + isLinkedCohort: previewTrackResolution.isLinkedCohort, + dropZone: dropTarget.zone, + trackIndexById, + tracks: tracksRef.current, + anchorTrackId: dragStateRef.current!.startTrackId, + targetAnchorTrackId: previewAnchorTrackId, + }) + if (!itemNewTrackId) return null return { id: draggedItem.id, @@ -1071,31 +1361,24 @@ export function useTimelineDrag( durationInFrames: number }> - // Wall-clamp the group: find tightest constraint across all items, - // then shift the entire group by the same delta so they stay together. + // Resolve one offset against every destination lane. Per-item wall + // clamps can move a previously clear member into another blocker. if (!isAltDragRef.current) { const groupExcludeIds = new Set(previewMovedItems.map((m) => m.id)) - let wallClampDelta = 0 - for (const previewItem of previewMovedItems) { - const clamped = clampToTrackWalls( - previewItem.newFrom, - previewItem.durationInFrames, - previewItem.newTrackId, - groupExcludeIds, - currentItems, - currentItemsByTrackId, - ) - const itemDelta = clamped - previewItem.newFrom - // Pick the tightest (smallest magnitude) clamp in each direction - if (itemDelta < 0 && (wallClampDelta >= 0 || itemDelta > wallClampDelta)) { - wallClampDelta = itemDelta - } else if (itemDelta > 0 && (wallClampDelta <= 0 || itemDelta < wallClampDelta)) { - wallClampDelta = itemDelta - } - } - if (wallClampDelta !== 0) { + const previewBlockers = currentItems.filter( + (currentItem) => !groupExcludeIds.has(currentItem.id), + ) + const sharedPreviewOffset = findNearestAvailableSharedOffset( + previewMovedItems.map((previewItem) => ({ + trackId: previewItem.newTrackId, + from: previewItem.newFrom, + durationInFrames: previewItem.durationInFrames, + })), + previewBlockers, + ) + if (sharedPreviewOffset !== null && sharedPreviewOffset !== 0) { for (const previewItem of previewMovedItems) { - previewItem.newFrom += wallClampDelta + previewItem.newFrom += sharedPreviewOffset } } } @@ -1210,31 +1493,60 @@ export function useTimelineDrag( const dragState = dragStateRef.current const deltaX = dragState.currentMouseX - dragState.startMouseX const isAltDrag = isAltDragRef.current + let dropAccepted = false // Calculate frame delta const deltaFrames = pixelsToFramePreciseRef.current(deltaX) const currentItems = getItems() + const currentTracks = useTimelineStore.getState().tracks + const hasLockedSource = !areItemSourceTracksUnlocked( + currentItems, + currentTracks, + dragState.draggedItems.map((draggedItem) => draggedItem.id), + ) const dropTarget = getTrackDropTarget(dragState.currentMouseY, dragState.startTrackId) - const resolvedTrackTargets = resolveDraggedTrackTargets({ - items: currentItems, - draggedItems: dragState.draggedItems, - tracks: tracksRef.current, - dropTarget, - preferredTrackHeight: - tracksRef.current.find((track) => track.id === dropTarget.trackId)?.height ?? - tracksRef.current.find((track) => track.id === dragState.startTrackId)?.height ?? - 64, - }) + const resolvedTrackResolution = hasLockedSource + ? { trackTargets: null, isLinkedCohort: isLinkedCohortDragRef.current } + : resolveDraggedTrackTargets({ + items: currentItems, + draggedItems: dragState.draggedItems, + anchorItemId: dragState.itemId, + isLinkedCohort: isLinkedCohortDragRef.current, + tracks: currentTracks, + dropTarget, + preferredTrackHeight: + currentTracks.find((track) => track.id === dropTarget.trackId)?.height ?? + currentTracks.find((track) => track.id === dragState.startTrackId)?.height ?? + 64, + }) + const resolvedTrackTargets = resolvedTrackResolution.trackTargets + const hasIncompleteLinkedTrackTargets = + resolvedTrackResolution.isLinkedCohort && + dropTarget.zone !== null && + (!resolvedTrackTargets || + dragState.draggedItems.some( + (draggedItem) => !resolvedTrackTargets.trackAssignments.has(draggedItem.id), + )) // Calculate new track for anchor item const newTrackId = - resolvedTrackTargets?.trackAssignments.get(dragState.itemId) ?? - getCompatibleTrackIdFromMouseY(dragState.currentMouseY, dragState.startTrackId, item.type) + hasLockedSource || hasIncompleteLinkedTrackTargets + ? null + : (resolvedTrackTargets?.trackAssignments.get(dragState.itemId) ?? + getCompatibleTrackIdFromMouseY( + dragState.currentMouseY, + dragState.startTrackId, + item.type, + )) // Multi-item drag or single? if (newTrackId === null) { - logger.warn('Cannot move items to an incompatible track') + logger.warn( + hasLockedSource + ? 'Cannot move items from a locked track' + : 'Cannot move items to an incompatible track', + ) } else if (dragState.draggedItems.length > 1) { // Multi-item drag: calculate group bounding box for snapping // Snap should only happen at the edges of the entire selection, not individual items @@ -1275,6 +1587,9 @@ export function useTimelineDrag( // Calculate group clamp offset - if any item would go below 0, shift the whole group const groupClampOffset = minProposedFrame < 0 ? -minProposedFrame : 0 + const resolvedTrackIndexById = new Map( + currentTracks.map((track, index) => [track.id, index]), + ) // Multi-item drag: calculate new positions for all items const movedItems = dragState.draggedItems @@ -1286,24 +1601,17 @@ export function useTimelineDrag( // Apply frame delta, snap adjustment, AND group clamp offset to all items uniformly const newFrom = draggedItem.initialFrame + deltaFrames + snapDelta + groupClampOffset - let itemNewTrackId = resolvedTrackTargets?.trackAssignments.get(draggedItem.id) - if (!itemNewTrackId) { - const anchorTrackIndex = tracksRef.current.findIndex( - (t) => t.id === dragState.startTrackId, - ) - const itemTrackIndex = tracksRef.current.findIndex( - (t) => t.id === draggedItem.initialTrackId, - ) - const newAnchorTrackIndex = tracksRef.current.findIndex((t) => t.id === newTrackId) - const trackOffset = itemTrackIndex - anchorTrackIndex - const newItemTrackIndex = Math.max( - 0, - Math.min(tracksRef.current.length - 1, newAnchorTrackIndex + trackOffset), - ) - - itemNewTrackId = - tracksRef.current[newItemTrackIndex]?.id || draggedItem.initialTrackId - } + const itemNewTrackId = resolveMultiDragTrackId({ + draggedItem, + trackTargets: resolvedTrackTargets, + isLinkedCohort: resolvedTrackResolution.isLinkedCohort, + dropZone: dropTarget.zone, + trackIndexById: resolvedTrackIndexById, + tracks: currentTracks, + anchorTrackId: dragState.startTrackId, + targetAnchorTrackId: newTrackId, + }) + if (!itemNewTrackId) return null return { id: draggedItem.id, @@ -1319,70 +1627,37 @@ export function useTimelineDrag( durationInFrames: number }> - // For multi-item drag: check if ANY item would collide, and if so, snap the whole group forward - // Find the earliest collision among all moved items const draggedItemIds = movedItems.map((m) => m.id) // For alt-drag (duplicate), include all items in collision check since originals stay in place const itemsExcludingDragged = isAltDrag ? currentItems : currentItems.filter((i) => !draggedItemIds.includes(i.id)) - let maxSnapForward = 0 // largest positive shift needed - let maxSnapBackward = 0 // largest negative shift needed (stored as negative) - - for (const movedItem of movedItems) { - const finalPosition = findNearestAvailableSpace( - movedItem.newFrom, - movedItem.durationInFrames, - movedItem.newTrackId, - itemsExcludingDragged, - ) - - if (finalPosition === null) { - logger.warn( - isAltDrag - ? 'Cannot duplicate items: no available space' - : 'Cannot move items: no available space', + const destinationTracks = resolvedTrackTargets?.tracks ?? currentTracks + const destinationsUnlocked = areDestinationTracksUnlocked( + destinationTracks, + movedItems.map((movedItem) => movedItem.newTrackId), + ) + const groupSnapDelta = destinationsUnlocked + ? findNearestAvailableSharedOffset( + movedItems.map((movedItem) => ({ + trackId: movedItem.newTrackId, + from: movedItem.newFrom, + durationInFrames: movedItem.durationInFrames, + })), + itemsExcludingDragged, ) - // Clean up and cancel - defer drag state to avoid render cascade - if (elementRef?.current) { - elementRef.current.style.transform = '' - } - dragOffsetRef.current = { x: 0, y: 0 } - dragVisualTopByTrackIdRef.current.clear() - dragPreviewOffsetByItemRef.current = {} - clearLargeAltDragCanvas() - clearLinkedMovePreview() - prevSnapTargetRef.current = null - magneticSnapTargetsRef.current = [] - dragStateRef.current = null - isAltDragRef.current = false - clearGlobalDragCursor() - document.body.style.userSelect = '' - setIsDragging(false) - setDragOffset({ x: 0, y: 0 }) - queueMicrotask(() => { - setActiveSnapTarget(null) - setActiveLinkedDropTarget(null) - setDragState(null) - }) - return - } - - const snapAmount = finalPosition - movedItem.newFrom - if (snapAmount > maxSnapForward) { - maxSnapForward = snapAmount - } - if (snapAmount < maxSnapBackward) { - maxSnapBackward = snapAmount - } - } - - // Pick whichever direction has the larger correction needed - const groupSnapDelta = - Math.abs(maxSnapForward) >= Math.abs(maxSnapBackward) ? maxSnapForward : maxSnapBackward + : null - if (isAltDrag) { + if (groupSnapDelta === null) { + logger.warn( + destinationsUnlocked + ? isAltDrag + ? 'Cannot duplicate items: no available space' + : 'Cannot move items: no available space' + : 'Cannot move items to a locked track', + ) + } else if (isAltDrag) { // ALT-DRAG: Duplicate items at new positions const itemIds = movedItems.map((m) => m.id) const positions = movedItems.map((m) => ({ @@ -1399,6 +1674,7 @@ export function useTimelineDrag( } else { duplicateItemsRef.current(itemIds, positions) } + dropAccepted = true } else { // Normal drag: Apply the snap to ALL items in the group const allUpdates = movedItems.map((m) => ({ @@ -1415,6 +1691,7 @@ export function useTimelineDrag( } else { moveItemsRef.current(allUpdates) } + dropAccepted = true } } else { // Single item drag @@ -1430,12 +1707,16 @@ export function useTimelineDrag( const itemsExcludingDragged = isAltDrag ? currentItems : currentItems.filter((i) => i.id !== item.id) - const finalFrame = findNearestAvailableSpace( - proposedFrame, - item.durationInFrames, - newTrackId, - itemsExcludingDragged, - ) + const destinationTracks = resolvedTrackTargets?.tracks ?? currentTracks + const destinationUnlocked = areDestinationTracksUnlocked(destinationTracks, [newTrackId]) + const finalFrame = destinationUnlocked + ? findNearestAvailableSpace( + proposedFrame, + item.durationInFrames, + newTrackId, + itemsExcludingDragged, + ) + : null if (finalFrame !== null) { const roundedFinalFrame = Math.round(finalFrame) @@ -1453,6 +1734,7 @@ export function useTimelineDrag( [{ from: roundedFinalFrame, trackId: newTrackId }], ) } + dropAccepted = true } else { // Normal drag: Move item const trackChanged = newTrackId !== dragState.startTrackId @@ -1463,48 +1745,33 @@ export function useTimelineDrag( } else { moveItemRef.current(item.id, roundedFinalFrame, trackChanged ? newTrackId : undefined) } + dropAccepted = true } } else { // No space available - cancel drag (keep at original position) logger.warn( - isAltDrag - ? 'Cannot duplicate item: no available space' - : 'Cannot move item: no available space', + destinationUnlocked + ? isAltDrag + ? 'Cannot duplicate item: no available space' + : 'Cannot move item: no available space' + : 'Cannot move item to a locked track', ) } } - // Clean up - defer drag state clearing to avoid multiple render cycles - // The move operation already triggered a re-render; clearing drag state - // should happen after that render completes - if (elementRef?.current) { - elementRef.current.style.transform = '' - } - dragOffsetRef.current = { x: 0, y: 0 } // Reset shared ref immediately - dragVisualTopByTrackIdRef.current.clear() - dragPreviewOffsetByItemRef.current = {} - clearLargeAltDragCanvas() - clearLinkedMovePreview() - prevSnapTargetRef.current = null // Reset snap target tracking - magneticSnapTargetsRef.current = [] - dragStateRef.current = null - isAltDragRef.current = false // Reset alt drag state - clearGlobalDragCursor() - document.body.style.userSelect = '' - - // Batch React state updates (React 18 batches these automatically) - setIsDragging(false) - setDragOffset({ x: 0, y: 0 }) - - // Defer selection store cleanup to next microtask to avoid - // synchronous re-render cascade after move operation - queueMicrotask(() => { - setActiveSnapTarget(null) - setActiveLinkedDropTarget(null) - setDragState(null) + finishDragInteraction({ + rollbackSelection: !dropAccepted, + suppressPostGestureClick: true, }) } + const handleCancellation = () => { + finishDragInteraction({ rollbackSelection: true, suppressPostGestureClick: true }) + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') handleCancellation() + } + if (dragStateRef.current) { const coalescedMouseMove = createRafCoalescedCallback(handleMouseMove) const handleCoalescedMouseUp = () => { @@ -1514,17 +1781,15 @@ export function useTimelineDrag( window.addEventListener('mousemove', coalescedMouseMove.queue) window.addEventListener('mouseup', handleCoalescedMouseUp) + window.addEventListener('pointercancel', handleCancellation) + window.addEventListener('keydown', handleKeyDown) return () => { window.removeEventListener('mousemove', coalescedMouseMove.queue) window.removeEventListener('mouseup', handleCoalescedMouseUp) + window.removeEventListener('pointercancel', handleCancellation) + window.removeEventListener('keydown', handleKeyDown) coalescedMouseMove.cancel() - magneticSnapTargetsRef.current = [] - dragVisualTopByTrackIdRef.current.clear() - clearLargeAltDragCanvas() - clearLinkedMovePreview() - clearGlobalDragCursor() - document.body.style.userSelect = '' } } }, [ @@ -1537,6 +1802,7 @@ export function useTimelineDrag( calculateMagneticSnap, getMagneticSnapTargets, clearLinkedMovePreview, + finishDragInteraction, elementRef, getItems, setActiveLinkedDropTarget, @@ -1545,6 +1811,19 @@ export function useTimelineDrag( setLinkedMovePreview, ]) + useEffect( + () => () => { + if (dragStateRef.current || selectionRollbackRef.current) { + finishDragInteraction({ + rollbackSelection: true, + suppressPostGestureClick: true, + updateReactState: false, + }) + } + }, + [finishDragInteraction], + ) + return { isDragging, dragOffset, diff --git a/src/features/timeline/utils/collision-utils.test.ts b/src/features/timeline/utils/collision-utils.test.ts new file mode 100644 index 000000000..059ea5bdc --- /dev/null +++ b/src/features/timeline/utils/collision-utils.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vite-plus/test' +import { findNearestAvailableSharedOffset } from './collision-utils' + +describe('findNearestAvailableSharedOffset', () => { + it('returns one nearest correction that is valid across conflicting lanes', () => { + const offset = findNearestAvailableSharedOffset( + [ + { trackId: 'v2', from: 20, durationInFrames: 10 }, + { trackId: 'v3', from: 50, durationInFrames: 10 }, + ], + [ + { trackId: 'v2', from: 18, durationInFrames: 8 }, + { trackId: 'v3', from: 60, durationInFrames: 10 }, + ], + ) + + expect(offset).toBe(-12) + }) + + it('breaks equidistant ties deterministically toward the earlier offset', () => { + expect( + findNearestAvailableSharedOffset( + [{ trackId: 'v1', from: 20, durationInFrames: 10 }], + [{ trackId: 'v1', from: 15, durationInFrames: 20 }], + ), + ).toBe(-15) + }) + + it('honors the frame-zero lower bound when the backward edge is unreachable', () => { + expect( + findNearestAvailableSharedOffset( + [{ trackId: 'v1', from: 2, durationInFrames: 10 }], + [{ trackId: 'v1', from: 0, durationInFrames: 8 }], + ), + ).toBe(6) + }) + + it('accepts touching edges and an empty cohort without adding an offset', () => { + expect( + findNearestAvailableSharedOffset( + [{ trackId: 'v1', from: 10, durationInFrames: 10 }], + [ + { trackId: 'v1', from: 0, durationInFrames: 10 }, + { trackId: 'v1', from: 20, durationInFrames: 10 }, + ], + ), + ).toBe(0) + expect(findNearestAvailableSharedOffset([], [])).toBe(0) + }) + + it('rejects non-finite positions and negative durations', () => { + expect( + findNearestAvailableSharedOffset( + [{ trackId: 'v1', from: Number.NaN, durationInFrames: 10 }], + [], + ), + ).toBeNull() + expect( + findNearestAvailableSharedOffset([{ trackId: 'v1', from: 0, durationInFrames: -1 }], []), + ).toBeNull() + }) +}) diff --git a/src/features/timeline/utils/collision-utils.ts b/src/features/timeline/utils/collision-utils.ts index f79d1846f..01fdb96f7 100644 --- a/src/features/timeline/utils/collision-utils.ts +++ b/src/features/timeline/utils/collision-utils.ts @@ -223,6 +223,68 @@ export function findNearestAvailableSpace( return findNearestAvailableSpaceInTrackItems(proposedFrom, durationInFrames, trackItems) } +/** + * Find one timeline offset that places every cohort member without colliding. + * + * Each blocker creates a finite open interval of invalid offsets for one + * placement. The nearest valid offset must therefore be zero, the frame-zero + * lower bound, or one of those interval boundaries. Checking that finite set + * makes the result deterministic and guarantees that the chosen correction is + * valid for the entire cohort, including placements on different tracks. + */ +export function findNearestAvailableSharedOffset( + placements: ReadonlyArray, + allItems: ReadonlyArray, +): number | null { + if (placements.length === 0) return 0 + if ( + placements.some( + (placement) => + !Number.isFinite(placement.from) || + !Number.isFinite(placement.durationInFrames) || + placement.durationInFrames < 0, + ) + ) { + return null + } + + const minimumOffset = Math.max(...placements.map((placement) => -placement.from)) + const blockersByTrackId = buildCollisionTrackItemsMap(allItems) + const candidates = new Set([minimumOffset]) + if (minimumOffset <= 0) { + candidates.add(0) + } + + for (const placement of placements) { + const placementEnd = placement.from + placement.durationInFrames + for (const blocker of blockersByTrackId.get(placement.trackId) ?? EMPTY_TRACK_ITEMS) { + const blockerEnd = blocker.from + blocker.durationInFrames + candidates.add(blocker.from - placementEnd) + candidates.add(blockerEnd - placement.from) + } + } + + const isValidOffset = (offset: number): boolean => { + if (!Number.isFinite(offset) || offset < minimumOffset) return false + + return placements.every((placement) => { + const start = placement.from + offset + const end = start + placement.durationInFrames + return (blockersByTrackId.get(placement.trackId) ?? EMPTY_TRACK_ITEMS).every((blocker) => { + const blockerEnd = blocker.from + blocker.durationInFrames + return !rangesOverlap(start, end, blocker.from, blockerEnd) + }) + }) + } + + return ( + [...candidates] + .filter((candidate) => candidate >= minimumOffset) + .sort((left, right) => Math.abs(left) - Math.abs(right) || left - right) + .find(isValidOffset) ?? null + ) +} + export interface OverlapInfo { itemA: string itemB: string diff --git a/src/features/timeline/utils/group-utils.test.ts b/src/features/timeline/utils/group-utils.test.ts index c9ab55af6..e41c906ff 100644 --- a/src/features/timeline/utils/group-utils.test.ts +++ b/src/features/timeline/utils/group-utils.test.ts @@ -61,6 +61,92 @@ describe('group-utils', () => { }) }) + it('propagates every effective property through arbitrarily deep group ancestry', () => { + const [effectiveChild] = resolveEffectiveTrackStates([ + makeTrack({ + id: 'outer-group', + isGroup: true, + locked: true, + }), + makeTrack({ + id: 'middle-group', + isGroup: true, + parentTrackId: 'outer-group', + muted: true, + }), + makeTrack({ + id: 'inner-group', + isGroup: true, + parentTrackId: 'middle-group', + visible: false, + solo: true, + }), + makeTrack({ + id: 'deep-child', + parentTrackId: 'inner-group', + }), + ]) + + expect(effectiveChild).toMatchObject({ + id: 'deep-child', + locked: true, + muted: true, + visible: false, + solo: true, + }) + }) + + it('fails closed for a missing declared parent without changing other effective properties', () => { + const [effectiveChild] = resolveEffectiveTrackStates([ + makeTrack({ + id: 'orphaned-child', + parentTrackId: 'missing-group', + muted: true, + visible: true, + solo: false, + }), + ]) + + expect(effectiveChild).toMatchObject({ + id: 'orphaned-child', + locked: true, + muted: true, + visible: true, + solo: false, + }) + }) + + it('terminates parent cycles deterministically and fails the cycle closed for locking', () => { + const cycleTracks = [ + makeTrack({ + id: 'group-a', + isGroup: true, + parentTrackId: 'group-b', + muted: true, + }), + makeTrack({ + id: 'group-b', + isGroup: true, + parentTrackId: 'group-a', + visible: false, + solo: true, + }), + makeTrack({ id: 'cycle-child', parentTrackId: 'group-a' }), + ] + + const [forwardResult] = resolveEffectiveTrackStates(cycleTracks) + const [reverseResult] = resolveEffectiveTrackStates(cycleTracks.toReversed()) + + expect(forwardResult).toMatchObject({ + id: 'cycle-child', + locked: true, + muted: true, + visible: false, + solo: true, + }) + expect(reverseResult).toEqual(forwardResult) + }) + it('uses propagated visibility when collecting visible track ids', () => { const visibleTrackIds = getVisibleTrackIds([ makeTrack({ id: 'group-1', isGroup: true, visible: false }), diff --git a/src/features/timeline/utils/group-utils.ts b/src/features/timeline/utils/group-utils.ts index beab29495..70dc30428 100644 --- a/src/features/timeline/utils/group-utils.ts +++ b/src/features/timeline/utils/group-utils.ts @@ -1,5 +1,88 @@ import type { TimelineItem, TimelineTrack } from '@/types/timeline' +type EffectiveTrackState = Pick + +const ROOT_EFFECTIVE_TRACK_STATE: EffectiveTrackState = { + locked: false, + muted: false, + visible: true, + solo: false, +} +const INVALID_PARENT_EFFECTIVE_TRACK_STATE: EffectiveTrackState = { + ...ROOT_EFFECTIVE_TRACK_STATE, + // A malformed ancestry chain must not make an otherwise inherited lock + // disappear. Other properties retain their canonical neutral defaults. + locked: true, +} + +function inheritTrackState( + track: TimelineTrack, + parentState: EffectiveTrackState, +): EffectiveTrackState { + return { + locked: track.locked || parentState.locked, + muted: track.muted || parentState.muted, + visible: track.visible !== false && parentState.visible, + solo: track.solo || parentState.solo, + } +} + +interface GroupAncestryTrace { + path: TimelineTrack[] + parentState: EffectiveTrackState + cycleStartIndex: number | null +} + +function traceGroupAncestry( + groupId: string, + groupsById: ReadonlyMap, + effectiveGroupStateById: ReadonlyMap, +): GroupAncestryTrace { + const path: TimelineTrack[] = [] + const pathIndexById = new Map() + let currentId = groupId + + while (true) { + const knownState = effectiveGroupStateById.get(currentId) + if (knownState) return { path, parentState: knownState, cycleStartIndex: null } + + const cycleStartIndex = pathIndexById.get(currentId) + if (cycleStartIndex !== undefined) { + return { + path, + parentState: INVALID_PARENT_EFFECTIVE_TRACK_STATE, + cycleStartIndex, + } + } + + const currentGroup = groupsById.get(currentId) + if (!currentGroup) { + return { + path, + parentState: INVALID_PARENT_EFFECTIVE_TRACK_STATE, + cycleStartIndex: null, + } + } + + pathIndexById.set(currentId, path.length) + path.push(currentGroup) + if (!currentGroup.parentTrackId) { + return { path, parentState: ROOT_EFFECTIVE_TRACK_STATE, cycleStartIndex: null } + } + currentId = currentGroup.parentTrackId + } +} + +function foldTrackStates( + tracks: readonly TimelineTrack[], + parentState: EffectiveTrackState, +): EffectiveTrackState { + return tracks.reduceRight( + (effectiveState, track) => inheritTrackState(track, effectiveState), + parentState, + ) +} + /** * Build a set of track IDs whose items should contribute snap targets. */ @@ -53,21 +136,52 @@ export function resolveEffectiveTrackStates(tracks: TimelineTrack[]): TimelineTr const groupsById = new Map( tracks.filter((track) => track.isGroup).map((track) => [track.id, track] as const), ) + const effectiveGroupStateById = new Map() + + const resolveGroupState = (groupId: string): EffectiveTrackState => { + const memoizedState = effectiveGroupStateById.get(groupId) + if (memoizedState) return memoizedState + + const trace = traceGroupAncestry(groupId, groupsById, effectiveGroupStateById) + let pathEndIndex = trace.path.length + let parentState = trace.parentState + + if (trace.cycleStartIndex !== null) { + const cycleGroups = trace.path.slice(trace.cycleStartIndex) + parentState = foldTrackStates(cycleGroups, INVALID_PARENT_EFFECTIVE_TRACK_STATE) + for (const cycleGroup of cycleGroups) { + effectiveGroupStateById.set(cycleGroup.id, parentState) + } + pathEndIndex = trace.cycleStartIndex + } + + for (let index = pathEndIndex - 1; index >= 0; index -= 1) { + const group = trace.path[index]! + parentState = inheritTrackState(group, parentState) + effectiveGroupStateById.set(group.id, parentState) + } + + return effectiveGroupStateById.get(groupId) ?? INVALID_PARENT_EFFECTIVE_TRACK_STATE + } + + for (const groupId of groupsById.keys()) { + resolveGroupState(groupId) + } return tracks .filter((track) => !track.isGroup) .map((track) => { - const parentGroup = track.parentTrackId ? groupsById.get(track.parentTrackId) : undefined - if (!parentGroup) { + if (!track.parentTrackId) { return track } + const parentState = groupsById.has(track.parentTrackId) + ? resolveGroupState(track.parentTrackId) + : INVALID_PARENT_EFFECTIVE_TRACK_STATE + return { ...track, - locked: track.locked || parentGroup.locked, - muted: track.muted || parentGroup.muted, - visible: track.visible !== false && parentGroup.visible !== false, - solo: track.solo || parentGroup.solo, + ...inheritTrackState(track, parentState), } }) } diff --git a/src/features/timeline/utils/linked-drag-targeting.test.ts b/src/features/timeline/utils/linked-drag-targeting.test.ts index b6ae1fda5..4489fe32b 100644 --- a/src/features/timeline/utils/linked-drag-targeting.test.ts +++ b/src/features/timeline/utils/linked-drag-targeting.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vite-plus/test' import type { TimelineTrack } from '@/types/timeline' import { resolveCreateNewDragTrackTargets, + resolveLinkedCohortDragTrackTargets, resolveLinkedDragTrackTargets, } from './linked-drag-targeting' @@ -108,6 +109,33 @@ describe('resolveLinkedDragTrackTargets', () => { name: 'A2', }) }) + + it('rejects a hovered child lane that inherits a lock from its parent group', () => { + const result = resolveLinkedDragTrackTargets({ + tracks: [ + makeTrack({ + id: 'locked-group', + name: 'Locked Group', + order: 0, + isGroup: true, + locked: true, + }), + makeTrack({ + id: 'v1', + name: 'V1', + kind: 'video', + order: 1, + parentTrackId: 'locked-group', + }), + makeTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + ], + hoveredTrackId: 'v1', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(result).toBeNull() + }) }) describe('resolveCreateNewDragTrackTargets', () => { @@ -187,3 +215,215 @@ describe('resolveCreateNewDragTrackTargets', () => { expect(result).toBeNull() }) }) + +describe('resolveLinkedCohortDragTrackTargets', () => { + const sectionTracks = [ + makeTrack({ id: 'v3', name: 'V3', kind: 'video', order: 0 }), + makeTrack({ id: 'v2', name: 'V2', kind: 'video', order: 1 }), + makeTrack({ id: 'v1', name: 'V1', kind: 'video', order: 2 }), + makeTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 3 }), + makeTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 4 }), + makeTrack({ id: 'a3', name: 'A3', kind: 'audio', order: 5 }), + ] + + it('moves two linked A/V pairs by one shared media-section delta', () => { + const result = resolveLinkedCohortDragTrackTargets({ + tracks: sectionTracks, + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + { id: 'video-2', initialTrackId: 'v2', type: 'video' }, + { id: 'audio-2', initialTrackId: 'a2', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(Object.fromEntries(result?.trackAssignments ?? [])).toEqual({ + 'video-1': 'v2', + 'audio-1': 'a2', + 'video-2': 'v3', + 'audio-2': 'a3', + }) + }) + + it('keeps an attached caption on its relative visual section', () => { + const result = resolveLinkedCohortDragTrackTargets({ + tracks: sectionTracks, + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + { id: 'caption-1', initialTrackId: 'v2', type: 'text' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(Object.fromEntries(result?.trackAssignments ?? [])).toEqual({ + 'video-1': 'v2', + 'audio-1': 'a2', + 'caption-1': 'v3', + }) + }) + + it('anchors a mixed-kind move through the dragged item linked companion section', () => { + const tracks = [ + makeTrack({ id: 'v4', name: 'V4', kind: 'video', order: 0 }), + ...sectionTracks.map((track) => ({ ...track, order: track.order + 1 })), + makeTrack({ id: 'a4', name: 'A4', kind: 'audio', order: 7 }), + ] + const result = resolveLinkedCohortDragTrackTargets({ + tracks, + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a2', type: 'audio' }, + { id: 'visual-extra', initialTrackId: 'v2', type: 'image' }, + { id: 'audio-extra', initialTrackId: 'a3', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'a3', + zone: 'audio', + preferredTrackHeight: 80, + }) + + expect(Object.fromEntries(result?.trackAssignments ?? [])).toEqual({ + 'video-1': 'v2', + 'audio-1': 'a3', + 'visual-extra': 'v3', + 'audio-extra': 'a4', + }) + }) + + it('creates corresponding outer video and audio lanes for multiple pairs', () => { + const tracks = [ + makeTrack({ id: 'v2', name: 'V2', kind: 'video', order: 0 }), + makeTrack({ id: 'v1', name: 'V1', kind: 'video', order: 1 }), + makeTrack({ id: 'a1', name: 'A1', kind: 'audio', order: 2 }), + makeTrack({ id: 'a2', name: 'A2', kind: 'audio', order: 3 }), + ] + const result = resolveLinkedCohortDragTrackTargets({ + tracks, + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + { id: 'video-2', initialTrackId: 'v2', type: 'video' }, + { id: 'audio-2', initialTrackId: 'a2', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + createNew: true, + preferredTrackHeight: 80, + }) + + const assignments = result?.trackAssignments + expect(assignments?.get('video-1')).toBe('v2') + expect(assignments?.get('audio-1')).toBe('a2') + expect(result?.tracks.find((track) => track.id === assignments?.get('video-2'))).toMatchObject({ + kind: 'video', + name: 'V3', + }) + expect(result?.tracks.find((track) => track.id === assignments?.get('audio-2'))).toMatchObject({ + kind: 'audio', + name: 'A3', + }) + expect(result?.tracks).toHaveLength(6) + }) + + it('rejects the cohort when an implicit companion source track is locked', () => { + const result = resolveLinkedCohortDragTrackTargets({ + tracks: sectionTracks.map((track) => + track.id === 'a1' ? { ...track, locked: true } : track, + ), + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(result).toBeNull() + }) + + it('rejects a source lane that inherits a lock through nested groups', () => { + const result = resolveLinkedCohortDragTrackTargets({ + tracks: [ + ...sectionTracks.map((track) => + track.id === 'a1' ? { ...track, parentTrackId: 'inner-group' } : track, + ), + makeTrack({ + id: 'outer-group', + name: 'Outer Group', + order: 6, + isGroup: true, + locked: true, + }), + makeTrack({ + id: 'inner-group', + name: 'Inner Group', + order: 7, + isGroup: true, + parentTrackId: 'outer-group', + }), + ], + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(result).toBeNull() + }) + + it('rejects an implicit destination lane that inherits a lock through nested groups', () => { + const result = resolveLinkedCohortDragTrackTargets({ + tracks: [ + ...sectionTracks.map((track) => + track.id === 'a2' ? { ...track, parentTrackId: 'inner-group' } : track, + ), + makeTrack({ + id: 'outer-group', + name: 'Outer Group', + order: 6, + isGroup: true, + locked: true, + }), + makeTrack({ + id: 'inner-group', + name: 'Inner Group', + order: 7, + isGroup: true, + parentTrackId: 'outer-group', + }), + ], + draggedItems: [ + { id: 'video-1', initialTrackId: 'v1', type: 'video' }, + { id: 'audio-1', initialTrackId: 'a1', type: 'audio' }, + ], + anchorItemId: 'video-1', + anchorRelatedItemIds: ['video-1', 'audio-1'], + hoveredTrackId: 'v2', + zone: 'video', + preferredTrackHeight: 80, + }) + + expect(result).toBeNull() + }) +}) diff --git a/src/features/timeline/utils/linked-drag-targeting.ts b/src/features/timeline/utils/linked-drag-targeting.ts index b1333b35e..3c4da0e02 100644 --- a/src/features/timeline/utils/linked-drag-targeting.ts +++ b/src/features/timeline/utils/linked-drag-targeting.ts @@ -5,6 +5,7 @@ import { renameTrackForKind, type TrackKind, } from './classic-tracks' +import { resolveEffectiveTrackStates } from './group-utils' export type LinkedDragDropZone = 'video' | 'audio' @@ -32,6 +33,28 @@ export interface CreateNewDragTrackTargetResult { trackAssignments: Map } +export interface LinkedDragCohortItem { + id: string + initialTrackId: string + type: TimelineItem['type'] +} + +export interface LinkedDragCohortTrackTargetResult { + tracks: TimelineTrack[] + trackAssignments: Map +} + +function getEffectiveTrackById( + tracks: TimelineTrack[], + trackId: string, +): TimelineTrack | undefined { + return resolveEffectiveTrackStates(tracks).find((track) => track.id === trackId) +} + +function isTrackEffectivelyLocked(tracks: TimelineTrack[], trackId: string): boolean { + return getEffectiveTrackById(tracks, trackId)?.locked !== false +} + function getKindTracks(tracks: TimelineTrack[], kind: TrackKind): TimelineTrack[] { return [...tracks] .filter((track) => getTrackKind(track) === kind) @@ -116,6 +139,285 @@ function getDraggedItemTrackKind(type: TimelineItem['type']): TrackKind { return type === 'audio' ? 'audio' : 'video' } +/** + * Return lanes in section order, starting at the A/V divider and moving + * outward. Video order is therefore the reverse of its visual top-to-bottom + * order, while audio order already starts at the divider. + */ +function getSectionTracks(tracks: TimelineTrack[], kind: TrackKind): TimelineTrack[] { + const kindTracks = getKindTracks(tracks, kind) + return kind === 'video' ? kindTracks.reverse() : kindTracks +} + +function getTrackSectionIndex(tracks: TimelineTrack[], kind: TrackKind, trackId: string): number { + return getSectionTracks(tracks, kind).findIndex((track) => track.id === trackId) +} + +function ensureTrackSectionIndex(params: EnsureTrackIndexParams): { + tracks: TimelineTrack[] + trackId: string +} { + const { kind, index, preferredTrackHeight } = params + let workingTracks = [...params.tracks] + + while (getSectionTracks(workingTracks, kind).length <= index) { + workingTracks = addCreateNewTrack({ + tracks: workingTracks, + kind, + preferredTrackHeight, + }) + } + + return { + tracks: workingTracks, + trackId: getSectionTracks(workingTracks, kind)[index]!.id, + } +} + +interface CohortTrackPlan { + item: LinkedDragCohortItem + kind: TrackKind + sourceSection: number +} + +interface CohortTrackPlanState { + tracks: TimelineTrack[] + plans: CohortTrackPlan[] +} + +function upgradeCohortSourceTracks( + tracks: TimelineTrack[], + draggedItems: LinkedDragCohortItem[], +): TimelineTrack[] | null { + let workingTracks = [...tracks] + for (const draggedItem of draggedItems) { + const kind = getDraggedItemTrackKind(draggedItem.type) + const sourceTrack = workingTracks.find((track) => track.id === draggedItem.initialTrackId) + if ( + !sourceTrack || + sourceTrack.isGroup || + isTrackEffectivelyLocked(workingTracks, sourceTrack.id) + ) { + return null + } + + const sourceKind = getTrackKind(sourceTrack) + if (sourceKind !== null && sourceKind !== kind) return null + if (sourceKind === null) { + const upgradedTrack = renameTrackForKind(sourceTrack, workingTracks, kind) + workingTracks = workingTracks.map((track) => + track.id === sourceTrack.id ? upgradedTrack : track, + ) + } + } + + return workingTracks +} + +function createCohortTrackPlans( + tracks: TimelineTrack[], + draggedItems: LinkedDragCohortItem[], +): CohortTrackPlan[] | null { + const plans: CohortTrackPlan[] = [] + for (const draggedItem of draggedItems) { + const kind = getDraggedItemTrackKind(draggedItem.type) + const sourceSection = getTrackSectionIndex(tracks, kind, draggedItem.initialTrackId) + if (sourceSection < 0) return null + plans.push({ item: draggedItem, kind, sourceSection }) + } + + return plans +} + +function buildCohortTrackPlans( + tracks: TimelineTrack[], + draggedItems: LinkedDragCohortItem[], +): CohortTrackPlanState | null { + if (draggedItems.length === 0) return null + + const workingTracks = upgradeCohortSourceTracks(tracks, draggedItems) + if (!workingTracks) return null + + const plans = createCohortTrackPlans(workingTracks, draggedItems) + if (!plans) return null + + return { tracks: workingTracks, plans } +} + +function getSourceAnchorSection(params: { + plans: CohortTrackPlan[] + zoneKind: TrackKind + anchorItemId: string + anchorRelatedItemIds: readonly string[] +}): number | null { + const relatedIds = new Set([params.anchorItemId, ...params.anchorRelatedItemIds]) + const anchorPlan = params.plans.find( + (plan) => plan.item.id === params.anchorItemId && plan.kind === params.zoneKind, + ) + const relatedZonePlan = params.plans.find( + (plan) => relatedIds.has(plan.item.id) && plan.kind === params.zoneKind, + ) + const fallbackAnchorPlan = params.plans.find((plan) => plan.item.id === params.anchorItemId) + + return ( + anchorPlan?.sourceSection ?? + relatedZonePlan?.sourceSection ?? + fallbackAnchorPlan?.sourceSection ?? + null + ) +} + +function resolveExistingCohortDrop(params: { + tracks: TimelineTrack[] + plans: CohortTrackPlan[] + zoneKind: TrackKind + anchorItemId: string + anchorRelatedItemIds: readonly string[] + hoveredTrackId: string +}): { tracks: TimelineTrack[]; sectionDelta: number } | null { + let workingTracks = params.tracks + let hoveredTrack = workingTracks.find((track) => track.id === params.hoveredTrackId) + if ( + !hoveredTrack || + hoveredTrack.isGroup || + isTrackEffectivelyLocked(workingTracks, hoveredTrack.id) + ) { + return null + } + + let hoveredKind = getTrackKind(hoveredTrack) + if (hoveredKind === null) { + const upgradedTrack = renameTrackForKind(hoveredTrack, workingTracks, params.zoneKind) + workingTracks = workingTracks.map((track) => + track.id === hoveredTrack!.id ? upgradedTrack : track, + ) + hoveredTrack = upgradedTrack + hoveredKind = params.zoneKind + } + + const targetSection = getTrackSectionIndex(workingTracks, hoveredKind, hoveredTrack.id) + const sourceAnchorSection = getSourceAnchorSection(params) + if (targetSection < 0 || sourceAnchorSection === null) return null + + return { + tracks: workingTracks, + sectionDelta: targetSection - sourceAnchorSection, + } +} + +function getCreateNewCohortSectionDelta( + tracks: TimelineTrack[], + plans: CohortTrackPlan[], + zoneKind: TrackKind, +): number | null { + const zonePlans = plans.filter((plan) => plan.kind === zoneKind) + if (zonePlans.length === 0) return null + + const outermostSourceSection = Math.max(...zonePlans.map((plan) => plan.sourceSection)) + return getSectionTracks(tracks, zoneKind).length - outermostSourceSection +} + +function assignCohortTrackTargets(params: { + tracks: TimelineTrack[] + plans: CohortTrackPlan[] + sectionDelta: number + preferredTrackHeight: number +}): LinkedDragCohortTrackTargetResult | null { + let workingTracks = params.tracks + const targetTrackIdBySource = new Map() + const sourcePlans = Array.from( + new Map( + params.plans.map((plan) => [ + `${plan.kind}:${plan.item.initialTrackId}`, + { + key: `${plan.kind}:${plan.item.initialTrackId}`, + kind: plan.kind, + targetSection: plan.sourceSection + params.sectionDelta, + }, + ]), + ).values(), + ).sort((left, right) => left.targetSection - right.targetSection) + + for (const sourcePlan of sourcePlans) { + const ensuredTrack = ensureTrackSectionIndex({ + tracks: workingTracks, + kind: sourcePlan.kind, + index: sourcePlan.targetSection, + preferredTrackHeight: params.preferredTrackHeight, + }) + workingTracks = ensuredTrack.tracks + + if (isTrackEffectivelyLocked(workingTracks, ensuredTrack.trackId)) return null + targetTrackIdBySource.set(sourcePlan.key, ensuredTrack.trackId) + } + + const trackAssignments = new Map() + for (const plan of params.plans) { + const targetTrackId = targetTrackIdBySource.get(`${plan.kind}:${plan.item.initialTrackId}`) + if (!targetTrackId) return null + trackAssignments.set(plan.item.id, targetTrackId) + } + + return { tracks: workingTracks, trackAssignments } +} + +/** + * Resolve every member of a linked drag cohort by media section instead of by + * raw global track index. The same section delta is applied to video, audio, + * and attached visual items, preserving relative lane relationships while + * keeping each item in a compatible media section. + */ +export function resolveLinkedCohortDragTrackTargets(params: { + tracks: TimelineTrack[] + draggedItems: LinkedDragCohortItem[] + anchorItemId: string + anchorRelatedItemIds?: readonly string[] + hoveredTrackId: string + zone: LinkedDragDropZone + createNew?: boolean + preferredTrackHeight: number +}): LinkedDragCohortTrackTargetResult | null { + const { + tracks, + draggedItems, + anchorItemId, + anchorRelatedItemIds = [], + hoveredTrackId, + zone, + createNew = false, + preferredTrackHeight, + } = params + const planState = buildCohortTrackPlans(tracks, draggedItems) + if (!planState) return null + + const { plans } = planState + const zoneKind: TrackKind = zone + const dropState = createNew + ? { + tracks: planState.tracks, + sectionDelta: getCreateNewCohortSectionDelta(planState.tracks, plans, zoneKind), + } + : resolveExistingCohortDrop({ + tracks: planState.tracks, + plans, + zoneKind, + anchorItemId, + anchorRelatedItemIds, + hoveredTrackId, + }) + if (!dropState || dropState.sectionDelta === null) return null + + const innermostSourceSection = Math.min(...plans.map((plan) => plan.sourceSection)) + const sectionDelta = Math.max(dropState.sectionDelta, -innermostSourceSection) + + return assignCohortTrackTargets({ + tracks: dropState.tracks, + plans, + sectionDelta, + preferredTrackHeight, + }) +} + function buildContiguousTrackAssignment(params: { sourceTrackIds: string[] targetTracks: TimelineTrack[] @@ -153,6 +455,11 @@ export function resolveCreateNewDragTrackTargets(params: { if (draggedItems.length === 0) { return null } + if ( + draggedItems.some((draggedItem) => isTrackEffectivelyLocked(tracks, draggedItem.initialTrackId)) + ) { + return null + } const selectionKinds = Array.from( new Set(draggedItems.map((item) => getDraggedItemTrackKind(item.type))), @@ -312,7 +619,11 @@ export function resolveLinkedDragTrackTargets(params: { }): LinkedDragTrackTargetResult | null { const { tracks, hoveredTrackId, zone, createNew = false, preferredTrackHeight } = params const hoveredTrack = tracks.find((track) => track.id === hoveredTrackId) - if (!hoveredTrack) { + if ( + !hoveredTrack || + hoveredTrack.isGroup || + (!createNew && isTrackEffectivelyLocked(tracks, hoveredTrackId)) + ) { return null } @@ -346,7 +657,7 @@ export function resolveLinkedDragTrackTargets(params: { let sectionIndex: number const hoveredTrackNumber = hoveredKind ? getClassicTrackNumber(hoveredTrack, hoveredKind) : null - if (!hoveredTrack.locked && (hoveredKind === zoneKind || hoveredKind === null)) { + if (hoveredKind === zoneKind || hoveredKind === null) { const upgradedTrack = renameTrackForKind(hoveredTrack, workingTracks, zoneKind) if (upgradedTrack !== hoveredTrack) { workingTracks = workingTracks.map((track) => @@ -401,6 +712,13 @@ export function resolveLinkedDragTrackTargets(params: { }) workingTracks = ensuredCompanionTrack.tracks + if ( + isTrackEffectivelyLocked(workingTracks, zoneTrackId) || + isTrackEffectivelyLocked(workingTracks, ensuredCompanionTrack.trackId) + ) { + return null + } + if (zone === 'video') { return { tracks: workingTracks,