summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/hooks
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/timeline/hooks
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/timeline/hooks')
-rw-r--r--apps/web/src/timeline/hooks/element/use-element-interaction.ts760
-rw-r--r--apps/web/src/timeline/hooks/element/use-element-selection.ts137
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-box-select.ts217
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-drag.ts328
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-selection.ts229
-rw-r--r--apps/web/src/timeline/hooks/use-edge-auto-scroll.ts97
-rw-r--r--apps/web/src/timeline/hooks/use-element-preview.ts39
-rw-r--r--apps/web/src/timeline/hooks/use-initial-scroll-bottom.ts42
-rw-r--r--apps/web/src/timeline/hooks/use-scroll-position.ts52
-rw-r--r--apps/web/src/timeline/hooks/use-scroll-sync.ts173
-rw-r--r--apps/web/src/timeline/hooks/use-snap-indicator-position.ts55
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-drag-drop.ts648
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-playhead.ts301
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-resize.ts332
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-seek.ts197
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-zoom.ts291
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,
+ };
+}