diff options
Diffstat (limited to 'apps/web/src/hooks/timeline/use-timeline-resize.ts')
| -rw-r--r-- | apps/web/src/hooks/timeline/use-timeline-resize.ts | 322 |
1 files changed, 322 insertions, 0 deletions
diff --git a/apps/web/src/hooks/timeline/use-timeline-resize.ts b/apps/web/src/hooks/timeline/use-timeline-resize.ts new file mode 100644 index 00000000..f246261a --- /dev/null +++ b/apps/web/src/hooks/timeline/use-timeline-resize.ts @@ -0,0 +1,322 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { EditorCore } from "@/core"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; +import { useEditor } from "@/hooks/use-editor"; +import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { + computeGroupResize, + type GroupResizeMember, + type GroupResizeResult, + type ResizeSide, +} from "@/lib/timeline/group-resize"; +import { + findSnapPoints, + snapToNearestPoint, + type SnapPoint, +} from "@/lib/timeline/snap-utils"; +import type { + SceneTracks, + TimelineElement, + TimelineTrack, +} from "@/lib/timeline"; +import { isRetimableElement } from "@/lib/timeline"; +import { registerCanceller } from "@/lib/cancel-interaction"; + +interface ResizeInteractionState { + side: ResizeSide; + startX: number; + members: GroupResizeMember[]; +} + +export function useTimelineResize({ + zoomLevel, + onSnapPointChange, +}: { + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); + const { selectedElements } = useElementSelection(); + const [resizeState, setResizeState] = useState<ResizeInteractionState | null>( + null, + ); + const latestResultRef = useRef<GroupResizeResult | null>(null); + + const cancelResize = useCallback(() => { + editor.timeline.discardPreview(); + setResizeState(null); + latestResultRef.current = null; + onSnapPointChange?.(null); + }, [editor.timeline, onSnapPointChange]); + + useEffect(() => { + if (!resizeState) { + return; + } + + return registerCanceller({ fn: cancelResize }); + }, [resizeState, cancelResize]); + + const handleResizeStart = useCallback( + ({ + event, + element, + track, + side, + }: { + event: React.MouseEvent; + element: TimelineElement; + track: TimelineTrack; + side: ResizeSide; + }) => { + event.stopPropagation(); + event.preventDefault(); + + const elementRef = { + trackId: track.id, + elementId: element.id, + }; + const activeSelection = selectedElements.some( + (selectedElement) => + selectedElement.trackId === track.id && + selectedElement.elementId === element.id, + ) + ? selectedElements + : [elementRef]; + const members = buildResizeMembers({ + tracks: editor.scenes.getActiveScene().tracks, + selectedElements: activeSelection, + }); + if (members.length === 0) { + return; + } + + editor.timeline.discardPreview(); + latestResultRef.current = null; + setResizeState({ + side, + startX: event.clientX, + members, + }); + }, + [selectedElements, editor], + ); + + useEffect(() => { + if (!resizeState) { + return; + } + + const handleMouseMove = ({ clientX }: MouseEvent) => { + const deltaX = clientX - resizeState.startX; + const rawDeltaTime = Math.round( + (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * + TICKS_PER_SECOND, + ); + const snappedDeltaTime = getSnappedResizeDelta({ + editor, + resizeState, + rawDeltaTime, + zoomLevel, + shouldSnap: snappingEnabled && !isShiftHeldRef.current, + onSnapPointChange, + }); + const fps = editor.project.getActive().settings.fps; + const result = computeGroupResize({ + members: resizeState.members, + side: resizeState.side, + deltaTime: snappedDeltaTime.deltaTime, + fps, + }); + + latestResultRef.current = result; + editor.timeline.previewElements({ + updates: result.updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + updates: patch, + })), + }); + }; + + const handleMouseUp = () => { + const result = latestResultRef.current; + editor.timeline.discardPreview(); + if ( + result && + hasResizeChanges({ members: resizeState.members, result }) + ) { + editor.timeline.updateElements({ + updates: result.updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + patch, + })), + }); + } + + setResizeState(null); + latestResultRef.current = null; + onSnapPointChange?.(null); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + resizeState, + zoomLevel, + snappingEnabled, + isShiftHeldRef, + editor, + onSnapPointChange, + ]); + + return { + isResizing: resizeState !== null, + handleResizeStart, + }; +} + +function buildResizeMembers({ + tracks, + selectedElements, +}: { + tracks: SceneTracks; + selectedElements: Array<{ trackId: string; elementId: string }>; +}): GroupResizeMember[] { + const selectedElementIds = new Set( + selectedElements.map((selectedElement) => selectedElement.elementId), + ); + const trackMap = new Map( + [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ + track.id, + track, + ]), + ); + + return selectedElements.flatMap(({ trackId, elementId }) => { + const track = trackMap.get(trackId); + const element = track?.elements.find( + (trackElement) => trackElement.id === elementId, + ); + if (!track || !element) { + return []; + } + + const otherElements = track.elements.filter( + (trackElement) => !selectedElementIds.has(trackElement.id), + ); + const leftNeighborBound = otherElements + .filter( + (trackElement) => + trackElement.startTime + trackElement.duration <= element.startTime, + ) + .reduce((bound, trackElement) => { + return Math.max(bound, trackElement.startTime + trackElement.duration); + }, -Infinity); + const rightNeighborBound = otherElements + .filter( + (trackElement) => + trackElement.startTime >= element.startTime + element.duration, + ) + .reduce((bound, trackElement) => { + return Math.min(bound, trackElement.startTime); + }, Infinity); + + return [ + { + trackId, + elementId, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + sourceDuration: element.sourceDuration, + retime: isRetimableElement(element) ? element.retime : undefined, + leftNeighborBound, + rightNeighborBound, + }, + ]; + }); +} + +function getSnappedResizeDelta({ + editor, + resizeState, + rawDeltaTime, + zoomLevel, + shouldSnap, + onSnapPointChange, +}: { + editor: EditorCore; + resizeState: ResizeInteractionState; + rawDeltaTime: number; + zoomLevel: number; + shouldSnap: boolean; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +}): { deltaTime: number } { + if (!shouldSnap) { + onSnapPointChange?.(null); + return { deltaTime: rawDeltaTime }; + } + + const snapPoints = findSnapPoints({ + tracks: editor.scenes.getActiveScene().tracks, + playheadTime: editor.playback.getCurrentTime(), + excludeElementIds: new Set( + resizeState.members.map((member) => member.elementId), + ), + }); + let closestSnapPoint: SnapPoint | null = null; + let closestSnapDistance = Infinity; + let deltaTime = rawDeltaTime; + + for (const member of resizeState.members) { + const baseEdgeTime = + resizeState.side === "left" + ? member.startTime + : member.startTime + member.duration; + const snapResult = snapToNearestPoint({ + targetTime: baseEdgeTime + rawDeltaTime, + snapPoints, + zoomLevel, + }); + if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) { + closestSnapDistance = snapResult.snapDistance; + closestSnapPoint = snapResult.snapPoint; + deltaTime = snapResult.snappedTime - baseEdgeTime; + } + } + + onSnapPointChange?.(closestSnapPoint); + return { deltaTime }; +} + +function hasResizeChanges({ + members, + result, +}: { + members: GroupResizeMember[]; + result: GroupResizeResult; +}): boolean { + return result.updates.some((update) => { + const member = members.find( + (candidateMember) => candidateMember.elementId === update.elementId, + ); + return ( + member?.trimStart !== update.patch.trimStart || + member?.trimEnd !== update.patch.trimEnd || + member?.startTime !== update.patch.startTime || + member?.duration !== update.patch.duration + ); + }); +} |
