From 6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sat, 25 Apr 2026 03:56:22 +0200 Subject: 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 --- .../controllers/keyframe-drag-controller.ts | 342 +++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 apps/web/src/timeline/controllers/keyframe-drag-controller.ts (limited to 'apps/web/src/timeline/controllers/keyframe-drag-controller.ts') diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts new file mode 100644 index 00000000..51c60dee --- /dev/null +++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts @@ -0,0 +1,342 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { roundToFrame, snappedSeekTime, type FrameRate } from "opencut-wasm"; +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 { timelineTimeToSnappedPixels } from "@/timeline"; +import { getKeyframeById } from "@/animation"; +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"; + +// --- Session --- + +interface PendingSession { + kind: "pending"; + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; +} + +interface ActiveSession { + kind: "active"; + keyframeRefs: SelectedKeyframeRef[]; + startMouseX: number; + deltaTicks: number; +} + +type Session = { kind: "idle" } | PendingSession | ActiveSession; + +// --- Public state --- + +export interface KeyframeDragState { + isDragging: boolean; + draggingKeyframeIds: Set; + deltaTicks: number; +} + +const IDLE_DRAG_STATE: KeyframeDragState = { + isDragging: false, + draggingKeyframeIds: new Set(), + deltaTicks: 0, +}; + +// --- Config --- + +export interface KeyframeDragConfig { + zoomLevel: number; + getFps: () => FrameRate | null; + element: TimelineElement; + displayedStartTime: number; + selectedKeyframes: SelectedKeyframeRef[]; + isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean; + setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void; + toggleKeyframeSelection: (args: { + keyframes: SelectedKeyframeRef[]; + isMultiKey: boolean; + }) => void; + selectKeyframeRange: (args: { + orderedKeyframes: SelectedKeyframeRef[]; + targetKeyframes: SelectedKeyframeRef[]; + isAdditive: boolean; + }) => void; + executeCommand: (command: Command) => void; + seek: (args: { time: number }) => void; + getTotalDuration: () => number; +} + +export interface KeyframeDragConfigRef { + readonly current: KeyframeDragConfig; +} + +// --- Controller --- + +export class KeyframeDragController { + private session: Session = { kind: "idle" }; + // Persists through mouseup so the click handler can detect drag vs click + private mouseDownX: number | null = null; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: KeyframeDragConfigRef; + + constructor(deps: { configRef: KeyframeDragConfigRef }) { + this.configRef = deps.configRef; + this.onKeyframeMouseDown = this.onKeyframeMouseDown.bind(this); + this.onKeyframeClick = this.onKeyframeClick.bind(this); + this.getVisualOffsetPx = this.getVisualOffsetPx.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + private get config(): KeyframeDragConfig { + return this.configRef.current; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + get keyframeDragState(): KeyframeDragState { + if (this.session.kind !== "active") return IDLE_DRAG_STATE; + return { + isDragging: true, + draggingKeyframeIds: new Set( + this.session.keyframeRefs.map((kf) => kf.keyframeId), + ), + deltaTicks: this.session.deltaTicks, + }; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel(): void { + this.mouseDownX = null; + this.finishSession(); + } + + destroy(): void { + this.deactivate(); + this.subscribers.clear(); + } + + onKeyframeMouseDown({ + event, + keyframes, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + }): void { + event.preventDefault(); + event.stopPropagation(); + + this.mouseDownX = event.clientX; + + const anySelected = keyframes.some((kf) => + this.config.isKeyframeSelected({ keyframe: kf }), + ); + const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; + + if (!anySelected && !isModifierKey) { + this.config.setKeyframeSelection({ keyframes }); + } + + this.session = { + kind: "pending", + keyframeRefs: anySelected ? this.config.selectedKeyframes : keyframes, + startMouseX: event.clientX, + }; + this.activate(); + this.notify(); + } + + onKeyframeClick({ + event, + keyframes, + orderedKeyframes, + indicatorTime, + }: { + event: ReactMouseEvent; + keyframes: SelectedKeyframeRef[]; + orderedKeyframes: SelectedKeyframeRef[]; + indicatorTime: number; + }): void { + event.stopPropagation(); + + const wasDrag = + this.mouseDownX !== null && + Math.abs(event.clientX - this.mouseDownX) > TIMELINE_DRAG_THRESHOLD_PX; + this.mouseDownX = null; + + if (wasDrag) return; + + const { displayedStartTime, getFps, getTotalDuration, seek } = this.config; + const fps = getFps(); + const seekTime = + fps != null + ? (snappedSeekTime({ + time: displayedStartTime + indicatorTime, + duration: getTotalDuration(), + rate: fps, + }) ?? displayedStartTime + indicatorTime) + : displayedStartTime + indicatorTime; + seek({ time: seekTime }); + + if (event.shiftKey) { + this.config.selectKeyframeRange({ + orderedKeyframes, + targetKeyframes: keyframes, + isAdditive: event.metaKey || event.ctrlKey, + }); + return; + } + + this.config.toggleKeyframeSelection({ + keyframes, + isMultiKey: event.metaKey || event.ctrlKey, + }); + } + + getVisualOffsetPx({ + indicatorTime, + indicatorOffsetPx, + isBeingDragged, + displayedStartTime, + elementLeft, + }: { + indicatorTime: number; + indicatorOffsetPx: number; + isBeingDragged: boolean; + displayedStartTime: number; + elementLeft: number; + }): number { + if (!isBeingDragged || this.session.kind !== "active") + return indicatorOffsetPx; + const clampedTime = Math.max( + 0, + Math.min( + this.config.element.duration, + indicatorTime + this.session.deltaTicks, + ), + ); + return ( + timelineTimeToSnappedPixels({ + time: displayedStartTime + clampedTime, + zoomLevel: this.config.zoomLevel, + }) - elementLeft + ); + } + + private activate(): void { + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("mouseup", this.handleMouseUp); + } + + private deactivate(): void { + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private finishSession(): void { + this.session = { kind: "idle" }; + this.deactivate(); + this.notify(); + } + + private commitDrag({ + keyframeRefs, + deltaTicks, + }: { + keyframeRefs: SelectedKeyframeRef[]; + deltaTicks: number; + }): void { + const { element } = this.config; + const commands: Command[] = keyframeRefs.flatMap((ref) => { + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath: ref.propertyPath, + keyframeId: ref.keyframeId, + }); + if (!keyframe) return []; + return [ + new RetimeKeyframeCommand({ + trackId: ref.trackId, + elementId: ref.elementId, + propertyPath: ref.propertyPath, + keyframeId: ref.keyframeId, + nextTime: Math.max( + 0, + Math.min(element.duration, keyframe.time + deltaTicks), + ), + }), + ]; + }); + + const [first, ...rest] = commands; + if (!first) return; + if (rest.length === 0) { + this.config.executeCommand(first); + } else { + this.config.executeCommand(new BatchCommand([first, ...rest])); + } + } + + private handleMouseMove({ clientX }: MouseEvent): void { + if (this.session.kind === "pending") { + const deltaX = Math.abs(clientX - this.session.startMouseX); + if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; + + this.session = { + kind: "active", + keyframeRefs: this.session.keyframeRefs, + startMouseX: this.session.startMouseX, + deltaTicks: 0, + }; + this.notify(); + return; + } + + if (this.session.kind !== "active") return; + + const fps = this.config.getFps(); + if (!fps) return; + + const pixelsPerSecond = + BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel; + const rawDeltaTicks = Math.round( + ((clientX - this.session.startMouseX) / pixelsPerSecond) * + TICKS_PER_SECOND, + ); + this.session.deltaTicks = + roundToFrame({ time: rawDeltaTicks, rate: fps }) ?? rawDeltaTicks; + this.notify(); + } + + private handleMouseUp(): void { + if (this.session.kind === "pending") { + this.finishSession(); + return; + } + + if (this.session.kind !== "active") return; + + const { selectedKeyframes, element } = this.config; + const { keyframeRefs, deltaTicks } = this.session; + const draggingIds = new Set(keyframeRefs.map((r) => r.keyframeId)); + const draggingRefs = selectedKeyframes.filter( + (kf) => kf.elementId === element.id && draggingIds.has(kf.keyframeId), + ); + + if (draggingRefs.length > 0 && deltaTicks !== 0) { + this.commitDrag({ keyframeRefs: draggingRefs, deltaTicks }); + } + + this.finishSession(); + } +} -- cgit v1.3.1