358 lines
9.0 KiB
TypeScript
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();
|
|
}
|
|
}
|