mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: thinner hooks
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user