diff options
Diffstat (limited to 'apps/web/src/timeline/hooks')
16 files changed, 3898 insertions, 0 deletions
diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts new file mode 100644 index 00000000..621259b9 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -0,0 +1,760 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, + type RefObject, +} from "react"; +import { useEditor } from "@/editor/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { + buildMoveGroup, + resolveGroupMove, + snapGroupEdges, + type GroupMoveResult, + type MoveGroup, +} from "@/timeline/group-move"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { roundToFrame } from "opencut-wasm"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; +import { generateUUID } from "@/utils/id"; +import type { SnapPoint } from "@/timeline/snapping"; +import { registerCanceller } from "@/editor/cancel-interaction"; +import type { + DropTarget, + ElementRef, + ElementDragState, + SceneTracks, + TimelineElement, + TimelineTrack, +} from "@/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/timeline/hooks/element/use-element-selection.ts b/apps/web/src/timeline/hooks/element/use-element-selection.ts new file mode 100644 index 00000000..19037e10 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-element-selection.ts @@ -0,0 +1,137 @@ +import { useCallback } from "react";
+import { useEditor } from "@/editor/use-editor";
+import type { ElementRef } from "@/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/timeline/hooks/element/use-keyframe-box-select.ts b/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts new file mode 100644 index 00000000..e471e2d4 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts @@ -0,0 +1,217 @@ +import { useCallback, useRef, useMemo } from "react"; +import { useBoxSelect } from "@/selection/hooks/use-box-select"; +import { + useKeyframeSelection, + getSelectedKeyframeId, +} from "./use-keyframe-selection"; +import type { SelectedKeyframeRef, ElementKeyframe } from "@/animation/types"; +import type { ExpandedRow } from "@/timeline/components/expanded-layout"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { + KEYFRAME_LANE_HEIGHT_PX, + KEYFRAME_DIAMOND_SIZE_PX, +} from "@/timeline/components/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/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts new file mode 100644 index 00000000..92b6f0d8 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -0,0 +1,328 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { useEditor } from "@/editor/use-editor"; +import { getKeyframeById } from "@/animation"; +import { useKeyframeSelection } from "./use-keyframe-selection"; +import { roundToFrame, snappedSeekTime } from "opencut-wasm"; +import { timelineTimeToSnappedPixels } from "@/timeline"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe"; +import { BatchCommand } from "@/commands"; +import type { SelectedKeyframeRef } from "@/animation/types"; +import type { TimelineElement } from "@/timeline"; +import type { Command } from "@/commands/base-command"; +import { registerCanceller } from "@/editor/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/timeline/hooks/element/use-keyframe-selection.ts b/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts new file mode 100644 index 00000000..c5d3ffc9 --- /dev/null +++ b/apps/web/src/timeline/hooks/element/use-keyframe-selection.ts @@ -0,0 +1,229 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { useEditor } from "@/editor/use-editor"; +import type { SelectedKeyframeRef } from "@/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/timeline/hooks/use-edge-auto-scroll.ts b/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts new file mode 100644 index 00000000..509031bb --- /dev/null +++ b/apps/web/src/timeline/hooks/use-edge-auto-scroll.ts @@ -0,0 +1,97 @@ +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/timeline/hooks/use-element-preview.ts b/apps/web/src/timeline/hooks/use-element-preview.ts new file mode 100644 index 00000000..67213094 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-element-preview.ts @@ -0,0 +1,39 @@ +import { useEditor } from "@/editor/use-editor";
+import { findTrackInSceneTracks, type TimelineElement } from "@/timeline";
+
+/**
+ * Subscribes to render tracks and returns the live (preview-aware) version of
+ * an element alongside helpers for previewing and committing updates.
+ *
+ * Use this wherever property fields need to reflect in-progress preview state
+ * (e.g. a slider being dragged) rather than the last committed value.
+ */
+export function useElementPreview<T extends TimelineElement>({
+ trackId,
+ elementId,
+ fallback,
+}: {
+ trackId: string;
+ elementId: string;
+ fallback: T;
+}) {
+ const editor = useEditor();
+ useEditor((e) => e.timeline.getPreviewTracks());
+
+ const previewTracks = editor.timeline.getPreviewTracks();
+ const renderElement =
+ (findTrackInSceneTracks({
+ tracks: previewTracks ?? editor.scenes.getActiveScene().tracks,
+ trackId,
+ })?.elements.find((element) => element.id === elementId) as T | undefined) ??
+ fallback;
+
+ const previewUpdates = (updates: Partial<TimelineElement>) =>
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates }],
+ });
+
+ const commit = () => editor.timeline.commitPreview();
+
+ return { renderElement, previewUpdates, commit };
+}
diff --git a/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts b/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts new file mode 100644 index 00000000..0b1cf92a --- /dev/null +++ b/apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts @@ -0,0 +1,42 @@ +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/timeline/hooks/use-scroll-position.ts b/apps/web/src/timeline/hooks/use-scroll-position.ts new file mode 100644 index 00000000..ad2c6374 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-scroll-position.ts @@ -0,0 +1,52 @@ +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/timeline/hooks/use-scroll-sync.ts b/apps/web/src/timeline/hooks/use-scroll-sync.ts new file mode 100644 index 00000000..e147c106 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-scroll-sync.ts @@ -0,0 +1,173 @@ +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/timeline/hooks/use-snap-indicator-position.ts b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts new file mode 100644 index 00000000..2081b939 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-snap-indicator-position.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from "react";
+import { timelineTimeToSnappedPixels } from "@/timeline";
+import { TIMELINE_TRACK_LABELS_COLUMN_WIDTH_PX } from "@/timeline/components/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/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts new file mode 100644 index 00000000..775b7337 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -0,0 +1,648 @@ +import { useState, useCallback, type RefObject } from "react"; +import { useEditor } from "@/editor/use-editor"; +import { processMediaAssets } from "@/media/processing"; +import { toast } from "sonner"; +import { showMediaUploadToast } from "@/media/upload-toast"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { roundToFrame } from "opencut-wasm"; +import { + buildTextElement, + buildGraphicElement, + buildStickerElement, + buildElementFromMedia, + buildEffectElement, +} from "@/timeline/element-utils"; +import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; +import { BatchCommand } from "@/commands"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import { getDragData, hasDragData } from "@/timeline/drag-data"; +import type { TrackType, DropTarget, ElementType } from "@/timeline"; +import type { + MediaDragData, + GraphicDragData, + StickerDragData, + EffectDragData, +} from "@/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/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts new file mode 100644 index 00000000..7d9cf447 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -0,0 +1,301 @@ +import { snappedSeekTime } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useCallback, useRef } from "react"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useEditor } from "@/editor/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, +} from "@/timeline/snapping"; +import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + getCenteredLineLeft, + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/timeline"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/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 = buildTimelineSnapPoints({ + sources: [ + () => getElementEdgeSnapPoints({ tracks }), + () => getBookmarkSnapPoints({ bookmarks }), + () => getAnimationKeyframeSnapPointsForTimeline({ tracks }), + ], + }); + const snapResult = resolveTimelineSnap({ + targetTime: frameTime, + snapPoints, + maxSnapDistance: getTimelineSnapThresholdInTicks({ 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, + ], + ); + + 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/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts new file mode 100644 index 00000000..fb8da291 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -0,0 +1,332 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { EditorCore } from "@/core"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEditor } from "@/editor/use-editor"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { useTimelineStore } from "@/timeline/timeline-store"; +import { + computeGroupResize, + type GroupResizeMember, + type GroupResizeResult, + type ResizeSide, +} from "@/timeline/group-resize"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, + type SnapPoint, +} from "@/timeline/snapping"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; +import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline"; +import { isRetimableElement } from "@/timeline"; +import { registerCanceller } from "@/editor/cancel-interaction"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; + +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 tracks = editor.scenes.getActiveScene().tracks; + const playheadTime = editor.playback.getCurrentTime(); + const excludeElementIds = new Set( + resizeState.members.map((member) => member.elementId), + ); + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => getElementEdgeSnapPoints({ tracks, excludeElementIds }), + () => getPlayheadSnapPoints({ playheadTime }), + () => + getAnimationKeyframeSnapPointsForTimeline({ + tracks, + excludeElementIds, + }), + ], + }); + const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel }); + 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 = resolveTimelineSnap({ + targetTime: baseEdgeTime + rawDeltaTime, + snapPoints, + maxSnapDistance, + }); + 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/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts new file mode 100644 index 00000000..889177c9 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -0,0 +1,197 @@ +import { useCallback, useRef } from "react"; +import type { MutableRefObject, RefObject } from "react"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { snappedSeekTime } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/wasm"; +import { useEditor } from "@/editor/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/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts new file mode 100644 index 00000000..7929d495 --- /dev/null +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -0,0 +1,291 @@ +import { + type WheelEvent as ReactWheelEvent, + type RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; +import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { useEditor } from "@/editor/use-editor"; +import { zoomToSlider } from "@/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, + }; +} |
