OpenCut/apps/web/src/timeline/controllers/keyframe-drag-controller.ts

358 lines
9.0 KiB
TypeScript

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();
}
}