diff options
Diffstat (limited to 'apps/web/src/hooks/timeline/element/use-element-interaction.ts')
| -rw-r--r-- | apps/web/src/hooks/timeline/element/use-element-interaction.ts | 299 |
1 files changed, 192 insertions, 107 deletions
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index 854c4f4e..98d21d9b 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -9,6 +9,13 @@ import { import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { + buildMoveGroup, + resolveGroupMove, + snapGroupEdges, + type GroupMoveResult, + type MoveGroup, +} from "@/lib/timeline/group-move"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; import { TICKS_PER_SECOND } from "@/lib/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; @@ -16,10 +23,11 @@ import { roundToFrame } from "opencut-wasm"; import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; -import { snapElementEdge, type SnapPoint } from "@/lib/timeline/snap-utils"; +import type { SnapPoint } from "@/lib/timeline/snap-utils"; import { registerCanceller } from "@/lib/cancel-interaction"; import type { DropTarget, + ElementRef, ElementDragState, SceneTracks, TimelineElement, @@ -55,6 +63,7 @@ const initialDragState: ElementDragState = { interface PendingDragState { elementId: string; trackId: string; + selectedElements: ElementRef[]; startMouseX: number; startMouseY: number; startElementTime: number; @@ -116,11 +125,9 @@ function getDragDropTarget({ const scrollContainer = tracksScrollRef.current; if (!containerRect || !scrollContainer) return null; - const sourceTrack = [ - ...tracks.overlay, - tracks.main, - ...tracks.audio, - ].find(({ id }) => id === trackId); + const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( + ({ id }) => id === trackId, + ); const movingElement = sourceTrack?.elements.find( ({ id }) => id === elementId, ); @@ -153,7 +160,7 @@ function getDragDropTarget({ interface StartDragParams extends Omit< ElementDragState, - "isDragging" | "currentTime" | "currentMouseY" | "dragElementIds" | "dragTimeOffsets" + "isDragging" | "currentTime" | "currentMouseY" > { initialCurrentTime: number; initialCurrentMouseY: number; @@ -171,11 +178,6 @@ export function useElementInteraction({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const sceneTracks = editor.scenes.getActiveScene().tracks; - const tracks = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ]; const { selectedElements, isElementSelected, @@ -188,12 +190,16 @@ export function useElementInteraction({ const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null); const [isPendingDrag, setIsPendingDrag] = useState(false); const pendingDragRef = useRef<PendingDragState | null>(null); + const moveGroupRef = useRef<MoveGroup | null>(null); + const groupMoveResultRef = useRef<GroupMoveResult | null>(null); const lastMouseXRef = useRef(0); const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); const startDrag = useCallback( ({ elementId, + dragElementIds, + dragTimeOffsets, trackId, startMouseX, startMouseY, @@ -205,8 +211,8 @@ export function useElementInteraction({ setDragState({ isDragging: true, elementId, - dragElementIds: elementId ? [elementId] : [], - dragTimeOffsets: {}, + dragElementIds, + dragTimeOffsets, trackId, startMouseX, startMouseY, @@ -220,6 +226,8 @@ export function useElementInteraction({ ); const endDrag = useCallback(() => { + moveGroupRef.current = null; + groupMoveResultRef.current = null; setDragState(initialDragState); setDragDropTarget(null); }, []); @@ -232,6 +240,70 @@ export function useElementInteraction({ onSnapPointChange?.(null); }, [endDrag, onSnapPointChange]); + const resolveGroupDragMove = useCallback( + ({ + group, + snappedTime, + dropTarget, + }: { + group: MoveGroup; + snappedTime: number; + dropTarget: DropTarget | null; + }): GroupMoveResult | null => { + if (!dropTarget) { + return null; + } + + if (dropTarget.isNewTrack) { + return resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: group.members.map(() => generateUUID()), + }, + }); + } + + const orderedTracks = [ + ...sceneTracks.overlay, + sceneTracks.main, + ...sceneTracks.audio, + ]; + const targetTrack = orderedTracks[dropTarget.trackIndex]; + if (!targetTrack) { + return null; + } + + const existingTrackResult = resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "existingTrack", + anchorTargetTrackId: targetTrack.id, + }, + }); + if (existingTrackResult) { + return existingTrackResult; + } + + return resolveGroupMove({ + group, + tracks: sceneTracks, + anchorStartTime: snappedTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: group.members.map(() => generateUUID()), + }, + }); + }, + [sceneTracks], + ); + useEffect(() => { if (!dragState.isDragging && !isPendingDrag) return; @@ -241,51 +313,29 @@ export function useElementInteraction({ const getDragSnapResult = useCallback( ({ frameSnappedTime, - movingElement, + group, }: { frameSnappedTime: number; - movingElement: TimelineElement | null | undefined; + group: MoveGroup | null; }) => { - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - if (!shouldSnap || !movingElement) { + if (!group || !snappingEnabled || isShiftHeldRef.current) { return { snappedTime: frameSnappedTime, snapPoint: null }; } - const elementDuration = movingElement.duration; - const playheadTime = editor.playback.getCurrentTime(); - - const startSnap = snapElementEdge({ - targetTime: frameSnappedTime, - elementDuration, + const groupSnap = snapGroupEdges({ + group, + anchorStartTime: frameSnappedTime, tracks: sceneTracks, - playheadTime, + playheadTime: editor.playback.getCurrentTime(), zoomLevel, - excludeElementId: movingElement.id, - snapToStart: true, }); - const endSnap = snapElementEdge({ - targetTime: frameSnappedTime, - elementDuration, - tracks: sceneTracks, - playheadTime, - zoomLevel, - excludeElementId: movingElement.id, - snapToStart: false, - }); - - const snapResult = - startSnap.snapDistance <= endSnap.snapDistance ? startSnap : endSnap; - if (!snapResult.snapPoint) { - return { snappedTime: frameSnappedTime, snapPoint: null }; - } - return { - snappedTime: snapResult.snappedTime, - snapPoint: snapResult.snapPoint, + snappedTime: groupSnap.snappedAnchorStartTime, + snapPoint: groupSnap.snapPoint, }; }, - [snappingEnabled, editor.playback, tracks, zoomLevel, isShiftHeldRef], + [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], ); useEffect(() => { @@ -314,17 +364,38 @@ export function useElementInteraction({ zoomLevel, scrollLeft, }); - const adjustedTime = Math.max( - 0, - mouseTime - pendingDragRef.current.clickOffsetTime, - ); - const snappedTime = roundToFrame({ - time: adjustedTime, - rate: activeProject.settings.fps, - }) ?? adjustedTime; - startDrag({ - ...pendingDragRef.current, - initialCurrentTime: snappedTime, + const adjustedTime = Math.max( + 0, + mouseTime - pendingDragRef.current.clickOffsetTime, + ); + const snappedTime = + roundToFrame({ + time: adjustedTime, + rate: activeProject.settings.fps, + }) ?? adjustedTime; + const moveGroup = buildMoveGroup({ + anchorRef: { + trackId: pendingDragRef.current.trackId, + elementId: pendingDragRef.current.elementId, + }, + selectedElements: pendingDragRef.current.selectedElements, + tracks: sceneTracks, + }); + if (!moveGroup) { + return; + } + + moveGroupRef.current = moveGroup; + groupMoveResultRef.current = null; + const dragTimeOffsets: Record<string, number> = {}; + for (const member of moveGroup.members) { + dragTimeOffsets[member.elementId] = member.timeOffset; + } + startDrag({ + ...pendingDragRef.current, + dragElementIds: moveGroup.members.map((member) => member.elementId), + dragTimeOffsets, + initialCurrentTime: snappedTime, initialCurrentMouseY: clientY, }); startedDragThisEvent = true; @@ -364,15 +435,13 @@ export function useElementInteraction({ }); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); const fps = activeProject.settings.fps; - const frameSnappedTime = roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; + const frameSnappedTime = + roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; - const sourceTrack = tracks.find(({ id }) => id === dragState.trackId); - const movingElement = sourceTrack?.elements.find( - ({ id }) => id === dragState.elementId, - ); + const moveGroup = moveGroupRef.current; const { snappedTime, snapPoint } = getDragSnapResult({ frameSnappedTime, - movingElement, + group: moveGroup, }); setDragState((previousDragState) => ({ ...previousDragState, @@ -386,7 +455,7 @@ export function useElementInteraction({ startMouseY: dragState.startMouseY, currentMouseY: clientY, }); - const dropTarget = getDragDropTarget({ + const anchorDropTarget = getDragDropTarget({ clientX, clientY, elementId: dragState.elementId, @@ -399,7 +468,24 @@ export function useElementInteraction({ snappedTime, verticalDragDirection, }); - setDragDropTarget(dropTarget?.isNewTrack ? dropTarget : null); + const nextGroupMoveResult = + moveGroup && anchorDropTarget + ? resolveGroupDragMove({ + group: moveGroup, + snappedTime, + dropTarget: anchorDropTarget, + }) + : null; + groupMoveResultRef.current = nextGroupMoveResult; + setDragDropTarget( + anchorDropTarget && + (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { + ...anchorDropTarget, + isNewTrack: true, + } + : null, + ); } }; @@ -419,10 +505,11 @@ export function useElementInteraction({ tracksScrollRef, tracksContainerRef, headerRef, - tracks, isPendingDrag, startDrag, getDragSnapResult, + resolveGroupDragMove, + sceneTracks, onSnapPointChange, ]); @@ -468,55 +555,45 @@ export function useElementInteraction({ return; } const snappedTime = dragState.currentTime; - - const sourceTrack = tracks.find(({ id }) => id === dragState.trackId); - if (!sourceTrack) { + const moveGroup = moveGroupRef.current; + if (!moveGroup) { endDrag(); onSnapPointChange?.(null); return; } - const movingElement = - sourceTrack.elements.find(({ id }) => id === dragState.elementId) ?? null; - if ( - movingElement && - !dropTarget.isNewTrack && - tracks[dropTarget.trackIndex]?.id === dragState.trackId && - snappedTime === movingElement.startTime - ) { + + const groupMoveResult = resolveGroupDragMove({ + group: moveGroup, + snappedTime, + dropTarget, + }); + if (!groupMoveResult) { endDrag(); onSnapPointChange?.(null); return; } - if (dropTarget.isNewTrack) { - const newTrackId = generateUUID(); - - editor.timeline.moveElement({ - sourceTrackId: dragState.trackId, - targetTrackId: newTrackId, - elementId: dragState.elementId, - newStartTime: snappedTime, - createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, - }); - selectElement({ trackId: newTrackId, elementId: dragState.elementId }); - } else { - const targetTrack = tracks[dropTarget.trackIndex]; - if (targetTrack) { - editor.timeline.moveElement({ - sourceTrackId: dragState.trackId, - targetTrackId: targetTrack.id, - elementId: dragState.elementId, - newStartTime: snappedTime, - }); - if (targetTrack.id !== dragState.trackId) { - selectElement({ - trackId: targetTrack.id, - elementId: dragState.elementId, - }); - } - } + const didMove = groupMoveResult.moves.some((move) => { + const currentMember = moveGroup.members.find( + (member) => member.elementId === move.elementId, + ); + const originalStartTime = + dragState.startElementTime + (currentMember?.timeOffset ?? 0); + return ( + currentMember?.trackId !== move.targetTrackId || + originalStartTime !== move.newStartTime + ); + }); + if (!didMove && groupMoveResult.createTracks.length === 0) { + endDrag(); + onSnapPointChange?.(null); + return; } + editor.timeline.moveElements({ + moves: groupMoveResult.moves, + createTracks: groupMoveResult.createTracks, + }); endDrag(); onSnapPointChange?.(null); }; @@ -531,14 +608,14 @@ export function useElementInteraction({ dragState.trackId, dragState.currentTime, zoomLevel, - tracks, endDrag, onSnapPointChange, editor.timeline, tracksContainerRef, tracksScrollRef, headerRef, - selectElement, + resolveGroupDragMove, + sceneTracks, ]); useEffect(() => { @@ -600,9 +677,17 @@ export function useElementInteraction({ elementRect: event.currentTarget.getBoundingClientRect(), zoomLevel, }); + const elementRef = { + trackId: track.id, + elementId: element.id, + }; + const pendingSelectedElements = isElementSelected(elementRef) + ? selectedElements + : [elementRef]; pendingDragRef.current = { elementId: element.id, trackId: track.id, + selectedElements: pendingSelectedElements, startMouseX: event.clientX, startMouseY: event.clientY, startElementTime: element.startTime, @@ -610,7 +695,7 @@ export function useElementInteraction({ }; setIsPendingDrag(true); }, - [zoomLevel, isElementSelected, handleSelectionClick], + [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], ); const handleElementClick = useCallback( |
