summaryrefslogtreecommitdiff
path: root/apps/web/src/preview/controllers/preview-interaction-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/preview/controllers/preview-interaction-controller.ts
parente6c6193638e4247ff1e6f2df40de2a057112c773 (diff)
downloadopencut-56ca09692a308b22455420d11376ff8d41244c47.tar.gz
refactor: thinner hooks
Diffstat (limited to 'apps/web/src/preview/controllers/preview-interaction-controller.ts')
-rw-r--r--apps/web/src/preview/controllers/preview-interaction-controller.ts583
1 files changed, 583 insertions, 0 deletions
diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts
new file mode 100644
index 00000000..00573662
--- /dev/null
+++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts
@@ -0,0 +1,583 @@
+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 SnapLine,
+} 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 "@/timeline";
+
+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: SnapLine[]) => 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 SnapLine[],
+ };
+
+ 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,
+ },
+ },
+ },
+ })),
+ );
+ }
+}