summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-26 17:31:28 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-26 17:31:28 +0200
commit56ca09692a308b22455420d11376ff8d41244c47 (patch)
tree4eca5ae032451c5db954801008aa9f164b2e2b1d /apps/web/src/timeline/controllers/keyframe-drag-controller.ts
parente6c6193638e4247ff1e6f2df40de2a057112c773 (diff)
downloadopencut-56ca09692a308b22455420d11376ff8d41244c47.tar.gz
refactor: thinner hooks
Diffstat (limited to 'apps/web/src/timeline/controllers/keyframe-drag-controller.ts')
-rw-r--r--apps/web/src/timeline/controllers/keyframe-drag-controller.ts358
1 files changed, 358 insertions, 0 deletions
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..29dfc5dd
--- /dev/null
+++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
@@ -0,0 +1,358 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import type { FrameRate } from "opencut-wasm";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import {
+ addMediaTime,
+ clampMediaTime,
+ type MediaTime,
+ mediaTime,
+ roundFrameTicks,
+ snapSeekMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} 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<string>;
+ 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: MediaTime;
+ 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: MediaTime }) => void;
+ getTotalDuration: () => MediaTime;
+}
+
+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: MediaTime;
+ }): 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 absoluteIndicatorTime = addMediaTime({
+ a: displayedStartTime,
+ b: indicatorTime,
+ });
+ const seekTime =
+ fps != null
+ ? snapSeekMediaTime({
+ time: absoluteIndicatorTime,
+ duration: getTotalDuration(),
+ fps,
+ })
+ : absoluteIndicatorTime;
+ 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: MediaTime;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: MediaTime;
+ elementLeft: number;
+ }): number {
+ if (!isBeingDragged || this.session.kind !== "active")
+ return indicatorOffsetPx;
+ const deltaTime = mediaTime({ ticks: this.session.deltaTicks });
+ const clampedTime = clampMediaTime({
+ time: addMediaTime({ a: indicatorTime, b: deltaTime }),
+ min: ZERO_MEDIA_TIME,
+ max: this.config.element.duration,
+ });
+ return (
+ timelineTimeToSnappedPixels({
+ time: addMediaTime({ a: displayedStartTime, b: 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: clampMediaTime({
+ time: addMediaTime({
+ a: keyframe.time,
+ b: mediaTime({ ticks: deltaTicks }),
+ }),
+ min: ZERO_MEDIA_TIME,
+ max: element.duration,
+ }),
+ }),
+ ];
+ });
+
+ 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 =
+ roundFrameTicks({ ticks: rawDeltaTicks, fps });
+ 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();
+ }
+}