diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/hooks/timeline | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/hooks/timeline')
16 files changed, 0 insertions, 4112 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 deleted file mode 100644 index 5458efe7..00000000 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ /dev/null @@ -1,760 +0,0 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, - type RefObject, -} from "react"; -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"; -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 type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { registerCanceller } from "@/lib/cancel-interaction"; -import type { - DropTarget, - ElementRef, - ElementDragState, - SceneTracks, - TimelineElement, - TimelineTrack, -} from "@/lib/timeline"; - -interface UseElementInteractionProps { - zoomLevel: number; - timelineRef: RefObject<HTMLDivElement | null>; - tracksContainerRef: RefObject<HTMLDivElement | null>; - tracksScrollRef: RefObject<HTMLDivElement | null>; - headerRef?: RefObject<HTMLElement | null>; - snappingEnabled: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -} - -const MOUSE_BUTTON_RIGHT = 2; - -const initialDragState: ElementDragState = { - isDragging: false, - elementId: null, - dragElementIds: [], - dragTimeOffsets: {}, - trackId: null, - startMouseX: 0, - startMouseY: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - currentMouseY: 0, -}; - -interface PendingDragState { - elementId: string; - trackId: string; - selectedElements: ElementRef[]; - startMouseX: number; - startMouseY: number; - startElementTime: number; - clickOffsetTime: number; -} - -function getClickOffsetTime({ - clientX, - elementRect, - zoomLevel, -}: { - clientX: number; - elementRect: DOMRect; - zoomLevel: number; -}): number { - const clickOffsetX = clientX - elementRect.left; - const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return Math.round(seconds * TICKS_PER_SECOND); -} - -function getVerticalDragDirection({ - startMouseY, - currentMouseY, -}: { - startMouseY: number; - currentMouseY: number; -}): "up" | "down" | null { - if (currentMouseY < startMouseY) return "up"; - if (currentMouseY > startMouseY) return "down"; - return null; -} - -function getDragDropTarget({ - clientX, - clientY, - elementId, - trackId, - tracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, -}: { - clientX: number; - clientY: number; - elementId: string; - trackId: string; - tracks: SceneTracks; - tracksContainerRef: RefObject<HTMLDivElement | null>; - tracksScrollRef: RefObject<HTMLDivElement | null>; - headerRef?: RefObject<HTMLElement | null>; - zoomLevel: number; - snappedTime: number; - verticalDragDirection?: "up" | "down" | null; -}): DropTarget | null { - const containerRect = tracksContainerRef.current?.getBoundingClientRect(); - const scrollContainer = tracksScrollRef.current; - if (!containerRect || !scrollContainer) return null; - - const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( - ({ id }) => id === trackId, - ); - const movingElement = sourceTrack?.elements.find( - ({ id }) => id === elementId, - ); - if (!movingElement) return null; - - const elementDuration = movingElement.duration; - const scrollLeft = scrollContainer.scrollLeft; - const scrollTop = scrollContainer.scrollTop; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseX = clientX - scrollContainerRect.left + scrollLeft; - const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; - - return computeDropTarget({ - elementType: movingElement.type, - mouseX, - mouseY, - tracks, - playheadTime: snappedTime, - isExternalDrop: false, - elementDuration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - startTimeOverride: snappedTime, - excludeElementId: movingElement.id, - verticalDragDirection, - }); -} - -interface StartDragParams - extends Omit< - ElementDragState, - "isDragging" | "currentTime" | "currentMouseY" - > { - initialCurrentTime: number; - initialCurrentMouseY: number; -} - -export function useElementInteraction({ - zoomLevel, - timelineRef, - tracksContainerRef, - tracksScrollRef, - headerRef, - snappingEnabled, - onSnapPointChange, -}: UseElementInteractionProps) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const sceneTracks = editor.scenes.getActiveScene().tracks; - const { - selectedElements, - isElementSelected, - selectElement, - handleElementClick: handleSelectionClick, - } = useElementSelection(); - - const [dragState, setDragState] = - useState<ElementDragState>(initialDragState); - 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 newTrackIdsRef = useRef<string[]>([]); - 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, - startElementTime, - clickOffsetTime, - initialCurrentTime, - initialCurrentMouseY, - }: StartDragParams) => { - setDragState({ - isDragging: true, - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - currentTime: initialCurrentTime, - currentMouseY: initialCurrentMouseY, - }); - }, - [], - ); - - const endDrag = useCallback(() => { - moveGroupRef.current = null; - newTrackIdsRef.current = []; - groupMoveResultRef.current = null; - setDragState(initialDragState); - setDragDropTarget(null); - }, []); - - const cancelCurrentDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownLocationRef.current = null; - setIsPendingDrag(false); - endDrag(); - 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: newTrackIdsRef.current, - }, - }); - } - - 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: newTrackIdsRef.current, - }, - }); - }, - [sceneTracks], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelCurrentDrag }); - }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); - - const getDragSnapResult = useCallback( - ({ - frameSnappedTime, - group, - }: { - frameSnappedTime: number; - group: MoveGroup | null; - }) => { - if (!group || !snappingEnabled || isShiftHeldRef.current) { - return { snappedTime: frameSnappedTime, snapPoint: null }; - } - - const groupSnap = snapGroupEdges({ - group, - anchorStartTime: frameSnappedTime, - tracks: sceneTracks, - playheadTime: editor.playback.getCurrentTime(), - zoomLevel, - }); - - return { - snappedTime: groupSnap.snappedAnchorStartTime, - snapPoint: groupSnap.snapPoint, - }; - }, - [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { - let startedDragThisEvent = false; - const timeline = timelineRef.current; - const scrollContainer = tracksScrollRef.current; - if (!timeline || !scrollContainer) return; - lastMouseXRef.current = clientX; - - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - const activeProject = editor.project.getActive(); - if (!activeProject) return; - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - 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; - newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); - const dragTimeOffsets: Record<string, number> = {}; - for (const member of moveGroup.members) { - dragTimeOffsets[member.elementId] = member.timeOffset; - } - const { - snappedTime: initialSnappedTime, - snapPoint: initialSnapPoint, - } = getDragSnapResult({ - frameSnappedTime: snappedTime, - group: moveGroup, - }); - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: pendingDragRef.current.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: pendingDragRef.current.elementId, - trackId: pendingDragRef.current.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime: initialSnappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - anchorDropTarget != null - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime: initialSnappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - startDrag({ - ...pendingDragRef.current, - dragElementIds: moveGroup.members.map((member) => member.elementId), - dragTimeOffsets, - initialCurrentTime: initialSnappedTime, - initialCurrentMouseY: clientY, - }); - onSnapPointChange?.(initialSnapPoint); - startedDragThisEvent = true; - pendingDragRef.current = null; - setIsPendingDrag(false); - } else { - return; - } - } - - if (startedDragThisEvent) { - return; - } - - if (dragState.elementId && dragState.trackId) { - const alreadySelected = isElementSelected({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - if (!alreadySelected) { - selectElement({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - } - } - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - const fps = activeProject.settings.fps; - const frameSnappedTime = - roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; - - const moveGroup = moveGroupRef.current; - const { snappedTime, snapPoint } = getDragSnapResult({ - frameSnappedTime, - group: moveGroup, - }); - setDragState((previousDragState) => ({ - ...previousDragState, - currentTime: snappedTime, - currentMouseY: clientY, - })); - onSnapPointChange?.(snapPoint); - - if (dragState.elementId && dragState.trackId) { - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: dragState.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: dragState.elementId, - trackId: dragState.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - moveGroup && anchorDropTarget - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.startMouseY, - dragState.trackId, - zoomLevel, - isElementSelected, - selectElement, - editor.project, - timelineRef, - tracksScrollRef, - tracksContainerRef, - headerRef, - isPendingDrag, - startDrag, - getDragSnapResult, - resolveGroupDragMove, - sceneTracks, - onSnapPointChange, - ]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = ({ clientX, clientY }: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); - if ( - deltaX <= TIMELINE_DRAG_THRESHOLD_PX && - deltaY <= TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - endDrag(); - onSnapPointChange?.(null); - return; - } - } - - const moveGroup = moveGroupRef.current; - if (!moveGroup) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const groupMoveResult = groupMoveResultRef.current; - if (!groupMoveResult) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - 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); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.elementId, - dragState.startElementTime, - dragState.trackId, - endDrag, - onSnapPointChange, - editor.timeline, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag, onSnapPointChange]); - - const handleElementMouseDown = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: false, - }); - } - return; - } - - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - - if (isMultiSelect) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: true, - }); - } - - const clickOffsetTime = getClickOffsetTime({ - clientX: event.clientX, - 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, - clickOffsetTime, - }; - setIsPendingDrag(true); - }, - [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], - ); - - const handleElementClick = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - event.stopPropagation(); - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - return; - } - } - - // modifier keys already handled in mousedown - if (event.metaKey || event.ctrlKey || event.shiftKey) return; - - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected || selectedElements.length > 1) { - selectElement({ trackId: track.id, elementId: element.id }); - return; - } - - editor.selection.clearKeyframeSelection(); - }, - [editor.selection, isElementSelected, selectElement, selectedElements], - ); - - return { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, - }; -} diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts deleted file mode 100644 index 42187b7f..00000000 --- a/apps/web/src/hooks/timeline/element/use-element-selection.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { useCallback } from "react";
-import { useEditor } from "@/hooks/use-editor";
-import type { ElementRef } from "@/lib/timeline/types";
-
-export function useElementSelection() {
- const editor = useEditor();
- const selectedElements = useEditor((e) => e.selection.getSelectedElements());
-
- const isElementSelected = useCallback(
- ({ trackId, elementId }: ElementRef) =>
- selectedElements.some(
- (element) =>
- element.trackId === trackId && element.elementId === elementId,
- ),
- [selectedElements],
- );
-
- const selectElement = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- editor.selection.setSelectedElements({
- elements: [{ trackId, elementId }],
- });
- },
- [editor],
- );
-
- const addElementToSelection = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- const alreadySelected = selectedElements.some(
- (element) =>
- element.trackId === trackId && element.elementId === elementId,
- );
- if (alreadySelected) return;
-
- editor.selection.setSelectedElements({
- elements: [...selectedElements, { trackId, elementId }],
- });
- },
- [selectedElements, editor],
- );
-
- const removeElementFromSelection = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- editor.selection.setSelectedElements({
- elements: selectedElements.filter(
- (element) =>
- !(element.trackId === trackId && element.elementId === elementId),
- ),
- });
- },
- [selectedElements, editor],
- );
-
- const toggleElementSelection = useCallback(
- ({ trackId, elementId }: ElementRef) => {
- const alreadySelected = selectedElements.some(
- (element) =>
- element.trackId === trackId && element.elementId === elementId,
- );
-
- if (alreadySelected) {
- removeElementFromSelection({ trackId, elementId });
- } else {
- addElementToSelection({ trackId, elementId });
- }
- },
- [selectedElements, addElementToSelection, removeElementFromSelection],
- );
-
- const clearElementSelection = useCallback(() => {
- editor.selection.clearSelection();
- }, [editor]);
-
- const setElementSelection = useCallback(
- ({ elements }: { elements: ElementRef[] }) => {
- editor.selection.setSelectedElements({ elements });
- },
- [editor],
- );
-
-
- /**
- * Merges elements into the current selection, deduplicating by identity.
- * Used for additive box-select where the pre-drag selection is preserved.
- */
- const mergeElementsIntoSelection = useCallback(
- ({ elements }: { elements: ElementRef[] }) => {
- const merged = [
- ...selectedElements.filter(
- (selectedElement) =>
- !elements.some(
- (element) =>
- element.trackId === selectedElement.trackId &&
- element.elementId === selectedElement.elementId,
- ),
- ),
- ...elements,
- ];
- editor.selection.setSelectedElements({ elements: merged });
- },
- [selectedElements, editor],
- );
-
-
- /**
- * Handles click interaction on an element.
- * - Regular click: select only this element
- * - Multi-key click (Ctrl/Cmd): toggle this element in selection
- */
- const handleElementClick = useCallback(
- ({
- trackId,
- elementId,
- isMultiKey,
- }: ElementRef & { isMultiKey: boolean }) => {
- if (isMultiKey) {
- toggleElementSelection({ trackId, elementId });
- } else {
- selectElement({ trackId, elementId });
- }
- },
- [toggleElementSelection, selectElement],
- );
-
- return {
- selectedElements,
- isElementSelected,
- selectElement,
- setElementSelection,
- mergeElementsIntoSelection,
- addElementToSelection,
- removeElementFromSelection,
- toggleElementSelection,
- clearElementSelection,
- handleElementClick,
- };
-}
diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts b/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts deleted file mode 100644 index 0fc46372..00000000 --- a/apps/web/src/hooks/timeline/element/use-keyframe-box-select.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { useCallback, useRef, useMemo } from "react"; -import { useBoxSelect } from "@/lib/selection/hooks/use-box-select"; -import { - useKeyframeSelection, - getSelectedKeyframeId, -} from "./use-keyframe-selection"; -import type { - SelectedKeyframeRef, - ElementKeyframe, -} from "@/lib/animation/types"; -import type { ExpandedRow } from "@/components/editor/panels/timeline/expanded-layout"; -import { timelineTimeToSnappedPixels } from "@/lib/timeline"; -import { - KEYFRAME_LANE_HEIGHT_PX, - KEYFRAME_DIAMOND_SIZE_PX, -} from "@/components/editor/panels/timeline/layout"; - -export function useKeyframeBoxSelect({ - trackId, - elementId, - rows, - keyframes, - displayedStartTime, - zoomLevel, - elementLeft, -}: { - trackId: string; - elementId: string; - rows: ExpandedRow[]; - keyframes: ElementKeyframe[]; - displayedStartTime: number; - zoomLevel: number; - elementLeft: number; -}) { - const { - selectedKeyframes, - keyframeSelectionAnchor, - setKeyframeSelection, - clearKeyframeSelection, - } = useKeyframeSelection(); - - const containerRef = useRef<HTMLDivElement>(null); - const initialKeyframesRef = useRef<SelectedKeyframeRef[]>([]); - - const keyframeEntries = useMemo(() => { - const entries: Array<{ - id: string; - ref: SelectedKeyframeRef; - rowIndex: number; - offsetPx: number; - }> = []; - - for (const kf of keyframes) { - const rowIndex = rows.findIndex( - (r) => r.propertyPath === kf.propertyPath, - ); - if (rowIndex === -1) continue; - - const ref: SelectedKeyframeRef = { - trackId, - elementId, - propertyPath: kf.propertyPath, - keyframeId: kf.id, - }; - - const kfLeft = timelineTimeToSnappedPixels({ - time: displayedStartTime + kf.time, - zoomLevel, - }); - - entries.push({ - id: getSelectedKeyframeId({ keyframe: ref }), - ref, - rowIndex, - offsetPx: kfLeft - elementLeft, - }); - } - - return entries; - }, [ - keyframes, - rows, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, - ]); - - const idToRefMap = useMemo(() => { - const map = new Map<string, SelectedKeyframeRef>(); - for (const entry of keyframeEntries) { - map.set(entry.id, entry.ref); - } - return map; - }, [keyframeEntries]); - - const selectedIds = useMemo( - () => selectedKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), - [selectedKeyframes], - ); - - const anchorId = useMemo( - () => - keyframeSelectionAnchor - ? getSelectedKeyframeId({ keyframe: keyframeSelectionAnchor }) - : null, - [keyframeSelectionAnchor], - ); - - const resolveIntersections = useCallback( - ({ - startPos, - currentPos, - }: { - startPos: { x: number; y: number }; - currentPos: { x: number; y: number }; - }) => { - const container = containerRef.current; - if (!container) return []; - - const containerRect = container.getBoundingClientRect(); - - const sx = startPos.x - containerRect.left; - const sy = startPos.y - containerRect.top; - const cx = currentPos.x - containerRect.left; - const cy = currentPos.y - containerRect.top; - - const selLeft = Math.min(sx, cx); - const selTop = Math.min(sy, cy); - const selRight = Math.max(sx, cx); - const selBottom = Math.max(sy, cy); - - const halfHit = KEYFRAME_DIAMOND_SIZE_PX / 2; - - return keyframeEntries - .filter((entry) => { - const kfX = entry.offsetPx; - const kfY = - entry.rowIndex * KEYFRAME_LANE_HEIGHT_PX + - KEYFRAME_LANE_HEIGHT_PX / 2; - - return !( - kfX + halfHit < selLeft || - kfX - halfHit > selRight || - kfY + halfHit < selTop || - kfY - halfHit > selBottom - ); - }) - .map((entry) => entry.id); - }, - [keyframeEntries], - ); - - const onSelectionChange = useCallback( - ({ - intersectedIds, - isAdditive, - }: { - intersectedIds: string[]; - initialSelectedIds: string[]; - initialAnchorId: string | null; - isAdditive: boolean; - }) => { - const intersectedRefs = intersectedIds - .map((id) => idToRefMap.get(id)) - .filter((ref): ref is SelectedKeyframeRef => ref != null); - - if (isAdditive) { - setKeyframeSelection({ - keyframes: [ - ...initialKeyframesRef.current, - ...intersectedRefs, - ], - }); - } else { - setKeyframeSelection({ keyframes: intersectedRefs }); - } - }, - [idToRefMap, setKeyframeSelection], - ); - - const { - selectionBox, - handleMouseDown: boxSelectMouseDown, - isSelecting, - shouldIgnoreClick, - } = useBoxSelect<string>({ - containerRef, - resolveIntersections, - selectedIds, - anchorId, - onSelectionChange, - }); - - const handleExpandedAreaMouseDown = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - initialKeyframesRef.current = selectedKeyframes; - boxSelectMouseDown(event); - }, - [boxSelectMouseDown, selectedKeyframes], - ); - - const handleExpandedAreaClick = useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - if (shouldIgnoreClick()) return; - if (event.metaKey || event.ctrlKey || event.shiftKey) return; - clearKeyframeSelection(); - }, - [shouldIgnoreClick, clearKeyframeSelection], - ); - - return { - containerRef, - selectionBox, - isBoxSelecting: isSelecting, - handleExpandedAreaMouseDown, - handleExpandedAreaClick, - }; -} diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts deleted file mode 100644 index 24bff26a..00000000 --- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, -} from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { getKeyframeById } from "@/lib/animation"; -import { useKeyframeSelection } from "./use-keyframe-selection"; -import { roundToFrame, snappedSeekTime } from "opencut-wasm"; -import { timelineTimeToSnappedPixels } from "@/lib/timeline"; -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"; -import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe"; -import { BatchCommand } from "@/lib/commands"; -import type { SelectedKeyframeRef } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; -import type { Command } from "@/lib/commands/base-command"; -import { registerCanceller } from "@/lib/cancel-interaction"; -export interface KeyframeDragState { - isDragging: boolean; - draggingKeyframeIds: Set<string>; - deltaTime: number; -} - -const initialDragState: KeyframeDragState = { - isDragging: false, - draggingKeyframeIds: new Set(), - deltaTime: 0, -}; - -interface PendingKeyframeDrag { - keyframeRefs: SelectedKeyframeRef[]; - startMouseX: number; -} - -export function useKeyframeDrag({ - zoomLevel, - element, - displayedStartTime, -}: { - zoomLevel: number; - element: TimelineElement; - displayedStartTime: number; -}) { - const editor = useEditor(); - const { - selectedKeyframes, - isKeyframeSelected, - setKeyframeSelection, - toggleKeyframeSelection, - selectKeyframeRange, - } = useKeyframeSelection(); - - const [dragState, setDragState] = - useState<KeyframeDragState>(initialDragState); - const [isPendingDrag, setIsPendingDrag] = useState(false); - - const pendingDragRef = useRef<PendingKeyframeDrag | null>(null); - const mouseDownXRef = useRef<number | null>(null); - - const activeProject = editor.project.getActive(); - const fps = activeProject.settings.fps; - - const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - - const endDrag = useCallback(() => { - setDragState(initialDragState); - }, []); - - const cancelDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownXRef.current = null; - setIsPendingDrag(false); - endDrag(); - }, [endDrag]); - - const commitDrag = useCallback( - ({ - keyframeRefs, - deltaTime, - }: { - keyframeRefs: SelectedKeyframeRef[]; - deltaTime: number; - }) => { - const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const keyframe = getKeyframeById({ - animations: element.animations, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - }); - if (!keyframe) return []; - const nextTime = Math.max( - 0, - Math.min(element.duration, keyframe.time + deltaTime), - ); - return [ - new RetimeKeyframeCommand({ - trackId: keyframeRef.trackId, - elementId: keyframeRef.elementId, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - nextTime, - }), - ]; - }); - - if (commands.length === 1) { - editor.command.execute({ command: commands[0] }); - } else if (commands.length > 1) { - editor.command.execute({ command: new BatchCommand(commands) }); - } - }, - [editor.command, element], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelDrag }); - }, [dragState.isDragging, isPendingDrag, cancelDrag]); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX }: MouseEvent) => { - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; - - const pending = pendingDragRef.current; - pendingDragRef.current = null; - setIsPendingDrag(false); - setDragState({ - isDragging: true, - draggingKeyframeIds: new Set( - pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), - ), - deltaTime: 0, - }); - return; - } - - if (!dragState.isDragging) return; - - const startX = mouseDownXRef.current ?? clientX; - const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND); - const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta; - - setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = () => { - const draggingRefs = selectedKeyframes.filter( - (keyframe) => - keyframe.elementId === element.id && - dragState.draggingKeyframeIds.has(keyframe.keyframeId), - ); - - if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { - commitDrag({ - keyframeRefs: draggingRefs, - deltaTime: dragState.deltaTime, - }); - } - - endDrag(); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.draggingKeyframeIds, - dragState.deltaTime, - selectedKeyframes, - element.id, - commitDrag, - endDrag, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag]); - - const handleKeyframeMouseDown = useCallback( - ({ - event, - keyframes, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => { - event.preventDefault(); - event.stopPropagation(); - - mouseDownXRef.current = event.clientX; - - const anySelected = keyframes.some((keyframe) => - isKeyframeSelected({ keyframe }), - ); - - const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; - if (!anySelected && !isModifierKey) { - setKeyframeSelection({ keyframes }); - } - - const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; - - pendingDragRef.current = { - keyframeRefs: keyframeRefsToTrack, - startMouseX: event.clientX, - }; - setIsPendingDrag(true); - }, - [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], -); - - const handleKeyframeClick = useCallback( - ({ - event, - keyframes, - orderedKeyframes, - indicatorTime, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; - }) => { - event.stopPropagation(); - - const wasDrag = - mouseDownXRef.current !== null && - Math.abs(event.clientX - mouseDownXRef.current) > - TIMELINE_DRAG_THRESHOLD_PX; - mouseDownXRef.current = null; - - if (wasDrag) return; - - const duration = editor.timeline.getTotalDuration(); - const seekTime = snappedSeekTime({ time: displayedStartTime + indicatorTime, duration, rate: fps }) ?? displayedStartTime + indicatorTime; - editor.playback.seek({ time: seekTime }); - - if (event.shiftKey) { - selectKeyframeRange({ - orderedKeyframes, - targetKeyframes: keyframes, - isAdditive: event.metaKey || event.ctrlKey, - }); - return; - } - - toggleKeyframeSelection({ - keyframes, - isMultiKey: event.metaKey || event.ctrlKey, - }); - }, - [toggleKeyframeSelection, selectKeyframeRange, editor, displayedStartTime, fps], - ); - - const getVisualOffsetPx = useCallback( - ({ - indicatorTime, - indicatorOffsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }): number => { - if (!isBeingDragged) return indicatorOffsetPx; - const clampedTime = Math.max( - 0, - Math.min(element.duration, indicatorTime + dragState.deltaTime), - ); - return ( - timelineTimeToSnappedPixels({ - time: displayedStartTime + clampedTime, - zoomLevel, - }) - elementLeft - ); - }, - [dragState.deltaTime, element.duration, zoomLevel], - ); - - return { - keyframeDragState: dragState, - handleKeyframeMouseDown, - handleKeyframeClick, - getVisualOffsetPx, - }; -} diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts b/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts deleted file mode 100644 index 2b5a0566..00000000 --- a/apps/web/src/hooks/timeline/element/use-keyframe-selection.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { useCallback, useSyncExternalStore } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import type { SelectedKeyframeRef } from "@/lib/animation/types"; - -export function getSelectedKeyframeId({ - keyframe, -}: { - keyframe: SelectedKeyframeRef; -}): string { - return `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}:${keyframe.keyframeId}`; -} - -function mergeUniqueKeyframes({ - keyframes, -}: { - keyframes: SelectedKeyframeRef[]; -}): SelectedKeyframeRef[] { - const keyframesById = new Map<string, SelectedKeyframeRef>(); - for (const keyframe of keyframes) { - keyframesById.set(getSelectedKeyframeId({ keyframe }), keyframe); - } - return [...keyframesById.values()]; -} - -export function useKeyframeSelection() { - const editor = useEditor(); - const selectedKeyframes = useSyncExternalStore( - (listener) => editor.selection.subscribe(listener), - () => editor.selection.getSelectedKeyframes(), - ); - const keyframeSelectionAnchor = useSyncExternalStore( - (listener) => editor.selection.subscribe(listener), - () => editor.selection.getKeyframeSelectionAnchor(), - ); - - const isKeyframeSelected = useCallback( - ({ keyframe }: { keyframe: SelectedKeyframeRef }) => { - const keyframeId = getSelectedKeyframeId({ keyframe }); - return selectedKeyframes.some( - (selectedKeyframe) => - getSelectedKeyframeId({ keyframe: selectedKeyframe }) === keyframeId, - ); - }, - [selectedKeyframes], - ); - - const setKeyframeSelection = useCallback( - ({ - keyframes, - anchorKeyframe, - }: { - keyframes: SelectedKeyframeRef[]; - anchorKeyframe?: SelectedKeyframeRef; - }) => { - const uniqueKeyframes = mergeUniqueKeyframes({ keyframes }); - editor.selection.setSelectedKeyframes({ - keyframes: uniqueKeyframes, - anchorKeyframe: - anchorKeyframe ?? uniqueKeyframes[uniqueKeyframes.length - 1] ?? null, - }); - }, - [editor], - ); - - const addKeyframesToSelection = useCallback( - ({ - keyframes, - anchorKeyframe, - }: { - keyframes: SelectedKeyframeRef[]; - anchorKeyframe?: SelectedKeyframeRef; - }) => { - const mergedKeyframes = mergeUniqueKeyframes({ - keyframes: [...selectedKeyframes, ...keyframes], - }); - editor.selection.setSelectedKeyframes({ - keyframes: mergedKeyframes, - anchorKeyframe: - anchorKeyframe ?? mergedKeyframes[mergedKeyframes.length - 1] ?? null, - }); - }, - [selectedKeyframes, editor], - ); - - const removeKeyframesFromSelection = useCallback( - ({ - keyframes, - anchorKeyframe, - }: { - keyframes: SelectedKeyframeRef[]; - anchorKeyframe?: SelectedKeyframeRef; - }) => { - const keyframeIdsToRemove = new Set( - keyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), - ); - const nextKeyframes = selectedKeyframes.filter( - (selectedKeyframe) => - !keyframeIdsToRemove.has( - getSelectedKeyframeId({ keyframe: selectedKeyframe }), - ), - ); - editor.selection.setSelectedKeyframes({ - keyframes: nextKeyframes, - anchorKeyframe: - anchorKeyframe ?? nextKeyframes[nextKeyframes.length - 1] ?? null, - }); - }, - [selectedKeyframes, editor], - ); - - const clearKeyframeSelection = useCallback(() => { - editor.selection.clearKeyframeSelection(); - }, [editor]); - - const toggleKeyframeSelection = useCallback( - ({ - keyframes, - isMultiKey, - }: { - keyframes: SelectedKeyframeRef[]; - isMultiKey: boolean; - }) => { - const anchorKeyframe = keyframes[0]; - const areAllKeyframesSelected = keyframes.every((keyframe) => - isKeyframeSelected({ keyframe }), - ); - if (!isMultiKey) { - setKeyframeSelection({ keyframes, anchorKeyframe }); - return; - } - - if (areAllKeyframesSelected) { - removeKeyframesFromSelection({ keyframes, anchorKeyframe }); - return; - } - - addKeyframesToSelection({ keyframes, anchorKeyframe }); - }, - [ - setKeyframeSelection, - isKeyframeSelected, - removeKeyframesFromSelection, - addKeyframesToSelection, - ], - ); - - const selectKeyframeRange = useCallback( - ({ - orderedKeyframes, - targetKeyframes, - isAdditive, - }: { - orderedKeyframes: SelectedKeyframeRef[]; - targetKeyframes: SelectedKeyframeRef[]; - isAdditive: boolean; - }) => { - if (orderedKeyframes.length === 0 || targetKeyframes.length === 0) { - return; - } - - const anchorKeyframe = - keyframeSelectionAnchor ?? - selectedKeyframes[selectedKeyframes.length - 1] ?? - targetKeyframes[0]; - if (!anchorKeyframe) { - return; - } - - const targetKeyframeIds = new Set( - targetKeyframes.map((keyframe) => getSelectedKeyframeId({ keyframe })), - ); - const anchorId = getSelectedKeyframeId({ keyframe: anchorKeyframe }); - const anchorIndex = orderedKeyframes.findIndex( - (keyframe) => getSelectedKeyframeId({ keyframe }) === anchorId, - ); - if (anchorIndex === -1) { - if (isAdditive) { - addKeyframesToSelection({ - keyframes: targetKeyframes, - anchorKeyframe, - }); - return; - } - setKeyframeSelection({ keyframes: targetKeyframes, anchorKeyframe }); - return; - } - - const targetIndexes = orderedKeyframes - .map((keyframe, index) => ({ - keyframeId: getSelectedKeyframeId({ keyframe }), - index, - })) - .filter(({ keyframeId }) => targetKeyframeIds.has(keyframeId)) - .map(({ index }) => index); - if (targetIndexes.length === 0) { - return; - } - - const rangeStart = Math.min(anchorIndex, ...targetIndexes); - const rangeEnd = Math.max(anchorIndex, ...targetIndexes); - const rangeKeyframes = orderedKeyframes.slice(rangeStart, rangeEnd + 1); - - if (isAdditive) { - addKeyframesToSelection({ keyframes: rangeKeyframes, anchorKeyframe }); - return; - } - - setKeyframeSelection({ keyframes: rangeKeyframes, anchorKeyframe }); - }, - [ - keyframeSelectionAnchor, - selectedKeyframes, - addKeyframesToSelection, - setKeyframeSelection, - ], - ); - - return { - selectedKeyframes, - keyframeSelectionAnchor, - isKeyframeSelected, - setKeyframeSelection, - addKeyframesToSelection, - removeKeyframesFromSelection, - clearKeyframeSelection, - toggleKeyframeSelection, - selectKeyframeRange, - }; -} diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts deleted file mode 100644 index 3ff1cb21..00000000 --- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type RefObject, -} from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; -import { roundToFrame } from "opencut-wasm"; -import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; -import { - findSnapPoints, - snapToNearestPoint, - type SnapPoint, -} from "@/lib/timeline/snap-utils"; -import type { Bookmark } from "@/lib/timeline"; - -export interface BookmarkDragState { - isDragging: boolean; - bookmarkTime: number | null; - currentTime: number; -} - -interface PendingBookmarkDrag { - bookmarkTime: number; - startMouseX: number; - startMouseY: number; -} - -interface UseBookmarkDragProps { - zoomLevel: number; - scrollRef: RefObject<HTMLElement | null>; - snappingEnabled: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -} - -export function useBookmarkDrag({ - zoomLevel, - scrollRef, - snappingEnabled, - onSnapPointChange, -}: UseBookmarkDragProps) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const tracks = editor.scenes.getActiveScene().tracks; - const activeScene = editor.scenes.getActiveScene(); - const bookmarks = activeScene?.bookmarks ?? []; - const playheadTime = editor.playback.getCurrentTime(); - const duration = editor.timeline.getTotalDuration(); - - const [dragState, setDragState] = useState<BookmarkDragState>({ - isDragging: false, - bookmarkTime: null, - currentTime: 0, - }); - const [isPendingDrag, setIsPendingDrag] = useState(false); - const pendingDragRef = useRef<PendingBookmarkDrag | null>(null); - const lastMouseXRef = useRef(0); - - const startDrag = useCallback( - ({ - bookmarkTime, - initialCurrentTime, - }: { - bookmarkTime: number; - initialCurrentTime: number; - }) => { - setDragState({ - isDragging: true, - bookmarkTime, - currentTime: initialCurrentTime, - }); - }, - [], - ); - - const endDrag = useCallback(() => { - setDragState({ - isDragging: false, - bookmarkTime: null, - currentTime: 0, - }); - }, []); - - const getSnapResult = useCallback( - ({ - rawTime, - excludeBookmarkTime, - }: { - rawTime: number; - excludeBookmarkTime: number; - }): { snappedTime: number; snapPoint: SnapPoint | null } => { - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - if (!shouldSnap) { - return { snappedTime: rawTime, snapPoint: null }; - } - - const snapPoints = findSnapPoints({ - tracks, - playheadTime, - bookmarks, - excludeBookmarkTime, - }); - const result = snapToNearestPoint({ - targetTime: rawTime, - snapPoints, - zoomLevel, - }); - return { - snappedTime: result.snappedTime, - snapPoint: result.snapPoint, - }; - }, - [snappingEnabled, tracks, playheadTime, bookmarks, zoomLevel, isShiftHeldRef], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = (event: MouseEvent) => { - lastMouseXRef.current = event.clientX; - - const scrollContainer = scrollRef.current; - if (!scrollContainer) return; - - if (isPendingDrag && pendingDragRef.current) { - const { startMouseX, startMouseY, bookmarkTime } = - pendingDragRef.current; - const deltaX = Math.abs(event.clientX - startMouseX); - const deltaY = Math.abs(event.clientY - startMouseY); - - if ( - deltaX <= TIMELINE_DRAG_THRESHOLD_PX && - deltaY <= TIMELINE_DRAG_THRESHOLD_PX - ) { - return; - } - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX: event.clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const frameSnappedTime = roundToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), rate: activeProject.settings.fps }) ?? Math.max(0, Math.min(mouseTime, duration)); - const { snappedTime: initialTime } = getSnapResult({ - rawTime: frameSnappedTime, - excludeBookmarkTime: bookmarkTime, - }); - - startDrag({ - bookmarkTime, - initialCurrentTime: initialTime, - }); - pendingDragRef.current = null; - setIsPendingDrag(false); - return; - } - - if (!dragState.isDragging || dragState.bookmarkTime === null) return; - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX: event.clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const clampedTime = Math.max(0, Math.min(mouseTime, duration)); - const frameSnappedTime = roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? clampedTime; - const snapResult = getSnapResult({ - rawTime: frameSnappedTime, - excludeBookmarkTime: dragState.bookmarkTime, - }); - - setDragState((previousDragState) => ({ - ...previousDragState, - currentTime: snapResult.snappedTime, - })); - onSnapPointChange?.(snapResult.snapPoint); - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [ - dragState.isDragging, - dragState.bookmarkTime, - zoomLevel, - duration, - editor.project, - scrollRef, - isPendingDrag, - startDrag, - getSnapResult, - onSnapPointChange, - ]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = () => { - if (dragState.bookmarkTime === null) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const clampedTime = Math.max( - 0, - Math.min(dragState.currentTime, duration), - ); - - editor.scenes.moveBookmark({ - fromTime: dragState.bookmarkTime, - toTime: clampedTime, - }); - - endDrag(); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.bookmarkTime, - dragState.currentTime, - duration, - endDrag, - onSnapPointChange, - editor.scenes, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag, onSnapPointChange]); - - const handleBookmarkMouseDown = useCallback( - ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => { - if (event.button !== 0) return; - - event.preventDefault(); - event.stopPropagation(); - - pendingDragRef.current = { - bookmarkTime: bookmark.time, - startMouseX: event.clientX, - startMouseY: event.clientY, - }; - setIsPendingDrag(true); - }, - [], - ); - - return { - dragState, - handleBookmarkMouseDown, - lastMouseXRef, - }; -} diff --git a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts b/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts deleted file mode 100644 index 509031bb..00000000 --- a/apps/web/src/hooks/timeline/use-edge-auto-scroll.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useEffect, useRef } from "react";
-
-interface UseEdgeAutoScrollParams {
- isActive: boolean;
- getMouseClientX: () => number;
- rulerScrollRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- contentWidth: number;
- edgeThreshold?: number;
- maxScrollSpeed?: number;
-}
-
-export function useEdgeAutoScroll({
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold = 100,
- maxScrollSpeed = 15,
-}: UseEdgeAutoScrollParams): void {
- const rafRef = useRef<number | null>(null);
-
- useEffect(() => {
- if (!isActive) {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- return;
- }
-
- const step = () => {
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) {
- rafRef.current = requestAnimationFrame(step);
- return;
- }
-
- const viewportRect = rulerViewport.getBoundingClientRect();
- const mouseX = getMouseClientX();
- const mouseXRelative = mouseX - viewportRect.left;
-
- const viewportWidth = rulerViewport.clientWidth;
- const intrinsicContentWidth = rulerViewport.scrollWidth;
- const effectiveContentWidth = Math.max(
- contentWidth,
- intrinsicContentWidth,
- );
- const scrollMax = Math.max(0, effectiveContentWidth - viewportWidth);
-
- let scrollSpeed = 0;
-
- if (mouseXRelative < edgeThreshold && rulerViewport.scrollLeft > 0) {
- const edgeDistance = Math.max(0, mouseXRelative);
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = -maxScrollSpeed * intensity;
- } else if (
- mouseXRelative > viewportWidth - edgeThreshold &&
- rulerViewport.scrollLeft < scrollMax
- ) {
- const edgeDistance = Math.max(0, viewportWidth - mouseXRelative);
- const intensity = 1 - edgeDistance / edgeThreshold;
- scrollSpeed = maxScrollSpeed * intensity;
- }
-
- if (scrollSpeed !== 0) {
- const newScrollLeft = Math.max(
- 0,
- Math.min(scrollMax, rulerViewport.scrollLeft + scrollSpeed),
- );
- rulerViewport.scrollLeft = newScrollLeft;
- tracksViewport.scrollLeft = newScrollLeft;
- }
-
- rafRef.current = requestAnimationFrame(step);
- };
-
- rafRef.current = requestAnimationFrame(step);
-
- return () => {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current);
- rafRef.current = null;
- }
- };
- }, [
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold,
- maxScrollSpeed,
- ]);
-}
diff --git a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts b/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts deleted file mode 100644 index 0b1cf92a..00000000 --- a/apps/web/src/hooks/timeline/use-initial-scroll-bottom.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { type RefObject, useLayoutEffect, useRef } from "react";
-
-interface UseInitialScrollBottomProps {
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- trackLabelsScrollRef: RefObject<HTMLDivElement | null>;
- onAfterScroll?: () => void;
- /** Defers the scroll until there is at least one track to measure against. */
- isReady: boolean;
-}
-
-/**
- * Scrolls the timeline tracks viewport to the bottom once on mount.
- * useLayoutEffect runs synchronously after React commits the DOM but before
- * the browser paints, so the initial scroll position is never visible.
- */
-export function useInitialScrollBottom({
- tracksScrollRef,
- trackLabelsScrollRef,
- onAfterScroll,
- isReady,
-}: UseInitialScrollBottomProps): void {
- const hasScrolledRef = useRef(false);
-
- useLayoutEffect(() => {
- if (!isReady || hasScrolledRef.current) return;
-
- const viewport = tracksScrollRef.current;
- if (!viewport) return;
-
- const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
- if (maxScrollTop <= 0) return;
-
- viewport.scrollTop = maxScrollTop;
-
- if (trackLabelsScrollRef.current) {
- trackLabelsScrollRef.current.scrollTop = maxScrollTop;
- }
-
- onAfterScroll?.();
- hasScrolledRef.current = true;
- }, [isReady, tracksScrollRef, trackLabelsScrollRef, onAfterScroll]);
-}
diff --git a/apps/web/src/hooks/timeline/use-scroll-position.ts b/apps/web/src/hooks/timeline/use-scroll-position.ts deleted file mode 100644 index ad2c6374..00000000 --- a/apps/web/src/hooks/timeline/use-scroll-position.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useState, useRef } from "react"; - -interface UseScrollPositionReturn { - scrollLeft: number; - viewportWidth: number; -} - -export function useScrollPosition({ - scrollRef, -}: { - scrollRef: React.RefObject<HTMLElement | null>; -}): UseScrollPositionReturn { - const [scrollLeft, setScrollLeft] = useState(0); - const [viewportWidth, setViewportWidth] = useState(0); - const rafIdRef = useRef<number | null>(null); - - useEffect(() => { - const scrollElement = scrollRef.current; - if (!scrollElement) return; - - const updatePosition = () => { - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - } - - rafIdRef.current = requestAnimationFrame(() => { - setScrollLeft(scrollElement.scrollLeft); - setViewportWidth(scrollElement.clientWidth); - rafIdRef.current = null; - }); - }; - - const resizeObserver = new ResizeObserver(() => { - updatePosition(); - }); - - updatePosition(); - - scrollElement.addEventListener("scroll", updatePosition, { passive: true }); - resizeObserver.observe(scrollElement); - - return () => { - scrollElement.removeEventListener("scroll", updatePosition); - resizeObserver.disconnect(); - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - } - }; - }, [scrollRef]); - - return { scrollLeft, viewportWidth }; -} diff --git a/apps/web/src/hooks/timeline/use-scroll-sync.ts b/apps/web/src/hooks/timeline/use-scroll-sync.ts deleted file mode 100644 index e147c106..00000000 --- a/apps/web/src/hooks/timeline/use-scroll-sync.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { useEffect, useRef } from "react";
-
-interface UseScrollSyncProps {
- rulerScrollRef?: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- trackLabelsScrollRef?: React.RefObject<HTMLDivElement | null>;
- bookmarksScrollRef?: React.RefObject<HTMLDivElement | null>;
-}
-
-export function useScrollSync({
- tracksScrollRef,
- rulerScrollRef,
- trackLabelsScrollRef,
- bookmarksScrollRef,
-}: UseScrollSyncProps) {
- const isUpdatingRef = useRef(false);
- const lastRulerSync = useRef(0);
- const lastTracksSync = useRef(0);
- const lastVerticalSync = useRef(0);
- const lastBookmarksSync = useRef(0);
-
- useEffect(() => {
- const rulerViewport = rulerScrollRef?.current ?? null;
- const tracksViewport = tracksScrollRef.current;
- const trackLabelsViewport = trackLabelsScrollRef?.current;
- const bookmarksViewport = bookmarksScrollRef?.current;
- let handleBookmarksScroll: (() => void) | null = null;
-
- if (!tracksViewport) return;
-
- const syncScrollLeft = ({
- target,
- scrollLeft,
- }: {
- target: HTMLDivElement | null | undefined;
- scrollLeft: number;
- }) => {
- if (target) {
- target.scrollLeft = scrollLeft;
- }
- };
-
- const handleRulerScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastRulerSync.current < 16) return;
- lastRulerSync.current = now;
- isUpdatingRef.current = true;
- tracksViewport.scrollLeft = rulerViewport?.scrollLeft ?? 0;
- syncScrollLeft({
- target: bookmarksViewport,
- scrollLeft: rulerViewport?.scrollLeft ?? 0,
- });
- isUpdatingRef.current = false;
- };
-
- const handleTracksScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastTracksSync.current < 16) return;
- lastTracksSync.current = now;
- isUpdatingRef.current = true;
- syncScrollLeft({
- target: rulerViewport,
- scrollLeft: tracksViewport.scrollLeft,
- });
- syncScrollLeft({
- target: bookmarksViewport,
- scrollLeft: tracksViewport.scrollLeft,
- });
- isUpdatingRef.current = false;
- };
-
- if (rulerViewport && rulerViewport !== tracksViewport) {
- rulerViewport.addEventListener("scroll", handleRulerScroll);
- }
- if (tracksViewport !== rulerViewport) {
- tracksViewport.addEventListener("scroll", handleTracksScroll);
- }
-
- if (bookmarksViewport) {
- handleBookmarksScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastBookmarksSync.current < 16)
- return;
- lastBookmarksSync.current = now;
- isUpdatingRef.current = true;
- const nextScrollLeft = bookmarksViewport.scrollLeft;
- tracksViewport.scrollLeft = nextScrollLeft;
- syncScrollLeft({
- target: rulerViewport,
- scrollLeft: nextScrollLeft,
- });
- isUpdatingRef.current = false;
- };
-
- if (bookmarksViewport !== tracksViewport) {
- bookmarksViewport.addEventListener("scroll", handleBookmarksScroll);
- }
- }
-
- if (trackLabelsViewport) {
- const handleTrackLabelsScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
- return;
- lastVerticalSync.current = now;
- isUpdatingRef.current = true;
- tracksViewport.scrollTop = trackLabelsViewport.scrollTop;
- isUpdatingRef.current = false;
- };
-
- const handleTracksVerticalScroll = () => {
- const now = Date.now();
- if (isUpdatingRef.current || now - lastVerticalSync.current < 16)
- return;
- lastVerticalSync.current = now;
- isUpdatingRef.current = true;
- trackLabelsViewport.scrollTop = tracksViewport.scrollTop;
- isUpdatingRef.current = false;
- };
-
- trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll);
- tracksViewport.addEventListener("scroll", handleTracksVerticalScroll);
-
- return () => {
- if (rulerViewport && rulerViewport !== tracksViewport) {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- }
- if (tracksViewport !== rulerViewport) {
- tracksViewport.removeEventListener("scroll", handleTracksScroll);
- }
- if (
- bookmarksViewport &&
- bookmarksViewport !== tracksViewport &&
- handleBookmarksScroll
- ) {
- bookmarksViewport.removeEventListener(
- "scroll",
- handleBookmarksScroll,
- );
- }
- trackLabelsViewport.removeEventListener(
- "scroll",
- handleTrackLabelsScroll,
- );
- tracksViewport.removeEventListener(
- "scroll",
- handleTracksVerticalScroll,
- );
- };
- }
-
- return () => {
- if (rulerViewport && rulerViewport !== tracksViewport) {
- rulerViewport.removeEventListener("scroll", handleRulerScroll);
- }
- if (tracksViewport !== rulerViewport) {
- tracksViewport.removeEventListener("scroll", handleTracksScroll);
- }
- if (
- bookmarksViewport &&
- bookmarksViewport !== tracksViewport &&
- handleBookmarksScroll
- ) {
- bookmarksViewport.removeEventListener("scroll", handleBookmarksScroll);
- }
- };
- }, [
- rulerScrollRef,
- tracksScrollRef,
- trackLabelsScrollRef,
- bookmarksScrollRef,
- ]);
-}
diff --git a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts b/apps/web/src/hooks/timeline/use-snap-indicator-position.ts deleted file mode 100644 index b941dcb5..00000000 --- a/apps/web/src/hooks/timeline/use-snap-indicator-position.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect, useState } from "react";
-import { timelineTimeToSnappedPixels } from "@/lib/timeline";
-import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/components/editor/panels/timeline/layout";
-interface UseSnapIndicatorPositionParams {
- snapPoint: { time: number } | null;
- zoomLevel: number;
- timelineRef: React.RefObject<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
-}
-
-interface SnapIndicatorPosition {
- leftPosition: number;
- topPosition: number;
- height: number;
-}
-
-export function useSnapIndicatorPosition({
- snapPoint,
- zoomLevel,
- timelineRef,
- tracksScrollRef,
-}: UseSnapIndicatorPositionParams): SnapIndicatorPosition {
- const [scrollLeft, setScrollLeft] = useState(0);
-
- useEffect(() => {
- const tracksViewport = tracksScrollRef.current;
-
- if (!tracksViewport) return;
-
- const handleScroll = () => {
- setScrollLeft(tracksViewport.scrollLeft);
- };
-
- setScrollLeft(tracksViewport.scrollLeft);
-
- tracksViewport.addEventListener("scroll", handleScroll);
- return () => tracksViewport.removeEventListener("scroll", handleScroll);
- }, [tracksScrollRef]);
-
- const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
- const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
-
- const timelinePosition = timelineTimeToSnappedPixels({
- time: snapPoint?.time ?? 0,
- zoomLevel,
- });
- const leftPosition =
- TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX + timelinePosition - scrollLeft;
-
- return {
- leftPosition,
- topPosition: 0,
- height: totalHeight,
- };
-}
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts deleted file mode 100644 index 25c24b8b..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ /dev/null @@ -1,648 +0,0 @@ -import { useState, useCallback, type RefObject } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { processMediaAssets } from "@/lib/media/processing"; -import { toast } from "sonner"; -import { showMediaUploadToast } from "@/lib/media/upload-toast"; -import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { roundToFrame } from "opencut-wasm"; -import { - buildTextElement, - buildGraphicElement, - buildStickerElement, - buildElementFromMedia, - buildEffectElement, -} from "@/lib/timeline/element-utils"; -import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; -import { BatchCommand } from "@/lib/commands"; -import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target"; -import { getDragData, hasDragData } from "@/lib/drag-data"; -import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; -import type { - MediaDragData, - GraphicDragData, - StickerDragData, - EffectDragData, -} from "@/lib/timeline/drag"; - -interface UseTimelineDragDropProps { - containerRef: RefObject<HTMLDivElement | null>; - headerRef?: RefObject<HTMLElement | null>; - tracksScrollRef?: RefObject<HTMLDivElement | null>; - zoomLevel: number; -} - -export function useTimelineDragDrop({ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, -}: UseTimelineDragDropProps) { - const editor = useEditor(); - const [isDragOver, setIsDragOver] = useState(false); - const [dropTarget, setDropTarget] = useState<DropTarget | null>(null); - const [dragElementType, setElementType] = useState<ElementType | null>(null); - - const getSnappedTime = useCallback( - ({ time }: { time: number }) => { - const projectFps = editor.project.getActive().settings.fps; - return roundToFrame({ time, rate: projectFps }) ?? time; - }, - [editor], - ); - - const getElementType = useCallback( - ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => { - const dragData = getDragData({ dataTransfer }); - if (!dragData) return null; - - if (dragData.type === "text") return "text"; - if (dragData.type === "graphic") return "graphic"; - if (dragData.type === "sticker") return "sticker"; - if (dragData.type === "effect") return "effect"; - if (dragData.type === "media") { - return dragData.mediaType; - } - return null; - }, - [], - ); - - const getElementDuration = useCallback( - ({ - elementType, - mediaId, - }: { - elementType: ElementType; - mediaId?: string; - }): number => { - if ( - elementType === "text" || - elementType === "graphic" || - elementType === "sticker" || - elementType === "effect" - ) { - return DEFAULT_NEW_ELEMENT_DURATION; - } - if (mediaId) { - const mediaAssets = editor.media.getAssets(); - const media = mediaAssets.find((m) => m.id === mediaId); - return media?.duration != null - ? Math.round(media.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - } - return DEFAULT_NEW_ELEMENT_DURATION; - }, - [editor], - ); - - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.types.includes("Files"); - if (!hasAsset && !hasFiles) return; - setIsDragOver(true); - }, []); - - const handleDragOver = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const hasFiles = e.dataTransfer.types.includes("Files"); - const isExternal = - hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); - - const elementType = getElementType({ dataTransfer: e.dataTransfer }); - - if (!elementType && hasFiles && isExternal) { - setDropTarget(null); - setElementType(null); - return; - } - - if (!elementType) return; - - setElementType(elementType); - - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - const duration = getElementDuration({ - elementType, - mediaId: dragData?.type === "media" ? dragData.id : undefined, - }); - - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; - - const targetElementTypes = - dragData?.type === "effect" - ? (dragData as EffectDragData).targetElementTypes - : dragData?.type === "media" - ? (dragData as MediaDragData).targetElementTypes - : undefined; - - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const target = computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: isExternal, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - targetElementTypes, - }); - - target.xPosition = getSnappedTime({ time: target.xPosition }); - - setDropTarget(target); - e.dataTransfer.dropEffect = "copy"; - }, - [ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, - getElementType, - getElementDuration, - getSnappedTime, - editor, - ], - ); - - const handleDragLeave = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - const rect = containerRef.current?.getBoundingClientRect(); - if (rect) { - const { clientX, clientY } = e; - if ( - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - setIsDragOver(false); - setDropTarget(null); - setElementType(null); - } - } - }, - [containerRef], - ); - - const executeTextDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: { name?: string; content?: string }; - }) => { - const element = buildTextElement({ - raw: { - name: dragData.name ?? "", - content: dragData.content ?? "", - }, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("text", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeStickerDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: StickerDragData; - }) => { - const element = buildStickerElement({ - stickerId: dragData.stickerId, - name: dragData.name, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeGraphicDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: GraphicDragData; - }) => { - const element = buildGraphicElement({ - definitionId: dragData.definitionId, - name: dragData.name, - startTime: target.xPosition, - params: dragData.params, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeMediaDrop = useCallback( - ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { - if (target.targetElement) { - toast.info("Replace media source is coming soon!"); - return; - } - - const mediaAssets = editor.media.getAssets(); - const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); - if (!mediaAsset) return; - - const trackType: TrackType = - dragData.mediaType === "audio" ? "audio" : "video"; - - const duration = - mediaAsset.duration != null - ? Math.round(mediaAsset.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - const element = buildElementFromMedia({ - mediaId: mediaAsset.id, - mediaType: mediaAsset.type, - name: mediaAsset.name, - duration, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeEffectDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: EffectDragData; - }) => { - if (target.targetElement) { - editor.timeline.addClipEffect({ - trackId: target.targetElement.trackId, - elementId: target.targetElement.elementId, - effectType: dragData.effectType, - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const effectTrack = tracks.find((t) => t.type === "effect"); - let trackId: string; - - if (effectTrack) { - trackId = effectTrack.id; - } else if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element: buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }), - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } else { - const track = tracks[target.trackIndex]; - if (!track || track.type !== "effect") return; - trackId = track.id; - } - - const element = buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }); - - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); - }, - [editor], - ); - - const executeFileDrop = useCallback( - async ({ - files, - mouseX, - mouseY, - }: { - files: File[]; - mouseX: number; - mouseY: number; - }) => { - const activeProject = editor.project.getActiveOrNull(); - if (!activeProject) return; - - await showMediaUploadToast({ - filesCount: files.length, - promise: async () => { - const processedAssets = await processMediaAssets({ files }); - const projectId = activeProject.metadata.id; - - for (const asset of processedAssets) { - const createdAsset = await editor.media.addMediaAsset({ - projectId, - asset, - }); - if (!createdAsset) continue; - - const duration = - createdAsset.duration != null - ? Math.round(createdAsset.duration * TICKS_PER_SECOND) - : DEFAULT_NEW_ELEMENT_DURATION; - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const reuseMainTrackId = - createdAsset.type !== "audio" && - sceneTracks.overlay.length === 0 && - sceneTracks.audio.length === 0 && - sceneTracks.main.elements.length === 0 - ? sceneTracks.main.id - : null; - const dropTarget = reuseMainTrackId - ? null - : computeDropTarget({ - elementType: createdAsset.type, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - }); - - const trackType: TrackType = - createdAsset.type === "audio" ? "audio" : "video"; - - const startTime = dropTarget?.xPosition ?? currentTime; - const element = buildElementFromMedia({ - mediaId: createdAsset.id, - mediaType: createdAsset.type, - name: createdAsset.name, - duration, - startTime, - }); - - if (reuseMainTrackId) { - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: reuseMainTrackId }, - }), - }); - } else { - if (!dropTarget) continue; - if (dropTarget.isNewTrack) { - const addTrackCmd = new AddTrackCommand( - trackType, - dropTarget.trackIndex, - ); - editor.command.execute({ - command: new BatchCommand([ - addTrackCmd, - new InsertElementCommand({ - element, - placement: { - mode: "explicit", - trackId: addTrackCmd.getTrackId(), - }, - }), - ]), - }); - } else { - const trackId = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ][dropTarget.trackIndex]?.id; - if (!trackId) continue; - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId }, - }), - }); - } - } - } - - return { - uploadedCount: processedAssets.length, - assetNames: processedAssets.map((asset) => asset.name), - }; - }, - }); - }, - [editor, zoomLevel], - ); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.files?.length > 0; - - if (!hasAsset && !hasFiles) return; - - const currentTarget = dropTarget; - setIsDragOver(false); - setDropTarget(null); - setElementType(null); - - try { - if (hasAsset) { - if (!currentTarget) return; - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - if (!dragData) return; - - if (dragData.type === "text") { - executeTextDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "graphic") { - executeGraphicDrop({ - target: currentTarget, - dragData: dragData as GraphicDragData, - }); - } else if (dragData.type === "sticker") { - executeStickerDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "effect") { - executeEffectDrop({ - target: currentTarget, - dragData: dragData as EffectDragData, - }); - } else { - executeMediaDrop({ target: currentTarget, dragData }); - } - } else if (hasFiles) { - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseY = - e.clientY - referenceRect.top + scrollTop - headerHeight; - await executeFileDrop({ - files: Array.from(e.dataTransfer.files), - mouseX, - mouseY, - }); - } - } catch (err) { - console.error("Failed to process drop:", err); - } - }, - [ - dropTarget, - executeTextDrop, - executeGraphicDrop, - executeStickerDrop, - executeMediaDrop, - executeEffectDrop, - executeFileDrop, - containerRef, - headerRef, - tracksScrollRef, - ], - ); - - return { - isDragOver, - dropTarget, - dragElementType, - dragProps: { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, - }, - }; -} diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts deleted file mode 100644 index 1886222f..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { snappedSeekTime } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { useEffect, useCallback, useRef } from "react"; -import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; -import { useEditor } from "../use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils"; -import { - getCenteredLineLeft, - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/lib/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; - -interface UseTimelinePlayheadProps { - zoomLevel: number; - rulerRef: React.RefObject<HTMLDivElement | null>; - rulerScrollRef: React.RefObject<HTMLDivElement | null>; - tracksScrollRef: React.RefObject<HTMLDivElement | null>; - playheadRef?: React.RefObject<HTMLDivElement | null>; -} - -export function useTimelinePlayhead({ - zoomLevel, - rulerRef, - rulerScrollRef, - tracksScrollRef, - playheadRef, -}: UseTimelinePlayheadProps) { - const editor = useEditor(); - const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const activeProject = editor.project.getActive(); - const duration = editor.timeline.getTotalDuration(); - const isShiftHeldRef = useShiftKey(); - - const zoomLevelRef = useRef(zoomLevel); - const durationRef = useRef(duration); - const isScrubbingRef = useRef(isScrubbing); - const isPlayingRef = useRef(false); - - useEffect(() => { - zoomLevelRef.current = zoomLevel; - durationRef.current = duration; - isScrubbingRef.current = isScrubbing; - isPlayingRef.current = editor.playback.getIsPlaying(); - }, [zoomLevel, duration, isScrubbing, editor.playback]); - - const seek = useCallback( - ({ time }: { time: number }) => editor.playback.seek({ time }), - [editor.playback], - ); - - const scrubTimeRef = useRef<number | null>(null); - const isDraggingRulerRef = useRef(false); - const hasDraggedRulerRef = useRef(false); - const lastMouseXRef = useRef<number>(0); - - const handleScrub = useCallback( - ({ - event, - snappingEnabled = true, - }: { - event: MouseEvent | React.MouseEvent; - snappingEnabled?: boolean; - }) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rulerRect = ruler.getBoundingClientRect(); - const relativeMouseX = event.clientX - rulerRect.left; - - const timelineContentWidth = timelineTimeToPixels({ time: duration, zoomLevel }); - - const clampedMouseX = Math.max( - 0, - Math.min(timelineContentWidth, relativeMouseX), - ); - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); - - const rate = activeProject.settings.fps; - const frameTime = snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime; - - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - const time = (() => { - if (!shouldSnap) return frameTime; - const tracks = editor.scenes.getActiveScene().tracks; - const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; - const snapPoints = findSnapPoints({ - tracks, - playheadTime: frameTime, - bookmarks, - enablePlayheadSnapping: false, - }); - const snapResult = snapToNearestPoint({ - targetTime: frameTime, - snapPoints, - zoomLevel, - }); - return snapResult.snapPoint ? snapResult.snappedTime : frameTime; - })(); - - scrubTimeRef.current = time; - seek({ time }); - - lastMouseXRef.current = event.clientX; - }, - [ - duration, - zoomLevel, - seek, - rulerRef, - activeProject.settings.fps, - isShiftHeldRef, - editor.scenes, - editor.timeline, - ], - ); - - const handlePlayheadMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - event.preventDefault(); - event.stopPropagation(); - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event }); - }, - [handleScrub, editor.playback], - ); - - const handleRulerMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - if (event.button !== 0) return; - if (playheadRef?.current?.contains(event.target as Node)) return; - - event.preventDefault(); - isDraggingRulerRef.current = true; - hasDraggedRulerRef.current = false; - - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event, snappingEnabled: false }); - }, - [handleScrub, playheadRef, editor.playback], - ); - - const handlePlayheadMouseDownEvent = useCallback( - (event: React.MouseEvent) => handlePlayheadMouseDown({ event }), - [handlePlayheadMouseDown], - ); - - const handleRulerMouseDownEvent = useCallback( - (event: React.MouseEvent) => handleRulerMouseDown({ event }), - [handleRulerMouseDown], - ); - - useEdgeAutoScroll({ - isActive: isScrubbing, - getMouseClientX: () => lastMouseXRef.current, - rulerScrollRef, - tracksScrollRef, - contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }), - }); - - useEffect(() => { - if (!isScrubbing) return; - - const handleMouseMove = ({ event }: { event: MouseEvent }) => { - handleScrub({ event }); - if (isDraggingRulerRef.current) { - hasDraggedRulerRef.current = true; - } - }; - - const handleMouseUp = ({ event }: { event: MouseEvent }) => { - editor.playback.setScrubbing({ isScrubbing: false }); - const finalTime = scrubTimeRef.current; - if (finalTime !== null) { - seek({ time: finalTime }); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, - playheadTime: finalTime, - }, - }); - } - scrubTimeRef.current = null; - - if (isDraggingRulerRef.current) { - isDraggingRulerRef.current = false; - if (!hasDraggedRulerRef.current) { - handleScrub({ event, snappingEnabled: false }); - } - hasDraggedRulerRef.current = false; - } - }; - - const onMouseMove = (event: MouseEvent) => handleMouseMove({ event }); - const onMouseUp = (event: MouseEvent) => handleMouseUp({ event }); - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); - - const updatePlayheadLeft = useCallback( - (time: number) => { - const playheadEl = playheadRef?.current; - if (!playheadEl) return; - const centerPosition = timelineTimeToSnappedPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); - const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0; - playheadEl.style.left = `${leftPosition - scrollLeft}px`; - }, - [playheadRef, rulerScrollRef], - ); - - useEffect(() => { - const scrollEl = rulerScrollRef.current; - if (!scrollEl) return; - - const handleScroll = () => { - updatePlayheadLeft(editor.playback.getCurrentTime()); - }; - - scrollEl.addEventListener("scroll", handleScroll, { passive: true }); - return () => scrollEl.removeEventListener("scroll", handleScroll); - }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); - - useEffect(() => { - const handlePlaybackUpdate = (e: Event) => { - const time = (e as CustomEvent<{ time: number }>).detail.time; - updatePlayheadLeft(time); - - if (!isPlayingRef.current || isScrubbingRef.current) return; - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - if (!rulerViewport || !tracksViewport) return; - - const playheadPixels = timelineTimeToPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const viewportWidth = rulerViewport.clientWidth; - const scrollMinimum = 0; - const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; - - const needsScroll = - playheadPixels < rulerViewport.scrollLeft || - playheadPixels > rulerViewport.scrollLeft + viewportWidth; - - if (needsScroll) { - const desiredScroll = Math.max( - scrollMinimum, - Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), - ); - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }; - - const initialTime = editor.playback.getCurrentTime(); - handlePlaybackUpdate({ - detail: { time: initialTime }, - } as CustomEvent<{ time: number }>); - - window.addEventListener("playback-update", handlePlaybackUpdate); - window.addEventListener("playback-seek", handlePlaybackUpdate); - return () => { - window.removeEventListener("playback-update", handlePlaybackUpdate); - window.removeEventListener("playback-seek", handlePlaybackUpdate); - }; - }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]); - - return { - handlePlayheadMouseDown: handlePlayheadMouseDownEvent, - handleRulerMouseDown: handleRulerMouseDownEvent, - }; -} diff --git a/apps/web/src/hooks/timeline/use-timeline-resize.ts b/apps/web/src/hooks/timeline/use-timeline-resize.ts deleted file mode 100644 index f246261a..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-resize.ts +++ /dev/null @@ -1,322 +0,0 @@ -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 - ); - }); -} diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts deleted file mode 100644 index 756ec7ee..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-seek.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { useCallback, useRef } from "react"; -import type { MutableRefObject, RefObject } from "react"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { snappedSeekTime } from "opencut-wasm"; -import { TICKS_PER_SECOND } from "@/lib/wasm"; -import { useEditor } from "../use-editor"; - -interface UseTimelineSeekProps { - playheadRef: RefObject<HTMLDivElement | null>; - trackLabelsRef: RefObject<HTMLDivElement | null>; - rulerScrollRef: RefObject<HTMLDivElement | null>; - tracksScrollRef: RefObject<HTMLDivElement | null>; - zoomLevel: number; - duration: number; - isSelecting: boolean; - clearSelectedElements: () => void; - seek: (time: number) => void; -} - -function resetMouseTracking({ - mouseTrackingRef, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; -}) { - mouseTrackingRef.current = { - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }; -} - -function setMouseTracking({ - mouseTrackingRef, - event, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; - event: React.MouseEvent; -}) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: event.clientX, - downY: event.clientY, - downTime: event.timeStamp, - }; -} - -export function useTimelineSeek({ - playheadRef, - trackLabelsRef, - rulerScrollRef, - tracksScrollRef, - zoomLevel, - duration, - isSelecting, - clearSelectedElements, - seek, -}: UseTimelineSeekProps) { - const editor = useEditor(); - const activeProject = editor.project.getActive(); - - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); - - const handleTracksMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const handleRulerMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const shouldProcessTimelineClick = useCallback( - ({ event }: { event: React.MouseEvent }) => { - const target = event.target as HTMLElement; - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - const deltaX = Math.abs(event.clientX - downX); - const deltaY = Math.abs(event.clientY - downY); - const deltaTime = event.timeStamp - downTime; - const isPlayhead = !!playheadRef.current?.contains(target); - const isTrackLabels = !!trackLabelsRef.current?.contains(target); - const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500; - - if (!isMouseDown) return false; - if (shouldBlockForDrag) return false; - if (isSelecting) return false; - if (isPlayhead) return false; - if (isTrackLabels) { - clearSelectedElements(); - return false; - } - - return true; - }, - [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef], - ); - - const handleTimelineSeek = useCallback( - ({ - event, - source, - }: { - event: React.MouseEvent; - source: "ruler" | "tracks"; - }) => { - const scrollContainer = - source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current; - - if (!scrollContainer) return; - - const rect = scrollContainer.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const scrollLeft = scrollContainer.scrollLeft; - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - (mouseX + scrollLeft) / - (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); - - const rate = activeProject?.settings.fps; - const time = rate ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) : rawTime; - seek(time); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollContainer.scrollLeft, - playheadTime: time, - }, - }); - }, - [ - duration, - zoomLevel, - rulerScrollRef, - tracksScrollRef, - seek, - editor, - activeProject?.settings.fps.numerator, - activeProject?.settings.fps.denominator, - ], - ); - - const handleTracksClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); - - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "tracks" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); - - const handleRulerClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); - - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "ruler" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); - - return { - handleTracksMouseDown, - handleTracksClick, - handleRulerMouseDown, - handleRulerClick, - }; -} diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts deleted file mode 100644 index 1f79a5b2..00000000 --- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { - type WheelEvent as ReactWheelEvent, - type RefObject, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; -import { - TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD, -} from "@/components/editor/panels/timeline/interaction"; -import { - TIMELINE_ZOOM_MAX, - TIMELINE_ZOOM_MIN, -} from "@/lib/timeline/scale"; -import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils"; -import { useEditor } from "@/hooks/use-editor"; -import { zoomToSlider } from "@/lib/timeline/zoom-utils"; - -interface UseTimelineZoomProps { - containerRef: RefObject<HTMLDivElement | null>; - minZoom?: number; - initialZoom?: number; - initialScrollLeft?: number; - initialPlayheadTime?: number; - tracksScrollRef: RefObject<HTMLDivElement | null>; - rulerScrollRef: RefObject<HTMLDivElement | null>; -} - -interface UseTimelineZoomReturn { - zoomLevel: number; - setZoomLevel: (zoomLevel: number | ((prev: number) => number)) => void; - handleWheel: (event: ReactWheelEvent) => void; - saveScrollPosition: () => void; -} - -export function useTimelineZoom({ - containerRef, - minZoom = TIMELINE_ZOOM_MIN, - initialZoom, - initialScrollLeft, - initialPlayheadTime, - tracksScrollRef, - rulerScrollRef, -}: UseTimelineZoomProps): UseTimelineZoomReturn { - const editor = useEditor(); - const hasInitializedRef = useRef(false); - const hasRestoredPlayheadRef = useRef(false); - const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>( - null, - ); - - const [zoomLevel, setZoomLevelRaw] = useState(() => { - if (initialZoom !== undefined) { - hasInitializedRef.current = true; - return Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, initialZoom), - ); - } - return minZoom; - }); - const previousZoomRef = useRef(zoomLevel); - const hasRestoredScrollRef = useRef(false); - const preZoomScrollLeftRef = useRef(0); - const prePlayheadAnchorScrollLeftRef = useRef(0); - const isInPlayheadAnchorModeRef = useRef(false); - - const setZoomLevel = useCallback( - (updater: number | ((prev: number) => number)) => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - preZoomScrollLeftRef.current = scrollElement.scrollLeft; - } - setZoomLevelRaw(updater); - }, - [tracksScrollRef], - ); - - const handleWheel = useCallback( - (event: ReactWheelEvent) => { - const isZoomGesture = event.ctrlKey || event.metaKey; - const isHorizontalScrollGesture = - event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); - - if (isHorizontalScrollGesture) { - return; - } - - // pinch-zoom (ctrl/meta + wheel) - if (isZoomGesture) { - const normalizedDelta = - event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; - const cappedDelta = - Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); - const zoomFactor = Math.exp(-cappedDelta / 300); - setZoomLevel((prev) => { - const nextZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor), - ); - return nextZoom; - }); - return; - } - }, - [minZoom, setZoomLevel], - ); - - useEffect(() => { - if (initialZoom !== undefined && !hasInitializedRef.current) { - hasInitializedRef.current = true; - setZoomLevel( - Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)), - ); - return; - } - setZoomLevel((prev) => { - if (prev < minZoom) { - return minZoom; - } - return prev; - }); - }, [minZoom, initialZoom, setZoomLevel]); - - const wrappedSetZoomLevel = useCallback( - (zoomLevelOrUpdater: number | ((prev: number) => number)) => { - setZoomLevel((prev) => { - const nextZoom = - typeof zoomLevelOrUpdater === "function" - ? zoomLevelOrUpdater(prev) - : zoomLevelOrUpdater; - const clampedZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, nextZoom), - ); - return clampedZoom; - }); - }, - [minZoom, setZoomLevel], - ); - - useLayoutEffect(() => { - const previousZoom = previousZoomRef.current; - if (previousZoom === zoomLevel) return; - - const scrollElement = tracksScrollRef.current; - if (!scrollElement) { - previousZoomRef.current = zoomLevel; - return; - } - - const currentScrollLeft = preZoomScrollLeftRef.current; - const playheadTime = editor.playback.getCurrentTime(); - const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); - const previousSliderPercent = zoomToSlider({ - zoomLevel: previousZoom, - minZoom, - }); - const isCrossingThresholdUp = - previousSliderPercent < - TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - const isCrossingThresholdDown = - previousSliderPercent >= - TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - - const syncScroll = (scrollLeft: number) => { - scrollElement.scrollLeft = scrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = scrollLeft; - } - }; - - const clampScrollLeft = (scrollLeft: number) => { - const maxScrollLeft = - scrollElement.scrollWidth - scrollElement.clientWidth; - return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); - }; - - if (isCrossingThresholdUp) { - prePlayheadAnchorScrollLeftRef.current = currentScrollLeft; - isInPlayheadAnchorModeRef.current = true; - } - - if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { - const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom }); - const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel }); - - const viewportOffset = playheadPixelsBefore - currentScrollLeft; - const newScrollLeft = playheadPixelsAfter - viewportOffset; - - syncScroll(clampScrollLeft(newScrollLeft)); - } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) { - syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current)); - isInPlayheadAnchorModeRef.current = false; - } - - previousZoomRef.current = zoomLevel; - - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime, - }, - }); - }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref - const saveScrollPosition = useCallback(() => { - if (scrollSaveTimeoutRef.current) { - clearTimeout(scrollSaveTimeoutRef.current); - } - scrollSaveTimeoutRef.current = setTimeout(() => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime: editor.playback.getCurrentTime(), - }, - }); - } - }, 300); - }, [zoomLevel, editor]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable - useEffect(() => { - if (initialScrollLeft === undefined) return; - if (hasRestoredScrollRef.current) return; - const scrollElement = tracksScrollRef.current; - if (!scrollElement) return; - - const restoreScroll = () => { - scrollElement.scrollLeft = initialScrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = initialScrollLeft; - } - hasRestoredScrollRef.current = true; - }; - - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - } else { - const observer = new ResizeObserver(() => { - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - observer.disconnect(); - } - }); - observer.observe(scrollElement); - return () => observer.disconnect(); - } - }, [initialScrollLeft]); - - useEffect(() => { - if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { - hasRestoredPlayheadRef.current = true; - editor.playback.seek({ time: initialPlayheadTime }); - } - }, [initialPlayheadTime, editor]); - - // prevent browser zoom in the timeline - useEffect(() => { - const preventZoom = (event: WheelEvent) => { - const isZoomKeyPressed = event.ctrlKey || event.metaKey; - const isInContainer = containerRef.current?.contains( - event.target as Node, - ); - // only check isInContainer, not isInTimeline state - the state check - // causes race conditions where the closure captures stale state - if (isZoomKeyPressed && isInContainer) { - event.preventDefault(); - } - }; - - document.addEventListener("wheel", preventZoom, { - passive: false, - capture: true, - }); - - return () => { - document.removeEventListener("wheel", preventZoom, { capture: true }); - }; - }, [containerRef]); - - return { - zoomLevel, - setZoomLevel: wrappedSetZoomLevel, - handleWheel, - saveScrollPosition, - }; -} |
