Files
OpenCut/apps/web/src/preview/controllers/preview-interaction-controller.ts
T
Maze WintherandCursor 6ac2116daa refactor: extract timeline model types
Move project entity shapes into the model layer so sibling domains no longer depend on timeline for core data types.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 03:49:52 +02:00

577 lines
14 KiB
TypeScript

import type {
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
} from "react";
import type { MediaAsset } from "@/media/types";
import {
getVisibleElementsWithBounds,
type ElementWithBounds,
} from "@/preview/element-bounds";
import {
getHitElements,
hitTest,
resolvePreferredHit,
} from "@/preview/hit-test";
import {
SNAP_THRESHOLD_SCREEN_PIXELS,
snapPosition,
type PreviewSnapLine,
} from "@/preview/preview-snap";
import type { TCanvasSize } from "@/project/types";
import type { Transform } from "@/rendering";
import { isVisualElement } from "@/timeline/element-utils";
import type { ElementRef, SceneTracks, TextElement, TimelineElement, TimelineTrack, VisualElement } from "@/model";
const MIN_DRAG_DISTANCE = 0.5;
const PRIMARY_POINTER_BUTTON = 0;
type Point = { readonly x: number; readonly y: number };
interface CapturedPointerState {
readonly pointerId: number;
readonly captureTarget: HTMLElement;
}
interface PendingGesture extends CapturedPointerState {
readonly kind: "pending";
readonly origin: Point;
readonly topmostHit: ElementWithBounds | null;
readonly selectedHit: ElementWithBounds | null;
readonly selectedElements: readonly ElementRef[];
}
interface DragElementSnapshot {
readonly trackId: string;
readonly elementId: string;
readonly initialTransform: Transform;
}
interface DraggingGesture extends CapturedPointerState {
readonly kind: "dragging";
readonly origin: Point;
readonly bounds: {
readonly width: number;
readonly height: number;
readonly rotation: number;
};
readonly elements: readonly DragElementSnapshot[];
}
type GestureSession =
| { readonly kind: "idle" }
| PendingGesture
| DraggingGesture;
const IDLE_GESTURE: GestureSession = { kind: "idle" };
export interface EditingTextState {
readonly trackId: string;
readonly elementId: string;
readonly element: TextElement;
}
export interface PreviewViewportAdapter {
screenToCanvas: ({
clientX,
clientY,
}: {
clientX: number;
clientY: number;
}) => Point | null;
screenPixelsToLogicalThreshold: ({
screenPixels,
}: {
screenPixels: number;
}) => Point;
}
export interface InputAdapter {
isShiftHeld: () => boolean;
}
export interface SceneReader {
getTracks: () => SceneTracks;
getCurrentTime: () => number;
getMediaAssets: () => MediaAsset[];
getCanvasSize: () => TCanvasSize;
}
export interface SelectionApi {
getSelected: () => readonly ElementRef[];
setSelected: (elements: readonly ElementRef[]) => void;
clearSelection: () => void;
}
export interface TimelinePreviewUpdate {
readonly trackId: string;
readonly elementId: string;
readonly updates: Partial<TimelineElement>;
}
export interface TimelineOps {
getElementsWithTracks: ({
elements,
}: {
elements: readonly ElementRef[];
}) => Array<{ track: TimelineTrack; element: TimelineElement }>;
previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
commitPreview: () => void;
discardPreview: () => void;
}
export interface PlaybackApi {
getIsPlaying: () => boolean;
subscribe: (listener: () => void) => () => void;
}
export interface PreviewOptions {
isMaskMode: () => boolean;
onSnapLinesChange?: (lines: PreviewSnapLine[]) => void;
}
export interface PreviewInteractionDeps {
viewport: PreviewViewportAdapter;
input: InputAdapter;
scene: SceneReader;
selection: SelectionApi;
timeline: TimelineOps;
playback: PlaybackApi;
preview: PreviewOptions;
}
export interface PreviewInteractionDepsRef {
readonly current: PreviewInteractionDeps;
}
function isSameElementRef({
left,
right,
}: {
left: ElementRef;
right: ElementRef;
}): boolean {
return left.trackId === right.trackId && left.elementId === right.elementId;
}
function buildDragSelection({
selectedElements,
dragTarget,
}: {
selectedElements: readonly ElementRef[];
dragTarget: ElementWithBounds;
}): ElementRef[] {
const dragTargetRef = {
trackId: dragTarget.trackId,
elementId: dragTarget.elementId,
};
if (
!selectedElements.some((selectedElement) =>
isSameElementRef({ left: selectedElement, right: dragTargetRef }),
)
) {
return [dragTargetRef];
}
return [
dragTargetRef,
...selectedElements.filter(
(selectedElement) =>
!isSameElementRef({ left: selectedElement, right: dragTargetRef }),
),
];
}
function movedPastDragThreshold({
current,
origin,
}: {
current: Point;
origin: Point;
}): boolean {
return (
Math.abs(current.x - origin.x) > MIN_DRAG_DISTANCE ||
Math.abs(current.y - origin.y) > MIN_DRAG_DISTANCE
);
}
function toDragElementSnapshots({
elementsWithTracks,
}: {
elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>;
}): DragElementSnapshot[] {
const isVisualTrackedElement = (value: {
track: TimelineTrack;
element: TimelineElement;
}): value is { track: TimelineTrack; element: VisualElement } =>
isVisualElement(value.element);
return elementsWithTracks
.filter(isVisualTrackedElement)
.map(({ track, element }) => ({
trackId: track.id,
elementId: element.id,
initialTransform: element.transform,
}));
}
export class PreviewInteractionController {
private readonly depsRef: PreviewInteractionDepsRef;
private readonly subscribers = new Set<() => void>();
private gesture: GestureSession = IDLE_GESTURE;
private editingTextState: EditingTextState | null = null;
private wasPlaying: boolean;
private unsubscribePlayback: (() => void) | null = null;
constructor({ depsRef }: { depsRef: PreviewInteractionDepsRef }) {
this.depsRef = depsRef;
this.wasPlaying = this.deps.playback.getIsPlaying();
this.onDoubleClick = this.onDoubleClick.bind(this);
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.commitTextEdit = this.commitTextEdit.bind(this);
this.handlePlaybackChange = this.handlePlaybackChange.bind(this);
this.unsubscribePlayback = this.deps.playback.subscribe(
this.handlePlaybackChange,
);
}
private get deps(): PreviewInteractionDeps {
return this.depsRef.current;
}
get isDragging(): boolean {
return this.gesture.kind === "dragging";
}
get editingText(): EditingTextState | null {
return this.editingTextState;
}
subscribe({ listener }: { listener: () => void }): () => void {
this.subscribers.add(listener);
return () => this.subscribers.delete(listener);
}
destroy(): void {
this.unsubscribePlayback?.();
this.unsubscribePlayback = null;
this.abortActiveGesture();
this.editingTextState = null;
this.subscribers.clear();
}
cancel(): void {
if (this.gesture.kind === "idle") return;
this.abortActiveGesture();
this.notify();
}
private abortActiveGesture(): void {
if (this.gesture.kind === "idle") return;
if (this.gesture.kind === "dragging") {
this.deps.timeline.discardPreview();
}
this.releaseCapturedPointer({ pointerState: this.gesture });
this.gesture = IDLE_GESTURE;
this.clearSnapLines();
}
commitTextEdit(): void {
if (!this.editingTextState) return;
this.editingTextState = null;
this.deps.timeline.commitPreview();
this.notify();
}
onDoubleClick({ clientX, clientY }: ReactMouseEvent): void {
if (this.editingTextState || this.deps.preview.isMaskMode()) return;
const startPos = this.deps.viewport.screenToCanvas({
clientX,
clientY,
});
if (!startPos) return;
const hit = hitTest({
canvasX: startPos.x,
canvasY: startPos.y,
elementsWithBounds: this.getVisibleElementsWithBounds(),
});
if (!hit || hit.element.type !== "text") return;
this.editingTextState = {
trackId: hit.trackId,
elementId: hit.elementId,
element: hit.element,
};
this.notify();
}
onPointerDown({
clientX,
clientY,
currentTarget,
pointerId,
button,
}: ReactPointerEvent): void {
if (this.editingTextState) return;
if (this.deps.preview.isMaskMode()) return;
if (button !== PRIMARY_POINTER_BUTTON) return;
const startPos = this.deps.viewport.screenToCanvas({
clientX,
clientY,
});
if (!startPos) return;
const hits = getHitElements({
canvasX: startPos.x,
canvasY: startPos.y,
elementsWithBounds: this.getVisibleElementsWithBounds(),
});
const selectedElements = this.deps.selection.getSelected();
this.gesture = {
kind: "pending",
origin: startPos,
pointerId,
captureTarget: currentTarget as HTMLElement,
topmostHit: hits[0] ?? null,
selectedHit: resolvePreferredHit({
hits,
preferredElements: [...selectedElements],
}),
selectedElements,
};
currentTarget.setPointerCapture(pointerId);
}
onPointerMove({ clientX, clientY }: ReactPointerEvent): void {
const currentPos = this.deps.viewport.screenToCanvas({
clientX,
clientY,
});
if (!currentPos) return;
if (this.gesture.kind === "pending") {
const pending = this.gesture;
if (
!movedPastDragThreshold({
current: currentPos,
origin: pending.origin,
})
) {
this.clearSnapLines();
return;
}
this.beginDragFromPending({ pending });
}
if (this.gesture.kind !== "dragging") return;
this.updateDragPreview({
drag: this.gesture,
currentPos,
});
}
onPointerUp({ type }: ReactPointerEvent): void {
if (this.gesture.kind === "dragging") {
const drag = this.gesture;
if (type === "pointercancel") {
this.deps.timeline.discardPreview();
} else {
this.deps.timeline.commitPreview();
}
this.gesture = IDLE_GESTURE;
this.clearSnapLines();
this.releaseCapturedPointer({ pointerState: drag });
this.notify();
return;
}
if (this.gesture.kind !== "pending") return;
const pending = this.gesture;
if (type !== "pointercancel") {
const clickTarget = pending.topmostHit;
if (!clickTarget) {
this.deps.selection.clearSelection();
} else {
this.deps.selection.setSelected([
{
trackId: clickTarget.trackId,
elementId: clickTarget.elementId,
},
]);
}
}
this.gesture = IDLE_GESTURE;
this.clearSnapLines();
this.releaseCapturedPointer({ pointerState: pending });
}
private notify(): void {
for (const listener of this.subscribers) listener();
}
private clearSnapLines(): void {
this.deps.preview.onSnapLinesChange?.([]);
}
private releaseCapturedPointer({
pointerState,
}: {
pointerState: CapturedPointerState | null;
}): void {
if (!pointerState) return;
if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) {
return;
}
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
}
private getVisibleElementsWithBounds(): ElementWithBounds[] {
return getVisibleElementsWithBounds({
tracks: this.deps.scene.getTracks(),
currentTime: this.deps.scene.getCurrentTime(),
canvasSize: this.deps.scene.getCanvasSize(),
mediaAssets: this.deps.scene.getMediaAssets(),
});
}
private handlePlaybackChange(): void {
const isPlaying = this.deps.playback.getIsPlaying();
if (isPlaying && !this.wasPlaying && this.editingTextState) {
this.commitTextEdit();
}
this.wasPlaying = isPlaying;
}
private beginDragFromPending({ pending }: { pending: PendingGesture }): void {
const dragTarget = pending.selectedHit ?? pending.topmostHit;
if (!dragTarget) {
this.gesture = IDLE_GESTURE;
this.clearSnapLines();
this.releaseCapturedPointer({ pointerState: pending });
return;
}
const dragSelection = buildDragSelection({
selectedElements: pending.selectedElements,
dragTarget,
});
const draggableElements = toDragElementSnapshots({
elementsWithTracks: this.deps.timeline.getElementsWithTracks({
elements: dragSelection,
}),
});
if (draggableElements.length === 0) {
this.gesture = IDLE_GESTURE;
this.clearSnapLines();
this.releaseCapturedPointer({ pointerState: pending });
return;
}
if (pending.selectedHit === null) {
this.deps.selection.setSelected([
{
trackId: dragTarget.trackId,
elementId: dragTarget.elementId,
},
]);
}
this.gesture = {
kind: "dragging",
origin: pending.origin,
pointerId: pending.pointerId,
captureTarget: pending.captureTarget,
bounds: {
width: dragTarget.bounds.width,
height: dragTarget.bounds.height,
rotation: dragTarget.bounds.rotation,
},
elements: draggableElements,
};
this.notify();
}
private updateDragPreview({
drag,
currentPos,
}: {
drag: DraggingGesture;
currentPos: Point;
}): void {
const firstElement = drag.elements[0];
if (!firstElement) return;
const deltaX = currentPos.x - drag.origin.x;
const deltaY = currentPos.y - drag.origin.y;
const proposedPosition = {
x: firstElement.initialTransform.position.x + deltaX,
y: firstElement.initialTransform.position.y + deltaY,
};
const shouldSnap = !this.deps.input.isShiftHeld();
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
});
const { snappedPosition, activeLines } = shouldSnap
? snapPosition({
proposedPosition,
canvasSize: this.deps.scene.getCanvasSize(),
elementSize: drag.bounds,
rotation: drag.bounds.rotation,
snapThreshold,
})
: {
snappedPosition: proposedPosition,
activeLines: [] as PreviewSnapLine[],
};
this.deps.preview.onSnapLinesChange?.(activeLines);
const deltaSnappedX =
snappedPosition.x - firstElement.initialTransform.position.x;
const deltaSnappedY =
snappedPosition.y - firstElement.initialTransform.position.y;
this.deps.timeline.previewElements(
drag.elements.map(({ trackId, elementId, initialTransform }) => ({
trackId,
elementId,
updates: {
transform: {
...initialTransform,
position: {
x: initialTransform.position.x + deltaSnappedX,
y: initialTransform.position.y + deltaSnappedY,
},
},
},
})),
);
}
}