summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
commit6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 (patch)
tree976d60dbe1ac8e44d6a9259f9d6e20fe6530c698 /apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
parent0dffefdd59d8c70fece63436e8556f8ec1e43a49 (diff)
downloadopencut-6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692.tar.gz
refactor: centralize editor interaction state
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events. This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync. Made-with: Cursor
Diffstat (limited to 'apps/web/src/timeline/hooks/element/use-keyframe-drag.ts')
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-drag.ts334
1 files changed, 39 insertions, 295 deletions
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
index 92b6f0d8..5a1ff1f0 100644
--- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
+++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
@@ -1,40 +1,15 @@
-import {
- useState,
- useCallback,
- useEffect,
- useRef,
- type MouseEvent as ReactMouseEvent,
-} from "react";
+import { useEffect, useReducer, useRef } from "react";
import { useEditor } from "@/editor/use-editor";
-import { getKeyframeById } from "@/animation";
import { useKeyframeSelection } from "./use-keyframe-selection";
-import { roundToFrame, snappedSeekTime } from "opencut-wasm";
-import { timelineTimeToSnappedPixels } from "@/timeline";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { TICKS_PER_SECOND } from "@/wasm";
-import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
-import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
-import { BatchCommand } from "@/commands";
-import type { SelectedKeyframeRef } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import type { Command } from "@/commands/base-command";
import { registerCanceller } from "@/editor/cancel-interaction";
-export interface KeyframeDragState {
- isDragging: boolean;
- draggingKeyframeIds: Set<string>;
- deltaTime: number;
-}
-
-const initialDragState: KeyframeDragState = {
- isDragging: false,
- draggingKeyframeIds: new Set(),
- deltaTime: 0,
-};
+import {
+ KeyframeDragController,
+ type KeyframeDragConfig,
+ type KeyframeDragState,
+} from "@/timeline/controllers/keyframe-drag-controller";
+import type { TimelineElement } from "@/timeline";
-interface PendingKeyframeDrag {
- keyframeRefs: SelectedKeyframeRef[];
- startMouseX: number;
-}
+export type { KeyframeDragState };
export function useKeyframeDrag({
zoomLevel,
@@ -54,275 +29,44 @@ export function useKeyframeDrag({
selectKeyframeRange,
} = useKeyframeSelection();
- const [dragState, setDragState] =
- useState<KeyframeDragState>(initialDragState);
- const [isPendingDrag, setIsPendingDrag] = useState(false);
-
- const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
- const mouseDownXRef = useRef<number | null>(null);
-
- const activeProject = editor.project.getActive();
- const fps = activeProject.settings.fps;
-
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
-
- const endDrag = useCallback(() => {
- setDragState(initialDragState);
- }, []);
-
- const cancelDrag = useCallback(() => {
- pendingDragRef.current = null;
- mouseDownXRef.current = null;
- setIsPendingDrag(false);
- endDrag();
- }, [endDrag]);
-
- const commitDrag = useCallback(
- ({
- keyframeRefs,
- deltaTime,
- }: {
- keyframeRefs: SelectedKeyframeRef[];
- deltaTime: number;
- }) => {
- const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
- const keyframe = getKeyframeById({
- animations: element.animations,
- propertyPath: keyframeRef.propertyPath,
- keyframeId: keyframeRef.keyframeId,
- });
- if (!keyframe) return [];
- const nextTime = Math.max(
- 0,
- Math.min(element.duration, keyframe.time + deltaTime),
- );
- return [
- new RetimeKeyframeCommand({
- trackId: keyframeRef.trackId,
- elementId: keyframeRef.elementId,
- propertyPath: keyframeRef.propertyPath,
- keyframeId: keyframeRef.keyframeId,
- nextTime,
- }),
- ];
- });
-
- if (commands.length === 1) {
- editor.command.execute({ command: commands[0] });
- } else if (commands.length > 1) {
- editor.command.execute({ command: new BatchCommand(commands) });
- }
- },
- [editor.command, element],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- return registerCanceller({ fn: cancelDrag });
- }, [dragState.isDragging, isPendingDrag, cancelDrag]);
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- const handleMouseMove = ({ clientX }: MouseEvent) => {
- if (isPendingDrag && pendingDragRef.current) {
- const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
- if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
-
- const pending = pendingDragRef.current;
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- setDragState({
- isDragging: true,
- draggingKeyframeIds: new Set(
- pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
- ),
- deltaTime: 0,
- });
- return;
- }
-
- if (!dragState.isDragging) return;
-
- const startX = mouseDownXRef.current ?? clientX;
- const rawDelta = Math.round(
- ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND,
- );
- const snappedDelta =
- roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
-
- setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- return () => document.removeEventListener("mousemove", handleMouseMove);
- }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]);
-
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseUp = () => {
- const draggingRefs = selectedKeyframes.filter(
- (keyframe) =>
- keyframe.elementId === element.id &&
- dragState.draggingKeyframeIds.has(keyframe.keyframeId),
- );
-
- if (draggingRefs.length > 0 && dragState.deltaTime !== 0) {
- commitDrag({
- keyframeRefs: draggingRefs,
- deltaTime: dragState.deltaTime,
- });
- }
-
- endDrag();
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [
- dragState.isDragging,
- dragState.draggingKeyframeIds,
- dragState.deltaTime,
+ const config: KeyframeDragConfig = {
+ zoomLevel,
+ element,
+ displayedStartTime,
+ getFps: () => editor.project.getActive()?.settings.fps ?? null,
selectedKeyframes,
- element.id,
- commitDrag,
- endDrag,
- ]);
-
- useEffect(() => {
- if (!isPendingDrag) return;
-
- const handleMouseUp = () => {
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [isPendingDrag]);
-
- const handleKeyframeMouseDown = useCallback(
- ({
- event,
- keyframes,
- }: {
- event: ReactMouseEvent;
- keyframes: SelectedKeyframeRef[];
- }) => {
- event.preventDefault();
- event.stopPropagation();
-
- mouseDownXRef.current = event.clientX;
-
- const anySelected = keyframes.some((keyframe) =>
- isKeyframeSelected({ keyframe }),
- );
-
- const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
- if (!anySelected && !isModifierKey) {
- setKeyframeSelection({ keyframes });
- }
-
- const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
-
- pendingDragRef.current = {
- keyframeRefs: keyframeRefsToTrack,
- startMouseX: event.clientX,
- };
- setIsPendingDrag(true);
- },
- [isKeyframeSelected, selectedKeyframes, setKeyframeSelection],
- );
-
- const handleKeyframeClick = useCallback(
- ({
- event,
- keyframes,
- orderedKeyframes,
- indicatorTime,
- }: {
- event: ReactMouseEvent;
- keyframes: SelectedKeyframeRef[];
- orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
- }) => {
- event.stopPropagation();
-
- const wasDrag =
- mouseDownXRef.current !== null &&
- Math.abs(event.clientX - mouseDownXRef.current) >
- TIMELINE_DRAG_THRESHOLD_PX;
- mouseDownXRef.current = null;
+ isKeyframeSelected,
+ setKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ executeCommand: (command) => editor.command.execute({ command }),
+ seek: (args) => editor.playback.seek(args),
+ getTotalDuration: () => editor.timeline.getTotalDuration(),
+ };
- if (wasDrag) return;
+ const configRef = useRef<KeyframeDragConfig>(config);
+ configRef.current = config;
- const duration = editor.timeline.getTotalDuration();
- const seekTime =
- snappedSeekTime({
- time: displayedStartTime + indicatorTime,
- duration,
- rate: fps,
- }) ?? displayedStartTime + indicatorTime;
- editor.playback.seek({ time: seekTime });
+ const controllerRef = useRef<KeyframeDragController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new KeyframeDragController({ configRef });
+ }
+ const controller = controllerRef.current;
- if (event.shiftKey) {
- selectKeyframeRange({
- orderedKeyframes,
- targetKeyframes: keyframes,
- isAdditive: event.metaKey || event.ctrlKey,
- });
- return;
- }
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- toggleKeyframeSelection({
- keyframes,
- isMultiKey: event.metaKey || event.ctrlKey,
- });
- },
- [
- toggleKeyframeSelection,
- selectKeyframeRange,
- editor,
- displayedStartTime,
- fps,
- ],
- );
+ useEffect(() => {
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isActive, controller]);
- const getVisualOffsetPx = useCallback(
- ({
- indicatorTime,
- indicatorOffsetPx,
- isBeingDragged,
- displayedStartTime,
- elementLeft,
- }: {
- indicatorTime: number;
- indicatorOffsetPx: number;
- isBeingDragged: boolean;
- displayedStartTime: number;
- elementLeft: number;
- }): number => {
- if (!isBeingDragged) return indicatorOffsetPx;
- const clampedTime = Math.max(
- 0,
- Math.min(element.duration, indicatorTime + dragState.deltaTime),
- );
- return (
- timelineTimeToSnappedPixels({
- time: displayedStartTime + clampedTime,
- zoomLevel,
- }) - elementLeft
- );
- },
- [dragState.deltaTime, element.duration, zoomLevel],
- );
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- keyframeDragState: dragState,
- handleKeyframeMouseDown,
- handleKeyframeClick,
- getVisualOffsetPx,
+ keyframeDragState: controller.keyframeDragState,
+ handleKeyframeMouseDown: controller.onKeyframeMouseDown,
+ handleKeyframeClick: controller.onKeyframeClick,
+ getVisualOffsetPx: controller.getVisualOffsetPx,
};
}