summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/timeline/hooks')
-rw-r--r--apps/web/src/timeline/hooks/element/use-element-interaction.ts775
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-drag.ts334
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-drag-drop.ts659
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-playhead.ts327
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-resize.ts353
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-seek.ts195
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-zoom.ts277
7 files changed, 314 insertions, 2606 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
index 621259b9..26a21f85 100644
--- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts
+++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts
@@ -1,42 +1,17 @@
-import {
- useState,
- useCallback,
- useEffect,
- useRef,
- type MouseEvent as ReactMouseEvent,
- type RefObject,
-} from "react";
+import { useEffect, useReducer, useRef, 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 { registerCanceller } from "@/editor/cancel-interaction";
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";
+ ElementInteractionController,
+ type ElementInteractionDeps,
+ type ElementInteractionDepsRef,
+} from "@/timeline/controllers/element-interaction-controller";
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>;
@@ -44,131 +19,8 @@ interface UseElementInteractionProps {
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,
@@ -177,584 +29,65 @@ export function useElementInteraction({
}: UseElementInteractionProps) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
- const sceneTracks = editor.scenes.getActiveScene().tracks;
- const {
- selectedElements,
- isElementSelected,
- selectElement,
- handleElementClick: handleSelectionClick,
- } = useElementSelection();
+ const selection = 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 deps: ElementInteractionDeps = {
+ viewport: {
+ getZoomLevel: () => zoomLevel,
+ getTracksScrollEl: () => tracksScrollRef.current,
+ getTracksContainerEl: () => tracksContainerRef.current,
+ getHeaderEl: () => headerRef?.current ?? null,
},
- [],
- );
-
- 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,
- },
- });
+ input: {
+ isShiftHeld: () => isShiftHeldRef.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,
- };
+ scene: {
+ getTracks: () => editor.scenes.getActiveScene().tracks,
+ getActiveFps: () => editor.project.getActive()?.settings.fps ?? null,
},
- [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);
+ selection: {
+ getSelected: () => selection.selectedElements,
+ isSelected: selection.isElementSelected,
+ select: selection.selectElement,
+ handleClick: selection.handleElementClick,
+ clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(),
+ },
+ playback: {
+ getCurrentTime: () => editor.playback.getCurrentTime(),
+ },
+ timeline: {
+ moveElements: (args) => editor.timeline.moveElements(args),
},
- [zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
- );
+ snap: {
+ isEnabled: () => snappingEnabled,
+ onChange: onSnapPointChange,
+ },
+ };
- const handleElementClick = useCallback(
- ({
- event,
- element,
- track,
- }: {
- event: ReactMouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- }) => {
- event.stopPropagation();
+ const depsRef = useRef<ElementInteractionDeps>(deps);
+ depsRef.current = deps;
- 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;
- }
- }
+ const controllerRef = useRef<ElementInteractionController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ElementInteractionController({
+ depsRef: depsRef as ElementInteractionDepsRef,
+ });
+ }
+ const controller = controllerRef.current;
- // modifier keys already handled in mousedown
- if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- const alreadySelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
- if (!alreadySelected || selectedElements.length > 1) {
- selectElement({ trackId: track.id, elementId: element.id });
- return;
- }
+ useEffect(() => {
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isActive, controller]);
- editor.selection.clearKeyframeSelection();
- },
- [editor.selection, isElementSelected, selectElement, selectedElements],
- );
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- dragState,
- dragDropTarget,
- handleElementMouseDown,
- handleElementClick,
- lastMouseXRef,
+ dragView: controller.view,
+ handleElementMouseDown: controller.onElementMouseDown,
+ handleElementClick: controller.onElementClick,
};
}
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
index 92b6f0d8..5a1ff1f0 100644
--- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
+++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
@@ -1,40 +1,15 @@
-import {
- useState,
- useCallback,
- useEffect,
- useRef,
- type MouseEvent as ReactMouseEvent,
-} from "react";
+import { useEffect, useReducer, useRef } 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,
-};
+import {
+ KeyframeDragController,
+ type KeyframeDragConfig,
+ type KeyframeDragState,
+} from "@/timeline/controllers/keyframe-drag-controller";
+import type { TimelineElement } from "@/timeline";
-interface PendingKeyframeDrag {
- keyframeRefs: SelectedKeyframeRef[];
- startMouseX: number;
-}
+export type { KeyframeDragState };
export function useKeyframeDrag({
zoomLevel,
@@ -54,275 +29,44 @@ export function useKeyframeDrag({
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,
+ const config: KeyframeDragConfig = {
+ zoomLevel,
+ element,
+ displayedStartTime,
+ getFps: () => editor.project.getActive()?.settings.fps ?? null,
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;
+ isKeyframeSelected,
+ setKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ executeCommand: (command) => editor.command.execute({ command }),
+ seek: (args) => editor.playback.seek(args),
+ getTotalDuration: () => editor.timeline.getTotalDuration(),
+ };
- if (wasDrag) return;
+ const configRef = useRef<KeyframeDragConfig>(config);
+ configRef.current = config;
- const duration = editor.timeline.getTotalDuration();
- const seekTime =
- snappedSeekTime({
- time: displayedStartTime + indicatorTime,
- duration,
- rate: fps,
- }) ?? displayedStartTime + indicatorTime;
- editor.playback.seek({ time: seekTime });
+ const controllerRef = useRef<KeyframeDragController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new KeyframeDragController({ configRef });
+ }
+ const controller = controllerRef.current;
- if (event.shiftKey) {
- selectKeyframeRange({
- orderedKeyframes,
- targetKeyframes: keyframes,
- isAdditive: event.metaKey || event.ctrlKey,
- });
- return;
- }
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- toggleKeyframeSelection({
- keyframes,
- isMultiKey: event.metaKey || event.ctrlKey,
- });
- },
- [
- toggleKeyframeSelection,
- selectKeyframeRange,
- editor,
- displayedStartTime,
- fps,
- ],
- );
+ useEffect(() => {
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isActive, controller]);
- 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],
- );
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- keyframeDragState: dragState,
- handleKeyframeMouseDown,
- handleKeyframeClick,
- getVisualOffsetPx,
+ keyframeDragState: controller.keyframeDragState,
+ handleKeyframeMouseDown: controller.onKeyframeMouseDown,
+ handleKeyframeClick: controller.onKeyframeClick,
+ getVisualOffsetPx: controller.getVisualOffsetPx,
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
index 775b7337..4ec2b80b 100644
--- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
@@ -1,30 +1,9 @@
-import { useState, useCallback, type RefObject } from "react";
+import { useEffect, useReducer, useRef, 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";
+ DragDropController,
+ type DragDropConfig,
+} from "@/timeline/controllers/drag-drop-controller";
interface UseTimelineDragDropProps {
containerRef: RefObject<HTMLDivElement | null>;
@@ -40,609 +19,47 @@ export function useTimelineDragDrop({
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 config: DragDropConfig = {
+ zoomLevel,
+ getContainerEl: () => containerRef.current,
+ getHeaderEl: () => headerRef?.current ?? null,
+ getTracksScrollEl: () => tracksScrollRef?.current ?? null,
+ getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
+ getActiveProjectId: () =>
+ editor.project.getActiveOrNull()?.metadata.id ?? null,
+ getSceneTracks: () => editor.scenes.getActiveScene().tracks,
+ getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
+ getMediaAssets: () => editor.media.getAssets(),
+ dragSource: editor.timeline.dragSource,
+ addMediaAsset: (args) => editor.media.addMediaAsset(args),
+ executeCommand: (command) => editor.command.execute({ command }),
+ insertElement: (args) => editor.timeline.insertElement(args),
+ addClipEffect: (args) => editor.timeline.addClipEffect(args),
+ };
- const currentTarget = dropTarget;
- setIsDragOver(false);
- setDropTarget(null);
- setElementType(null);
+ const configRef = useRef<DragDropConfig>(config);
+ configRef.current = config;
- try {
- if (hasAsset) {
- if (!currentTarget) return;
- const dragData = getDragData({ dataTransfer: e.dataTransfer });
- if (!dragData) return;
+ const controllerRef = useRef<DragDropController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new DragDropController({ configRef });
+ }
+ const controller = controllerRef.current;
- 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,
- ],
- );
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- isDragOver,
- dropTarget,
- dragElementType,
+ isDragOver: controller.isDragOver,
+ dropTarget: controller.dropTarget,
+ dragElementType: controller.dragElementType,
dragProps: {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
+ onDragEnter: controller.onDragEnter,
+ onDragOver: controller.onDragOver,
+ onDragLeave: controller.onDragLeave,
+ onDrop: controller.onDrop,
},
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
index 7d9cf447..12bfe76a 100644
--- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
@@ -1,23 +1,12 @@
-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 { useEffect, useRef } from "react";
import { useEditor } from "@/editor/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
+import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
+import { timelineTimeToPixels } from "@/timeline";
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";
+ PlayheadController,
+ type PlayheadConfig,
+} from "@/timeline/controllers/playhead-controller";
interface UseTimelinePlayheadProps {
zoomLevel: number;
@@ -35,267 +24,75 @@ export function useTimelinePlayhead({
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();
+ // isScrubbing drives useEdgeAutoScroll — the controller sets it on the editor,
+ // so this reactive read naturally reflects whether scrubbing is active.
+ const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
- 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 config: PlayheadConfig = {
+ zoomLevel,
+ duration: editor.timeline.getTotalDuration(),
+ getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
+ isShiftHeld: () => isShiftHeldRef.current,
+ getIsPlaying: () => editor.playback.getIsPlaying(),
+ getRulerEl: () => rulerRef.current,
+ getRulerScrollEl: () => rulerScrollRef.current,
+ getTracksScrollEl: () => tracksScrollRef.current,
+ getPlayheadEl: () => playheadRef?.current ?? null,
+ getSceneTracks: () => editor.scenes.getActiveScene().tracks,
+ getSceneBookmarks: () => editor.scenes.getActiveScene()?.bookmarks ?? [],
+ seek: (time) => editor.playback.seek({ time }),
+ setScrubbing: (scrubbing) =>
+ editor.playback.setScrubbing({ isScrubbing: scrubbing }),
+ setTimelineViewState: (viewState) =>
+ editor.project.setTimelineViewState({ viewState }),
+ };
+ const configRef = useRef(config);
+ configRef.current = config;
- 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],
- );
+ const ctrlRef = useRef<PlayheadController | null>(null);
+ if (!ctrlRef.current) {
+ ctrlRef.current = new PlayheadController({ configRef });
+ }
+ const ctrl = ctrlRef.current;
+ // Scroll → keep playhead position in sync with scroll offset.
useEffect(() => {
const scrollEl = rulerScrollRef.current;
if (!scrollEl) return;
+ const handler = () =>
+ ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
+ scrollEl.addEventListener("scroll", handler, { passive: true });
+ return () => scrollEl.removeEventListener("scroll", handler);
+ }, [ctrl, editor.playback, rulerScrollRef]);
- const handleScroll = () => {
- updatePlayheadLeft(editor.playback.getCurrentTime());
- };
-
- scrollEl.addEventListener("scroll", handleScroll, { passive: true });
- return () => scrollEl.removeEventListener("scroll", handleScroll);
- }, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
-
+ // Playback events → update playhead position and auto-scroll during playback.
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 handler = (time: number) => ctrl.handlePlaybackUpdate(time);
+ ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
+ const unsubscribeUpdate = editor.playback.onUpdate(handler);
+ const unsubscribeSeek = editor.playback.onSeek(handler);
+ return () => {
+ unsubscribeUpdate();
+ unsubscribeSeek();
};
+ }, [ctrl, editor.playback]);
- const initialTime = editor.playback.getCurrentTime();
- handlePlaybackUpdate({
- detail: { time: initialTime },
- } as CustomEvent<{ time: number }>);
+ useEdgeAutoScroll({
+ isActive: isScrubbing,
+ getMouseClientX: () => ctrl.getLastMouseClientX(),
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: timelineTimeToPixels({
+ time: editor.timeline.getTotalDuration(),
+ zoomLevel,
+ }),
+ });
- 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]);
+ useEffect(() => () => ctrl.destroy(), [ctrl]);
return {
- handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
- handleRulerMouseDown: handleRulerMouseDownEvent,
+ handlePlayheadMouseDown: ctrl.onPlayheadMouseDown,
+ handleRulerMouseDown: ctrl.onRulerMouseDown,
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts
index fb8da291..46363fb6 100644
--- a/apps/web/src/timeline/hooks/use-timeline-resize.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts
@@ -1,332 +1,81 @@
-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 { useEffect, useReducer, useRef } from "react";
import { useEditor } from "@/editor/use-editor";
-import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
import { useShiftKey } from "@/hooks/use-shift-key";
+import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
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";
+import {
+ ResizeController,
+ type ResizeConfig,
+} from "@/timeline/controllers/resize-controller";
+import type { ResizeSide } from "@/timeline/group-resize";
+import type { SnapPoint } from "@/timeline/snapping";
+
+export type { ResizeSide };
-interface ResizeInteractionState {
- side: ResizeSide;
- startX: number;
- members: GroupResizeMember[];
+interface UseTimelineResizeProps {
+ zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
export function useTimelineResize({
zoomLevel,
onSnapPointChange,
-}: {
- zoomLevel: number;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}) {
+}: UseTimelineResizeProps) {
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;
+ const config: ResizeConfig = {
+ zoomLevel,
+ snappingEnabled,
+ isShiftHeld: () => isShiftHeldRef.current,
+ getSceneTracks: () => editor.scenes.getActiveScene().tracks,
+ getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
+ getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
+ selectedElements,
+ discardPreview: () => editor.timeline.discardPreview(),
+ previewElements: (updates) =>
editor.timeline.previewElements({
- updates: result.updates.map(({ trackId, elementId, patch }) => ({
+ updates: 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,
+ }),
+ commitElements: (updates) =>
+ editor.timeline.updateElements({
+ updates: updates.map(({ trackId, elementId, patch }) => ({
+ trackId,
+ elementId,
+ patch,
+ })),
+ }),
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);
+ const configRef = useRef(config);
+ configRef.current = config;
- 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 controllerRef = useRef<ResizeController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ResizeController({ configRef });
}
+ const controller = controllerRef.current;
- 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;
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- 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;
- }
- }
+ useEffect(() => {
+ if (!controller.isResizing) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isResizing, controller]);
- onSnapPointChange?.(closestSnapPoint);
- return { deltaTime };
-}
+ useEffect(() => () => controller.destroy(), [controller]);
-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
- );
- });
+ return {
+ isResizing: controller.isResizing,
+ handleResizeStart: controller.onResizeStart,
+ };
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts
index 889177c9..56f68ed6 100644
--- a/apps/web/src/timeline/hooks/use-timeline-seek.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts
@@ -1,9 +1,9 @@
-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 { useEffect, useRef, type RefObject } from "react";
import { useEditor } from "@/editor/use-editor";
+import {
+ SeekController,
+ type SeekConfig,
+} from "@/timeline/controllers/seek-controller";
interface UseTimelineSeekProps {
playheadRef: RefObject<HTMLDivElement | null>;
@@ -17,44 +17,6 @@ interface UseTimelineSeekProps {
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,
@@ -67,131 +29,36 @@ export function useTimelineSeek({
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 });
+ const config: SeekConfig = {
+ zoomLevel,
+ duration,
+ isSelecting,
+ getPlayheadEl: () => playheadRef.current,
+ getTrackLabelsEl: () => trackLabelsRef.current,
+ getRulerScrollEl: () => rulerScrollRef.current,
+ getTracksScrollEl: () => tracksScrollRef.current,
+ getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
+ clearSelectedElements,
+ seek,
+ setTimelineViewState: (viewState) =>
+ editor.project.setTimelineViewState({ viewState }),
+ };
- if (shouldProcess) {
- clearSelectedElements();
- handleTimelineSeek({ event, source: "tracks" });
- }
- },
- [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
- );
+ const configRef = useRef(config);
+ configRef.current = config;
- const handleRulerClick = useCallback(
- (event: React.MouseEvent) => {
- const shouldProcess = shouldProcessTimelineClick({ event });
- resetMouseTracking({ mouseTrackingRef });
+ const controllerRef = useRef<SeekController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new SeekController({ configRef });
+ }
+ const controller = controllerRef.current;
- if (shouldProcess) {
- clearSelectedElements();
- handleTimelineSeek({ event, source: "ruler" });
- }
- },
- [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
- );
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- handleTracksMouseDown,
- handleTracksClick,
- handleRulerMouseDown,
- handleRulerClick,
+ handleTracksMouseDown: controller.onTracksMouseDown,
+ handleTracksClick: controller.onTracksClick,
+ handleRulerMouseDown: controller.onRulerMouseDown,
+ handleRulerClick: controller.onRulerClick,
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
index 7929d495..a8825a37 100644
--- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
@@ -1,17 +1,17 @@
import {
type WheelEvent as ReactWheelEvent,
type RefObject,
- useCallback,
useEffect,
useLayoutEffect,
+ useReducer,
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";
+import { TIMELINE_ZOOM_MIN } from "@/timeline/scale";
+import {
+ ZoomController,
+ type ZoomConfig,
+} from "@/timeline/controllers/zoom-controller";
interface UseTimelineZoomProps {
containerRef: RefObject<HTMLDivElement | null>;
@@ -40,252 +40,53 @@ export function useTimelineZoom({
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);
+ const config: ZoomConfig = {
+ minZoom,
+ getContainerEl: () => containerRef.current,
+ getTracksScrollEl: () => tracksScrollRef.current,
+ getRulerScrollEl: () => rulerScrollRef.current,
+ getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
+ seek: (time) => editor.playback.seek({ time }),
+ setTimelineViewState: (viewState) =>
+ editor.project.setTimelineViewState({ viewState }),
+ };
+ const configRef = useRef(config);
+ configRef.current = config;
- if (isHorizontalScrollGesture) {
- return;
- }
+ const controllerRef = useRef<ZoomController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ZoomController({ configRef, initialZoom });
+ }
+ const controller = controllerRef.current;
+ const zoomLevel = controller.zoomLevel;
- // 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],
- );
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
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],
- );
+ controller.reconcileInitialAndMinZoom(minZoom, initialZoom);
+ }, [controller, minZoom, initialZoom]);
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]);
+ controller.applyZoomLayout(zoomLevel);
+ }, [controller, zoomLevel]);
useEffect(() => {
- if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
- hasRestoredPlayheadRef.current = true;
- editor.playback.seek({ time: initialPlayheadTime });
- }
- }, [initialPlayheadTime, editor]);
+ return controller.restoreInitialScrollIfNeeded(initialScrollLeft);
+ }, [controller, initialScrollLeft]);
- // 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();
- }
- };
+ controller.restoreInitialPlayheadIfNeeded(initialPlayheadTime);
+ }, [controller, initialPlayheadTime]);
- document.addEventListener("wheel", preventZoom, {
- passive: false,
- capture: true,
- });
+ useEffect(() => controller.bindPreventBrowserZoom(), [controller]);
- return () => {
- document.removeEventListener("wheel", preventZoom, { capture: true });
- };
- }, [containerRef]);
+ useEffect(() => () => controller.destroy(), [controller]);
return {
zoomLevel,
- setZoomLevel: wrappedSetZoomLevel,
- handleWheel,
- saveScrollPosition,
+ setZoomLevel: controller.setZoomLevel,
+ handleWheel: controller.handleWheel,
+ saveScrollPosition: controller.saveScrollPosition,
};
}