diff --git a/apps/web/src/animation/timeline-snap-points.ts b/apps/web/src/animation/timeline-snap-points.ts index f7bf549a..e64de11f 100644 --- a/apps/web/src/animation/timeline-snap-points.ts +++ b/apps/web/src/animation/timeline-snap-points.ts @@ -1,6 +1,7 @@ import { getElementKeyframes } from "@/animation"; import type { SceneTracks } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import { addMediaTime } from "@/wasm"; export function getAnimationKeyframeSnapPointsForTimeline({ tracks, @@ -22,7 +23,7 @@ export function getAnimationKeyframeSnapPointsForTimeline({ animations: element.animations, })) { snapPoints.push({ - time: element.startTime + keyframe.time, + time: addMediaTime({ a: element.startTime, b: keyframe.time }), type: "keyframe", elementId: element.id, trackId: track.id, diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx index a7085154..94e3f3e1 100644 --- a/apps/web/src/app/brand/page.tsx +++ b/apps/web/src/app/brand/page.tsx @@ -1,268 +1,268 @@ -"use client"; - -import type { CSSProperties } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { Check, Copy, Download } from "lucide-react"; -import { useState } from "react"; -import { BasePage } from "@/app/base-page"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/utils/ui"; - -function downloadAsset(src: string) { - const filename = src.split("/").pop() ?? "asset.svg"; - const a = document.createElement("a"); - a.href = src; - a.download = filename; - a.click(); -} - -async function copyAsset(src: string) { - const res = await fetch(src); - const text = await res.text(); - await navigator.clipboard.writeText(text); -} - -const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets); - -type AssetTheme = "dark" | "light" | "icon"; - -interface AssetVariant { - src: string; - theme: AssetTheme; - label: string; - width: number; - height: number; -} - -interface AssetSection { - title: string; - description: string; - cols: "1" | "2"; - assets: AssetVariant[]; -} - -const ASSET_SECTIONS: AssetSection[] = [ - { - title: "Symbol", - description: - "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", - cols: "2", - assets: [ - { - src: "/logos/opencut/symbol.svg", - theme: "dark", - label: "Symbol", - width: 400, - height: 400, - }, - { - src: "/logos/opencut/symbol-light.svg", - theme: "light", - label: "Symbol", - width: 400, - height: 400, - }, - ], - }, - { - title: "Lockup", - description: - "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", - cols: "2", - assets: [ - { - src: "/logos/opencut/logo.svg", - theme: "dark", - label: "Logo", - width: 1809, - height: 400, - }, - { - src: "/logos/opencut/logo-light.svg", - theme: "light", - label: "Logo", - width: 1809, - height: 400, - }, - { - src: "/logos/opencut/text.svg", - theme: "dark", - label: "Text", - width: 1760, - height: 400, - }, - { - src: "/logos/opencut/text-light.svg", - theme: "light", - label: "Text", - width: 1760, - height: 400, - }, - ], - }, -]; - -export default function BrandPage() { - return ( - - Download OpenCut brand assets for use in your projects.{" "} - - document - .getElementById("guidelines") - ?.scrollIntoView({ behavior: "smooth" }) - } - > - Read the brand guidelines. - - - } - action={ - - } - > -
- {ASSET_SECTIONS.map((section) => ( -
-
-

{section.title}

-

- {section.description} -

-
-
- {section.assets.map((variant) => ( - - ))} -
-
- ))} -
- - - -
-
-

Usage

-

- OpenCut is open source — the code is free to use under its license. - That license does not cover the name or logo. You can say you use - OpenCut, that your project integrates with OpenCut, or that it was - built on top of OpenCut. You cannot name your product OpenCut, imply - we made or endorse your product, or use the marks commercially - without asking first. For anything unclear, reach out at{" "} - - brand@opencut.app - - . -

-
- -
-

What's not allowed

-
    - {[ - "Using OpenCut in the name of your product, service, or domain.", - "Implying that OpenCut made, sponsors, or endorses your work.", - "Using the logo or name on merchandise or commercial marketing.", - "Modifying the marks.", - ].map((item) => ( -
  • - - - {item} -
  • - ))} -
-
-
-
- ); -} - -const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = { - light: { - backgroundImage: - "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)", - backgroundSize: "18px 18px", - backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", - backgroundColor: "#000", - }, - dark: { - backgroundImage: - "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)", - backgroundSize: "18px 18px", - backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", - backgroundColor: "#f5f5f5", - }, -}; - -function AssetCard({ variant }: { variant: AssetVariant }) { - const [copied, setCopied] = useState(false); - - async function handleCopy() { - await copyAsset(variant.src); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - - return ( - -
- {variant.label} -
- - -
- ); -} +"use client"; + +import type { CSSProperties } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Check, Copy, Download } from "lucide-react"; +import { useState } from "react"; +import { BasePage } from "@/app/base-page"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/utils/ui"; + +function downloadAsset(src: string) { + const filename = src.split("/").pop() ?? "asset.svg"; + const a = document.createElement("a"); + a.href = src; + a.download = filename; + a.click(); +} + +async function copyAsset(src: string) { + const res = await fetch(src); + const text = await res.text(); + await navigator.clipboard.writeText(text); +} + +const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets); + +type AssetTheme = "dark" | "light" | "icon"; + +interface AssetVariant { + src: string; + theme: AssetTheme; + label: string; + width: number; + height: number; +} + +interface AssetSection { + title: string; + description: string; + cols: "1" | "2"; + assets: AssetVariant[]; +} + +const ASSET_SECTIONS: AssetSection[] = [ + { + title: "Symbol", + description: + "Use the symbol on its own when the OpenCut name is already present nearby or space is limited.", + cols: "2", + assets: [ + { + src: "/logos/opencut/symbol.svg", + theme: "dark", + label: "Symbol", + width: 400, + height: 400, + }, + { + src: "/logos/opencut/symbol-light.svg", + theme: "light", + label: "Symbol", + width: 400, + height: 400, + }, + ], + }, + { + title: "Lockup", + description: + "The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.", + cols: "2", + assets: [ + { + src: "/logos/opencut/logo.svg", + theme: "dark", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/logo-light.svg", + theme: "light", + label: "Logo", + width: 1809, + height: 400, + }, + { + src: "/logos/opencut/text.svg", + theme: "dark", + label: "Text", + width: 1760, + height: 400, + }, + { + src: "/logos/opencut/text-light.svg", + theme: "light", + label: "Text", + width: 1760, + height: 400, + }, + ], + }, +]; + +export default function BrandPage() { + return ( + + Download OpenCut brand assets for use in your projects.{" "} + + document + .getElementById("guidelines") + ?.scrollIntoView({ behavior: "smooth" }) + } + > + Read the brand guidelines. + + + } + action={ + + } + > +
+ {ASSET_SECTIONS.map((section) => ( +
+
+

{section.title}

+

+ {section.description} +

+
+
+ {section.assets.map((variant) => ( + + ))} +
+
+ ))} +
+ + + +
+
+

Usage

+

+ OpenCut is open source — the code is free to use under its license. + That license does not cover the name or logo. You can say you use + OpenCut, that your project integrates with OpenCut, or that it was + built on top of OpenCut. You cannot name your product OpenCut, imply + we made or endorse your product, or use the marks commercially + without asking first. For anything unclear, reach out at{" "} + + brand@opencut.app + + . +

+
+ +
+

What's not allowed

+
    + {[ + "Using OpenCut in the name of your product, service, or domain.", + "Implying that OpenCut made, sponsors, or endorses your work.", + "Using the logo or name on merchandise or commercial marketing.", + "Modifying the marks.", + ].map((item) => ( +
  • + - + {item} +
  • + ))} +
+
+
+
+ ); +} + +const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = { + light: { + backgroundImage: + "linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)", + backgroundSize: "18px 18px", + backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", + backgroundColor: "#000", + }, + dark: { + backgroundImage: + "linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)", + backgroundSize: "18px 18px", + backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px", + backgroundColor: "#f5f5f5", + }, +}; + +function AssetCard({ variant }: { variant: AssetVariant }) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + await copyAsset(variant.src); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + +
+ {variant.label} +
+ + +
+ ); +} diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 8450cec2..aaae79a9 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -1,9 +1,17 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm"; +import { + formatTimecode, + type FrameRate, + type TimeCodeFormat, +} from "opencut-wasm"; import { cn } from "@/utils/ui"; -import type { MediaTime } from "@/wasm"; +import { + parseMediaTimecode, + snapSeekMediaTime, + type MediaTime, +} from "@/wasm"; interface EditableTimecodeProps { time: MediaTime; @@ -47,19 +55,20 @@ export function EditableTimecode({ }; const applyEdit = () => { - const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps }); + const parsedTime = parseMediaTimecode({ + timeCode: inputValue, + format, + fps, + }); if (parsedTime == null) { setHasError(true); return; } - const clampedTime = ( - duration - ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? - parsedTime) - : parsedTime - ) as MediaTime; + const clampedTime = duration + ? snapSeekMediaTime({ time: parsedTime, duration, fps }) + : parsedTime; onTimeChange?.({ time: clampedTime }); setIsEditing(false); diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index fdbf5e76..8cfb99c3 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -11,7 +11,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useEditor } from "@/editor/use-editor"; -import { clearDragData, setDragData } from "@/timeline/drag-data"; import type { TimelineDragData } from "@/timeline/drag"; import { cn } from "@/utils/ui"; import type { MediaTime } from "@/wasm"; @@ -74,21 +73,23 @@ export function DraggableItem({ }; }, [isDragging]); - const handleDragStart = (e: React.DragEvent) => { - e.dataTransfer.setDragImage(emptyImg, 0, 0); + const handleDragStart = (event: React.DragEvent) => { + event.dataTransfer.setDragImage(emptyImg, 0, 0); - setDragData({ dataTransfer: e.dataTransfer, dragData }); - e.dataTransfer.effectAllowed = "copy"; + editor.timeline.dragSource.begin({ + dataTransfer: event.dataTransfer, + dragData, + }); - setDragPosition({ x: e.clientX, y: e.clientY }); + setDragPosition({ x: event.clientX, y: event.clientY }); setIsDragging(true); - onDragStart?.({ e }); + onDragStart?.({ e: event }); }; const handleDragEnd = () => { setIsDragging(false); - clearDragData(); + editor.timeline.dragSource.end(); }; return ( diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts index 545565be..d0716aad 100644 --- a/apps/web/src/core/managers/audio-manager.ts +++ b/apps/web/src/core/managers/audio-manager.ts @@ -1,9 +1,6 @@ import type { EditorCore } from "@/core"; import { TICKS_PER_SECOND } from "@/wasm"; -import { - clampRetimeRate, - shouldMaintainPitch, -} from "@/retime/rate"; +import { clampRetimeRate, shouldMaintainPitch } from "@/retime/rate"; import type { AudioClipSource } from "@/media/audio"; import { createAudioContext, collectAudioClips } from "@/media/audio"; import { @@ -56,10 +53,8 @@ export class AudioManager { this.editor.playback.subscribe(this.handlePlaybackChange), this.editor.timeline.subscribe(this.handleTimelineChange), this.editor.media.subscribe(this.handleTimelineChange), + this.editor.playback.onSeek(this.handleSeek), ); - if (typeof window !== "undefined") { - window.addEventListener("playback-seek", this.handleSeek); - } } dispose(): void { @@ -68,9 +63,6 @@ export class AudioManager { unsub(); } this.unsubscribers = []; - if (typeof window !== "undefined") { - window.removeEventListener("playback-seek", this.handleSeek); - } this.disposeSinks(); this.preparedClipBuffers.clear(); this.decodedBuffers.clear(); @@ -102,17 +94,14 @@ export class AudioManager { } }; - private handleSeek = (event: Event): void => { - const detail = (event as CustomEvent<{ time: number }>).detail; - if (!detail) return; - + private handleSeek = (time: number): void => { if (this.editor.playback.getIsScrubbing()) { this.stopPlayback(); return; } if (this.editor.playback.getIsPlaying()) { - void this.startPlayback({ time: detail.time / TICKS_PER_SECOND }); + void this.startPlayback({ time: time / TICKS_PER_SECOND }); return; } @@ -126,7 +115,9 @@ export class AudioManager { if (!this.editor.playback.getIsPlaying()) return; - void this.startPlayback({ time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND }); + void this.startPlayback({ + time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND, + }); }; private ensureAudioContext(): AudioContext | null { diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index 6748fd42..8275344f 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -4,9 +4,9 @@ import { clampMediaTime, type MediaTime, mediaTimeFromSeconds, + roundFrameTime, ZERO_MEDIA_TIME, } from "@/wasm"; -import { roundToFrame } from "opencut-wasm"; export class PlaybackManager { private isPlaying = false; @@ -16,6 +16,8 @@ export class PlaybackManager { private previousVolume = 1; private isScrubbing = false; private listeners = new Set<() => void>(); + private updateListeners = new Set<(time: MediaTime) => void>(); + private seekListeners = new Set<(time: MediaTime) => void>(); private playbackTimer: number | null = null; private playbackStartWallTime = 0; private playbackStartTime: MediaTime = ZERO_MEDIA_TIME; @@ -139,6 +141,16 @@ export class PlaybackManager { return () => this.listeners.delete(listener); } + onUpdate(listener: (time: MediaTime) => void): () => void { + this.updateListeners.add(listener); + return () => this.updateListeners.delete(listener); + } + + onSeek(listener: (time: MediaTime) => void): () => void { + this.seekListeners.add(listener); + return () => this.seekListeners.delete(listener); + } + private reconcileTimelineScope(): void { const maxTime = this.editor.timeline.getTotalDuration(); const nextTime = this.clampTimeToTimeline(this.currentTime); @@ -158,6 +170,7 @@ export class PlaybackManager { this.notify(); if (timeChanged) { + this.notifySeek(this.currentTime); this.dispatchSeekEvent(this.currentTime); } } @@ -168,6 +181,18 @@ export class PlaybackManager { }); } + private notifyUpdate(time: MediaTime): void { + this.updateListeners.forEach((fn) => { + fn(time); + }); + } + + private notifySeek(time: MediaTime): void { + this.seekListeners.forEach((fn) => { + fn(time); + }); + } + private startTimer(): void { if (this.playbackTimer) { cancelAnimationFrame(this.playbackTimer); @@ -195,20 +220,20 @@ export class PlaybackManager { a: this.playbackStartTime, b: mediaTimeFromSeconds({ seconds: elapsedSeconds }), }); - const newTime = (fps - ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) - : rawTime) as MediaTime; + const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime; const maxTime = this.editor.timeline.getTotalDuration(); if (newTime >= maxTime) { this.pause(); this.currentTime = maxTime; this.notify(); + this.notifySeek(maxTime); this.dispatchSeekEvent(maxTime); return; } this.currentTime = newTime; + this.notifyUpdate(newTime); this.dispatchUpdateEvent(newTime); this.playbackTimer = requestAnimationFrame(this.updateTime); }; diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index a1ead6ca..3c6b7599 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -9,8 +9,9 @@ import type { RetimeConfig, } from "@/timeline"; import { calculateTotalDuration } from "@/timeline"; +import { TimelineDragSource } from "@/timeline/drag-source"; import { findTrackInSceneTracks } from "@/timeline/track-element-update"; -import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +import { lastFrameMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; import { canElementBeHidden, canElementHaveAudio, @@ -27,7 +28,6 @@ import { resolveAnimationTarget, resolveAnimationPathValueAtTime, } from "@/animation"; -import { lastFrameTime } from "opencut-wasm"; import { BatchCommand } from "@/commands"; import { AddTrackCommand, @@ -68,6 +68,7 @@ export class TimelineManager { private listeners = new Set<() => void>(); private previewOverlay = new Map>(); private previewTracks: SceneTracks | null = null; + public readonly dragSource = new TimelineDragSource(); constructor(private editor: EditorCore) {} @@ -214,7 +215,7 @@ export class TimelineManager { const duration = this.getTotalDuration(); const fps = this.editor.project.getActive()?.settings.fps; if (!fps || duration <= 0) return duration; - return (lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime; + return lastFrameMediaTime({ duration, fps }); } getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { @@ -707,11 +708,11 @@ export class TimelineManager { previewElements({ updates, }: { - updates: Array<{ + updates: readonly { trackId: string; elementId: string; updates: Partial; - }>; + }[]; }): void { let changedOverlayCount = 0; for (const { elementId, updates: elementUpdates } of updates) { diff --git a/apps/web/src/media/use-file-upload.ts b/apps/web/src/media/use-file-upload.ts index 1ee30ad9..c8c62073 100644 --- a/apps/web/src/media/use-file-upload.ts +++ b/apps/web/src/media/use-file-upload.ts @@ -1,5 +1,5 @@ import { useState, useRef } from "react"; -import { hasDragData } from "@/timeline/drag-data"; +import { useEditor } from "@/editor/use-editor"; interface UseFileUploadOptions { accept?: string; @@ -7,19 +7,23 @@ interface UseFileUploadOptions { onFilesSelected?: (files: File[]) => void; } -function containsFiles(dataTransfer: DataTransfer): boolean { - return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files"); -} - export function useFileUpload({ accept, multiple, onFilesSelected, }: UseFileUploadOptions = {}) { + const editor = useEditor(); const [isDragOver, setIsDragOver] = useState(false); const dragCounterRef = useRef(0); const inputRef = useRef(null); + function containsFiles(dataTransfer: DataTransfer): boolean { + return ( + !editor.timeline.dragSource.isActive() && + dataTransfer.types.includes("Files") + ); + } + function openFilePicker() { if (!inputRef.current) return; diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx index 084c5b55..857b43e2 100644 --- a/apps/web/src/preview/components/toolbar.tsx +++ b/apps/web/src/preview/components/toolbar.tsx @@ -73,15 +73,13 @@ function TimecodeDisplay() { ); useEffect(() => { - const handler = (e: Event) => - setCurrentTime((e as CustomEvent<{ time: MediaTime }>).detail.time); - window.addEventListener("playback-update", handler); - window.addEventListener("playback-seek", handler); + const unsubscribeUpdate = editor.playback.onUpdate(setCurrentTime); + const unsubscribeSeek = editor.playback.onSeek(setCurrentTime); return () => { - window.removeEventListener("playback-update", handler); - window.removeEventListener("playback-seek", handler); + unsubscribeUpdate(); + unsubscribeSeek(); }; - }, []); + }, [editor.playback]); return (
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; +} + +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, + }, + }, + }, + })), + ); + } +} diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts new file mode 100644 index 00000000..e9d251e3 --- /dev/null +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -0,0 +1,753 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import type { MediaAsset } from "@/media/types"; +import { + getVisibleElementsWithBounds, + type Corner, + type Edge, + type ElementBounds, + type ElementWithBounds, +} from "@/preview/element-bounds"; +import { + MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/preview/preview-snap"; +import { isVisualElement } from "@/timeline/element-utils"; +import { + getElementLocalTime, + hasKeyframesForPath, + resolveTransformAtTime, + setChannel, +} from "@/animation"; +import type { ElementAnimations } from "@/animation/types"; +import type { Transform } from "@/rendering"; +import type { + ElementRef, + SceneTracks, + TimelineElement, + VisualElement, +} from "@/timeline"; + +type Point = { readonly x: number; readonly y: number }; +type CanvasSize = { readonly width: number; readonly height: number }; +type HandleType = Corner | Edge | "rotation"; + +interface CapturedPointerState { + readonly pointerId: number; + readonly captureTarget: HTMLElement; +} + +interface CornerScaleSession extends CapturedPointerState { + readonly kind: "corner-scale"; + readonly corner: Corner; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialDistance: number; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly shouldClearScaleAnimation: boolean; + readonly animationsWithoutScale: ElementAnimations | undefined; +} + +interface EdgeScaleSession extends CapturedPointerState { + readonly kind: "edge-scale"; + readonly edge: Edge; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly rotationRad: number; + readonly shouldClearScaleAnimation: boolean; + readonly animationsWithoutScale: ElementAnimations | undefined; +} + +interface RotationSession extends CapturedPointerState { + readonly kind: "rotation"; + readonly trackId: string; + readonly elementId: string; + readonly initialTransform: Transform; + readonly initialAngle: number; + readonly initialBoundsCx: number; + readonly initialBoundsCy: number; +} + +type TransformSession = + | { readonly kind: "idle" } + | CornerScaleSession + | EdgeScaleSession + | RotationSession; + +const IDLE_SESSION: TransformSession = { kind: "idle" }; + +interface VisualSelectionContext { + readonly trackId: string; + readonly elementId: string; + readonly element: VisualElement; + readonly bounds: ElementBounds; + readonly resolvedTransform: Transform; +} + +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 { + getSelectedElements: () => readonly ElementRef[]; + getTracks: () => SceneTracks; + getCurrentTime: () => number; + getMediaAssets: () => MediaAsset[]; + getCanvasSize: () => CanvasSize; +} + +export interface TimelinePreviewUpdate { + readonly trackId: string; + readonly elementId: string; + readonly updates: Partial; +} + +export interface TimelineOps { + previewElements: (updates: readonly TimelinePreviewUpdate[]) => void; + commitPreview: () => void; + discardPreview: () => void; +} + +export interface PreviewOptions { + onSnapLinesChange?: (lines: SnapLine[]) => void; +} + +export interface TransformHandleDeps { + viewport: PreviewViewportAdapter; + input: InputAdapter; + scene: SceneReader; + timeline: TimelineOps; + preview: PreviewOptions; +} + +export interface TransformHandleDepsRef { + readonly current: TransformHandleDeps; +} + +function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference { + return edge === "right" + ? { right: true } + : edge === "left" + ? { left: true } + : { bottom: true }; +} + +function clampScaleNonZero(scale: number): number { + if (Math.abs(scale) < MIN_SCALE) { + return scale < 0 ? -MIN_SCALE : MIN_SCALE; + } + return scale; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): number { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; + const localY = + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +function buildSelectedWithBounds({ + selectedElements, + elementsWithBounds, +}: { + selectedElements: readonly ElementRef[]; + elementsWithBounds: readonly ElementWithBounds[]; +}): ElementWithBounds | null { + if (selectedElements.length !== 1) return null; + + return ( + elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null + ); +} + +function buildCornerScaleAnimationReset({ + animations, +}: { + animations: ElementAnimations | undefined; +}): { + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} { + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations, + propertyPath: "transform.scaleX", + }) || + hasKeyframesForPath({ + animations, + propertyPath: "transform.scaleY", + }); + + return { + shouldClearScaleAnimation, + animationsWithoutScale: shouldClearScaleAnimation + ? setChannel({ + animations: setChannel({ + animations, + propertyPath: "transform.scaleX", + channel: undefined, + }), + propertyPath: "transform.scaleY", + channel: undefined, + }) + : animations, + }; +} + +function buildEdgeScaleAnimationReset({ + animations, + edge, +}: { + animations: ElementAnimations | undefined; + edge: Edge; +}): { + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} { + const propertyPath = + edge === "right" || edge === "left" + ? "transform.scaleX" + : "transform.scaleY"; + + const shouldClearScaleAnimation = hasKeyframesForPath({ + animations, + propertyPath, + }); + + return { + shouldClearScaleAnimation, + animationsWithoutScale: shouldClearScaleAnimation + ? setChannel({ + animations, + propertyPath, + channel: undefined, + }) + : animations, + }; +} + +export class TransformHandleController { + private readonly depsRef: TransformHandleDepsRef; + private readonly subscribers = new Set<() => void>(); + + private session: TransformSession = IDLE_SESSION; + + constructor({ depsRef }: { depsRef: TransformHandleDepsRef }) { + this.depsRef = depsRef; + + this.onCornerPointerDown = this.onCornerPointerDown.bind(this); + this.onEdgePointerDown = this.onEdgePointerDown.bind(this); + this.onRotationPointerDown = this.onRotationPointerDown.bind(this); + this.onPointerMove = this.onPointerMove.bind(this); + this.onPointerUp = this.onPointerUp.bind(this); + } + + private get deps(): TransformHandleDeps { + return this.depsRef.current; + } + + get selectedWithBounds(): ElementWithBounds | null { + return buildSelectedWithBounds({ + selectedElements: this.deps.scene.getSelectedElements(), + elementsWithBounds: this.getVisibleElementsWithBounds(), + }); + } + + get activeHandle(): HandleType | null { + switch (this.session.kind) { + case "corner-scale": + return this.session.corner; + case "edge-scale": + return this.session.edge; + case "rotation": + return "rotation"; + default: + return null; + } + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + if (this.session.kind !== "idle") { + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.discardPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + } + + this.subscribers.clear(); + } + + cancel(): void { + if (this.session.kind === "idle") return; + + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.discardPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + this.notify(); + } + + onCornerPointerDown({ + event, + corner, + }: { + event: ReactPointerEvent; + corner: Corner; + }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const { shouldClearScaleAnimation, animationsWithoutScale } = + buildCornerScaleAnimationReset({ + animations: context.element.animations, + }); + + this.session = { + kind: "corner-scale", + corner, + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialDistance: getCornerDistance({ + bounds: context.bounds, + corner, + }), + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + baseWidth: context.bounds.width / context.resolvedTransform.scaleX, + baseHeight: context.bounds.height / context.resolvedTransform.scaleY, + shouldClearScaleAnimation, + animationsWithoutScale, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onRotationPointerDown({ event }: { event: ReactPointerEvent }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const position = this.deps.viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + const deltaX = position.x - context.bounds.cx; + const deltaY = position.y - context.bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + + this.session = { + kind: "rotation", + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialAngle, + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onEdgePointerDown({ + event, + edge, + }: { + event: ReactPointerEvent; + edge: Edge; + }): void { + const context = this.getSelectedVisualContext(); + if (!context) return; + + event.stopPropagation(); + + const { shouldClearScaleAnimation, animationsWithoutScale } = + buildEdgeScaleAnimationReset({ + animations: context.element.animations, + edge, + }); + + this.session = { + kind: "edge-scale", + edge, + trackId: context.trackId, + elementId: context.elementId, + initialTransform: context.resolvedTransform, + initialBoundsCx: context.bounds.cx, + initialBoundsCy: context.bounds.cy, + baseWidth: context.bounds.width / context.resolvedTransform.scaleX, + baseHeight: context.bounds.height / context.resolvedTransform.scaleY, + rotationRad: (context.bounds.rotation * Math.PI) / 180, + shouldClearScaleAnimation, + animationsWithoutScale, + pointerId: event.pointerId, + captureTarget: this.capturePointer({ + target: event.currentTarget as HTMLElement, + pointerId: event.pointerId, + }), + }; + + this.notify(); + } + + onPointerMove({ event }: { event: ReactPointerEvent }): void { + if (this.session.kind === "idle") return; + + const position = this.deps.viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + switch (this.session.kind) { + case "corner-scale": + this.previewCornerScale({ + session: this.session, + position, + }); + return; + case "edge-scale": + this.previewEdgeScale({ + session: this.session, + position, + }); + return; + case "rotation": + this.previewRotation({ + session: this.session, + position, + }); + return; + default: + return; + } + } + + onPointerUp(): void { + if (this.session.kind === "idle") return; + + const session = this.session; + this.session = IDLE_SESSION; + this.deps.timeline.commitPreview(); + this.clearSnapLines(); + this.releaseCapturedPointer(session); + this.notify(); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private clearSnapLines(): void { + this.deps.preview.onSnapLinesChange?.([]); + } + + private capturePointer({ + target, + pointerId, + }: { + target: HTMLElement; + pointerId: number; + }): HTMLElement { + target.setPointerCapture(pointerId); + return target; + } + + private releaseCapturedPointer(pointerState: CapturedPointerState): void { + 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 getSelectedVisualContext(): VisualSelectionContext | null { + const selectedWithBounds = this.selectedWithBounds; + if (!selectedWithBounds) return null; + if (!isVisualElement(selectedWithBounds.element)) return null; + + const localTime = getElementLocalTime({ + timelineTime: this.deps.scene.getCurrentTime(), + elementStartTime: selectedWithBounds.element.startTime, + elementDuration: selectedWithBounds.element.duration, + }); + + return { + trackId: selectedWithBounds.trackId, + elementId: selectedWithBounds.elementId, + element: selectedWithBounds.element, + bounds: selectedWithBounds.bounds, + resolvedTransform: resolveTransformAtTime({ + baseTransform: selectedWithBounds.element.transform, + animations: selectedWithBounds.element.animations, + localTime, + }), + }; + } + + private previewCornerScale({ + session, + position, + }: { + session: CornerScaleSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + const scaleFactor = currentDistance / session.initialDistance; + + // Use actual element dimensions (base * current scale) so snapping is + // computed from the rendered geometry when scaleX != scaleY. + const effectiveWidth = session.baseWidth * session.initialTransform.scaleX; + const effectiveHeight = + session.baseHeight * session.initialTransform.scaleY; + + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedScale, activeLines } = this.deps.input.isShiftHeld() + ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } + : snapScale({ + proposedScale: scaleFactor, + position: session.initialTransform.position, + baseWidth: effectiveWidth, + baseHeight: effectiveHeight, + rotation: session.initialTransform.rotate, + canvasSize: this.deps.scene.getCanvasSize(), + snapThreshold, + }); + + this.deps.preview.onSnapLinesChange?.(activeLines); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + scaleX: clampScaleNonZero( + session.initialTransform.scaleX * snappedScale, + ), + scaleY: clampScaleNonZero( + session.initialTransform.scaleY * snappedScale, + ), + }, + ...(session.shouldClearScaleAnimation && { + animations: session.animationsWithoutScale, + }), + }, + }, + ]); + } + + private previewEdgeScale({ + session, + position, + }: { + session: EdgeScaleSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const xProjection = + deltaX * Math.cos(session.rotationRad) + + deltaY * Math.sin(session.rotationRad); + const yProjection = + -deltaX * Math.sin(session.rotationRad) + + deltaY * Math.cos(session.rotationRad); + const projection = + session.edge === "right" + ? xProjection + : session.edge === "left" + ? -xProjection + : yProjection; + + const baseAxisHalf = + session.edge === "right" || session.edge === "left" + ? session.baseWidth / 2 + : session.baseHeight / 2; + const proposedScale = clampScaleNonZero(projection / baseAxisHalf); + + const proposedScaleX = + session.edge === "right" || session.edge === "left" + ? proposedScale + : session.initialTransform.scaleX; + const proposedScaleY = + session.edge === "bottom" + ? proposedScale + : session.initialTransform.scaleY; + + const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { x: xSnap, y: ySnap } = this.deps.input.isShiftHeld() + ? { + x: { + snappedScale: proposedScaleX, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + y: { + snappedScale: proposedScaleY, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + } + : snapScaleAxes({ + proposedScaleX, + proposedScaleY, + position: session.initialTransform.position, + baseWidth: session.baseWidth, + baseHeight: session.baseHeight, + rotation: session.initialTransform.rotate, + canvasSize: this.deps.scene.getCanvasSize(), + snapThreshold, + preferredEdges: getPreferredEdge({ edge: session.edge }), + }); + + const relevantSnap = + session.edge === "right" || session.edge === "left" ? xSnap : ySnap; + this.deps.preview.onSnapLinesChange?.(relevantSnap.activeLines); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + scaleX: + session.edge === "right" || session.edge === "left" + ? xSnap.snappedScale + : session.initialTransform.scaleX, + scaleY: + session.edge === "bottom" + ? ySnap.snappedScale + : session.initialTransform.scaleY, + }, + ...(session.shouldClearScaleAnimation && { + animations: session.animationsWithoutScale, + }), + }, + }, + ]); + } + + private previewRotation({ + session, + position, + }: { + session: RotationSession; + position: Point; + }): void { + const deltaX = position.x - session.initialBoundsCx; + const deltaY = position.y - session.initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + let deltaAngle = currentAngle - session.initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + const newRotate = session.initialTransform.rotate + deltaAngle; + const { snappedRotation } = this.deps.input.isShiftHeld() + ? { snappedRotation: newRotate } + : snapRotation({ proposedRotation: newRotate }); + + this.deps.timeline.previewElements([ + { + trackId: session.trackId, + elementId: session.elementId, + updates: { + transform: { + ...session.initialTransform, + rotate: snappedRotation, + }, + }, + }, + ]); + } +} diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts index 78212e74..d093dcf2 100644 --- a/apps/web/src/preview/hooks/use-preview-interaction.ts +++ b/apps/web/src/preview/hooks/use-preview-interaction.ts @@ -1,97 +1,17 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; -import type { Transform } from "@/rendering"; -import type { ElementRef, TextElement } from "@/timeline"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/preview/element-bounds"; -import { - getHitElements, - hitTest, - resolvePreferredHit, -} from "@/preview/hit-test"; -import { isVisualElement } from "@/timeline/element-utils"; -import { - SNAP_THRESHOLD_SCREEN_PIXELS, - snapPosition, - type SnapLine, -} from "@/preview/preview-snap"; +import type { SnapLine } from "@/preview/preview-snap"; import { registerCanceller } from "@/editor/cancel-interaction"; +import { + PreviewInteractionController, + type PreviewInteractionDeps, + type PreviewInteractionDepsRef, +} from "@/preview/controllers/preview-interaction-controller"; export type OnSnapLinesChange = (lines: SnapLine[]) => void; -const MIN_DRAG_DISTANCE = 0.5; - -interface CapturedPointerState { - pointerId: number; - captureTarget: HTMLElement; -} - -interface PendingGestureState extends CapturedPointerState { - startX: number; - startY: number; - topmostHit: ElementWithBounds | null; - selectedHit: ElementWithBounds | null; - selectedElements: ElementRef[]; -} - -interface DragState extends CapturedPointerState { - startX: number; - startY: number; - bounds: { - width: number; - height: number; - rotation: number; - }; - elements: Array<{ - trackId: string; - elementId: string; - initialTransform: Transform; - }>; -} - -function isSameElementRef({ - left, - right, -}: { - left: ElementRef; - right: ElementRef; -}): boolean { - return left.trackId === right.trackId && left.elementId === right.elementId; -} - -function buildDragSelection({ - selectedElements, - dragTarget, -}: { - selectedElements: 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 }), - ), - ]; -} - export function usePreviewInteraction({ onSnapLinesChange, isMaskMode = false, @@ -102,355 +22,74 @@ export function usePreviewInteraction({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const viewport = usePreviewViewport(); - const [isDragging, setIsDragging] = useState(false); - const [editingText, setEditingText] = useState<{ - trackId: string; - elementId: string; - element: TextElement; - originalOpacity: number; - } | null>(null); - const dragStateRef = useRef(null); - const pendingGestureRef = useRef(null); - const wasPlayingRef = useRef(editor.playback.getIsPlaying()); - const editingTextRef = useRef(editingText); - editingTextRef.current = editingText; - - const releaseCapturedPointer = useCallback( - (pointerState: CapturedPointerState | null) => { - if (!pointerState) return; - - if ( - !pointerState.captureTarget.hasPointerCapture(pointerState.pointerId) - ) { - return; - } - - pointerState.captureTarget.releasePointerCapture(pointerState.pointerId); + const deps: PreviewInteractionDeps = { + viewport: { + screenToCanvas: viewport.screenToCanvas, + screenPixelsToLogicalThreshold: viewport.screenPixelsToLogicalThreshold, }, - [], - ); + input: { + isShiftHeld: () => isShiftHeldRef.current, + }, + scene: { + getTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentTime: () => editor.playback.getCurrentTime(), + getMediaAssets: () => editor.media.getAssets(), + getCanvasSize: () => editor.project.getActive().settings.canvasSize, + }, + selection: { + getSelected: () => editor.selection.getSelectedElements(), + setSelected: (elements) => + editor.selection.setSelectedElements({ elements: [...elements] }), + clearSelection: () => editor.selection.clearSelection(), + }, + timeline: { + getElementsWithTracks: ({ elements }) => + editor.timeline.getElementsWithTracks({ elements: [...elements] }), + previewElements: (updates) => + editor.timeline.previewElements({ updates: [...updates] }), + commitPreview: () => editor.timeline.commitPreview(), + discardPreview: () => editor.timeline.discardPreview(), + }, + playback: { + getIsPlaying: () => editor.playback.getIsPlaying(), + subscribe: (listener) => editor.playback.subscribe(listener), + }, + preview: { + isMaskMode: () => isMaskMode, + onSnapLinesChange, + }, + }; - const commitTextEdit = useCallback(() => { - const current = editingTextRef.current; - if (!current) return; - editingTextRef.current = null; - editor.timeline.commitPreview(); - setEditingText(null); - }, [editor.timeline]); + const depsRef = useRef(deps); + depsRef.current = deps; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new PreviewInteractionController({ + depsRef: depsRef as PreviewInteractionDepsRef, + }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect( + () => controller.subscribe({ listener: rerender }), + [controller], + ); useEffect(() => { - const unsubscribe = editor.playback.subscribe(() => { - const isPlaying = editor.playback.getIsPlaying(); - if (isPlaying && !wasPlayingRef.current && editingTextRef.current) { - commitTextEdit(); - } - wasPlayingRef.current = isPlaying; - }); - return unsubscribe; - }, [editor.playback, commitTextEdit]); + if (!controller.isDragging) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isDragging, controller]); - useEffect(() => { - if (!isDragging) return; - - return registerCanceller({ - fn: () => { - const dragState = dragStateRef.current; - if (!dragState) return; - - editor.timeline.discardPreview(); - dragStateRef.current = null; - pendingGestureRef.current = null; - setIsDragging(false); - onSnapLinesChange?.([]); - releaseCapturedPointer(dragState); - }, - }); - }, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]); - - const handleDoubleClick = useCallback( - ({ clientX, clientY }: React.MouseEvent) => { - if (editingText || isMaskMode) return; - - const tracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; - - const startPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!startPos) return; - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const hit = hitTest({ - canvasX: startPos.x, - canvasY: startPos.y, - elementsWithBounds, - }); - - if (!hit || hit.element.type !== "text") return; - - const textElement = hit.element as TextElement; - setEditingText({ - trackId: hit.trackId, - elementId: hit.elementId, - element: textElement, - originalOpacity: textElement.opacity, - }); - }, - [editor, editingText, isMaskMode, viewport], - ); - - const handlePointerDown = useCallback( - ({ - clientX, - clientY, - currentTarget, - pointerId, - button, - }: React.PointerEvent) => { - if (editingText) return; - if (isMaskMode) return; - if (button !== 0) return; - - const tracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const mediaAssets = editor.media.getAssets(); - const canvasSize = editor.project.getActive().settings.canvasSize; - - const startPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!startPos) return; - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const hits = getHitElements({ - canvasX: startPos.x, - canvasY: startPos.y, - elementsWithBounds, - }); - const selectedElements = editor.selection.getSelectedElements(); - const topmostHit = hits[0] ?? null; - - pendingGestureRef.current = { - startX: startPos.x, - startY: startPos.y, - pointerId, - captureTarget: currentTarget as HTMLElement, - topmostHit, - selectedHit: resolvePreferredHit({ - hits, - preferredElements: selectedElements, - }), - selectedElements, - }; - currentTarget.setPointerCapture(pointerId); - }, - [editor, editingText, isMaskMode, viewport], - ); - - const handlePointerMove = useCallback( - ({ clientX, clientY }: React.PointerEvent) => { - const canvasSize = editor.project.getActive().settings.canvasSize; - - const currentPos = viewport.screenToCanvas({ - clientX, - clientY, - }); - if (!currentPos) return; - - let dragState = dragStateRef.current; - - if (!dragState) { - const pendingGesture = pendingGestureRef.current; - if (!pendingGesture) return; - - const deltaX = currentPos.x - pendingGesture.startX; - const deltaY = currentPos.y - pendingGesture.startY; - const hasMovement = - Math.abs(deltaX) > MIN_DRAG_DISTANCE || - Math.abs(deltaY) > MIN_DRAG_DISTANCE; - - if (!hasMovement) { - onSnapLinesChange?.([]); - return; - } - - const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit; - if (!dragTarget) { - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - return; - } - - const dragSelection = buildDragSelection({ - selectedElements: pendingGesture.selectedElements, - dragTarget, - }); - const elementsWithTracks = editor.timeline.getElementsWithTracks({ - elements: dragSelection, - }); - const draggableElements = elementsWithTracks.filter(({ element }) => - isVisualElement(element), - ); - - if (draggableElements.length === 0) { - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - return; - } - - if (pendingGesture.selectedHit === null) { - editor.selection.setSelectedElements({ - elements: [ - { - trackId: dragTarget.trackId, - elementId: dragTarget.elementId, - }, - ], - }); - } - - dragState = { - startX: pendingGesture.startX, - startY: pendingGesture.startY, - pointerId: pendingGesture.pointerId, - captureTarget: pendingGesture.captureTarget, - bounds: { - width: dragTarget.bounds.width, - height: dragTarget.bounds.height, - rotation: dragTarget.bounds.rotation, - }, - elements: draggableElements.map(({ track, element }) => ({ - trackId: track.id, - elementId: element.id, - initialTransform: (element as { transform: Transform }).transform, - })), - }; - dragStateRef.current = dragState; - pendingGestureRef.current = null; - setIsDragging(true); - } - - const deltaX = currentPos.x - dragState.startX; - const deltaY = currentPos.y - dragState.startY; - const firstElement = dragState.elements[0]; - const proposedPosition = { - x: firstElement.initialTransform.position.x + deltaX, - y: firstElement.initialTransform.position.y + deltaY, - }; - - const shouldSnap = !isShiftHeldRef.current; - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedPosition, activeLines } = shouldSnap - ? snapPosition({ - proposedPosition, - canvasSize, - elementSize: dragState.bounds, - rotation: dragState.bounds.rotation, - snapThreshold, - }) - : { - snappedPosition: proposedPosition, - activeLines: [] as SnapLine[], - }; - - onSnapLinesChange?.(activeLines); - - const deltaSnappedX = - snappedPosition.x - firstElement.initialTransform.position.x; - const deltaSnappedY = - snappedPosition.y - firstElement.initialTransform.position.y; - - const updates = dragState.elements.map( - ({ trackId, elementId, initialTransform }) => ({ - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - position: { - x: initialTransform.position.x + deltaSnappedX, - y: initialTransform.position.y + deltaSnappedY, - }, - }, - }, - }), - ); - - editor.timeline.previewElements({ updates }); - }, - [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport], - ); - - const handlePointerUp = useCallback( - ({ type }: React.PointerEvent) => { - const dragState = dragStateRef.current; - if (dragState) { - if (type === "pointercancel") { - editor.timeline.discardPreview(); - } else { - editor.timeline.commitPreview(); - } - - dragStateRef.current = null; - pendingGestureRef.current = null; - setIsDragging(false); - onSnapLinesChange?.([]); - releaseCapturedPointer(dragState); - return; - } - - const pendingGesture = pendingGestureRef.current; - if (!pendingGesture) return; - - if (type !== "pointercancel") { - const clickTarget = pendingGesture.topmostHit; - if (!clickTarget) { - editor.selection.clearSelection(); - } else { - editor.selection.setSelectedElements({ - elements: [ - { - trackId: clickTarget.trackId, - elementId: clickTarget.elementId, - }, - ], - }); - } - } - - pendingGestureRef.current = null; - onSnapLinesChange?.([]); - releaseCapturedPointer(pendingGesture); - }, - [editor, onSnapLinesChange, releaseCapturedPointer], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - onPointerDown: handlePointerDown, - onPointerMove: handlePointerMove, - onPointerUp: handlePointerUp, - onDoubleClick: handleDoubleClick, - editingText, - commitTextEdit, + onPointerDown: controller.onPointerDown, + onPointerMove: controller.onPointerMove, + onPointerUp: controller.onPointerUp, + onDoubleClick: controller.onDoubleClick, + editingText: controller.editingText, + commitTextEdit: controller.commitTextEdit, }; } diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts index 030e026c..f4b7e306 100644 --- a/apps/web/src/preview/hooks/use-transform-handles.ts +++ b/apps/web/src/preview/hooks/use-transform-handles.ts @@ -1,629 +1,84 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useReducer, useRef } from "react"; import { usePreviewViewport } from "@/preview/components/preview-viewport"; import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/preview/element-bounds"; -import { - MIN_SCALE, - SNAP_THRESHOLD_SCREEN_PIXELS, - snapRotation, - snapScale, - snapScaleAxes, - type ScaleEdgePreference, - type SnapLine, -} from "@/preview/preview-snap"; -import { isVisualElement } from "@/timeline/element-utils"; -import { - getElementLocalTime, - hasKeyframesForPath, - resolveTransformAtTime, - setChannel, -} from "@/animation"; -import type { Transform } from "@/rendering"; -import type { ElementAnimations } from "@/animation/types"; import { registerCanceller } from "@/editor/cancel-interaction"; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -type Edge = "right" | "left" | "bottom"; -type HandleType = Corner | Edge | "rotation"; - -function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference { - return edge === "right" - ? { right: true } - : edge === "left" - ? { left: true } - : { bottom: true }; -} - -interface ScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialDistance: number; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -interface RotationState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialAngle: number; - initialBoundsCx: number; - initialBoundsCy: number; -} - -interface EdgeScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - edge: Edge; - rotationRad: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -function clampScaleNonZero(scale: number): number { - if (Math.abs(scale) < MIN_SCALE) { - return scale < 0 ? -MIN_SCALE : MIN_SCALE; - } - return scale; -} - -function getCornerDistance({ - bounds, - corner, -}: { - bounds: { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; - }; - corner: Corner; -}): number { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; - const localY = - corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; - - const rotatedX = localX * cos - localY * sin; - const rotatedY = localX * sin + localY * cos; - return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; -} +import { + TransformHandleController, + type TransformHandleDeps, +} from "@/preview/controllers/transform-handle-controller"; export function useTransformHandles({ onSnapLinesChange, }: { onSnapLinesChange?: OnSnapLinesChange; }) { + const viewport = usePreviewViewport(); const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [activeHandle, setActiveHandle] = useState(null); - const scaleStateRef = useRef(null); - const rotationStateRef = useRef(null); - const edgeScaleStateRef = useRef(null); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); const tracks = useEditor( (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); - const currentTimeRef = useRef(currentTime); - currentTimeRef.current = currentTime; const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( (e) => e.project.getActive().settings.canvasSize, ); + const deps: TransformHandleDeps = { + viewport, + input: { + isShiftHeld: () => isShiftHeldRef.current, + }, + scene: { + getSelectedElements: () => selectedElements, + getTracks: () => tracks, + getCurrentTime: () => currentTime, + getMediaAssets: () => mediaAssets, + getCanvasSize: () => canvasSize, + }, + timeline: { + previewElements: (updates) => + editor.timeline.previewElements({ updates }), + commitPreview: () => editor.timeline.commitPreview(), + discardPreview: () => editor.timeline.discardPreview(), + }, + preview: { + onSnapLinesChange, + }, + }; - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); + const depsRef = useRef(deps); + depsRef.current = deps; - const selectedWithBounds: ElementWithBounds | null = - selectedElements.length === 1 - ? (elementsWithBounds.find( - (entry) => - entry.trackId === selectedElements[0].trackId && - entry.elementId === selectedElements[0].elementId, - ) ?? null) - : null; + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new TransformHandleController({ depsRef }); + } + const controller = controllerRef.current; - const hasVisualSelection = - selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); - - const clearActiveHandleState = useCallback(() => { - scaleStateRef.current = null; - rotationStateRef.current = null; - edgeScaleStateRef.current = null; - setActiveHandle(null); - onSnapLinesChange?.([]); - }, [onSnapLinesChange]); - - const releaseCapturedPointer = useCallback(() => { - const capture = captureRef.current; - if (!capture) return; - - if (capture.element.hasPointerCapture(capture.pointerId)) { - capture.element.releasePointerCapture(capture.pointerId); - } - - captureRef.current = null; - }, []); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (!activeHandle) return; + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller, controller.isActive]); - return registerCanceller({ - fn: () => { - editor.timeline.discardPreview(); - clearActiveHandleState(); - releaseCapturedPointer(); - }, - }); - }, [ - activeHandle, - clearActiveHandleState, - editor.timeline, - releaseCapturedPointer, - ]); + useEffect(() => () => controller.destroy(), [controller]); - const handleCornerPointerDown = useCallback( - ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const shouldClearScaleAnimation = - hasKeyframesForPath({ - animations: element.animations, - propertyPath: "transform.scaleX", - }) || - hasKeyframesForPath({ - animations: element.animations, - propertyPath: "transform.scaleY", - }); - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: setChannel({ - animations: element.animations, - propertyPath: "transform.scaleX", - channel: undefined, - }), - propertyPath: "transform.scaleY", - channel: undefined, - }) - : element.animations; - - scaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialDistance, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(corner); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handleRotationPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - const deltaX = position.x - bounds.cx; - const deltaY = position.y - bounds.cy; - const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - - rotationStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialAngle, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - }; - setActiveHandle("rotation"); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds, viewport], - ); - - const handleEdgePointerDown = useCallback( - ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const rotationRad = (bounds.rotation * Math.PI) / 180; - - const propertyPath = - edge === "right" || edge === "left" - ? "transform.scaleX" - : "transform.scaleY"; - const shouldClearScaleAnimation = hasKeyframesForPath({ - animations: element.animations, - propertyPath, - }); - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: element.animations, - propertyPath, - channel: undefined, - }) - : element.animations; - - edgeScaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(edge); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if ( - !scaleStateRef.current && - !rotationStateRef.current && - !edgeScaleStateRef.current - ) - return; - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - - if ( - scaleStateRef.current && - activeHandle && - activeHandle !== "rotation" - ) { - const { - trackId, - elementId, - initialTransform, - initialDistance, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - } = scaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentDistance = - Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; - const scaleFactor = currentDistance / initialDistance; - - // Use actual element dimensions (base * current scale) so snap - // computes the correct edges when scaleX ≠ scaleY - const effectiveWidth = baseWidth * initialTransform.scaleX; - const effectiveHeight = baseHeight * initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedScale: snappedFactor, activeLines } = - isShiftHeldRef.current - ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } - : snapScale({ - proposedScale: scaleFactor, - position: initialTransform.position, - baseWidth: effectiveWidth, - baseHeight: effectiveHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - }); - - onSnapLinesChange?.(activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: clampScaleNonZero( - initialTransform.scaleX * snappedFactor, - ), - scaleY: clampScaleNonZero( - initialTransform.scaleY * snappedFactor, - ), - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if ( - edgeScaleStateRef.current && - (activeHandle === "right" || - activeHandle === "left" || - activeHandle === "bottom") - ) { - const { - trackId, - elementId, - initialTransform, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - } = edgeScaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const xProjection = - deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); - const yProjection = - -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); - const projection = - edge === "right" - ? xProjection - : edge === "left" - ? -xProjection - : yProjection; - - const baseAxisHalf = - edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; - const proposedScale = clampScaleNonZero(projection / baseAxisHalf); - - const proposedScaleX = - edge === "right" || edge === "left" - ? proposedScale - : initialTransform.scaleX; - const proposedScaleY = - edge === "bottom" ? proposedScale : initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { x: xSnap, y: ySnap } = isShiftHeldRef.current - ? { - x: { - snappedScale: proposedScaleX, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - y: { - snappedScale: proposedScaleY, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - } - : snapScaleAxes({ - proposedScaleX, - proposedScaleY, - position: initialTransform.position, - baseWidth, - baseHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - preferredEdges: getPreferredEdge({ edge }), - }); - - const relevantSnap = - edge === "right" || edge === "left" ? xSnap : ySnap; - onSnapLinesChange?.(relevantSnap.activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: - edge === "right" || edge === "left" - ? xSnap.snappedScale - : initialTransform.scaleX, - scaleY: - edge === "bottom" - ? ySnap.snappedScale - : initialTransform.scaleY, - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if (rotationStateRef.current && activeHandle === "rotation") { - const { - trackId, - elementId, - initialTransform, - initialAngle, - initialBoundsCx, - initialBoundsCy, - } = rotationStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - let deltaAngle = currentAngle - initialAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - const newRotate = initialTransform.rotate + deltaAngle; - const { snappedRotation } = isShiftHeldRef.current - ? { snappedRotation: newRotate } - : snapRotation({ proposedRotation: newRotate }); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { ...initialTransform, rotate: snappedRotation }, - }, - }, - ], - }); - } - }, - [ - activeHandle, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); - - const handlePointerUp = useCallback(() => { - if ( - scaleStateRef.current || - rotationStateRef.current || - edgeScaleStateRef.current - ) { - editor.timeline.commitPreview(); - clearActiveHandleState(); - } - releaseCapturedPointer(); - }, [clearActiveHandleState, editor, releaseCapturedPointer]); + const selectedWithBounds = controller.selectedWithBounds; + const hasVisualSelection = selectedWithBounds !== null; return { selectedWithBounds, hasVisualSelection, - activeHandle, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, + activeHandle: controller.activeHandle, + handleCornerPointerDown: controller.onCornerPointerDown, + handleEdgePointerDown: controller.onEdgePointerDown, + handleRotationPointerDown: controller.onRotationPointerDown, + handlePointerMove: controller.onPointerMove, + handlePointerUp: controller.onPointerUp, }; } diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts index e5f3991d..cad4fc31 100644 --- a/apps/web/src/services/video-cache/service.ts +++ b/apps/web/src/services/video-cache/service.ts @@ -7,6 +7,7 @@ import { } from "mediabunny"; interface VideoSinkData { + input: Input; sink: CanvasSink; iterator: AsyncGenerator | null; currentFrame: WrappedCanvas | null; @@ -20,6 +21,7 @@ export class VideoCache { private sinks = new Map(); private initPromises = new Map>(); private frameChain = new Map>(); + private seekGenerations = new Map(); async getFrameAt({ mediaId, @@ -35,11 +37,20 @@ export class VideoCache { const sinkData = this.sinks.get(mediaId); if (!sinkData) return null; + const generation = (this.seekGenerations.get(mediaId) ?? 0) + 1; + this.seekGenerations.set(mediaId, generation); + const previous = this.frameChain.get(mediaId) ?? Promise.resolve(); - const current = previous.then(() => - this.resolveFrame({ sinkData, time }), + const current = previous.then(() => { + if (this.seekGenerations.get(mediaId) !== generation) { + return sinkData.currentFrame ?? null; + } + return this.resolveFrame({ sinkData, time }); + }); + this.frameChain.set( + mediaId, + current.catch(() => {}), ); - this.frameChain.set(mediaId, current.catch(() => {})); return current; } @@ -172,18 +183,7 @@ export class VideoCache { if (frame) { sinkData.currentFrame = frame; - - // Aggressively fetch next frame immediately to fill buffer - // This matches the mediaplayer example which fetches 2 frames on start - try { - const { value: next } = await sinkData.iterator.next(); - if (next) { - sinkData.nextFrame = next; - } - } catch (e) { - console.warn("Failed to pre-fetch next frame on seek:", e); - } - + this.startPrefetch({ sinkData }); return frame; } } catch (error) { @@ -262,12 +262,12 @@ export class VideoCache { mediaId: string; file: File; }): Promise { - try { - const input = new Input({ - source: new BlobSource(file), - formats: ALL_FORMATS, - }); + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, + }); + try { const videoTrack = await input.getPrimaryVideoTrack(); if (!videoTrack) { throw new Error("No video track found"); @@ -284,6 +284,7 @@ export class VideoCache { }); this.sinks.set(mediaId, { + input, sink, iterator: null, currentFrame: null, @@ -293,6 +294,7 @@ export class VideoCache { prefetchPromise: null, }); } catch (error) { + input.dispose(); console.error(`Failed to initialize video sink for ${mediaId}:`, error); throw error; } @@ -305,10 +307,13 @@ export class VideoCache { void sinkData.iterator.return(); } + sinkData.input.dispose(); this.sinks.delete(mediaId); } this.initPromises.delete(mediaId); + this.frameChain.delete(mediaId); + this.seekGenerations.delete(mediaId); } clearAll(): void { diff --git a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx index 0f0710cb..43ba4ffc 100644 --- a/apps/web/src/timeline/bookmarks/components/bookmarks.tsx +++ b/apps/web/src/timeline/bookmarks/components/bookmarks.tsx @@ -7,7 +7,6 @@ import type { BookmarkDragState } from "../hooks/use-bookmark-drag"; import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme"; import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout"; import { DEFAULT_FPS } from "@/fps/defaults"; -import { snappedSeekTime } from "opencut-wasm"; import { ArrowTurnBackwardIcon, Delete02Icon, @@ -30,6 +29,7 @@ import { type MediaTime, mediaTimeFromSeconds, mediaTimeToSeconds, + snapSeekMediaTime, subMediaTime, ZERO_MEDIA_TIME, } from "@/wasm"; @@ -51,7 +51,7 @@ function seekToBookmarkTime({ const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); const rate = activeProject?.settings.fps ?? DEFAULT_FPS; - const snappedTime = (snappedSeekTime({ time, duration, rate }) ?? time) as MediaTime; + const snappedTime = snapSeekMediaTime({ time, duration, fps: rate }); editor.playback.seek({ time: snappedTime }); } diff --git a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts index 8996330b..343fda80 100644 --- a/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts +++ b/apps/web/src/timeline/bookmarks/hooks/use-bookmark-drag.ts @@ -8,7 +8,6 @@ import { import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame } from "opencut-wasm"; import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; import { buildTimelineSnapPoints, @@ -21,7 +20,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; import type { Bookmark } from "@/timeline"; -import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; +import { roundFrameTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm"; export interface BookmarkDragState { isDragging: boolean; @@ -117,7 +116,7 @@ export function useBookmarkDrag({ maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), }); return { - snappedTime: result.snappedTime as MediaTime, + snappedTime: result.snappedTime, snapPoint: result.snapPoint, }; }, @@ -165,12 +164,10 @@ export function useBookmarkDrag({ }); const clampedTime = mouseTime > duration ? duration : mouseTime; - const frameSnappedTime = ( - roundToFrame({ - time: clampedTime, - rate: activeProject.settings.fps, - }) ?? clampedTime - ) as MediaTime; + const frameSnappedTime = roundFrameTime({ + time: clampedTime, + fps: activeProject.settings.fps, + }); const { snappedTime: initialTime } = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: bookmarkTime, @@ -199,10 +196,10 @@ export function useBookmarkDrag({ }); const clampedTime = mouseTime > duration ? duration : mouseTime; - const frameSnappedTime = ( - roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? - clampedTime - ) as MediaTime; + const frameSnappedTime = roundFrameTime({ + time: clampedTime, + fps: activeProject.settings.fps, + }); const snapResult = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: dragState.bookmarkTime, diff --git a/apps/web/src/timeline/bookmarks/snap-source.ts b/apps/web/src/timeline/bookmarks/snap-source.ts index 32a84baa..64d0c3c3 100644 --- a/apps/web/src/timeline/bookmarks/snap-source.ts +++ b/apps/web/src/timeline/bookmarks/snap-source.ts @@ -1,12 +1,13 @@ import type { Bookmark } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import type { MediaTime } from "@/wasm"; export function getBookmarkSnapPoints({ bookmarks, excludeBookmarkTime, }: { bookmarks: Bookmark[]; - excludeBookmarkTime?: number; + excludeBookmarkTime?: MediaTime; }): SnapPoint[] { return bookmarks.flatMap((bookmark) => { if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) { diff --git a/apps/web/src/timeline/bookmarks/utils.ts b/apps/web/src/timeline/bookmarks/utils.ts index 52e5aa60..702e66c3 100644 --- a/apps/web/src/timeline/bookmarks/utils.ts +++ b/apps/web/src/timeline/bookmarks/utils.ts @@ -1,7 +1,6 @@ import type { Bookmark } from "@/timeline"; import type { FrameRate } from "opencut-wasm"; -import { roundToFrame } from "opencut-wasm"; -import { addMediaTime, type MediaTime } from "@/wasm"; +import { addMediaTime, roundFrameTime, type MediaTime } from "@/wasm"; function bookmarkTimeEqual({ bookmarkTime, @@ -114,7 +113,7 @@ export function getFrameTime({ time: MediaTime; fps: FrameRate; }): MediaTime { - return (roundToFrame({ time, rate: fps }) ?? time) as MediaTime; + return roundFrameTime({ time, fps }); } export function getBookmarkAtTime({ diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx index 5d61a0a9..68f3f37f 100644 --- a/apps/web/src/timeline/components/index.tsx +++ b/apps/web/src/timeline/components/index.tsx @@ -30,7 +30,7 @@ import { type ReactNode, } from "react"; import type { MediaTime } from "@/wasm"; -import type { ElementDragState, DropTarget } from "@/timeline"; +import type { ElementDragView, DropTarget } from "@/timeline"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead } from "./timeline-playhead"; import { SelectionBox } from "@/selection/selection-box"; @@ -288,20 +288,15 @@ export function Timeline() { isReady: tracks.length > 0, }); - const { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, - } = useElementInteraction({ + const { dragView, handleElementMouseDown, handleElementClick } = + useElementInteraction({ zoomLevel, - timelineRef, tracksContainerRef, tracksScrollRef, snappingEnabled, onSnapPointChange: handleSnapPointChange, }); + const isElementDragging = dragView.kind === "dragging"; const { dragState: bookmarkDragState, @@ -392,10 +387,19 @@ export function Timeline() { contentWidth: dynamicTimelineWidth, }); + useEdgeAutoScroll({ + isActive: isElementDragging, + getMouseClientX: () => + dragView.kind === "dragging" ? dragView.currentMouseX : 0, + rulerScrollRef, + tracksScrollRef, + contentWidth: dynamicTimelineWidth, + }); + const showSnapIndicator = snappingEnabled && currentSnapPoint !== null && - (dragState.isDragging || bookmarkDragState.isDragging || isResizing); + (isElementDragging || bookmarkDragState.isDragging || isResizing); const { handleTracksMouseDown, @@ -458,9 +462,9 @@ export function Timeline() { headerHeight={timelineHeaderHeight} /> @@ -541,9 +545,7 @@ export function Timeline() { ; - lastMouseXRef: React.RefObject; + dragView: ElementDragView; onResizeStart: React.ComponentProps< typeof TimelineTrackContent >["onResizeStart"]; @@ -777,8 +775,16 @@ function TimelineTrackRows({ [tracks, expandedElementIds], ); + const draggingElementIds = useMemo( + () => + dragView.kind === "dragging" + ? dragView.memberTimeOffsets + : (null as ReadonlyMap | null), + [dragView], + ); const sortedTracks = useMemo(() => { - const draggingElementIds = new Set(dragState.dragElementIds); + if (!draggingElementIds) + return tracks.map((track, index) => ({ track, index })); return [...tracks] .map((track, index) => ({ track, index })) .sort((a, b) => { @@ -792,7 +798,7 @@ function TimelineTrackRows({ if (bHasDragged) return -1; return 0; }); - }, [tracks, dragState.dragElementIds]); + }, [tracks, draggingElementIds]); return ( <> @@ -812,10 +818,7 @@ function TimelineTrackRows({ void; - dragState: ElementDragState; + dragView: ElementDragView; isDropTarget?: boolean; } @@ -231,7 +226,7 @@ export function TimelineElement({ onResizeStart, onElementMouseDown, onElementClick, - dragState, + dragView, isDropTarget = false, }: TimelineElementProps) { const mediaAssets = useEditor((e) => e.media.getAssets()); @@ -257,16 +252,18 @@ export function TimelineElement({ selected.elementId === element.id && selected.trackId === track.id, ); - const isBeingDragged = dragState.dragElementIds.includes(element.id); + const isDragging = dragView.kind === "dragging"; + const dragTimeOffset = isDragging + ? dragView.memberTimeOffsets.get(element.id) + : undefined; + const isBeingDragged = dragTimeOffset !== undefined; const dragOffsetY = - isBeingDragged && dragState.isDragging - ? dragState.currentMouseY - dragState.startMouseY + isDragging && isBeingDragged + ? dragView.currentMouseY - dragView.startMouseY : 0; - const dragTimeOffset = - dragState.dragTimeOffsets[element.id] ?? ZERO_MEDIA_TIME; const elementStartTime = - isBeingDragged && dragState.isDragging - ? addMediaTime({ a: dragState.currentTime, b: dragTimeOffset }) + isDragging && isBeingDragged + ? addMediaTime({ a: dragView.currentTime, b: dragTimeOffset }) : renderElement.startTime; const displayedStartTime = elementStartTime; const displayedDuration = renderElement.duration; @@ -388,7 +385,7 @@ export function TimelineElement({ ? `${baseTrackHeight + expansionHeight}px` : "100%", transform: - isBeingDragged && dragState.isDragging + isDragging && isBeingDragged ? `translate3d(0, ${dragOffsetY}px, 0)` : undefined, }} diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx index 6c40637e..7608e6df 100644 --- a/apps/web/src/timeline/components/timeline-track.tsx +++ b/apps/web/src/timeline/components/timeline-track.tsx @@ -5,18 +5,12 @@ import { TimelineElement } from "./timeline-element"; import type { TimelineTrack } from "@/timeline"; import type { TimelineElement as TimelineElementType } from "@/timeline"; import { TIMELINE_LAYERS } from "./layers"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; -import type { ElementDragState } from "@/timeline"; -import { useEditor } from "@/editor/use-editor"; +import type { ElementDragView } from "@/timeline"; interface TimelineTrackContentProps { track: TimelineTrack; zoomLevel: number; - dragState: ElementDragState; - rulerScrollRef: React.RefObject; - tracksScrollRef: React.RefObject; - lastMouseXRef: React.RefObject; + dragView: ElementDragView; onResizeStart: (params: { event: React.MouseEvent; element: TimelineElementType; @@ -42,10 +36,7 @@ interface TimelineTrackContentProps { export function TimelineTrackContent({ track, zoomLevel, - dragState, - rulerScrollRef, - tracksScrollRef, - lastMouseXRef, + dragView, onResizeStart, onElementMouseDown, onElementClick, @@ -55,15 +46,6 @@ export function TimelineTrackContent({ targetElementId = null, }: TimelineTrackContentProps) { const { isElementSelected } = useElementSelection(); - const duration = useEditor((e) => e.timeline.getTotalDuration()); - - useEdgeAutoScroll({ - isActive: dragState.isDragging, - getMouseClientX: () => lastMouseXRef.current ?? 0, - rulerScrollRef, - tracksScrollRef, - contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel, - }); return (
@@ -120,7 +102,7 @@ export function TimelineTrackContent({ onElementClick={(event, element) => onElementClick({ event, element, track }) } - dragState={dragState} + dragView={dragView} isDropTarget={element.id === targetElementId} /> ); diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts new file mode 100644 index 00000000..7fca2676 --- /dev/null +++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts @@ -0,0 +1,577 @@ +import type { DragEvent } from "react"; +import { processMediaAssets } from "@/media/processing"; +import { showMediaUploadToast } from "@/media/upload-toast"; +import { + DEFAULT_NEW_ELEMENT_DURATION, + toElementDurationTicks, +} from "@/timeline/creation"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import type { FrameRate } from "opencut-wasm"; +import { + buildTextElement, + buildGraphicElement, + buildStickerElement, + buildElementFromMedia, + buildEffectElement, +} from "@/timeline/element-utils"; +import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; +import { BatchCommand } from "@/commands"; +import type { Command } from "@/commands/base-command"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import type { TimelineDragSource } from "@/timeline/drag-source"; +import type { + TrackType, + DropTarget, + ElementType, + SceneTracks, + TimelineTrack, + CreateTimelineElement, +} from "@/timeline"; +import type { TimelineDragData } from "@/timeline/drag"; +import type { MediaAsset } from "@/media/types"; +import type { ProcessedMediaAsset } from "@/media/processing"; +import { roundFrameTime, type MediaTime } from "@/wasm"; + +// --- Config --- + +export interface DragDropConfig { + zoomLevel: number; + getContainerEl: () => HTMLDivElement | null; + getHeaderEl: () => HTMLElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getActiveProjectFps: () => FrameRate | null; + getActiveProjectId: () => string | null; + getSceneTracks: () => SceneTracks; + getCurrentPlayheadTime: () => MediaTime; + getMediaAssets: () => MediaAsset[]; + dragSource: TimelineDragSource; + addMediaAsset: (args: { + projectId: string; + asset: ProcessedMediaAsset; + }) => Promise; + executeCommand: (command: Command) => void; + insertElement: (args: { + placement: { mode: "explicit"; trackId: string }; + element: CreateTimelineElement; + }) => void; + addClipEffect: (args: { + trackId: string; + elementId: string; + effectType: string; + }) => void; +} + +export interface DragDropConfigRef { + readonly current: DragDropConfig; +} + +// --- State --- + +interface DragOverState { + kind: "over"; + dropTarget: DropTarget | null; + elementType: ElementType | null; +} + +type DragDropState = { kind: "idle" } | DragOverState; + +interface TimelineCoords { + mouseX: number; + mouseY: number; +} + +// --- Pure helpers --- + +function elementTypeFromDrag({ + dragData, +}: { + dragData: TimelineDragData; +}): ElementType { + switch (dragData.type) { + case "text": + return "text"; + case "graphic": + return "graphic"; + case "sticker": + return "sticker"; + case "effect": + return "effect"; + case "media": + return dragData.mediaType; + } +} + +function getTargetElementTypesForDrag({ + dragData, +}: { + dragData: TimelineDragData; +}): string[] | undefined { + if (dragData.type === "effect") return dragData.targetElementTypes; + if (dragData.type === "media") return dragData.targetElementTypes; + return undefined; +} + +function getDurationForDrag({ + dragData, + mediaAssets, +}: { + dragData: TimelineDragData; + mediaAssets: MediaAsset[]; +}): MediaTime { + if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION; + const media = mediaAssets.find((asset) => asset.id === dragData.id); + return toElementDurationTicks({ seconds: media?.duration }); +} + +function orderedTracks({ + sceneTracks, +}: { + sceneTracks: SceneTracks; +}): TimelineTrack[] { + return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; +} + +// --- Controller --- + +export class DragDropController { + private state: DragDropState = { kind: "idle" }; + private enterCount = 0; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: DragDropConfigRef; + + constructor(deps: { configRef: DragDropConfigRef }) { + this.configRef = deps.configRef; + this.onDragEnter = this.onDragEnter.bind(this); + this.onDragOver = this.onDragOver.bind(this); + this.onDragLeave = this.onDragLeave.bind(this); + this.onDrop = this.onDrop.bind(this); + } + + private get config(): DragDropConfig { + return this.configRef.current; + } + + get isDragOver(): boolean { + return this.state.kind !== "idle"; + } + + get dropTarget(): DropTarget | null { + return this.state.kind === "over" ? this.state.dropTarget : null; + } + + get dragElementType(): ElementType | null { + return this.state.kind === "over" ? this.state.elementType : null; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + this.subscribers.clear(); + } + + // --- Drag event handlers (bound, stable, passed as React props) --- + + onDragEnter(event: DragEvent): void { + event.preventDefault(); + const hasAsset = this.config.dragSource.isActive(); + const hasFiles = event.dataTransfer.types.includes("Files"); + if (!hasAsset && !hasFiles) return; + + this.enterCount += 1; + if (this.state.kind === "idle") { + this.setOver({ dropTarget: null, elementType: null }); + } + } + + onDragOver(event: DragEvent): void { + event.preventDefault(); + + const coords = this.getMouseTimelineCoords({ event }); + if (!coords) return; + + const dragData = this.config.dragSource.getActive(); + const hasFiles = event.dataTransfer.types.includes("Files"); + const isExternal = hasFiles && !dragData; + + if (!dragData) { + if (hasFiles && isExternal) { + this.setOver({ dropTarget: null, elementType: null }); + } + return; + } + + const elementType = elementTypeFromDrag({ dragData }); + const duration = getDurationForDrag({ + dragData, + mediaAssets: this.config.getMediaAssets(), + }); + const targetElementTypes = getTargetElementTypesForDrag({ dragData }); + + const sceneTracks = this.config.getSceneTracks(); + const target = computeDropTarget({ + elementType, + mouseX: coords.mouseX, + mouseY: coords.mouseY, + tracks: sceneTracks, + playheadTime: this.config.getCurrentPlayheadTime(), + isExternalDrop: isExternal, + elementDuration: duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel: this.config.zoomLevel, + targetElementTypes, + }); + + const fps = this.config.getActiveProjectFps(); + target.xPosition = fps + ? roundFrameTime({ time: target.xPosition, fps }) + : target.xPosition; + + this.setOver({ dropTarget: target, elementType }); + event.dataTransfer.dropEffect = "copy"; + } + + onDragLeave(event: DragEvent): void { + event.preventDefault(); + if (this.enterCount === 0) return; + this.enterCount -= 1; + if (this.enterCount === 0) { + this.setIdle(); + } + } + + onDrop(event: DragEvent): void { + event.preventDefault(); + this.enterCount = 0; + + const dragData = this.config.dragSource.getActive(); + const hasFiles = event.dataTransfer.files?.length > 0; + if (!dragData && !hasFiles) return; + + const currentTarget = this.dropTarget; + this.setIdle(); + + try { + if (dragData) { + if (!currentTarget) return; + this.executeAssetDrop({ target: currentTarget, dragData }); + return; + } + + const coords = this.getMouseTimelineCoords({ event }); + if (!coords) return; + this.executeFileDrop({ + files: Array.from(event.dataTransfer.files), + mouseX: coords.mouseX, + mouseY: coords.mouseY, + }).catch((error) => { + console.error("Failed to process file drop:", error); + }); + } catch (error) { + console.error("Failed to process drop:", error); + } + } + + // --- Private --- + + private setOver(state: { + dropTarget: DropTarget | null; + elementType: ElementType | null; + }): void { + this.state = { kind: "over", ...state }; + this.notify(); + } + + private setIdle(): void { + this.state = { kind: "idle" }; + this.notify(); + } + + private notify(): void { + for (const fn of this.subscribers) fn(); + } + + private getMouseTimelineCoords({ + event, + }: { + event: DragEvent; + }): TimelineCoords | null { + const scrollContainer = this.config.getTracksScrollEl(); + const referenceRect = + scrollContainer?.getBoundingClientRect() ?? + this.config.getContainerEl()?.getBoundingClientRect(); + if (!referenceRect) return null; + + const scrollLeft = scrollContainer?.scrollLeft ?? 0; + const scrollTop = scrollContainer?.scrollTop ?? 0; + const headerHeight = + this.config.getHeaderEl()?.getBoundingClientRect().height ?? 0; + + return { + mouseX: event.clientX - referenceRect.left + scrollLeft, + mouseY: event.clientY - referenceRect.top + scrollTop - headerHeight, + }; + } + + // Shared insertion logic — new track vs existing track. + private insertAtTarget({ + element, + target, + trackType, + }: { + element: CreateTimelineElement; + target: DropTarget; + trackType: TrackType; + }): void { + if (target.isNewTrack) { + const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); + this.config.executeCommand( + new BatchCommand([ + addTrackCmd, + new InsertElementCommand({ + element, + placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, + }), + ]), + ); + return; + } + + const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() }); + const track = tracks[target.trackIndex]; + if (!track) return; + this.config.insertElement({ + placement: { mode: "explicit", trackId: track.id }, + element, + }); + } + + private executeAssetDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: TimelineDragData; + }): void { + switch (dragData.type) { + case "text": + this.executeTextDrop({ target, dragData }); + return; + case "graphic": + this.executeGraphicDrop({ target, dragData }); + return; + case "sticker": + this.executeStickerDrop({ target, dragData }); + return; + case "effect": + this.executeEffectDrop({ target, dragData }); + return; + case "media": + this.executeMediaDrop({ target, dragData }); + return; + } + } + + private executeTextDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildTextElement({ + raw: { name: dragData.name ?? "", content: dragData.content ?? "" }, + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType: "text" }); + } + + private executeStickerDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildStickerElement({ + stickerId: dragData.stickerId, + name: dragData.name, + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType: "graphic" }); + } + + private executeGraphicDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + const element = buildGraphicElement({ + definitionId: dragData.definitionId, + name: dragData.name, + startTime: target.xPosition, + params: dragData.params, + }); + this.insertAtTarget({ element, target, trackType: "graphic" }); + } + + private executeMediaDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + if (target.targetElement) { + // Replace media source — not yet implemented + return; + } + + const mediaAsset = this.config + .getMediaAssets() + .find((asset) => asset.id === dragData.id); + if (!mediaAsset) return; + + const trackType: TrackType = + dragData.mediaType === "audio" ? "audio" : "video"; + const element = buildElementFromMedia({ + mediaId: mediaAsset.id, + mediaType: mediaAsset.type, + name: mediaAsset.name, + duration: toElementDurationTicks({ seconds: mediaAsset.duration }), + startTime: target.xPosition, + }); + this.insertAtTarget({ element, target, trackType }); + } + + private executeEffectDrop({ + target, + dragData, + }: { + target: DropTarget; + dragData: Extract; + }): void { + if (target.targetElement) { + this.config.addClipEffect({ + trackId: target.targetElement.trackId, + elementId: target.targetElement.elementId, + effectType: dragData.effectType, + }); + return; + } + + const element = buildEffectElement({ + effectType: dragData.effectType, + startTime: target.xPosition, + }); + + const existingEffectTrack = orderedTracks({ + sceneTracks: this.config.getSceneTracks(), + }).find((track) => track.type === "effect"); + + if (existingEffectTrack) { + this.config.insertElement({ + placement: { mode: "explicit", trackId: existingEffectTrack.id }, + element, + }); + return; + } + + this.insertAtTarget({ element, target, trackType: "effect" }); + } + + private async executeFileDrop({ + files, + mouseX, + mouseY, + }: { + files: File[]; + mouseX: number; + mouseY: number; + }): Promise { + const projectId = this.config.getActiveProjectId(); + if (!projectId) return; + + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + + // Sequential on purpose: each iteration reads getSceneTracks() + // to decide placement (reuse empty main vs new track) and that + // decision depends on the effects of prior inserts. + for (const asset of processedAssets) { + const createdAsset = await this.config.addMediaAsset({ + projectId, + asset, + }); + if (!createdAsset) continue; + + const duration = toElementDurationTicks({ + seconds: createdAsset.duration, + }); + + const sceneTracks = this.config.getSceneTracks(); + const currentTime = this.config.getCurrentPlayheadTime(); + + const reuseMainTrackId = + createdAsset.type !== "audio" && + sceneTracks.overlay.length === 0 && + sceneTracks.audio.length === 0 && + sceneTracks.main.elements.length === 0 + ? sceneTracks.main.id + : null; + + if (reuseMainTrackId) { + this.config.insertElement({ + placement: { mode: "explicit", trackId: reuseMainTrackId }, + element: buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: currentTime, + }), + }); + continue; + } + + const dropTarget = computeDropTarget({ + elementType: createdAsset.type, + mouseX, + mouseY, + tracks: sceneTracks, + playheadTime: currentTime, + isExternalDrop: true, + elementDuration: duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel: this.config.zoomLevel, + }); + + const trackType: TrackType = + createdAsset.type === "audio" ? "audio" : "video"; + this.insertAtTarget({ + element: buildElementFromMedia({ + mediaId: createdAsset.id, + mediaType: createdAsset.type, + name: createdAsset.name, + duration, + startTime: dropTarget.xPosition, + }), + target: dropTarget, + trackType, + }); + } + + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); + } +} diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts new file mode 100644 index 00000000..d0d6426a --- /dev/null +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -0,0 +1,702 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { + buildMoveGroup, + resolveGroupMove, + snapGroupEdges, + type GroupMoveResult, + type MoveGroup, +} from "@/timeline/group-move"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { + maxMediaTime, + type MediaTime, + mediaTime, + roundFrameTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; +import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; +import type { FrameRate } from "opencut-wasm"; +import { computeDropTarget } from "@/timeline/components/drop-target"; +import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; +import { generateUUID } from "@/utils/id"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { + DropTarget, + ElementRef, + ElementDragView, + SceneTracks, + TimelineElement, + TimelineTrack, +} from "@/timeline"; + +const MOUSE_BUTTON_RIGHT = 2; + +// --- Config --- + +export interface ViewportAdapter { + getZoomLevel: () => number; + getTracksScrollEl: () => HTMLDivElement | null; + getTracksContainerEl: () => HTMLDivElement | null; + getHeaderEl: () => HTMLElement | null; +} + +export interface InputAdapter { + isShiftHeld: () => boolean; +} + +export interface SceneReader { + getTracks: () => SceneTracks; + getActiveFps: () => FrameRate | null; +} + +export interface ElementSelectionApi { + getSelected: () => readonly ElementRef[]; + isSelected: (ref: ElementRef) => boolean; + select: (ref: ElementRef) => void; + handleClick: (args: ElementRef & { isMultiKey: boolean }) => void; + clearKeyframeSelection: () => void; +} + +export interface PlaybackReader { + getCurrentTime: () => MediaTime; +} + +export interface TimelineOps { + moveElements: (args: Pick) => void; +} + +export interface SnapConfig { + isEnabled: () => boolean; + onChange?: (snapPoint: SnapPoint | null) => void; +} + +export interface ElementInteractionDeps { + viewport: ViewportAdapter; + input: InputAdapter; + scene: SceneReader; + selection: ElementSelectionApi; + playback: PlaybackReader; + timeline: TimelineOps; + snap: SnapConfig; +} + +export interface ElementInteractionDepsRef { + readonly current: ElementInteractionDeps; +} + +// --- Session --- + +type Point = { readonly x: number; readonly y: number }; + +interface MousedownSnapshot { + readonly origin: Point; + readonly elementId: string; + readonly trackId: string; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; + readonly selectedElements: readonly ElementRef[]; +} + +interface DragProgress { + moveGroup: MoveGroup; + // Pre-minted per member so the identity of any "new track" created by + // this drag stays stable across mousemove-driven drop-target recomputes. + // `resolveGroupMoveForDrop` runs every mousemove and emits a + // `createTracks[]` carrying these IDs; downstream consumers (snap + // indicator, drop-line, commit path) see the same entity every frame + // instead of a churning UUID. + reservedNewTrackIds: readonly string[]; + currentTime: MediaTime; + currentMouseX: number; + currentMouseY: number; + groupMoveResult: GroupMoveResult | null; + dropTarget: DropTarget | null; +} + +type Session = + | { kind: "idle" } + | { kind: "pending"; mousedown: MousedownSnapshot } + | { kind: "dragging"; mousedown: MousedownSnapshot; drag: DragProgress }; + +const IDLE_VIEW: ElementDragView = { kind: "idle" }; + +// --- Pure helpers --- + +function pixelToClickOffsetTime( + clientX: number, + elementRect: DOMRect, + zoomLevel: number, +): MediaTime { + const clickOffsetX = clientX - elementRect.left; + const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); + return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); +} + +function verticalDirection( + startMouseY: number, + currentMouseY: number, +): "up" | "down" | null { + if (currentMouseY < startMouseY) return "up"; + if (currentMouseY > startMouseY) return "down"; + return null; +} + +function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] { + return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; +} + +function movedPastDragThreshold(current: Point, origin: Point): boolean { + return ( + Math.abs(current.x - origin.x) > TIMELINE_DRAG_THRESHOLD_PX || + Math.abs(current.y - origin.y) > TIMELINE_DRAG_THRESHOLD_PX + ); +} + +function frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel, + clickOffsetTime, + fps, +}: { + clientX: number; + scrollContainer: HTMLDivElement; + zoomLevel: number; + clickOffsetTime: MediaTime; + fps: FrameRate; +}): MediaTime { + const mouseTime = getMouseTimeFromClientX({ + clientX, + containerRect: scrollContainer.getBoundingClientRect(), + zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + }); + const adjusted = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ a: mouseTime, b: clickOffsetTime }), + }); + return roundFrameTime({ time: adjusted, fps }); +} + +function resolveDropTarget({ + clientX, + clientY, + elementId, + trackId, + tracks, + viewport, + zoomLevel, + snappedTime, + verticalDragDirection, +}: { + clientX: number; + clientY: number; + elementId: string; + trackId: string; + tracks: SceneTracks; + viewport: ViewportAdapter; + zoomLevel: number; + snappedTime: MediaTime; + verticalDragDirection: "up" | "down" | null; +}): DropTarget | null { + const containerRect = viewport + .getTracksContainerEl() + ?.getBoundingClientRect(); + const scrollContainer = viewport.getTracksScrollEl(); + if (!containerRect || !scrollContainer) return null; + + const sourceTrack = orderedTracks(tracks).find(({ id }) => id === trackId); + const movingElement = sourceTrack?.elements.find( + ({ id }) => id === elementId, + ); + if (!movingElement) return null; + + const scrollRect = scrollContainer.getBoundingClientRect(); + const headerHeight = + viewport.getHeaderEl()?.getBoundingClientRect().height ?? 0; + + return computeDropTarget({ + elementType: movingElement.type, + mouseX: clientX - scrollRect.left + scrollContainer.scrollLeft, + mouseY: clientY - scrollRect.top + scrollContainer.scrollTop - headerHeight, + tracks, + playheadTime: snappedTime, + isExternalDrop: false, + elementDuration: movingElement.duration, + pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, + zoomLevel, + startTimeOverride: snappedTime, + excludeElementId: movingElement.id, + verticalDragDirection, + }); +} + +function resolveGroupMoveForDrop({ + group, + tracks, + anchorStartTime, + dropTarget, + reservedNewTrackIds, +}: { + group: MoveGroup; + tracks: SceneTracks; + anchorStartTime: MediaTime; + dropTarget: DropTarget; + reservedNewTrackIds: readonly string[]; +}): GroupMoveResult | null { + const newTracksFallback = () => + resolveGroupMove({ + group, + tracks, + anchorStartTime, + target: { + kind: "newTracks", + anchorInsertIndex: dropTarget.trackIndex, + newTrackIds: [...reservedNewTrackIds], + }, + }); + + if (dropTarget.isNewTrack) return newTracksFallback(); + + const targetTrack = orderedTracks(tracks)[dropTarget.trackIndex]; + if (!targetTrack) return null; + + return ( + resolveGroupMove({ + group, + tracks, + anchorStartTime, + target: { kind: "existingTrack", anchorTargetTrackId: targetTrack.id }, + }) ?? newTracksFallback() + ); +} + +// --- Controller --- + +export class ElementInteractionController { + private session: Session = { kind: "idle" }; + // True once the active gesture crossed the drag threshold. Read by + // onElementClick, which fires after mouseup — by which point the session + // has already returned to idle, so the "was this a drag?" answer must + // outlive the session. Reset on the next mousedown. + private lastGestureWasDrag = false; + + private readonly subscribers = new Set<() => void>(); + private readonly depsRef: ElementInteractionDepsRef; + + constructor(args: { depsRef: ElementInteractionDepsRef }) { + this.depsRef = args.depsRef; + } + + private get deps(): ElementInteractionDeps { + return this.depsRef.current; + } + + get view(): ElementDragView { + if (this.session.kind !== "dragging") return IDLE_VIEW; + const { mousedown, drag } = this.session; + const memberTimeOffsets = new Map(); + for (const member of drag.moveGroup.members) { + memberTimeOffsets.set(member.elementId, member.timeOffset); + } + return { + kind: "dragging", + anchorElementId: mousedown.elementId, + trackId: mousedown.trackId, + memberTimeOffsets, + startMouseX: mousedown.origin.x, + startMouseY: mousedown.origin.y, + startElementTime: mousedown.startElementTime, + clickOffsetTime: mousedown.clickOffsetTime, + currentTime: drag.currentTime, + currentMouseX: drag.currentMouseX, + currentMouseY: drag.currentMouseY, + dropTarget: drag.dropTarget, + }; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel = (): void => { + this.lastGestureWasDrag = false; + this.finishSession(); + }; + + destroy(): void { + this.cancel(); + this.subscribers.clear(); + } + + onElementMouseDown = ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }): void => { + // Right-click must not stopPropagation — ContextMenu needs the bubble. + if (event.button === MOUSE_BUTTON_RIGHT) { + const ref = { trackId: track.id, elementId: element.id }; + if (!this.deps.selection.isSelected(ref)) { + this.deps.selection.handleClick({ ...ref, isMultiKey: false }); + } + return; + } + + event.stopPropagation(); + this.lastGestureWasDrag = false; + + const ref = { trackId: track.id, elementId: element.id }; + + if (event.metaKey || event.ctrlKey || event.shiftKey) { + this.deps.selection.handleClick({ ...ref, isMultiKey: true }); + } + + const selectedElements = this.deps.selection.isSelected(ref) + ? this.deps.selection.getSelected() + : [ref]; + + this.session = { + kind: "pending", + mousedown: { + origin: { x: event.clientX, y: event.clientY }, + elementId: element.id, + trackId: track.id, + startElementTime: element.startTime, + clickOffsetTime: pixelToClickOffsetTime( + event.clientX, + event.currentTarget.getBoundingClientRect(), + this.deps.viewport.getZoomLevel(), + ), + selectedElements, + }, + }; + this.activate(); + this.notify(); + }; + + onElementClick = ({ + event, + element, + track, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + }): void => { + event.stopPropagation(); + + if (this.lastGestureWasDrag) { + this.lastGestureWasDrag = false; + return; + } + + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + + const ref = { trackId: track.id, elementId: element.id }; + if ( + !this.deps.selection.isSelected(ref) || + this.deps.selection.getSelected().length > 1 + ) { + this.deps.selection.select(ref); + return; + } + + this.deps.selection.clearKeyframeSelection(); + }; + + 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.deps.snap.onChange?.(null); + this.notify(); + } + + private snapResult( + frameSnappedTime: MediaTime, + group: MoveGroup, + ): { snappedTime: MediaTime; snapPoint: SnapPoint | null } { + const { snap, input, scene, viewport, playback } = this.deps; + + if (!snap.isEnabled() || input.isShiftHeld()) { + return { snappedTime: frameSnappedTime, snapPoint: null }; + } + + const result = snapGroupEdges({ + group, + anchorStartTime: frameSnappedTime, + tracks: scene.getTracks(), + playheadTime: playback.getCurrentTime(), + zoomLevel: viewport.getZoomLevel(), + }); + + return { + snappedTime: result.snappedAnchorStartTime, + snapPoint: result.snapPoint, + }; + } + + private updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }: { + clientX: number; + clientY: number; + mousedown: MousedownSnapshot; + drag: DragProgress; + snappedTime: MediaTime; + }): void { + const { scene, viewport } = this.deps; + const tracks = scene.getTracks(); + const zoomLevel = viewport.getZoomLevel(); + + const anchorDropTarget = resolveDropTarget({ + clientX, + clientY, + elementId: mousedown.elementId, + trackId: mousedown.trackId, + tracks, + viewport, + zoomLevel, + snappedTime, + verticalDragDirection: verticalDirection(mousedown.origin.y, clientY), + }); + + const nextGroupMoveResult = anchorDropTarget + ? resolveGroupMoveForDrop({ + group: drag.moveGroup, + tracks, + anchorStartTime: snappedTime, + dropTarget: anchorDropTarget, + reservedNewTrackIds: drag.reservedNewTrackIds, + }) + : null; + + drag.groupMoveResult = nextGroupMoveResult; + drag.dropTarget = + anchorDropTarget && (anchorDropTarget.isNewTrack || !nextGroupMoveResult) + ? { ...anchorDropTarget, isNewTrack: true } + : null; + } + + private handleMouseMove = ({ clientX, clientY }: MouseEvent): void => { + const scrollContainer = this.deps.viewport.getTracksScrollEl(); + if (!scrollContainer) return; + + if (this.session.kind === "pending") { + this.beginDragFromPending({ + mousedown: this.session.mousedown, + clientX, + clientY, + scrollContainer, + }); + return; + } + + if (this.session.kind === "dragging") { + this.updateActiveDrag({ + mousedown: this.session.mousedown, + drag: this.session.drag, + clientX, + clientY, + scrollContainer, + }); + } + }; + + private beginDragFromPending({ + mousedown, + clientX, + clientY, + scrollContainer, + }: { + mousedown: MousedownSnapshot; + clientX: number; + clientY: number; + scrollContainer: HTMLDivElement; + }): void { + if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + return; + } + + const fps = this.deps.scene.getActiveFps(); + if (!fps) return; + + const moveGroup = buildMoveGroup({ + anchorRef: { + trackId: mousedown.trackId, + elementId: mousedown.elementId, + }, + selectedElements: [...mousedown.selectedElements], + tracks: this.deps.scene.getTracks(), + }); + if (!moveGroup) return; + + const zoomLevel = this.deps.viewport.getZoomLevel(); + const frameSnappedTime = frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel, + clickOffsetTime: mousedown.clickOffsetTime, + fps, + }); + const { snappedTime, snapPoint } = this.snapResult( + frameSnappedTime, + moveGroup, + ); + + // Ensure the anchor is selected before we render the drag — covers the + // case where the selection store hasn't committed the mousedown-time + // selection click yet. + const anchorRef = { + trackId: mousedown.trackId, + elementId: mousedown.elementId, + }; + if (!this.deps.selection.isSelected(anchorRef)) { + this.deps.selection.select(anchorRef); + } + + const drag: DragProgress = { + moveGroup, + reservedNewTrackIds: moveGroup.members.map(() => generateUUID()), + currentTime: snappedTime, + currentMouseX: clientX, + currentMouseY: clientY, + groupMoveResult: null, + dropTarget: null, + }; + + this.session = { kind: "dragging", mousedown, drag }; + this.lastGestureWasDrag = true; + + this.updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }); + + this.deps.snap.onChange?.(snapPoint); + this.notify(); + } + + private updateActiveDrag({ + mousedown, + drag, + clientX, + clientY, + scrollContainer, + }: { + mousedown: MousedownSnapshot; + drag: DragProgress; + clientX: number; + clientY: number; + scrollContainer: HTMLDivElement; + }): void { + const fps = this.deps.scene.getActiveFps(); + if (!fps) return; + + const frameSnappedTime = frameSnappedMouseTime({ + clientX, + scrollContainer, + zoomLevel: this.deps.viewport.getZoomLevel(), + clickOffsetTime: mousedown.clickOffsetTime, + fps, + }); + const { snappedTime, snapPoint } = this.snapResult( + frameSnappedTime, + drag.moveGroup, + ); + + drag.currentTime = snappedTime; + drag.currentMouseX = clientX; + drag.currentMouseY = clientY; + + this.updateDropTarget({ + clientX, + clientY, + mousedown, + drag, + snappedTime, + }); + + this.deps.snap.onChange?.(snapPoint); + this.notify(); + } + + private handleMouseUp = ({ clientX, clientY }: MouseEvent): void => { + if (this.session.kind === "pending") { + this.finishSession(); + return; + } + + if (this.session.kind !== "dragging") return; + + const { mousedown, drag } = this.session; + + // If the drag returned within the click threshold of its origin, treat + // this as a cancel rather than a commit — the user dragged then put the + // element back. + if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + this.lastGestureWasDrag = false; + this.finishSession(); + return; + } + + const { moveGroup, groupMoveResult } = drag; + if (!groupMoveResult) { + this.finishSession(); + return; + } + + const didMove = groupMoveResult.moves.some((move) => { + const member = moveGroup.members.find( + (m) => m.elementId === move.elementId, + ); + const originalStartTime = + mousedown.startElementTime + (member?.timeOffset ?? 0); + return ( + member?.trackId !== move.targetTrackId || + originalStartTime !== move.newStartTime + ); + }); + + if (didMove || groupMoveResult.createTracks.length > 0) { + this.deps.timeline.moveElements({ + moves: groupMoveResult.moves, + createTracks: groupMoveResult.createTracks, + }); + } + + this.finishSession(); + }; +} diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts new file mode 100644 index 00000000..29dfc5dd --- /dev/null +++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts @@ -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; + 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(); + } +} diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts new file mode 100644 index 00000000..b96b868b --- /dev/null +++ b/apps/web/src/timeline/controllers/playhead-controller.ts @@ -0,0 +1,318 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import type { FrameRate } from "opencut-wasm"; +import { + mediaTime, + snapSeekMediaTime, + TICKS_PER_SECOND, + type MediaTime, +} from "@/wasm"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, +} from "@/timeline/snapping"; +import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + getCenteredLineLeft, + timelineTimeToPixels, + timelineTimeToSnappedPixels, +} from "@/timeline"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import type { Bookmark, SceneTracks } from "@/timeline"; + +// --- Session --- + +interface ScrubSession { + kind: "scrubbing"; + /** True when scrub started from a ruler click (not the playhead handle). */ + didStartFromRuler: boolean; + /** True once the mouse has moved during a ruler drag. */ + hasMoved: boolean; + /** Most recent frame-snapped time set by scrub(). */ + currentTime: MediaTime | null; +} + +type Session = { kind: "idle" } | ScrubSession; + +// --- Config --- + +export interface PlayheadConfig { + zoomLevel: number; + duration: MediaTime; + getActiveProjectFps: () => FrameRate | null; + isShiftHeld: () => boolean; + getIsPlaying: () => boolean; + getRulerEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getPlayheadEl: () => HTMLDivElement | null; + getSceneTracks: () => SceneTracks; + getSceneBookmarks: () => Bookmark[]; + seek: (time: MediaTime) => void; + setScrubbing: (isScrubbing: boolean) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: MediaTime; + }) => void; +} + +export interface PlayheadConfigRef { + readonly current: PlayheadConfig; +} + +// --- Pure helpers (px → logical) --- + +function pixelToTime({ + clientX, + rulerEl, + zoomLevel, + duration, +}: { + clientX: number; + rulerEl: HTMLDivElement; + zoomLevel: number; + duration: MediaTime; +}): MediaTime { + const rulerRect = rulerEl.getBoundingClientRect(); + const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel }); + const clampedX = Math.max( + 0, + Math.min(contentWidth, clientX - rulerRect.left), + ); + const seconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); +} + +// --- Controller --- + +export class PlayheadController { + private lastMouseClientX = 0; + + private session: Session = { kind: "idle" }; + private readonly configRef: PlayheadConfigRef; + + constructor(deps: { configRef: PlayheadConfigRef }) { + this.configRef = deps.configRef; + this.onPlayheadMouseDown = this.onPlayheadMouseDown.bind(this); + this.onRulerMouseDown = this.onRulerMouseDown.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + private get config(): PlayheadConfig { + return this.configRef.current; + } + + get isActive(): boolean { + return this.session.kind !== "idle"; + } + + getLastMouseClientX(): number { + return this.lastMouseClientX; + } + + destroy(): void { + this.deactivate(); + } + + // --- Public event handlers (bound, stable references) --- + + onPlayheadMouseDown(event: ReactMouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.session = { + kind: "scrubbing", + didStartFromRuler: false, + hasMoved: false, + currentTime: null, + }; + this.config.setScrubbing(true); + this.scrub({ event, isElementSnappingEnabled: true }); + this.activate(); + } + + onRulerMouseDown(event: ReactMouseEvent): void { + if (event.button !== 0) return; + if (this.config.getPlayheadEl()?.contains(event.target as Node)) return; + + event.preventDefault(); + this.session = { + kind: "scrubbing", + didStartFromRuler: true, + hasMoved: false, + currentTime: null, + }; + this.config.setScrubbing(true); + // No element-edge snapping on initial ruler click — avoids a jarring jump. + this.scrub({ event, isElementSnappingEnabled: false }); + this.activate(); + } + + // --- Public non-session methods --- + + /** + * Imperatively updates the playhead DOM element's `left` style. + * Called on scroll and playback events to avoid React re-renders + * during animation frame updates. + */ + updatePlayheadLeft(time: MediaTime): void { + const playheadEl = this.config.getPlayheadEl(); + if (!playheadEl) return; + + const centerPixel = timelineTimeToSnappedPixels({ + time, + zoomLevel: this.config.zoomLevel, + }); + const scrollLeft = this.config.getRulerScrollEl()?.scrollLeft ?? 0; + playheadEl.style.left = `${getCenteredLineLeft({ centerPixel }) - scrollLeft}px`; + } + + /** + * Updates the playhead position and auto-scrolls to keep the playhead + * visible during playback. + */ + handlePlaybackUpdate(time: MediaTime): void { + this.updatePlayheadLeft(time); + + // Auto-scroll only during playback, not while scrubbing. + if (!this.config.getIsPlaying() || this.session.kind === "scrubbing") + return; + + const rulerViewport = this.config.getRulerScrollEl(); + const tracksViewport = this.config.getTracksScrollEl(); + if (!rulerViewport || !tracksViewport) return; + + const playheadPixels = timelineTimeToPixels({ + time, + zoomLevel: this.config.zoomLevel, + }); + const viewportWidth = rulerViewport.clientWidth; + const isOutOfView = + playheadPixels < rulerViewport.scrollLeft || + playheadPixels > rulerViewport.scrollLeft + viewportWidth; + + if (isOutOfView) { + const desiredScroll = Math.max( + 0, + Math.min( + rulerViewport.scrollWidth - viewportWidth, + playheadPixels - viewportWidth / 2, + ), + ); + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + } + + // --- Private --- + + private activate(): void { + window.addEventListener("mousemove", this.handleMouseMove); + window.addEventListener("mouseup", this.handleMouseUp); + } + + private deactivate(): void { + window.removeEventListener("mousemove", this.handleMouseMove); + window.removeEventListener("mouseup", this.handleMouseUp); + } + + /** + * Converts pointer position to a frame-snapped timeline time and seeks. + * `isElementSnappingEnabled` controls element-edge snapping; frame-level snapping + * is always applied. + */ + private scrub({ + event, + isElementSnappingEnabled, + }: { + event: MouseEvent | ReactMouseEvent; + isElementSnappingEnabled: boolean; + }): void { + const ruler = this.config.getRulerEl(); + if (!ruler) return; + + const fps = this.config.getActiveProjectFps(); + if (!fps) return; + + const { zoomLevel, duration } = this.config; + const rawTime = pixelToTime({ + clientX: event.clientX, + rulerEl: ruler, + zoomLevel, + duration, + }); + const frameTime = snapSeekMediaTime({ time: rawTime, duration, fps }); + + const time = (() => { + if (!isElementSnappingEnabled || this.config.isShiftHeld()) + return frameTime; + + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => + getElementEdgeSnapPoints({ tracks: this.config.getSceneTracks() }), + () => + getBookmarkSnapPoints({ + bookmarks: this.config.getSceneBookmarks(), + }), + () => + getAnimationKeyframeSnapPointsForTimeline({ + tracks: this.config.getSceneTracks(), + }), + ], + }); + const result = resolveTimelineSnap({ + targetTime: frameTime, + snapPoints, + maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), + }); + return result.snapPoint ? result.snappedTime : frameTime; + })(); + + if (this.session.kind === "scrubbing") { + this.session.currentTime = time; + } + this.config.seek(time); + this.lastMouseClientX = event.clientX; + } + + private handleMouseMove(event: MouseEvent): void { + if (this.session.kind !== "scrubbing") return; + this.scrub({ event, isElementSnappingEnabled: true }); + if (this.session.didStartFromRuler) { + this.session.hasMoved = true; + } + } + + private handleMouseUp(event: MouseEvent): void { + if (this.session.kind !== "scrubbing") return; + + const session = this.session; + this.config.setScrubbing(false); + + if (session.currentTime !== null) { + this.config.seek(session.currentTime); + this.config.setTimelineViewState({ + zoomLevel: this.config.zoomLevel, + scrollLeft: this.config.getTracksScrollEl()?.scrollLeft ?? 0, + playheadTime: session.currentTime, + }); + } + + // Ruler click without drag: snap to clicked position on mouseup. + if (session.didStartFromRuler && !session.hasMoved) { + this.scrub({ event, isElementSnappingEnabled: false }); + } + + this.session = { kind: "idle" }; + this.deactivate(); + } +} diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts new file mode 100644 index 00000000..8b0119a0 --- /dev/null +++ b/apps/web/src/timeline/controllers/resize-controller.ts @@ -0,0 +1,360 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { + addMediaTime, + maxMediaTime, + type MediaTime, + mediaTime, + minMediaTime, + subMediaTime, + TICKS_PER_SECOND, +} from "@/wasm"; +import { + computeGroupResize, + type GroupResizeMember, + type GroupResizeResult, + type GroupResizeUpdate, + type ResizeSide, +} from "@/timeline/group-resize"; +import { + buildTimelineSnapPoints, + getTimelineSnapThresholdInTicks, + resolveTimelineSnap, + type SnapPoint, +} from "@/timeline/snapping"; +import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; +import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; +import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + isRetimableElement, + type SceneTracks, + type TimelineElement, + type TimelineTrack, +} from "@/timeline"; +import type { ElementRef } from "@/timeline/types"; +import type { FrameRate } from "opencut-wasm"; + +// --- Session --- + +interface ResizeSession { + kind: "active"; + side: ResizeSide; + startX: number; + fps: FrameRate; + members: GroupResizeMember[]; + result: GroupResizeResult | null; +} + +type Session = { kind: "idle" } | ResizeSession; + +// --- Config --- + +export interface ResizeConfig { + zoomLevel: number; + snappingEnabled: boolean; + isShiftHeld: () => boolean; + getSceneTracks: () => SceneTracks; + getCurrentPlayheadTime: () => MediaTime; + getActiveProjectFps: () => FrameRate | null; + selectedElements: ElementRef[]; + discardPreview: () => void; + previewElements: (updates: GroupResizeUpdate[]) => void; + commitElements: (updates: GroupResizeUpdate[]) => void; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; +} + +export interface ResizeConfigRef { + readonly current: ResizeConfig; +} + +// --- Pure helpers --- + +export function buildResizeMembers({ + tracks, + selectedElements, +}: { + tracks: SceneTracks; + selectedElements: ElementRef[]; +}): GroupResizeMember[] { + const selectedElementIds = new Set( + selectedElements.map((el) => el.elementId), + ); + const trackMap = new Map( + [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ + track.id, + track, + ]), + ); + + return selectedElements.flatMap(({ trackId, elementId }) => { + const track = trackMap.get(trackId); + const element = track?.elements.find((el) => el.id === elementId); + if (!track || !element) return []; + + const otherElements = track.elements.filter( + (el) => !selectedElementIds.has(el.id), + ); + const leftNeighborBound = otherElements + .filter( + (el) => + addMediaTime({ a: el.startTime, b: el.duration }) <= + element.startTime, + ) + .reduce((bound, el) => { + const elementEnd = addMediaTime({ + a: el.startTime, + b: el.duration, + }); + return bound === null + ? elementEnd + : maxMediaTime({ a: bound, b: elementEnd }); + }, null); + const rightNeighborBound = otherElements + .filter( + (el) => + el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }), + ) + .reduce( + (bound, el) => + bound === null + ? el.startTime + : minMediaTime({ a: bound, b: el.startTime }), + null, + ); + + return [ + { + trackId, + elementId, + startTime: element.startTime, + duration: element.duration, + trimStart: element.trimStart, + trimEnd: element.trimEnd, + sourceDuration: element.sourceDuration, + retime: isRetimableElement(element) ? element.retime : undefined, + leftNeighborBound, + rightNeighborBound, + }, + ]; + }); +} + +function hasResizeChanges({ + members, + result, +}: { + members: GroupResizeMember[]; + result: GroupResizeResult; +}): boolean { + return result.updates.some((update) => { + const member = members.find((m) => m.elementId === update.elementId); + return ( + member?.trimStart !== update.patch.trimStart || + member?.trimEnd !== update.patch.trimEnd || + member?.startTime !== update.patch.startTime || + member?.duration !== update.patch.duration + ); + }); +} + +// --- Controller --- + +export class ResizeController { + private session: Session = { kind: "idle" }; + private readonly subscribers = new Set<() => void>(); + private readonly configRef: ResizeConfigRef; + + constructor(deps: { configRef: ResizeConfigRef }) { + this.configRef = deps.configRef; + this.onResizeStart = this.onResizeStart.bind(this); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseUp = this.handleMouseUp.bind(this); + } + + private get config(): ResizeConfig { + return this.configRef.current; + } + + get isResizing(): boolean { + return this.session.kind === "active"; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + cancel(): void { + this.config.discardPreview(); + this.finishSession(); + } + + destroy(): void { + this.deactivate(); + this.subscribers.clear(); + } + + onResizeStart({ + event, + element, + track, + side, + }: { + event: ReactMouseEvent; + element: TimelineElement; + track: TimelineTrack; + side: ResizeSide; + }): void { + event.stopPropagation(); + event.preventDefault(); + + // UI should prevent this, but be explicit: a new resize start + // means the previous one is abandoned, not silently replaced. + if (this.session.kind === "active") this.cancel(); + + const fps = this.config.getActiveProjectFps(); + if (!fps) return; + + const ref = { trackId: track.id, elementId: element.id }; + const activeSelection = this.config.selectedElements.some( + (el) => el.trackId === track.id && el.elementId === element.id, + ) + ? this.config.selectedElements + : [ref]; + + const members = buildResizeMembers({ + tracks: this.config.getSceneTracks(), + selectedElements: activeSelection, + }); + if (members.length === 0) return; + + this.config.discardPreview(); + + this.session = { + kind: "active", + side, + startX: event.clientX, + fps, + members, + result: null, + }; + this.activate(); + this.notify(); + } + + 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.config.onSnapPointChange?.(null); + this.notify(); + } + + private snappedDelta( + session: ResizeSession, + rawDeltaTime: MediaTime, + ): MediaTime { + const { snappingEnabled, isShiftHeld, zoomLevel } = this.config; + + if (!snappingEnabled || isShiftHeld()) { + this.config.onSnapPointChange?.(null); + return rawDeltaTime; + } + + const tracks = this.config.getSceneTracks(); + const playheadTime = this.config.getCurrentPlayheadTime(); + const excludeElementIds = new Set(session.members.map((m) => m.elementId)); + + const snapPoints = buildTimelineSnapPoints({ + sources: [ + () => getElementEdgeSnapPoints({ tracks, excludeElementIds }), + () => getPlayheadSnapPoints({ playheadTime }), + () => + getAnimationKeyframeSnapPointsForTimeline({ + tracks, + excludeElementIds, + }), + ], + }); + const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel }); + + let closestSnapPoint: SnapPoint | null = null; + let closestSnapDistance = Infinity; + let deltaTime = rawDeltaTime; + + for (const member of session.members) { + const baseEdgeTime = + session.side === "left" + ? member.startTime + : addMediaTime({ a: member.startTime, b: member.duration }); + const snapResult = resolveTimelineSnap({ + targetTime: addMediaTime({ a: baseEdgeTime, b: rawDeltaTime }), + snapPoints, + maxSnapDistance, + }); + if ( + snapResult.snapPoint && + snapResult.snapDistance < closestSnapDistance + ) { + closestSnapDistance = snapResult.snapDistance; + closestSnapPoint = snapResult.snapPoint; + deltaTime = subMediaTime({ a: snapResult.snappedTime, b: baseEdgeTime }); + } + } + + this.config.onSnapPointChange?.(closestSnapPoint); + return deltaTime; + } + + private handleMouseMove({ clientX }: MouseEvent): void { + if (this.session.kind !== "active") return; + const session = this.session; + + const rawDeltaTime = mediaTime({ + ticks: Math.round( + ((clientX - session.startX) / + (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) * + TICKS_PER_SECOND, + ), + }); + const deltaTime = this.snappedDelta(session, rawDeltaTime); + const result = computeGroupResize({ + members: session.members, + side: session.side, + deltaTime, + fps: session.fps, + }); + + session.result = result; + this.config.previewElements(result.updates); + } + + private handleMouseUp(): void { + if (this.session.kind !== "active") return; + const session = this.session; + + this.config.discardPreview(); + + if ( + session.result && + hasResizeChanges({ members: session.members, result: session.result }) + ) { + this.config.commitElements(session.result.updates); + } + + this.finishSession(); + } +} diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts new file mode 100644 index 00000000..67dc6e96 --- /dev/null +++ b/apps/web/src/timeline/controllers/seek-controller.ts @@ -0,0 +1,210 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import type { FrameRate } from "opencut-wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { mediaTime, snapSeekMediaTime, TICKS_PER_SECOND, type MediaTime } from "@/wasm"; + +type SeekSource = "ruler" | "tracks"; + +interface PendingSeekSession { + kind: "pending"; + source: SeekSource; + downX: number; + downY: number; + downTime: number; +} + +type Session = { kind: "idle" } | PendingSeekSession; + +export interface SeekConfig { + zoomLevel: number; + duration: MediaTime; + isSelecting: boolean; + getPlayheadEl: () => HTMLDivElement | null; + getTrackLabelsEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getActiveProjectFps: () => FrameRate | null; + clearSelectedElements: () => void; + seek: (time: MediaTime) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: MediaTime; + }) => void; +} + +export interface SeekConfigRef { + readonly current: SeekConfig; +} + +function pixelToTime({ + clientX, + scrollContainer, + zoomLevel, + duration, +}: { + clientX: number; + scrollContainer: HTMLDivElement; + zoomLevel: number; + duration: MediaTime; +}): MediaTime { + const rect = scrollContainer.getBoundingClientRect(); + const mouseX = clientX - rect.left; + const scrollLeft = scrollContainer.scrollLeft; + + const rawTimeSeconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + + return mediaTime({ ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND) }); +} + +function isClickGesture({ + event, + session, +}: { + event: ReactMouseEvent; + session: PendingSeekSession; +}): boolean { + const deltaX = Math.abs(event.clientX - session.downX); + const deltaY = Math.abs(event.clientY - session.downY); + const deltaTime = event.timeStamp - session.downTime; + + return deltaX <= 5 && deltaY <= 5 && deltaTime <= 500; +} + +export class SeekController { + private session: Session = { kind: "idle" }; + private readonly configRef: SeekConfigRef; + + constructor(deps: { configRef: SeekConfigRef }) { + this.configRef = deps.configRef; + this.onTracksMouseDown = this.onTracksMouseDown.bind(this); + this.onRulerMouseDown = this.onRulerMouseDown.bind(this); + this.onTracksClick = this.onTracksClick.bind(this); + this.onRulerClick = this.onRulerClick.bind(this); + } + + private get config(): SeekConfig { + return this.configRef.current; + } + + destroy(): void { + this.session = { kind: "idle" }; + } + + onTracksMouseDown(event: ReactMouseEvent): void { + this.beginPendingSeek({ event, source: "tracks" }); + } + + onRulerMouseDown(event: ReactMouseEvent): void { + this.beginPendingSeek({ event, source: "ruler" }); + } + + onTracksClick(event: ReactMouseEvent): void { + this.handleClick({ event, source: "tracks" }); + } + + onRulerClick(event: ReactMouseEvent): void { + this.handleClick({ event, source: "ruler" }); + } + + private beginPendingSeek({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + if (event.button !== 0) return; + + this.session = { + kind: "pending", + source, + downX: event.clientX, + downY: event.clientY, + downTime: event.timeStamp, + }; + } + + private handleClick({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + const shouldProcess = this.shouldProcessClick({ event, source }); + this.session = { kind: "idle" }; + + if (!shouldProcess) return; + + this.config.clearSelectedElements(); + this.seekFromEvent({ event, source }); + } + + private shouldProcessClick({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): boolean { + if (this.session.kind !== "pending") return false; + if (this.session.source !== source) return false; + if (!isClickGesture({ event, session: this.session })) return false; + if (this.config.isSelecting) return false; + + const target = event.target as HTMLElement; + if (this.config.getPlayheadEl()?.contains(target)) return false; + + if (this.config.getTrackLabelsEl()?.contains(target)) { + this.config.clearSelectedElements(); + return false; + } + + return true; + } + + private seekFromEvent({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + const scrollContainer = + source === "ruler" + ? this.config.getRulerScrollEl() + : this.config.getTracksScrollEl(); + if (!scrollContainer) return; + + const rawTime = pixelToTime({ + clientX: event.clientX, + scrollContainer, + zoomLevel: this.config.zoomLevel, + duration: this.config.duration, + }); + + const fps = this.config.getActiveProjectFps(); + const time = + fps != null + ? snapSeekMediaTime({ + time: rawTime, + duration: this.config.duration, + fps, + }) + : rawTime; + + this.config.seek(time); + this.config.setTimelineViewState({ + zoomLevel: this.config.zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + playheadTime: time, + }); + } +} diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts new file mode 100644 index 00000000..c5090e16 --- /dev/null +++ b/apps/web/src/timeline/controllers/zoom-controller.ts @@ -0,0 +1,286 @@ +import type { WheelEvent as ReactWheelEvent } from "react"; +import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { TIMELINE_ZOOM_MAX } from "@/timeline/scale"; +import { zoomToSlider } from "@/timeline/zoom-utils"; +import type { MediaTime } from "@/wasm"; + +type ZoomUpdater = number | ((prev: number) => number); + +export interface ZoomConfig { + minZoom: number; + getContainerEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getCurrentPlayheadTime: () => MediaTime; + seek: (time: MediaTime) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: MediaTime; + }) => void; +} + +export interface ZoomConfigRef { + readonly current: ZoomConfig; +} + +function clampZoom(zoomLevel: number, minZoom: number): number { + return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel)); +} + +export class ZoomController { + private readonly configRef: ZoomConfigRef; + private readonly subscribers = new Set<() => void>(); + + private zoomLevelValue: number; + private hasInitialized = false; + private hasRestoredPlayhead = false; + private hasRestoredScroll = false; + private previousZoom: number; + private preZoomScrollLeft = 0; + private prePlayheadAnchorScrollLeft = 0; + private isInPlayheadAnchorMode = false; + private scrollSaveTimeout: ReturnType | null = null; + + constructor(deps: { configRef: ZoomConfigRef; initialZoom?: number }) { + this.configRef = deps.configRef; + + const minZoom = this.config.minZoom; + this.zoomLevelValue = + deps.initialZoom !== undefined + ? clampZoom(deps.initialZoom, minZoom) + : minZoom; + this.previousZoom = this.zoomLevelValue; + this.hasInitialized = deps.initialZoom !== undefined; + + this.setZoomLevel = this.setZoomLevel.bind(this); + this.handleWheel = this.handleWheel.bind(this); + this.saveScrollPosition = this.saveScrollPosition.bind(this); + } + + private get config(): ZoomConfig { + return this.configRef.current; + } + + get zoomLevel(): number { + return this.zoomLevelValue; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + if (this.scrollSaveTimeout) { + clearTimeout(this.scrollSaveTimeout); + this.scrollSaveTimeout = null; + } + } + + setZoomLevel(zoomLevelOrUpdater: ZoomUpdater): void { + const scrollElement = this.config.getTracksScrollEl(); + if (scrollElement) { + this.preZoomScrollLeft = scrollElement.scrollLeft; + } + + const nextZoomRaw = + typeof zoomLevelOrUpdater === "function" + ? zoomLevelOrUpdater(this.zoomLevelValue) + : zoomLevelOrUpdater; + const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom); + if (nextZoom === this.zoomLevelValue) return; + + this.zoomLevelValue = nextZoom; + this.notify(); + } + + handleWheel(event: ReactWheelEvent): void { + const isZoomGesture = event.ctrlKey || event.metaKey; + const isHorizontalScrollGesture = + event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); + + if (isHorizontalScrollGesture) { + return; + } + + if (isZoomGesture) { + const normalizedDelta = + event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; + const cappedDelta = + Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + this.setZoomLevel((prev) => prev * zoomFactor); + } + } + + reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void { + if (initialZoom !== undefined && !this.hasInitialized) { + this.hasInitialized = true; + this.setZoomLevel(clampZoom(initialZoom, minZoom)); + return; + } + + if (this.zoomLevelValue < minZoom) { + this.setZoomLevel(minZoom); + } + } + + applyZoomLayout(zoomLevel: number): void { + const previousZoom = this.previousZoom; + if (previousZoom === zoomLevel) return; + + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) { + this.previousZoom = zoomLevel; + return; + } + + const currentScrollLeft = this.preZoomScrollLeft; + const playheadTime = this.config.getCurrentPlayheadTime(); + const sliderPercent = zoomToSlider({ + zoomLevel, + minZoom: this.config.minZoom, + }); + const previousSliderPercent = zoomToSlider({ + zoomLevel: previousZoom, + minZoom: this.config.minZoom, + }); + const isCrossingThresholdUp = + previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + const isCrossingThresholdDown = + previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + + const syncScroll = (scrollLeft: number) => { + scrollElement.scrollLeft = scrollLeft; + const ruler = this.config.getRulerScrollEl(); + if (ruler) { + ruler.scrollLeft = scrollLeft; + } + }; + + const clampScrollLeft = (scrollLeft: number) => { + const maxScrollLeft = + scrollElement.scrollWidth - scrollElement.clientWidth; + return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); + }; + + if (isCrossingThresholdUp) { + this.prePlayheadAnchorScrollLeft = currentScrollLeft; + this.isInPlayheadAnchorMode = true; + } + + if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { + const playheadPixelsBefore = timelineTimeToPixels({ + time: playheadTime, + zoomLevel: previousZoom, + }); + const playheadPixelsAfter = timelineTimeToPixels({ + time: playheadTime, + zoomLevel, + }); + const viewportOffset = playheadPixelsBefore - currentScrollLeft; + const nextScrollLeft = playheadPixelsAfter - viewportOffset; + syncScroll(clampScrollLeft(nextScrollLeft)); + } else if (isCrossingThresholdDown && this.isInPlayheadAnchorMode) { + syncScroll(clampScrollLeft(this.prePlayheadAnchorScrollLeft)); + this.isInPlayheadAnchorMode = false; + } + + this.previousZoom = zoomLevel; + + this.config.setTimelineViewState({ + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime, + }); + } + + saveScrollPosition(): void { + if (this.scrollSaveTimeout) { + clearTimeout(this.scrollSaveTimeout); + } + + this.scrollSaveTimeout = setTimeout(() => { + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) return; + + this.config.setTimelineViewState({ + zoomLevel: this.zoomLevelValue, + scrollLeft: scrollElement.scrollLeft, + playheadTime: this.config.getCurrentPlayheadTime(), + }); + }, 300); + } + + restoreInitialScrollIfNeeded( + initialScrollLeft?: number, + ): (() => void) | undefined { + if (initialScrollLeft === undefined) return; + if (this.hasRestoredScroll) return; + + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) return; + + const restoreScroll = () => { + scrollElement.scrollLeft = initialScrollLeft; + const ruler = this.config.getRulerScrollEl(); + if (ruler) { + ruler.scrollLeft = initialScrollLeft; + } + this.hasRestoredScroll = true; + this.preZoomScrollLeft = initialScrollLeft; + }; + + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + return; + } + + const observer = new ResizeObserver(() => { + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + observer.disconnect(); + } + }); + observer.observe(scrollElement); + return () => observer.disconnect(); + } + + restoreInitialPlayheadIfNeeded(initialPlayheadTime?: MediaTime): void { + if (initialPlayheadTime === undefined) return; + if (this.hasRestoredPlayhead) return; + + this.hasRestoredPlayhead = true; + this.config.seek(initialPlayheadTime); + } + + bindPreventBrowserZoom(): () => void { + const preventZoom = (event: WheelEvent) => { + const isZoomKeyPressed = event.ctrlKey || event.metaKey; + const container = this.config.getContainerEl(); + const isInContainer = container?.contains(event.target as Node) ?? false; + if (isZoomKeyPressed && isInContainer) { + event.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { + passive: false, + capture: true, + }); + + return () => { + document.removeEventListener("wheel", preventZoom, { capture: true }); + }; + } + + private notify(): void { + for (const fn of this.subscribers) { + fn(); + } + } +} diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts index f3c93b8d..0f28af56 100644 --- a/apps/web/src/timeline/creation.ts +++ b/apps/web/src/timeline/creation.ts @@ -1,5 +1,17 @@ -import { mediaTime, TICKS_PER_SECOND } from "@/wasm"; +import { mediaTime, mediaTimeFromSeconds, TICKS_PER_SECOND } from "@/wasm"; export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({ ticks: 5 * TICKS_PER_SECOND, }); + +export function toElementDurationTicks({ + seconds, +}: { + seconds: number | null | undefined; +}) { + if (seconds == null) { + return DEFAULT_NEW_ELEMENT_DURATION; + } + + return mediaTimeFromSeconds({ seconds }); +} diff --git a/apps/web/src/timeline/drag-data.ts b/apps/web/src/timeline/drag-data.ts deleted file mode 100644 index 1fc9ce9a..00000000 --- a/apps/web/src/timeline/drag-data.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { TimelineDragData } from "@/timeline/drag"; - -const MIME_TYPE = "application/x-timeline-drag"; -let lastDragData: TimelineDragData | null = null; - -export function setDragData({ - dataTransfer, - dragData, -}: { - dataTransfer: DataTransfer; - dragData: TimelineDragData; -}): void { - dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData)); - dataTransfer.setData("text/plain", JSON.stringify(dragData)); - lastDragData = dragData; -} - -export function getDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): TimelineDragData | null { - const data = dataTransfer.getData(MIME_TYPE); - if (data) return JSON.parse(data) as TimelineDragData; - - const textData = dataTransfer.getData("text/plain"); - if (textData) { - try { - return JSON.parse(textData) as TimelineDragData; - } catch { - return lastDragData; - } - } - - return lastDragData; -} - -export function hasDragData({ - dataTransfer, -}: { - dataTransfer: DataTransfer; -}): boolean { - return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null; -} - -export function clearDragData(): void { - lastDragData = null; -} diff --git a/apps/web/src/timeline/drag-source.ts b/apps/web/src/timeline/drag-source.ts new file mode 100644 index 00000000..503c547b --- /dev/null +++ b/apps/web/src/timeline/drag-source.ts @@ -0,0 +1,40 @@ +import type { TimelineDragData } from "@/timeline/drag"; + +const TIMELINE_DRAG_MIME = "application/x-timeline-drag"; + +/** + * Owns the state of an in-progress timeline drag session. + * + * Exists because browsers restrict `DataTransfer.getData()` to the `drop` + * event for security — during `dragover`/`dragenter` only `types` is + * readable. The drop target needs the payload (element type, target + * element types, source duration) while the pointer is hovering, so we + * keep a live copy here and hand it out via {@link getActive}. + */ +export class TimelineDragSource { + private active: TimelineDragData | null = null; + + begin({ + dataTransfer, + dragData, + }: { + dataTransfer: DataTransfer; + dragData: TimelineDragData; + }): void { + dataTransfer.setData(TIMELINE_DRAG_MIME, JSON.stringify(dragData)); + dataTransfer.effectAllowed = "copy"; + this.active = dragData; + } + + end(): void { + this.active = null; + } + + getActive(): TimelineDragData | null { + return this.active; + } + + isActive(): boolean { + return this.active !== null; + } +} diff --git a/apps/web/src/timeline/element-snap-source.ts b/apps/web/src/timeline/element-snap-source.ts index ec1e5f65..26aa86f6 100644 --- a/apps/web/src/timeline/element-snap-source.ts +++ b/apps/web/src/timeline/element-snap-source.ts @@ -1,5 +1,6 @@ import type { SceneTracks } from "@/timeline"; import type { SnapPoint } from "@/timeline/snapping"; +import { addMediaTime } from "@/wasm"; export function getElementEdgeSnapPoints({ tracks, @@ -25,7 +26,7 @@ export function getElementEdgeSnapPoints({ trackId: track.id, }, { - time: element.startTime + element.duration, + time: addMediaTime({ a: element.startTime, b: element.duration }), type: "element-end", elementId: element.id, trackId: track.id, diff --git a/apps/web/src/timeline/group-move/snap.ts b/apps/web/src/timeline/group-move/snap.ts index aa06a35b..8b6d196b 100644 --- a/apps/web/src/timeline/group-move/snap.ts +++ b/apps/web/src/timeline/group-move/snap.ts @@ -63,7 +63,7 @@ export function snapGroupEdges({ ) { closestSnapDistance = memberStartSnap.snapDistance; snappedAnchorStartTime = subMediaTime({ - a: memberStartSnap.snappedTime as MediaTime, + a: memberStartSnap.snappedTime, b: member.timeOffset, }); snapPoint = memberStartSnap.snapPoint; @@ -84,7 +84,7 @@ export function snapGroupEdges({ closestSnapDistance = memberEndSnap.snapDistance; snappedAnchorStartTime = subMediaTime({ a: subMediaTime({ - a: memberEndSnap.snappedTime as MediaTime, + a: memberEndSnap.snappedTime, b: member.duration, }), b: member.timeOffset, diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts index bbe829cc..465531bf 100644 --- a/apps/web/src/timeline/group-resize/compute-resize.ts +++ b/apps/web/src/timeline/group-resize/compute-resize.ts @@ -1,9 +1,20 @@ -import { roundToFrame } from "opencut-wasm"; import { getSourceSpanAtClipTime, getTimelineDurationForSourceSpan, } from "@/retime"; -import { TICKS_PER_SECOND, roundMediaTime } from "@/wasm"; +import { + addMediaTime, + clampMediaTime, + maxMediaTime, + type MediaTime, + mediaTime, + minMediaTime, + roundFrameTicks, + roundMediaTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import type { ComputeGroupResizeArgs, GroupResizeMember, @@ -18,31 +29,54 @@ export function computeGroupResize({ deltaTime, fps, }: ComputeGroupResizeArgs): GroupResizeResult { - const minDuration = Math.round( - (TICKS_PER_SECOND * fps.denominator) / fps.numerator, - ); - const minimumDeltaTime = Math.max( - ...members.map((member) => - getMinimumAllowedDeltaTime({ + if (members.length === 0) { + return { deltaTime: ZERO_MEDIA_TIME, updates: [] }; + } + + const minDuration = mediaTime({ + ticks: Math.round((TICKS_PER_SECOND * fps.denominator) / fps.numerator), + }); + let minimumDeltaTime = getMinimumAllowedDeltaTime({ + member: members[0], + side, + minDuration, + }); + let maximumDeltaTime = getMaximumAllowedDeltaTime({ + member: members[0], + side, + minDuration, + }); + + for (const member of members.slice(1)) { + minimumDeltaTime = maxMediaTime({ + a: minimumDeltaTime, + b: getMinimumAllowedDeltaTime({ member, side, minDuration, }), - ), - ); - const maximumDeltaTime = Math.min( - ...members.map((member) => - getMaximumAllowedDeltaTime({ - member, - side, - minDuration, - }), - ), - ); + }); + const memberMaximum = getMaximumAllowedDeltaTime({ + member, + side, + minDuration, + }); + if (memberMaximum !== null) { + maximumDeltaTime = + maximumDeltaTime === null + ? memberMaximum + : minMediaTime({ a: maximumDeltaTime, b: memberMaximum }); + } + } + const clampedDeltaTime = - minimumDeltaTime > maximumDeltaTime - ? minimumDeltaTime - : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime)); + maximumDeltaTime === null + ? maxMediaTime({ a: minimumDeltaTime, b: deltaTime }) + : clampMediaTime({ + time: deltaTime, + min: minimumDeltaTime, + max: maximumDeltaTime, + }); // Snap the drag delta to a frame exactly once, then derive every patch // field from that single snapped value. This keeps the invariant @@ -51,22 +85,24 @@ export function computeGroupResize({ // so the rounding cancels by construction. Per-field rounding (the old // approach) couldn't preserve this because the individual rounds don't // compose when `sourceDuration` isn't frame-aligned. - const snappedDeltaTime = - roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime; + const snappedDeltaTime = mediaTime({ + ticks: roundFrameTicks({ ticks: clampedDeltaTime, fps }), + }); // Re-clamp after rounding. Bounds derived from other elements are // frame-aligned, so this is normally a no-op; at the source-extent limit // the bound may not be frame-aligned, and honouring the bound takes // precedence over frame alignment (you can't extend past real content). const finalDeltaTime = - minimumDeltaTime > maximumDeltaTime - ? minimumDeltaTime - : Math.min( - maximumDeltaTime, - Math.max(minimumDeltaTime, snappedDeltaTime), - ); + maximumDeltaTime === null + ? maxMediaTime({ a: minimumDeltaTime, b: snappedDeltaTime }) + : clampMediaTime({ + time: snappedDeltaTime, + min: minimumDeltaTime, + max: maximumDeltaTime, + }); return { - deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime, + deltaTime: Object.is(finalDeltaTime, -0) ? ZERO_MEDIA_TIME : finalDeltaTime, updates: members.map((member) => buildResizeUpdate({ member, @@ -84,7 +120,7 @@ function buildResizeUpdate({ }: { member: GroupResizeMember; side: ResizeSide; - deltaTime: number; + deltaTime: MediaTime; }): GroupResizeUpdate { const sourceDelta = getSourceDeltaForClipDelta({ member, @@ -96,10 +132,13 @@ function buildResizeUpdate({ trackId: member.trackId, elementId: member.elementId, patch: { - trimStart: Math.max(0, member.trimStart + sourceDelta), + trimStart: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: addMediaTime({ a: member.trimStart, b: sourceDelta }), + }), trimEnd: member.trimEnd, - startTime: member.startTime + deltaTime, - duration: member.duration - deltaTime, + startTime: addMediaTime({ a: member.startTime, b: deltaTime }), + duration: subMediaTime({ a: member.duration, b: deltaTime }), }, }; } @@ -109,9 +148,12 @@ function buildResizeUpdate({ elementId: member.elementId, patch: { trimStart: member.trimStart, - trimEnd: Math.max(0, member.trimEnd - sourceDelta), + trimEnd: maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ a: member.trimEnd, b: sourceDelta }), + }), startTime: member.startTime, - duration: member.duration + deltaTime, + duration: addMediaTime({ a: member.duration, b: deltaTime }), }, }; } @@ -123,29 +165,37 @@ function getMinimumAllowedDeltaTime({ }: { member: GroupResizeMember; side: ResizeSide; - minDuration: number; -}): number { + minDuration: MediaTime; +}): MediaTime { if (side === "right") { - return minDuration - member.duration; + return subMediaTime({ a: minDuration, b: member.duration }); } - const leftNeighborFloor = Number.isFinite(member.leftNeighborBound) - ? member.leftNeighborBound - member.startTime - : -member.startTime; + const leftNeighborFloor = + member.leftNeighborBound !== null + ? subMediaTime({ a: member.leftNeighborBound, b: member.startTime }) + : subMediaTime({ a: ZERO_MEDIA_TIME, b: member.startTime }); if (member.sourceDuration == null) { return leftNeighborFloor; } - const maximumSourceExtension = - getDurationForVisibleSourceSpan({ + const maximumSourceExtension = subMediaTime({ + a: getDurationForVisibleSourceSpan({ member, - sourceSpan: - getVisibleSourceSpanForDuration({ + sourceSpan: addMediaTime({ + a: getVisibleSourceSpanForDuration({ member, duration: member.duration, - }) + member.trimStart, - }) - member.duration; - return Math.max(leftNeighborFloor, -maximumSourceExtension); + }), + b: member.trimStart, + }), + }), + b: member.duration, + }); + return maxMediaTime({ + a: leftNeighborFloor, + b: subMediaTime({ a: ZERO_MEDIA_TIME, b: maximumSourceExtension }), + }); } function getMaximumAllowedDeltaTime({ @@ -155,26 +205,38 @@ function getMaximumAllowedDeltaTime({ }: { member: GroupResizeMember; side: ResizeSide; - minDuration: number; -}): number { + minDuration: MediaTime; +}): MediaTime | null { if (side === "left") { - return member.duration - minDuration; + return subMediaTime({ a: member.duration, b: minDuration }); } - const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound) - ? member.rightNeighborBound - (member.startTime + member.duration) - : Infinity; + const rightNeighborCeiling = + member.rightNeighborBound === null + ? null + : subMediaTime({ + a: member.rightNeighborBound, + b: addMediaTime({ a: member.startTime, b: member.duration }), + }); if (member.sourceDuration == null) { return rightNeighborCeiling; } - const maximumVisibleSourceSpan = - getSourceDuration({ member }) - member.trimStart; + const maximumVisibleSourceSpan = subMediaTime({ + a: getSourceDuration({ member }), + b: member.trimStart, + }); const maximumDuration = getDurationForVisibleSourceSpan({ member, sourceSpan: maximumVisibleSourceSpan, }); - return Math.min(rightNeighborCeiling, maximumDuration - member.duration); + const sourceDurationCeiling = subMediaTime({ + a: maximumDuration, + b: member.duration, + }); + return rightNeighborCeiling === null + ? sourceDurationCeiling + : minMediaTime({ a: rightNeighborCeiling, b: sourceDurationCeiling }); } function getSourceDeltaForClipDelta({ @@ -182,8 +244,8 @@ function getSourceDeltaForClipDelta({ clipDelta, }: { member: GroupResizeMember; - clipDelta: number; -}): number { + clipDelta: MediaTime; +}): MediaTime { if (!member.retime) { return clipDelta; } @@ -206,15 +268,17 @@ function getVisibleSourceSpanForDuration({ duration, }: { member: GroupResizeMember; - duration: number; -}): number { + duration: MediaTime; +}): MediaTime { if (!member.retime) { return duration; } - return getSourceSpanAtClipTime({ - clipTime: duration, - retime: member.retime, + return roundMediaTime({ + time: getSourceSpanAtClipTime({ + clipTime: duration, + retime: member.retime, + }), }); } @@ -223,8 +287,8 @@ function getDurationForVisibleSourceSpan({ sourceSpan, }: { member: GroupResizeMember; - sourceSpan: number; -}): number { + sourceSpan: MediaTime; +}): MediaTime { if (!member.retime) { return sourceSpan; } @@ -237,17 +301,19 @@ function getDurationForVisibleSourceSpan({ }); } -function getSourceDuration({ member }: { member: GroupResizeMember }): number { - if (typeof member.sourceDuration === "number") { +function getSourceDuration({ member }: { member: GroupResizeMember }): MediaTime { + if (member.sourceDuration != null) { return member.sourceDuration; } - return ( - member.trimStart + - getVisibleSourceSpanForDuration({ + return addMediaTime({ + a: addMediaTime({ + a: member.trimStart, + b: getVisibleSourceSpanForDuration({ member, duration: member.duration, - }) + - member.trimEnd - ); + }), + }), + b: member.trimEnd, + }); } diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts index b07e6fe6..5c0f77f6 100644 --- a/apps/web/src/timeline/group-resize/types.ts +++ b/apps/web/src/timeline/group-resize/types.ts @@ -1,36 +1,37 @@ import type { FrameRate } from "opencut-wasm"; import type { ElementRef, RetimeConfig } from "@/timeline/types"; +import type { MediaTime } from "@/wasm"; export type ResizeSide = "left" | "right"; export interface GroupResizeMember extends ElementRef { - startTime: number; - duration: number; - trimStart: number; - trimEnd: number; - sourceDuration?: number; + startTime: MediaTime; + duration: MediaTime; + trimStart: MediaTime; + trimEnd: MediaTime; + sourceDuration?: MediaTime; retime?: RetimeConfig; - leftNeighborBound: number; - rightNeighborBound: number; + leftNeighborBound: MediaTime | null; + rightNeighborBound: MediaTime | null; } export interface GroupResizeUpdate extends ElementRef { patch: { - trimStart: number; - trimEnd: number; - startTime: number; - duration: number; + trimStart: MediaTime; + trimEnd: MediaTime; + startTime: MediaTime; + duration: MediaTime; }; } export interface GroupResizeResult { - deltaTime: number; + deltaTime: MediaTime; updates: GroupResizeUpdate[]; } export interface ComputeGroupResizeArgs { members: GroupResizeMember[]; side: ResizeSide; - deltaTime: number; + deltaTime: MediaTime; fps: FrameRate; } diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts index 9caca28a..26a21f85 100644 --- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts +++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts @@ -1,49 +1,17 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, - type RefObject, -} from "react"; +import { useEffect, useReducer, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; -import { - buildMoveGroup, - resolveGroupMove, - snapGroupEdges, - type GroupMoveResult, - type MoveGroup, -} from "@/timeline/group-move"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { - addMediaTime, - type MediaTime, - mediaTime, - subMediaTime, - TICKS_PER_SECOND, - ZERO_MEDIA_TIME, -} from "@/wasm"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame } from "opencut-wasm"; -import { computeDropTarget } from "@/timeline/components/drop-target"; -import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; -import { generateUUID } from "@/utils/id"; -import type { SnapPoint } from "@/timeline/snapping"; import { registerCanceller } from "@/editor/cancel-interaction"; -import type { - DropTarget, - ElementRef, - ElementDragState, - SceneTracks, - TimelineElement, - TimelineTrack, -} from "@/timeline"; +import { + ElementInteractionController, + type ElementInteractionDeps, + type ElementInteractionDepsRef, +} from "@/timeline/controllers/element-interaction-controller"; +import type { SnapPoint } from "@/timeline/snapping"; interface UseElementInteractionProps { zoomLevel: number; - timelineRef: RefObject; tracksContainerRef: RefObject; tracksScrollRef: RefObject; headerRef?: RefObject; @@ -51,133 +19,8 @@ interface UseElementInteractionProps { onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } -const MOUSE_BUTTON_RIGHT = 2; - -const initialDragState: ElementDragState = { - isDragging: false, - elementId: null, - dragElementIds: [], - dragTimeOffsets: {}, - trackId: null, - startMouseX: 0, - startMouseY: 0, - startElementTime: ZERO_MEDIA_TIME, - clickOffsetTime: ZERO_MEDIA_TIME, - currentTime: ZERO_MEDIA_TIME, - currentMouseY: 0, -}; - -interface PendingDragState { - elementId: string; - trackId: string; - selectedElements: ElementRef[]; - startMouseX: number; - startMouseY: number; - startElementTime: MediaTime; - clickOffsetTime: MediaTime; -} - -function getClickOffsetTime({ - clientX, - elementRect, - zoomLevel, -}: { - clientX: number; - elementRect: DOMRect; - zoomLevel: number; -}): MediaTime { - const clickOffsetX = clientX - elementRect.left; - const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return mediaTime({ - ticks: Math.round(seconds * TICKS_PER_SECOND), - }); -} - -function getVerticalDragDirection({ - startMouseY, - currentMouseY, -}: { - startMouseY: number; - currentMouseY: number; -}): "up" | "down" | null { - if (currentMouseY < startMouseY) return "up"; - if (currentMouseY > startMouseY) return "down"; - return null; -} - -function getDragDropTarget({ - clientX, - clientY, - elementId, - trackId, - tracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, -}: { - clientX: number; - clientY: number; - elementId: string; - trackId: string; - tracks: SceneTracks; - tracksContainerRef: RefObject; - tracksScrollRef: RefObject; - headerRef?: RefObject; - zoomLevel: number; - snappedTime: MediaTime; - verticalDragDirection?: "up" | "down" | null; -}): DropTarget | null { - const containerRect = tracksContainerRef.current?.getBoundingClientRect(); - const scrollContainer = tracksScrollRef.current; - if (!containerRect || !scrollContainer) return null; - - const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find( - ({ id }) => id === trackId, - ); - const movingElement = sourceTrack?.elements.find( - ({ id }) => id === elementId, - ); - if (!movingElement) return null; - - const elementDuration = movingElement.duration; - const scrollLeft = scrollContainer.scrollLeft; - const scrollTop = scrollContainer.scrollTop; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); - const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseX = clientX - scrollContainerRect.left + scrollLeft; - const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight; - - return computeDropTarget({ - elementType: movingElement.type, - mouseX, - mouseY, - tracks, - playheadTime: snappedTime, - isExternalDrop: false, - elementDuration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - startTimeOverride: snappedTime, - excludeElementId: movingElement.id, - verticalDragDirection, - }); -} - -interface StartDragParams - extends Omit< - ElementDragState, - "isDragging" | "currentTime" | "currentMouseY" - > { - initialCurrentTime: MediaTime; - initialCurrentMouseY: number; -} - export function useElementInteraction({ zoomLevel, - timelineRef, tracksContainerRef, tracksScrollRef, headerRef, @@ -186,597 +29,65 @@ export function useElementInteraction({ }: UseElementInteractionProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const sceneTracks = editor.scenes.getActiveScene().tracks; - const { - selectedElements, - isElementSelected, - selectElement, - handleElementClick: handleSelectionClick, - } = useElementSelection(); + const selection = useElementSelection(); - const [dragState, setDragState] = - useState(initialDragState); - const [dragDropTarget, setDragDropTarget] = useState(null); - const [isPendingDrag, setIsPendingDrag] = useState(false); - const pendingDragRef = useRef(null); - const moveGroupRef = useRef(null); - const newTrackIdsRef = useRef([]); - const groupMoveResultRef = useRef(null); - const lastMouseXRef = useRef(0); - const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null); - - const startDrag = useCallback( - ({ - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - initialCurrentTime, - initialCurrentMouseY, - }: StartDragParams) => { - setDragState({ - isDragging: true, - elementId, - dragElementIds, - dragTimeOffsets, - trackId, - startMouseX, - startMouseY, - startElementTime, - clickOffsetTime, - currentTime: initialCurrentTime, - currentMouseY: initialCurrentMouseY, - }); + const deps: ElementInteractionDeps = { + viewport: { + getZoomLevel: () => zoomLevel, + getTracksScrollEl: () => tracksScrollRef.current, + getTracksContainerEl: () => tracksContainerRef.current, + getHeaderEl: () => headerRef?.current ?? null, }, - [], - ); - - const endDrag = useCallback(() => { - moveGroupRef.current = null; - newTrackIdsRef.current = []; - groupMoveResultRef.current = null; - setDragState(initialDragState); - setDragDropTarget(null); - }, []); - - const cancelCurrentDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownLocationRef.current = null; - setIsPendingDrag(false); - endDrag(); - onSnapPointChange?.(null); - }, [endDrag, onSnapPointChange]); - - const resolveGroupDragMove = useCallback( - ({ - group, - snappedTime, - dropTarget, - }: { - group: MoveGroup; - snappedTime: MediaTime; - dropTarget: DropTarget | null; - }): GroupMoveResult | null => { - if (!dropTarget) { - return null; - } - - if (dropTarget.isNewTrack) { - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); - } - - const orderedTracks = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ]; - const targetTrack = orderedTracks[dropTarget.trackIndex]; - if (!targetTrack) { - return null; - } - - const existingTrackResult = resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "existingTrack", - anchorTargetTrackId: targetTrack.id, - }, - }); - if (existingTrackResult) { - return existingTrackResult; - } - - return resolveGroupMove({ - group, - tracks: sceneTracks, - anchorStartTime: snappedTime, - target: { - kind: "newTracks", - anchorInsertIndex: dropTarget.trackIndex, - newTrackIds: newTrackIdsRef.current, - }, - }); + input: { + isShiftHeld: () => isShiftHeldRef.current, }, - [sceneTracks], - ); + scene: { + getTracks: () => editor.scenes.getActiveScene().tracks, + getActiveFps: () => editor.project.getActive()?.settings.fps ?? null, + }, + selection: { + getSelected: () => selection.selectedElements, + isSelected: selection.isElementSelected, + select: selection.selectElement, + handleClick: selection.handleElementClick, + clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(), + }, + playback: { + getCurrentTime: () => editor.playback.getCurrentTime(), + }, + timeline: { + moveElements: (args) => editor.timeline.moveElements(args), + }, + snap: { + isEnabled: () => snappingEnabled, + onChange: onSnapPointChange, + }, + }; + + const depsRef = useRef(deps); + depsRef.current = deps; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ElementInteractionController({ + depsRef: depsRef as ElementInteractionDepsRef, + }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isActive, controller]); - return registerCanceller({ fn: cancelCurrentDrag }); - }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]); - - const getDragSnapResult = useCallback( - ({ - frameSnappedTime, - group, - }: { - frameSnappedTime: MediaTime; - group: MoveGroup | null; - }) => { - if (!group || !snappingEnabled || isShiftHeldRef.current) { - return { snappedTime: frameSnappedTime, snapPoint: null }; - } - - const groupSnap = snapGroupEdges({ - group, - anchorStartTime: frameSnappedTime, - tracks: sceneTracks, - playheadTime: editor.playback.getCurrentTime(), - zoomLevel, - }); - - return { - snappedTime: groupSnap.snappedAnchorStartTime, - snapPoint: groupSnap.snapPoint, - }; - }, - [snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX, clientY }: MouseEvent) => { - let startedDragThisEvent = false; - const timeline = timelineRef.current; - const scrollContainer = tracksScrollRef.current; - if (!timeline || !scrollContainer) return; - lastMouseXRef.current = clientX; - - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - const activeProject = editor.project.getActive(); - if (!activeProject) return; - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = - mouseTime > pendingDragRef.current.clickOffsetTime - ? subMediaTime({ - a: mouseTime, - b: pendingDragRef.current.clickOffsetTime, - }) - : ZERO_MEDIA_TIME; - const snappedTime = ( - roundToFrame({ - time: adjustedTime, - rate: activeProject.settings.fps, - }) ?? adjustedTime - ) as MediaTime; - const moveGroup = buildMoveGroup({ - anchorRef: { - trackId: pendingDragRef.current.trackId, - elementId: pendingDragRef.current.elementId, - }, - selectedElements: pendingDragRef.current.selectedElements, - tracks: sceneTracks, - }); - if (!moveGroup) { - return; - } - - moveGroupRef.current = moveGroup; - newTrackIdsRef.current = moveGroup.members.map(() => generateUUID()); - const dragTimeOffsets: Record = {}; - for (const member of moveGroup.members) { - dragTimeOffsets[member.elementId] = member.timeOffset; - } - const { - snappedTime: initialSnappedTime, - snapPoint: initialSnapPoint, - } = getDragSnapResult({ - frameSnappedTime: snappedTime, - group: moveGroup, - }); - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: pendingDragRef.current.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: pendingDragRef.current.elementId, - trackId: pendingDragRef.current.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime: initialSnappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - anchorDropTarget != null - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime: initialSnappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - startDrag({ - ...pendingDragRef.current, - dragElementIds: moveGroup.members.map((member) => member.elementId), - dragTimeOffsets, - initialCurrentTime: initialSnappedTime, - initialCurrentMouseY: clientY, - }); - onSnapPointChange?.(initialSnapPoint); - startedDragThisEvent = true; - pendingDragRef.current = null; - setIsPendingDrag(false); - } else { - return; - } - } - - if (startedDragThisEvent) { - return; - } - - if (dragState.elementId && dragState.trackId) { - const alreadySelected = isElementSelected({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - if (!alreadySelected) { - selectElement({ - trackId: dragState.trackId, - elementId: dragState.elementId, - }); - } - } - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const scrollLeft = scrollContainer.scrollLeft; - const mouseTime = getMouseTimeFromClientX({ - clientX, - containerRect: scrollContainer.getBoundingClientRect(), - zoomLevel, - scrollLeft, - }); - const adjustedTime = - mouseTime > dragState.clickOffsetTime - ? subMediaTime({ - a: mouseTime, - b: dragState.clickOffsetTime, - }) - : ZERO_MEDIA_TIME; - const fps = activeProject.settings.fps; - const frameSnappedTime = ( - roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime - ) as MediaTime; - - const moveGroup = moveGroupRef.current; - const { snappedTime, snapPoint } = getDragSnapResult({ - frameSnappedTime, - group: moveGroup, - }); - setDragState((previousDragState) => ({ - ...previousDragState, - currentTime: snappedTime, - currentMouseY: clientY, - })); - onSnapPointChange?.(snapPoint); - - if (dragState.elementId && dragState.trackId) { - const verticalDragDirection = getVerticalDragDirection({ - startMouseY: dragState.startMouseY, - currentMouseY: clientY, - }); - const anchorDropTarget = getDragDropTarget({ - clientX, - clientY, - elementId: dragState.elementId, - trackId: dragState.trackId, - tracks: sceneTracks, - tracksContainerRef, - tracksScrollRef, - headerRef, - zoomLevel, - snappedTime, - verticalDragDirection, - }); - const nextGroupMoveResult = - moveGroup && anchorDropTarget - ? resolveGroupDragMove({ - group: moveGroup, - snappedTime, - dropTarget: anchorDropTarget, - }) - : null; - groupMoveResultRef.current = nextGroupMoveResult; - setDragDropTarget( - anchorDropTarget && - (anchorDropTarget.isNewTrack || !nextGroupMoveResult) - ? { - ...anchorDropTarget, - isNewTrack: true, - } - : null, - ); - } - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.elementId, - dragState.startMouseY, - dragState.trackId, - zoomLevel, - isElementSelected, - selectElement, - editor.project, - timelineRef, - tracksScrollRef, - tracksContainerRef, - headerRef, - isPendingDrag, - startDrag, - getDragSnapResult, - resolveGroupDragMove, - sceneTracks, - onSnapPointChange, - ]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = ({ clientX, clientY }: MouseEvent) => { - if (!dragState.elementId || !dragState.trackId) return; - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y); - if ( - deltaX <= TIMELINE_DRAG_THRESHOLD_PX && - deltaY <= TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - endDrag(); - onSnapPointChange?.(null); - return; - } - } - - const moveGroup = moveGroupRef.current; - if (!moveGroup) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const groupMoveResult = groupMoveResultRef.current; - if (!groupMoveResult) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - const didMove = groupMoveResult.moves.some((move) => { - const currentMember = moveGroup.members.find( - (member) => member.elementId === move.elementId, - ); - const originalStartTime = addMediaTime({ - a: dragState.startElementTime, - b: currentMember?.timeOffset ?? ZERO_MEDIA_TIME, - }); - return ( - currentMember?.trackId !== move.targetTrackId || - originalStartTime !== move.newStartTime - ); - }); - if (!didMove && groupMoveResult.createTracks.length === 0) { - endDrag(); - onSnapPointChange?.(null); - return; - } - - editor.timeline.moveElements({ - moves: groupMoveResult.moves, - createTracks: groupMoveResult.createTracks, - }); - endDrag(); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.elementId, - dragState.startElementTime, - dragState.trackId, - endDrag, - onSnapPointChange, - editor.timeline, - ]); - - useEffect(() => { - if (!isPendingDrag) return; - - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - onSnapPointChange?.(null); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag, onSnapPointChange]); - - const handleElementMouseDown = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - const isRightClick = event.button === MOUSE_BUTTON_RIGHT; - - // right-click: don't stop propagation so ContextMenu can open - if (isRightClick) { - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: false, - }); - } - return; - } - - event.stopPropagation(); - mouseDownLocationRef.current = { x: event.clientX, y: event.clientY }; - - const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey; - - if (isMultiSelect) { - handleSelectionClick({ - trackId: track.id, - elementId: element.id, - isMultiKey: true, - }); - } - - const clickOffsetTime = getClickOffsetTime({ - clientX: event.clientX, - elementRect: event.currentTarget.getBoundingClientRect(), - zoomLevel, - }); - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const pendingSelectedElements = isElementSelected(elementRef) - ? selectedElements - : [elementRef]; - pendingDragRef.current = { - elementId: element.id, - trackId: track.id, - selectedElements: pendingSelectedElements, - startMouseX: event.clientX, - startMouseY: event.clientY, - startElementTime: element.startTime, - clickOffsetTime, - }; - setIsPendingDrag(true); - }, - [zoomLevel, isElementSelected, handleSelectionClick, selectedElements], - ); - - const handleElementClick = useCallback( - ({ - event, - element, - track, - }: { - event: ReactMouseEvent; - element: TimelineElement; - track: TimelineTrack; - }) => { - event.stopPropagation(); - - if (mouseDownLocationRef.current) { - const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x); - const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y); - if ( - deltaX > TIMELINE_DRAG_THRESHOLD_PX || - deltaY > TIMELINE_DRAG_THRESHOLD_PX - ) { - mouseDownLocationRef.current = null; - return; - } - } - - // modifier keys already handled in mousedown - if (event.metaKey || event.ctrlKey || event.shiftKey) return; - - const alreadySelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - if (!alreadySelected || selectedElements.length > 1) { - selectElement({ trackId: track.id, elementId: element.id }); - return; - } - - editor.selection.clearKeyframeSelection(); - }, - [editor.selection, isElementSelected, selectElement, selectedElements], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - dragState, - dragDropTarget, - handleElementMouseDown, - handleElementClick, - lastMouseXRef, + dragView: controller.view, + handleElementMouseDown: controller.onElementMouseDown, + handleElementClick: controller.onElementClick, }; } diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts index afead4e9..ea03cb5c 100644 --- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts +++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts @@ -1,48 +1,16 @@ -import { - useState, - useCallback, - useEffect, - useRef, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; -import { getKeyframeById } from "@/animation"; import { useKeyframeSelection } from "./use-keyframe-selection"; -import { roundToFrame, snappedSeekTime } from "opencut-wasm"; -import { timelineTimeToSnappedPixels } from "@/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { - addMediaTime, - type MediaTime, - maxMediaTime, - mediaTime, - minMediaTime, - TICKS_PER_SECOND, - ZERO_MEDIA_TIME, -} from "@/wasm"; -import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -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"; import { registerCanceller } from "@/editor/cancel-interaction"; -export interface KeyframeDragState { - isDragging: boolean; - draggingKeyframeIds: Set; - deltaTime: MediaTime; -} +import { + KeyframeDragController, + type KeyframeDragConfig, + type KeyframeDragState, +} from "@/timeline/controllers/keyframe-drag-controller"; +import type { TimelineElement } from "@/timeline"; +import type { MediaTime } from "@/wasm"; -const initialDragState: KeyframeDragState = { - isDragging: false, - draggingKeyframeIds: new Set(), - deltaTime: ZERO_MEDIA_TIME, -}; - -interface PendingKeyframeDrag { - keyframeRefs: SelectedKeyframeRef[]; - startMouseX: number; -} +export type { KeyframeDragState }; export function useKeyframeDrag({ zoomLevel, @@ -62,286 +30,44 @@ export function useKeyframeDrag({ selectKeyframeRange, } = useKeyframeSelection(); - const [dragState, setDragState] = - useState(initialDragState); - const [isPendingDrag, setIsPendingDrag] = useState(false); - - const pendingDragRef = useRef(null); - const mouseDownXRef = useRef(null); - - const activeProject = editor.project.getActive(); - const fps = activeProject.settings.fps; - - const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - - const endDrag = useCallback(() => { - setDragState(initialDragState); - }, []); - - const cancelDrag = useCallback(() => { - pendingDragRef.current = null; - mouseDownXRef.current = null; - setIsPendingDrag(false); - endDrag(); - }, [endDrag]); - - const commitDrag = useCallback( - ({ - keyframeRefs, - deltaTime, - }: { - keyframeRefs: SelectedKeyframeRef[]; - deltaTime: MediaTime; - }) => { - const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const keyframe = getKeyframeById({ - animations: element.animations, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - }); - if (!keyframe) return []; - const nextTime = maxMediaTime({ - a: ZERO_MEDIA_TIME, - b: minMediaTime({ - a: element.duration, - b: addMediaTime({ a: keyframe.time, b: deltaTime }), - }), - }); - return [ - new RetimeKeyframeCommand({ - trackId: keyframeRef.trackId, - elementId: keyframeRef.elementId, - propertyPath: keyframeRef.propertyPath, - keyframeId: keyframeRef.keyframeId, - nextTime, - }), - ]; - }); - - if (commands.length === 1) { - editor.command.execute({ command: commands[0] }); - } else if (commands.length > 1) { - editor.command.execute({ command: new BatchCommand(commands) }); - } - }, - [editor.command, element], - ); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - return registerCanceller({ fn: cancelDrag }); - }, [dragState.isDragging, isPendingDrag, cancelDrag]); - - useEffect(() => { - if (!dragState.isDragging && !isPendingDrag) return; - - const handleMouseMove = ({ clientX }: MouseEvent) => { - if (isPendingDrag && pendingDragRef.current) { - const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX); - if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return; - - const pending = pendingDragRef.current; - pendingDragRef.current = null; - setIsPendingDrag(false); - setDragState({ - isDragging: true, - draggingKeyframeIds: new Set( - pending.keyframeRefs.map((keyframe) => keyframe.keyframeId), - ), - deltaTime: ZERO_MEDIA_TIME, - }); - return; - } - - if (!dragState.isDragging) return; - - const startX = mouseDownXRef.current ?? clientX; - const rawDelta = mediaTime({ - ticks: Math.round( - ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND, - ), - }); - const snappedDelta = ( - roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta - ) as MediaTime; - - setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); - }; - - document.addEventListener("mousemove", handleMouseMove); - return () => document.removeEventListener("mousemove", handleMouseMove); - }, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]); - - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseUp = () => { - const draggingRefs = selectedKeyframes.filter( - (keyframe) => - keyframe.elementId === element.id && - dragState.draggingKeyframeIds.has(keyframe.keyframeId), - ); - - if (draggingRefs.length > 0 && dragState.deltaTime !== 0) { - commitDrag({ - keyframeRefs: draggingRefs, - deltaTime: dragState.deltaTime, - }); - } - - endDrag(); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [ - dragState.isDragging, - dragState.draggingKeyframeIds, - dragState.deltaTime, + const config: KeyframeDragConfig = { + zoomLevel, + element, + displayedStartTime, + getFps: () => editor.project.getActive()?.settings.fps ?? null, selectedKeyframes, - element.id, - commitDrag, - endDrag, - ]); + isKeyframeSelected, + setKeyframeSelection, + toggleKeyframeSelection, + selectKeyframeRange, + executeCommand: (command) => editor.command.execute({ command }), + seek: ({ time }) => editor.playback.seek({ time }), + getTotalDuration: () => editor.timeline.getTotalDuration(), + }; + + const configRef = useRef(config); + configRef.current = config; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new KeyframeDragController({ configRef }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (!isPendingDrag) return; + if (!controller.isActive) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isActive, controller]); - const handleMouseUp = () => { - pendingDragRef.current = null; - setIsPendingDrag(false); - }; - - document.addEventListener("mouseup", handleMouseUp); - return () => document.removeEventListener("mouseup", handleMouseUp); - }, [isPendingDrag]); - - const handleKeyframeMouseDown = useCallback( - ({ - event, - keyframes, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => { - event.preventDefault(); - event.stopPropagation(); - - mouseDownXRef.current = event.clientX; - - const anySelected = keyframes.some((keyframe) => - isKeyframeSelected({ keyframe }), - ); - - const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey; - if (!anySelected && !isModifierKey) { - setKeyframeSelection({ keyframes }); - } - - const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes; - - pendingDragRef.current = { - keyframeRefs: keyframeRefsToTrack, - startMouseX: event.clientX, - }; - setIsPendingDrag(true); - }, - [isKeyframeSelected, selectedKeyframes, setKeyframeSelection], - ); - - const handleKeyframeClick = useCallback( - ({ - event, - keyframes, - orderedKeyframes, - indicatorTime, - }: { - event: ReactMouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: MediaTime; - }) => { - event.stopPropagation(); - - const wasDrag = - mouseDownXRef.current !== null && - Math.abs(event.clientX - mouseDownXRef.current) > - TIMELINE_DRAG_THRESHOLD_PX; - mouseDownXRef.current = null; - - if (wasDrag) return; - - const duration = editor.timeline.getTotalDuration(); - const absoluteIndicatorTime = addMediaTime({ - a: displayedStartTime, - b: indicatorTime, - }); - const seekTime = ( - snappedSeekTime({ - time: absoluteIndicatorTime, - duration, - rate: fps, - }) ?? absoluteIndicatorTime - ) as MediaTime; - editor.playback.seek({ time: seekTime }); - - if (event.shiftKey) { - selectKeyframeRange({ - orderedKeyframes, - targetKeyframes: keyframes, - isAdditive: event.metaKey || event.ctrlKey, - }); - return; - } - - toggleKeyframeSelection({ - keyframes, - isMultiKey: event.metaKey || event.ctrlKey, - }); - }, - [ - toggleKeyframeSelection, - selectKeyframeRange, - editor, - displayedStartTime, - fps, - ], - ); - - const getVisualOffsetPx = useCallback( - ({ - indicatorTime, - indicatorOffsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }): number => { - if (!isBeingDragged) return indicatorOffsetPx; - const clampedTime = Math.max( - 0, - Math.min(element.duration, indicatorTime + dragState.deltaTime), - ); - return ( - timelineTimeToSnappedPixels({ - time: displayedStartTime + clampedTime, - zoomLevel, - }) - elementLeft - ); - }, - [dragState.deltaTime, element.duration, zoomLevel], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - keyframeDragState: dragState, - handleKeyframeMouseDown, - handleKeyframeClick, - getVisualOffsetPx, + keyframeDragState: controller.keyframeDragState, + handleKeyframeMouseDown: controller.onKeyframeMouseDown, + handleKeyframeClick: controller.onKeyframeClick, + getVisualOffsetPx: controller.getVisualOffsetPx, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts index 5a892ccf..4ec2b80b 100644 --- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts +++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts @@ -1,30 +1,9 @@ -import { useState, useCallback, type RefObject } from "react"; +import { useEffect, useReducer, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; -import { processMediaAssets } from "@/media/processing"; -import { toast } from "sonner"; -import { showMediaUploadToast } from "@/media/upload-toast"; -import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation"; -import { mediaTimeFromSeconds, type MediaTime } from "@/wasm"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { roundToFrame } from "opencut-wasm"; import { - buildTextElement, - buildGraphicElement, - buildStickerElement, - buildElementFromMedia, - buildEffectElement, -} from "@/timeline/element-utils"; -import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline"; -import { BatchCommand } from "@/commands"; -import { computeDropTarget } from "@/timeline/components/drop-target"; -import { getDragData, hasDragData } from "@/timeline/drag-data"; -import type { TrackType, DropTarget, ElementType } from "@/timeline"; -import type { - MediaDragData, - GraphicDragData, - StickerDragData, - EffectDragData, -} from "@/timeline/drag"; + DragDropController, + type DragDropConfig, +} from "@/timeline/controllers/drag-drop-controller"; interface UseTimelineDragDropProps { containerRef: RefObject; @@ -40,609 +19,47 @@ export function useTimelineDragDrop({ zoomLevel, }: UseTimelineDragDropProps) { const editor = useEditor(); - const [isDragOver, setIsDragOver] = useState(false); - const [dropTarget, setDropTarget] = useState(null); - const [dragElementType, setElementType] = useState(null); - const getSnappedTime = useCallback( - ({ time }: { time: MediaTime }) => { - const projectFps = editor.project.getActive().settings.fps; - return (roundToFrame({ time, rate: projectFps }) ?? time) as MediaTime; - }, - [editor], - ); + const config: DragDropConfig = { + zoomLevel, + getContainerEl: () => containerRef.current, + getHeaderEl: () => headerRef?.current ?? null, + getTracksScrollEl: () => tracksScrollRef?.current ?? null, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + getActiveProjectId: () => + editor.project.getActiveOrNull()?.metadata.id ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getMediaAssets: () => editor.media.getAssets(), + dragSource: editor.timeline.dragSource, + addMediaAsset: (args) => editor.media.addMediaAsset(args), + executeCommand: (command) => editor.command.execute({ command }), + insertElement: (args) => editor.timeline.insertElement(args), + addClipEffect: (args) => editor.timeline.addClipEffect(args), + }; - const getElementType = useCallback( - ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => { - const dragData = getDragData({ dataTransfer }); - if (!dragData) return null; + const configRef = useRef(config); + configRef.current = config; - if (dragData.type === "text") return "text"; - if (dragData.type === "graphic") return "graphic"; - if (dragData.type === "sticker") return "sticker"; - if (dragData.type === "effect") return "effect"; - if (dragData.type === "media") { - return dragData.mediaType; - } - return null; - }, - [], - ); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new DragDropController({ configRef }); + } + const controller = controllerRef.current; - const getElementDuration = useCallback( - ({ - elementType, - mediaId, - }: { - elementType: ElementType; - mediaId?: string; - }): MediaTime => { - if ( - elementType === "text" || - elementType === "graphic" || - elementType === "sticker" || - elementType === "effect" - ) { - return DEFAULT_NEW_ELEMENT_DURATION; - } - if (mediaId) { - const mediaAssets = editor.media.getAssets(); - const media = mediaAssets.find((m) => m.id === mediaId); - return media?.duration != null - ? mediaTimeFromSeconds({ seconds: media.duration }) - : DEFAULT_NEW_ELEMENT_DURATION; - } - return DEFAULT_NEW_ELEMENT_DURATION; - }, - [editor], - ); - - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.types.includes("Files"); - if (!hasAsset && !hasFiles) return; - setIsDragOver(true); - }, []); - - const handleDragOver = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const hasFiles = e.dataTransfer.types.includes("Files"); - const isExternal = - hasFiles && !hasDragData({ dataTransfer: e.dataTransfer }); - - const elementType = getElementType({ dataTransfer: e.dataTransfer }); - - if (!elementType && hasFiles && isExternal) { - setDropTarget(null); - setElementType(null); - return; - } - - if (!elementType) return; - - setElementType(elementType); - - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - const duration = getElementDuration({ - elementType, - mediaId: dragData?.type === "media" ? dragData.id : undefined, - }); - - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight; - - const targetElementTypes = - dragData?.type === "effect" - ? (dragData as EffectDragData).targetElementTypes - : dragData?.type === "media" - ? (dragData as MediaDragData).targetElementTypes - : undefined; - - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const target = computeDropTarget({ - elementType, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: isExternal, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - targetElementTypes, - }); - - target.xPosition = getSnappedTime({ time: target.xPosition }); - - setDropTarget(target); - e.dataTransfer.dropEffect = "copy"; - }, - [ - containerRef, - headerRef, - tracksScrollRef, - zoomLevel, - getElementType, - getElementDuration, - getSnappedTime, - editor, - ], - ); - - const handleDragLeave = useCallback( - (e: React.DragEvent) => { - e.preventDefault(); - const rect = containerRef.current?.getBoundingClientRect(); - if (rect) { - const { clientX, clientY } = e; - if ( - clientX < rect.left || - clientX > rect.right || - clientY < rect.top || - clientY > rect.bottom - ) { - setIsDragOver(false); - setDropTarget(null); - setElementType(null); - } - } - }, - [containerRef], - ); - - const executeTextDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: { name?: string; content?: string }; - }) => { - const element = buildTextElement({ - raw: { - name: dragData.name ?? "", - content: dragData.content ?? "", - }, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("text", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeStickerDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: StickerDragData; - }) => { - const element = buildStickerElement({ - stickerId: dragData.stickerId, - name: dragData.name, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeGraphicDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: GraphicDragData; - }) => { - const element = buildGraphicElement({ - definitionId: dragData.definitionId, - name: dragData.name, - startTime: target.xPosition, - params: dragData.params, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeMediaDrop = useCallback( - ({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => { - if (target.targetElement) { - toast.info("Replace media source is coming soon!"); - return; - } - - const mediaAssets = editor.media.getAssets(); - const mediaAsset = mediaAssets.find((m) => m.id === dragData.id); - if (!mediaAsset) return; - - const trackType: TrackType = - dragData.mediaType === "audio" ? "audio" : "video"; - - const duration = - mediaAsset.duration != null - ? mediaTimeFromSeconds({ seconds: mediaAsset.duration }) - : DEFAULT_NEW_ELEMENT_DURATION; - const element = buildElementFromMedia({ - mediaId: mediaAsset.id, - mediaType: mediaAsset.type, - name: mediaAsset.name, - duration, - startTime: target.xPosition, - }); - - if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex); - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const track = tracks[target.trackIndex]; - if (!track) return; - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId: track.id }, - element, - }); - }, - [editor], - ); - - const executeEffectDrop = useCallback( - ({ - target, - dragData, - }: { - target: DropTarget; - dragData: EffectDragData; - }) => { - if (target.targetElement) { - editor.timeline.addClipEffect({ - trackId: target.targetElement.trackId, - elementId: target.targetElement.elementId, - effectType: dragData.effectType, - }); - return; - } - - const tracks = [ - ...editor.scenes.getActiveScene().tracks.overlay, - editor.scenes.getActiveScene().tracks.main, - ...editor.scenes.getActiveScene().tracks.audio, - ]; - const effectTrack = tracks.find((t) => t.type === "effect"); - let trackId: string; - - if (effectTrack) { - trackId = effectTrack.id; - } else if (target.isNewTrack) { - const addTrackCmd = new AddTrackCommand("effect", target.trackIndex); - const insertCmd = new InsertElementCommand({ - element: buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }), - placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() }, - }); - editor.command.execute({ - command: new BatchCommand([addTrackCmd, insertCmd]), - }); - return; - } else { - const track = tracks[target.trackIndex]; - if (!track || track.type !== "effect") return; - trackId = track.id; - } - - const element = buildEffectElement({ - effectType: dragData.effectType, - startTime: target.xPosition, - }); - - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); - }, - [editor], - ); - - const executeFileDrop = useCallback( - async ({ - files, - mouseX, - mouseY, - }: { - files: File[]; - mouseX: number; - mouseY: number; - }) => { - const activeProject = editor.project.getActiveOrNull(); - if (!activeProject) return; - - await showMediaUploadToast({ - filesCount: files.length, - promise: async () => { - const processedAssets = await processMediaAssets({ files }); - const projectId = activeProject.metadata.id; - - for (const asset of processedAssets) { - const createdAsset = await editor.media.addMediaAsset({ - projectId, - asset, - }); - if (!createdAsset) continue; - - const duration = - createdAsset.duration != null - ? mediaTimeFromSeconds({ seconds: createdAsset.duration }) - : DEFAULT_NEW_ELEMENT_DURATION; - const sceneTracks = editor.scenes.getActiveScene().tracks; - const currentTime = editor.playback.getCurrentTime(); - const reuseMainTrackId = - createdAsset.type !== "audio" && - sceneTracks.overlay.length === 0 && - sceneTracks.audio.length === 0 && - sceneTracks.main.elements.length === 0 - ? sceneTracks.main.id - : null; - const dropTarget = reuseMainTrackId - ? null - : computeDropTarget({ - elementType: createdAsset.type, - mouseX, - mouseY, - tracks: sceneTracks, - playheadTime: currentTime, - isExternalDrop: true, - elementDuration: duration, - pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND, - zoomLevel, - }); - - const trackType: TrackType = - createdAsset.type === "audio" ? "audio" : "video"; - - const startTime = dropTarget?.xPosition ?? currentTime; - const element = buildElementFromMedia({ - mediaId: createdAsset.id, - mediaType: createdAsset.type, - name: createdAsset.name, - duration, - startTime, - }); - - if (reuseMainTrackId) { - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId: reuseMainTrackId }, - }), - }); - } else { - if (!dropTarget) continue; - if (dropTarget.isNewTrack) { - const addTrackCmd = new AddTrackCommand( - trackType, - dropTarget.trackIndex, - ); - editor.command.execute({ - command: new BatchCommand([ - addTrackCmd, - new InsertElementCommand({ - element, - placement: { - mode: "explicit", - trackId: addTrackCmd.getTrackId(), - }, - }), - ]), - }); - } else { - const trackId = [ - ...sceneTracks.overlay, - sceneTracks.main, - ...sceneTracks.audio, - ][dropTarget.trackIndex]?.id; - if (!trackId) continue; - editor.command.execute({ - command: new InsertElementCommand({ - element, - placement: { mode: "explicit", trackId }, - }), - }); - } - } - } - - return { - uploadedCount: processedAssets.length, - assetNames: processedAssets.map((asset) => asset.name), - }; - }, - }); - }, - [editor, zoomLevel], - ); - - const handleDrop = useCallback( - async (e: React.DragEvent) => { - e.preventDefault(); - - const hasAsset = hasDragData({ dataTransfer: e.dataTransfer }); - const hasFiles = e.dataTransfer.files?.length > 0; - - if (!hasAsset && !hasFiles) return; - - const currentTarget = dropTarget; - setIsDragOver(false); - setDropTarget(null); - setElementType(null); - - try { - if (hasAsset) { - if (!currentTarget) return; - const dragData = getDragData({ dataTransfer: e.dataTransfer }); - if (!dragData) return; - - if (dragData.type === "text") { - executeTextDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "graphic") { - executeGraphicDrop({ - target: currentTarget, - dragData: dragData as GraphicDragData, - }); - } else if (dragData.type === "sticker") { - executeStickerDrop({ target: currentTarget, dragData }); - } else if (dragData.type === "effect") { - executeEffectDrop({ - target: currentTarget, - dragData: dragData as EffectDragData, - }); - } else { - executeMediaDrop({ target: currentTarget, dragData }); - } - } else if (hasFiles) { - const scrollContainer = tracksScrollRef?.current; - const referenceRect = - scrollContainer?.getBoundingClientRect() ?? - containerRef.current?.getBoundingClientRect(); - if (!referenceRect) return; - const scrollLeft = scrollContainer?.scrollLeft ?? 0; - const scrollTop = scrollContainer?.scrollTop ?? 0; - const mouseX = e.clientX - referenceRect.left + scrollLeft; - const headerHeight = - headerRef?.current?.getBoundingClientRect().height ?? 0; - const mouseY = - e.clientY - referenceRect.top + scrollTop - headerHeight; - await executeFileDrop({ - files: Array.from(e.dataTransfer.files), - mouseX, - mouseY, - }); - } - } catch (err) { - console.error("Failed to process drop:", err); - } - }, - [ - dropTarget, - executeTextDrop, - executeGraphicDrop, - executeStickerDrop, - executeMediaDrop, - executeEffectDrop, - executeFileDrop, - containerRef, - headerRef, - tracksScrollRef, - ], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); + useEffect(() => () => controller.destroy(), [controller]); return { - isDragOver, - dropTarget, - dragElementType, + isDragOver: controller.isDragOver, + dropTarget: controller.dropTarget, + dragElementType: controller.dragElementType, dragProps: { - onDragEnter: handleDragEnter, - onDragOver: handleDragOver, - onDragLeave: handleDragLeave, - onDrop: handleDrop, + onDragEnter: controller.onDragEnter, + onDragOver: controller.onDragOver, + onDragLeave: controller.onDragLeave, + onDrop: controller.onDrop, }, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts index 0ec39f69..15ef3575 100644 --- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts +++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts @@ -1,23 +1,13 @@ -import { snappedSeekTime } from "opencut-wasm"; -import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; -import { useEffect, useCallback, useRef } from "react"; -import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { useEffect, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll"; +import { timelineTimeToPixels } from "@/timeline"; import { - buildTimelineSnapPoints, - getTimelineSnapThresholdInTicks, - resolveTimelineSnap, -} from "@/timeline/snapping"; -import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index"; -import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; -import { - getCenteredLineLeft, - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; + PlayheadController, + type PlayheadConfig, +} from "@/timeline/controllers/playhead-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelinePlayheadProps { zoomLevel: number; @@ -35,272 +25,81 @@ export function useTimelinePlayhead({ playheadRef, }: UseTimelinePlayheadProps) { const editor = useEditor(); - const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const activeProject = editor.project.getActive(); - const duration = editor.timeline.getTotalDuration(); const isShiftHeldRef = useShiftKey(); + // isScrubbing drives useEdgeAutoScroll — the controller sets it on the editor, + // so this reactive read naturally reflects whether scrubbing is active. + const isScrubbing = useEditor((e) => e.playback.getIsScrubbing()); - const zoomLevelRef = useRef(zoomLevel); - const durationRef = useRef(duration); - const isScrubbingRef = useRef(isScrubbing); - const isPlayingRef = useRef(false); + const config: PlayheadConfig = { + zoomLevel, + duration: editor.timeline.getTotalDuration(), + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + isShiftHeld: () => isShiftHeldRef.current, + getIsPlaying: () => editor.playback.getIsPlaying(), + getRulerEl: () => rulerRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getPlayheadEl: () => playheadRef?.current ?? null, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getSceneBookmarks: () => editor.scenes.getActiveScene()?.bookmarks ?? [], + seek: (time) => editor.playback.seek({ time }), + setScrubbing: (scrubbing) => + editor.playback.setScrubbing({ isScrubbing: scrubbing }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), + }; + const configRef = useRef(config); + configRef.current = config; - useEffect(() => { - zoomLevelRef.current = zoomLevel; - durationRef.current = duration; - isScrubbingRef.current = isScrubbing; - isPlayingRef.current = editor.playback.getIsPlaying(); - }, [zoomLevel, duration, isScrubbing, editor.playback]); - - const seek = useCallback( - ({ time }: { time: MediaTime }) => editor.playback.seek({ time }), - [editor.playback], - ); - - const scrubTimeRef = useRef(null); - const isDraggingRulerRef = useRef(false); - const hasDraggedRulerRef = useRef(false); - const lastMouseXRef = useRef(0); - - const handleScrub = useCallback( - ({ - event, - snappingEnabled = true, - }: { - event: MouseEvent | React.MouseEvent; - snappingEnabled?: boolean; - }) => { - const ruler = rulerRef.current; - if (!ruler) return; - const rulerRect = ruler.getBoundingClientRect(); - const relativeMouseX = event.clientX - rulerRect.left; - - const timelineContentWidth = timelineTimeToPixels({ - time: duration, - zoomLevel, - }); - - const clampedMouseX = Math.max( - 0, - Math.min(timelineContentWidth, relativeMouseX), - ); - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = mediaTime({ - ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND), - }); - - const rate = activeProject.settings.fps; - const frameTime = ( - snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime - ) as MediaTime; - - const shouldSnap = snappingEnabled && !isShiftHeldRef.current; - const time: MediaTime = (() => { - if (!shouldSnap) return frameTime; - const tracks = editor.scenes.getActiveScene().tracks; - const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; - const snapPoints = buildTimelineSnapPoints({ - sources: [ - () => getElementEdgeSnapPoints({ tracks }), - () => getBookmarkSnapPoints({ bookmarks }), - () => getAnimationKeyframeSnapPointsForTimeline({ tracks }), - ], - }); - const snapResult = resolveTimelineSnap({ - targetTime: frameTime, - snapPoints, - maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }), - }); - return snapResult.snapPoint - ? (snapResult.snappedTime as MediaTime) - : frameTime; - })(); - - scrubTimeRef.current = time; - seek({ time }); - - lastMouseXRef.current = event.clientX; - }, - [ - duration, - zoomLevel, - seek, - rulerRef, - activeProject.settings.fps, - isShiftHeldRef, - editor.scenes, - ], - ); - - const handlePlayheadMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - event.preventDefault(); - event.stopPropagation(); - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event }); - }, - [handleScrub, editor.playback], - ); - - const handleRulerMouseDown = useCallback( - ({ event }: { event: React.MouseEvent }) => { - if (event.button !== 0) return; - if (playheadRef?.current?.contains(event.target as Node)) return; - - event.preventDefault(); - isDraggingRulerRef.current = true; - hasDraggedRulerRef.current = false; - - editor.playback.setScrubbing({ isScrubbing: true }); - handleScrub({ event, snappingEnabled: false }); - }, - [handleScrub, playheadRef, editor.playback], - ); - - const handlePlayheadMouseDownEvent = useCallback( - (event: React.MouseEvent) => handlePlayheadMouseDown({ event }), - [handlePlayheadMouseDown], - ); - - const handleRulerMouseDownEvent = useCallback( - (event: React.MouseEvent) => handleRulerMouseDown({ event }), - [handleRulerMouseDown], - ); - - useEdgeAutoScroll({ - isActive: isScrubbing, - getMouseClientX: () => lastMouseXRef.current, - rulerScrollRef, - tracksScrollRef, - contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }), - }); - - useEffect(() => { - if (!isScrubbing) return; - - const handleMouseMove = ({ event }: { event: MouseEvent }) => { - handleScrub({ event }); - if (isDraggingRulerRef.current) { - hasDraggedRulerRef.current = true; - } - }; - - const handleMouseUp = ({ event }: { event: MouseEvent }) => { - editor.playback.setScrubbing({ isScrubbing: false }); - const finalTime = scrubTimeRef.current; - if (finalTime !== null) { - seek({ time: finalTime }); - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0, - playheadTime: finalTime, - }, - }); - } - scrubTimeRef.current = null; - - if (isDraggingRulerRef.current) { - isDraggingRulerRef.current = false; - if (!hasDraggedRulerRef.current) { - handleScrub({ event, snappingEnabled: false }); - } - hasDraggedRulerRef.current = false; - } - }; - - const onMouseMove = (event: MouseEvent) => handleMouseMove({ event }); - const onMouseUp = (event: MouseEvent) => handleMouseUp({ event }); - - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - - return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]); - - const updatePlayheadLeft = useCallback( - (time: MediaTime) => { - const playheadEl = playheadRef?.current; - if (!playheadEl) return; - const centerPosition = timelineTimeToSnappedPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition }); - const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0; - playheadEl.style.left = `${leftPosition - scrollLeft}px`; - }, - [playheadRef, rulerScrollRef], - ); + const ctrlRef = useRef(null); + if (!ctrlRef.current) { + ctrlRef.current = new PlayheadController({ configRef }); + } + const ctrl = ctrlRef.current; + // Scroll → keep playhead position in sync with scroll offset. useEffect(() => { const scrollEl = rulerScrollRef.current; if (!scrollEl) return; + const handler = () => + ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); + scrollEl.addEventListener("scroll", handler, { passive: true }); + return () => scrollEl.removeEventListener("scroll", handler); + }, [ctrl, editor.playback, rulerScrollRef]); - const handleScroll = () => { - updatePlayheadLeft(editor.playback.getCurrentTime()); - }; - - scrollEl.addEventListener("scroll", handleScroll, { passive: true }); - return () => scrollEl.removeEventListener("scroll", handleScroll); - }, [editor.playback, rulerScrollRef, updatePlayheadLeft]); - + // Playback events → update playhead position and auto-scroll during playback. useEffect(() => { - const handlePlaybackTime = (time: MediaTime) => { - updatePlayheadLeft(time); - - if (!isPlayingRef.current || isScrubbingRef.current) return; - const rulerViewport = rulerScrollRef.current; - const tracksViewport = tracksScrollRef.current; - if (!rulerViewport || !tracksViewport) return; - - const playheadPixels = timelineTimeToPixels({ - time, - zoomLevel: zoomLevelRef.current, - }); - const viewportWidth = rulerViewport.clientWidth; - const scrollMinimum = 0; - const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; - - const needsScroll = - playheadPixels < rulerViewport.scrollLeft || - playheadPixels > rulerViewport.scrollLeft + viewportWidth; - - if (needsScroll) { - const desiredScroll = Math.max( - scrollMinimum, - Math.min(scrollMaximum, playheadPixels - viewportWidth / 2), - ); - rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; - } - }; - const handlePlaybackUpdate = (e: Event) => { - handlePlaybackTime((e as CustomEvent<{ time: MediaTime }>).detail.time); - }; - - const initialTime = editor.playback.getCurrentTime(); - handlePlaybackTime(initialTime); - - window.addEventListener("playback-update", handlePlaybackUpdate); - window.addEventListener("playback-seek", handlePlaybackUpdate); + const handler = (time: MediaTime) => ctrl.handlePlaybackUpdate(time); + ctrl.updatePlayheadLeft(editor.playback.getCurrentTime()); + const unsubscribeUpdate = editor.playback.onUpdate(handler); + const unsubscribeSeek = editor.playback.onSeek(handler); return () => { - window.removeEventListener("playback-update", handlePlaybackUpdate); - window.removeEventListener("playback-seek", handlePlaybackUpdate); + unsubscribeUpdate(); + unsubscribeSeek(); }; - }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]); + }, [ctrl, editor.playback]); + + useEdgeAutoScroll({ + isActive: isScrubbing, + getMouseClientX: () => ctrl.getLastMouseClientX(), + rulerScrollRef, + tracksScrollRef, + contentWidth: timelineTimeToPixels({ + time: editor.timeline.getTotalDuration(), + zoomLevel, + }), + }); + + useEffect(() => () => ctrl.destroy(), [ctrl]); return { - handlePlayheadMouseDown: handlePlayheadMouseDownEvent, - handleRulerMouseDown: handleRulerMouseDownEvent, + handlePlayheadMouseDown: ctrl.onPlayheadMouseDown, + handleRulerMouseDown: ctrl.onRulerMouseDown, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts index accda4a1..3579bd75 100644 --- a/apps/web/src/timeline/hooks/use-timeline-resize.ts +++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts @@ -1,332 +1,82 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { EditorCore } from "@/core"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useReducer, useRef } from "react"; import { useEditor } from "@/editor/use-editor"; -import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useShiftKey } from "@/hooks/use-shift-key"; +import { useElementSelection } from "@/timeline/hooks/element/use-element-selection"; import { useTimelineStore } from "@/timeline/timeline-store"; -import { - computeGroupResize, - type GroupResizeMember, - type GroupResizeResult, - type ResizeSide, -} from "@/timeline/group-resize"; -import { - buildTimelineSnapPoints, - getTimelineSnapThresholdInTicks, - resolveTimelineSnap, - type SnapPoint, -} from "@/timeline/snapping"; -import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source"; -import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source"; -import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline"; -import { isRetimableElement } from "@/timeline"; import { registerCanceller } from "@/editor/cancel-interaction"; -import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points"; +import { + ResizeController, + type ResizeConfig, +} from "@/timeline/controllers/resize-controller"; +import type { ResizeSide } from "@/timeline/group-resize"; +import type { SnapPoint } from "@/timeline/snapping"; +import type { TimelineElement } from "@/timeline"; -interface ResizeInteractionState { - side: ResizeSide; - startX: number; - members: GroupResizeMember[]; +export type { ResizeSide }; + +interface UseTimelineResizeProps { + zoomLevel: number; + onSnapPointChange?: (snapPoint: SnapPoint | null) => void; } export function useTimelineResize({ zoomLevel, onSnapPointChange, -}: { - zoomLevel: number; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}) { +}: UseTimelineResizeProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); const { selectedElements } = useElementSelection(); - const [resizeState, setResizeState] = useState( - null, - ); - const latestResultRef = useRef(null); - const cancelResize = useCallback(() => { - editor.timeline.discardPreview(); - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }, [editor.timeline, onSnapPointChange]); - - useEffect(() => { - if (!resizeState) { - return; - } - - return registerCanceller({ fn: cancelResize }); - }, [resizeState, cancelResize]); - - const handleResizeStart = useCallback( - ({ - event, - element, - track, - side, - }: { - event: React.MouseEvent; - element: TimelineElement; - track: TimelineTrack; - side: ResizeSide; - }) => { - event.stopPropagation(); - event.preventDefault(); - - const elementRef = { - trackId: track.id, - elementId: element.id, - }; - const activeSelection = selectedElements.some( - (selectedElement) => - selectedElement.trackId === track.id && - selectedElement.elementId === element.id, - ) - ? selectedElements - : [elementRef]; - const members = buildResizeMembers({ - tracks: editor.scenes.getActiveScene().tracks, - selectedElements: activeSelection, - }); - if (members.length === 0) { - return; - } - - editor.timeline.discardPreview(); - latestResultRef.current = null; - setResizeState({ - side, - startX: event.clientX, - members, - }); - }, - [selectedElements, editor], - ); - - useEffect(() => { - if (!resizeState) { - return; - } - - const handleMouseMove = ({ clientX }: MouseEvent) => { - const deltaX = clientX - resizeState.startX; - const rawDeltaTime = Math.round( - (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * - TICKS_PER_SECOND, - ); - const snappedDeltaTime = getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap: snappingEnabled && !isShiftHeldRef.current, - onSnapPointChange, - }); - const fps = editor.project.getActive().settings.fps; - const result = computeGroupResize({ - members: resizeState.members, - side: resizeState.side, - deltaTime: snappedDeltaTime.deltaTime, - fps, - }); - - latestResultRef.current = result; + const config: ResizeConfig = { + zoomLevel, + snappingEnabled, + isShiftHeld: () => isShiftHeldRef.current, + getSceneTracks: () => editor.scenes.getActiveScene().tracks, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + selectedElements, + discardPreview: () => editor.timeline.discardPreview(), + previewElements: (updates) => editor.timeline.previewElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ + updates: updates.map(({ trackId, elementId, patch }) => ({ trackId, elementId, updates: patch as Partial, })), - }); - }; - - const handleMouseUp = () => { - const result = latestResultRef.current; - editor.timeline.discardPreview(); - if ( - result && - hasResizeChanges({ members: resizeState.members, result }) - ) { - editor.timeline.updateElements({ - updates: result.updates.map(({ trackId, elementId, patch }) => ({ - trackId, - elementId, - patch: patch as Partial, - })), - }); - } - - setResizeState(null); - latestResultRef.current = null; - onSnapPointChange?.(null); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - resizeState, - zoomLevel, - snappingEnabled, - isShiftHeldRef, - editor, + }), + commitElements: (updates) => + editor.timeline.updateElements({ + updates: updates.map(({ trackId, elementId, patch }) => ({ + trackId, + elementId, + patch: patch as Partial, + })), + }), onSnapPointChange, - ]); + }; + + const configRef = useRef(config); + configRef.current = config; + + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ResizeController({ configRef }); + } + const controller = controllerRef.current; + + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); + + useEffect(() => { + if (!controller.isResizing) return; + return registerCanceller({ fn: () => controller.cancel() }); + }, [controller.isResizing, controller]); + + useEffect(() => () => controller.destroy(), [controller]); return { - isResizing: resizeState !== null, - handleResizeStart, + isResizing: controller.isResizing, + handleResizeStart: controller.onResizeStart, }; } - -function buildResizeMembers({ - tracks, - selectedElements, -}: { - tracks: SceneTracks; - selectedElements: Array<{ trackId: string; elementId: string }>; -}): GroupResizeMember[] { - const selectedElementIds = new Set( - selectedElements.map((selectedElement) => selectedElement.elementId), - ); - const trackMap = new Map( - [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [ - track.id, - track, - ]), - ); - - return selectedElements.flatMap(({ trackId, elementId }) => { - const track = trackMap.get(trackId); - const element = track?.elements.find( - (trackElement) => trackElement.id === elementId, - ); - if (!track || !element) { - return []; - } - - const otherElements = track.elements.filter( - (trackElement) => !selectedElementIds.has(trackElement.id), - ); - const leftNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime + trackElement.duration <= element.startTime, - ) - .reduce((bound, trackElement) => { - return Math.max(bound, trackElement.startTime + trackElement.duration); - }, -Infinity); - const rightNeighborBound = otherElements - .filter( - (trackElement) => - trackElement.startTime >= element.startTime + element.duration, - ) - .reduce((bound, trackElement) => { - return Math.min(bound, trackElement.startTime); - }, Infinity); - - return [ - { - trackId, - elementId, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - sourceDuration: element.sourceDuration, - retime: isRetimableElement(element) ? element.retime : undefined, - leftNeighborBound, - rightNeighborBound, - }, - ]; - }); -} - -function getSnappedResizeDelta({ - editor, - resizeState, - rawDeltaTime, - zoomLevel, - shouldSnap, - onSnapPointChange, -}: { - editor: EditorCore; - resizeState: ResizeInteractionState; - rawDeltaTime: number; - zoomLevel: number; - shouldSnap: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; -}): { deltaTime: number } { - if (!shouldSnap) { - onSnapPointChange?.(null); - return { deltaTime: rawDeltaTime }; - } - - const tracks = editor.scenes.getActiveScene().tracks; - const playheadTime = editor.playback.getCurrentTime(); - const excludeElementIds = new Set( - resizeState.members.map((member) => member.elementId), - ); - const snapPoints = buildTimelineSnapPoints({ - sources: [ - () => getElementEdgeSnapPoints({ tracks, excludeElementIds }), - () => getPlayheadSnapPoints({ playheadTime }), - () => - getAnimationKeyframeSnapPointsForTimeline({ - tracks, - excludeElementIds, - }), - ], - }); - const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel }); - let closestSnapPoint: SnapPoint | null = null; - let closestSnapDistance = Infinity; - let deltaTime = rawDeltaTime; - - for (const member of resizeState.members) { - const baseEdgeTime = - resizeState.side === "left" - ? member.startTime - : member.startTime + member.duration; - const snapResult = resolveTimelineSnap({ - targetTime: baseEdgeTime + rawDeltaTime, - snapPoints, - maxSnapDistance, - }); - if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) { - closestSnapDistance = snapResult.snapDistance; - closestSnapPoint = snapResult.snapPoint; - deltaTime = snapResult.snappedTime - baseEdgeTime; - } - } - - onSnapPointChange?.(closestSnapPoint); - return { deltaTime }; -} - -function hasResizeChanges({ - members, - result, -}: { - members: GroupResizeMember[]; - result: GroupResizeResult; -}): boolean { - return result.updates.some((update) => { - const member = members.find( - (candidateMember) => candidateMember.elementId === update.elementId, - ); - return ( - member?.trimStart !== update.patch.trimStart || - member?.trimEnd !== update.patch.trimEnd || - member?.startTime !== update.patch.startTime || - member?.duration !== update.patch.duration - ); - }); -} diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts index 88a8268d..bf033dae 100644 --- a/apps/web/src/timeline/hooks/use-timeline-seek.ts +++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts @@ -1,9 +1,10 @@ -import { useCallback, useRef } from "react"; -import type { MutableRefObject, RefObject } from "react"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { snappedSeekTime } from "opencut-wasm"; -import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; +import { useEffect, useRef, type RefObject } from "react"; import { useEditor } from "@/editor/use-editor"; +import { + SeekController, + type SeekConfig, +} from "@/timeline/controllers/seek-controller"; +import type { MediaTime } from "@/wasm"; interface UseTimelineSeekProps { playheadRef: RefObject; @@ -17,44 +18,6 @@ interface UseTimelineSeekProps { seek: (time: MediaTime) => void; } -function resetMouseTracking({ - mouseTrackingRef, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; -}) { - mouseTrackingRef.current = { - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }; -} - -function setMouseTracking({ - mouseTrackingRef, - event, -}: { - mouseTrackingRef: MutableRefObject<{ - isMouseDown: boolean; - downX: number; - downY: number; - downTime: number; - }>; - event: React.MouseEvent; -}) { - mouseTrackingRef.current = { - isMouseDown: true, - downX: event.clientX, - downY: event.clientY, - downTime: event.timeStamp, - }; -} - export function useTimelineSeek({ playheadRef, trackLabelsRef, @@ -67,133 +30,42 @@ export function useTimelineSeek({ seek, }: UseTimelineSeekProps) { const editor = useEditor(); - const activeProject = editor.project.getActive(); - - const mouseTrackingRef = useRef({ - isMouseDown: false, - downX: 0, - downY: 0, - downTime: 0, - }); - - const handleTracksMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const handleRulerMouseDown = useCallback((event: React.MouseEvent) => { - if (event.button !== 0) return; - setMouseTracking({ mouseTrackingRef, event }); - }, []); - - const shouldProcessTimelineClick = useCallback( - ({ event }: { event: React.MouseEvent }) => { - const target = event.target as HTMLElement; - const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; - const deltaX = Math.abs(event.clientX - downX); - const deltaY = Math.abs(event.clientY - downY); - const deltaTime = event.timeStamp - downTime; - const isPlayhead = !!playheadRef.current?.contains(target); - const isTrackLabels = !!trackLabelsRef.current?.contains(target); - const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500; - - if (!isMouseDown) return false; - if (shouldBlockForDrag) return false; - if (isSelecting) return false; - if (isPlayhead) return false; - if (isTrackLabels) { - clearSelectedElements(); - return false; - } - - return true; - }, - [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef], - ); - - const handleTimelineSeek = useCallback( - ({ - event, - source, - }: { - event: React.MouseEvent; - source: "ruler" | "tracks"; - }) => { - const scrollContainer = - source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current; - - if (!scrollContainer) return; - - const rect = scrollContainer.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const scrollLeft = scrollContainer.scrollLeft; - - const rawTimeSeconds = Math.max( - 0, - Math.min( - duration / TICKS_PER_SECOND, - (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); - const rawTime = mediaTime({ - ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND), - }); - - const rate = activeProject?.settings.fps; - const time = rate - ? ((snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) as MediaTime) - : rawTime; - seek(time); + const config: SeekConfig = { + zoomLevel, + duration, + isSelecting, + getPlayheadEl: () => playheadRef.current, + getTrackLabelsEl: () => trackLabelsRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null, + clearSelectedElements, + seek, + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => editor.project.setTimelineViewState({ viewState: { zoomLevel, - scrollLeft: scrollContainer.scrollLeft, - playheadTime: time, + scrollLeft, + playheadTime, }, - }); - }, - [ - duration, - zoomLevel, - rulerScrollRef, - tracksScrollRef, - seek, - editor, - activeProject?.settings.fps.numerator, - activeProject?.settings.fps.denominator, - ], - ); + }), + }; - const handleTracksClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); + const configRef = useRef(config); + configRef.current = config; - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "tracks" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new SeekController({ configRef }); + } + const controller = controllerRef.current; - const handleRulerClick = useCallback( - (event: React.MouseEvent) => { - const shouldProcess = shouldProcessTimelineClick({ event }); - resetMouseTracking({ mouseTrackingRef }); - - if (shouldProcess) { - clearSelectedElements(); - handleTimelineSeek({ event, source: "ruler" }); - } - }, - [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements], - ); + useEffect(() => () => controller.destroy(), [controller]); return { - handleTracksMouseDown, - handleTracksClick, - handleRulerMouseDown, - handleRulerClick, + handleTracksMouseDown: controller.onTracksMouseDown, + handleTracksClick: controller.onTracksClick, + handleRulerMouseDown: controller.onRulerMouseDown, + handleRulerClick: controller.onRulerClick, }; } diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts index 0f75face..3020504f 100644 --- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts +++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts @@ -1,17 +1,17 @@ import { type WheelEvent as ReactWheelEvent, type RefObject, - useCallback, useEffect, useLayoutEffect, + useReducer, useRef, - useState, } from "react"; -import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; -import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale"; -import { timelineTimeToPixels } from "@/timeline/pixel-utils"; import { useEditor } from "@/editor/use-editor"; -import { zoomToSlider } from "@/timeline/zoom-utils"; +import { TIMELINE_ZOOM_MIN } from "@/timeline/scale"; +import { + ZoomController, + type ZoomConfig, +} from "@/timeline/controllers/zoom-controller"; import type { MediaTime } from "@/wasm"; interface UseTimelineZoomProps { @@ -41,252 +41,59 @@ export function useTimelineZoom({ rulerScrollRef, }: UseTimelineZoomProps): UseTimelineZoomReturn { const editor = useEditor(); - const hasInitializedRef = useRef(false); - const hasRestoredPlayheadRef = useRef(false); - const scrollSaveTimeoutRef = useRef | null>( - null, - ); + const config: ZoomConfig = { + minZoom, + getContainerEl: () => containerRef.current, + getTracksScrollEl: () => tracksScrollRef.current, + getRulerScrollEl: () => rulerScrollRef.current, + getCurrentPlayheadTime: () => editor.playback.getCurrentTime(), + seek: (time) => editor.playback.seek({ time }), + setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) => + editor.project.setTimelineViewState({ + viewState: { + zoomLevel, + scrollLeft, + playheadTime, + }, + }), + }; + const configRef = useRef(config); + configRef.current = config; - const [zoomLevel, setZoomLevelRaw] = useState(() => { - if (initialZoom !== undefined) { - hasInitializedRef.current = true; - return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)); - } - return minZoom; - }); - const previousZoomRef = useRef(zoomLevel); - const hasRestoredScrollRef = useRef(false); - const preZoomScrollLeftRef = useRef(0); - const prePlayheadAnchorScrollLeftRef = useRef(0); - const isInPlayheadAnchorModeRef = useRef(false); + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = new ZoomController({ configRef, initialZoom }); + } + const controller = controllerRef.current; + const zoomLevel = controller.zoomLevel; - const setZoomLevel = useCallback( - (updater: number | ((prev: number) => number)) => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - preZoomScrollLeftRef.current = scrollElement.scrollLeft; - } - setZoomLevelRaw(updater); - }, - [tracksScrollRef], - ); - - const handleWheel = useCallback( - (event: ReactWheelEvent) => { - const isZoomGesture = event.ctrlKey || event.metaKey; - const isHorizontalScrollGesture = - event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); - - if (isHorizontalScrollGesture) { - return; - } - - // pinch-zoom (ctrl/meta + wheel) - if (isZoomGesture) { - const normalizedDelta = - event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; - const cappedDelta = - Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); - const zoomFactor = Math.exp(-cappedDelta / 300); - setZoomLevel((prev) => { - const nextZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor), - ); - return nextZoom; - }); - return; - } - }, - [minZoom, setZoomLevel], - ); + const [, rerender] = useReducer((n: number) => n + 1, 0); + useEffect(() => controller.subscribe(rerender), [controller]); useEffect(() => { - if (initialZoom !== undefined && !hasInitializedRef.current) { - hasInitializedRef.current = true; - setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom))); - return; - } - setZoomLevel((prev: number) => { - if (prev < minZoom) { - return minZoom; - } - return prev; - }); - }, [minZoom, initialZoom, setZoomLevel]); - - const wrappedSetZoomLevel = useCallback( - (zoomLevelOrUpdater: number | ((prev: number) => number)) => { - setZoomLevel((prev: number) => { - const nextZoom = - typeof zoomLevelOrUpdater === "function" - ? zoomLevelOrUpdater(prev) - : zoomLevelOrUpdater; - const clampedZoom = Math.max( - minZoom, - Math.min(TIMELINE_ZOOM_MAX, nextZoom), - ); - return clampedZoom; - }); - }, - [minZoom, setZoomLevel], - ); + controller.reconcileInitialAndMinZoom(minZoom, initialZoom); + }, [controller, minZoom, initialZoom]); useLayoutEffect(() => { - const previousZoom = previousZoomRef.current; - if (previousZoom === zoomLevel) return; - - const scrollElement = tracksScrollRef.current; - if (!scrollElement) { - previousZoomRef.current = zoomLevel; - return; - } - - const currentScrollLeft = preZoomScrollLeftRef.current; - const playheadTime = editor.playback.getCurrentTime(); - const sliderPercent = zoomToSlider({ zoomLevel, minZoom }); - const previousSliderPercent = zoomToSlider({ - zoomLevel: previousZoom, - minZoom, - }); - const isCrossingThresholdUp = - previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - const isCrossingThresholdDown = - previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && - sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; - - const syncScroll = (scrollLeft: number) => { - scrollElement.scrollLeft = scrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = scrollLeft; - } - }; - - const clampScrollLeft = (scrollLeft: number) => { - const maxScrollLeft = - scrollElement.scrollWidth - scrollElement.clientWidth; - return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); - }; - - if (isCrossingThresholdUp) { - prePlayheadAnchorScrollLeftRef.current = currentScrollLeft; - isInPlayheadAnchorModeRef.current = true; - } - - if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { - const playheadPixelsBefore = timelineTimeToPixels({ - time: playheadTime, - zoomLevel: previousZoom, - }); - const playheadPixelsAfter = timelineTimeToPixels({ - time: playheadTime, - zoomLevel, - }); - - const viewportOffset = playheadPixelsBefore - currentScrollLeft; - const newScrollLeft = playheadPixelsAfter - viewportOffset; - - syncScroll(clampScrollLeft(newScrollLeft)); - } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) { - syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current)); - isInPlayheadAnchorModeRef.current = false; - } - - previousZoomRef.current = zoomLevel; - - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime, - }, - }); - }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref - const saveScrollPosition = useCallback(() => { - if (scrollSaveTimeoutRef.current) { - clearTimeout(scrollSaveTimeoutRef.current); - } - scrollSaveTimeoutRef.current = setTimeout(() => { - const scrollElement = tracksScrollRef.current; - if (scrollElement) { - editor.project.setTimelineViewState({ - viewState: { - zoomLevel, - scrollLeft: scrollElement.scrollLeft, - playheadTime: editor.playback.getCurrentTime(), - }, - }); - } - }, 300); - }, [zoomLevel, editor]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable - useEffect(() => { - if (initialScrollLeft === undefined) return; - if (hasRestoredScrollRef.current) return; - const scrollElement = tracksScrollRef.current; - if (!scrollElement) return; - - const restoreScroll = () => { - scrollElement.scrollLeft = initialScrollLeft; - if (rulerScrollRef.current) { - rulerScrollRef.current.scrollLeft = initialScrollLeft; - } - hasRestoredScrollRef.current = true; - }; - - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - } else { - const observer = new ResizeObserver(() => { - if (scrollElement.scrollWidth > 0) { - restoreScroll(); - observer.disconnect(); - } - }); - observer.observe(scrollElement); - return () => observer.disconnect(); - } - }, [initialScrollLeft]); + controller.applyZoomLayout(zoomLevel); + }, [controller, zoomLevel]); useEffect(() => { - if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) { - hasRestoredPlayheadRef.current = true; - editor.playback.seek({ time: initialPlayheadTime }); - } - }, [initialPlayheadTime, editor]); + return controller.restoreInitialScrollIfNeeded(initialScrollLeft); + }, [controller, initialScrollLeft]); - // prevent browser zoom in the timeline useEffect(() => { - const preventZoom = (event: WheelEvent) => { - const isZoomKeyPressed = event.ctrlKey || event.metaKey; - const isInContainer = containerRef.current?.contains( - event.target as Node, - ); - // only check isInContainer, not isInTimeline state - the state check - // causes race conditions where the closure captures stale state - if (isZoomKeyPressed && isInContainer) { - event.preventDefault(); - } - }; + controller.restoreInitialPlayheadIfNeeded(initialPlayheadTime); + }, [controller, initialPlayheadTime]); - document.addEventListener("wheel", preventZoom, { - passive: false, - capture: true, - }); + useEffect(() => controller.bindPreventBrowserZoom(), [controller]); - return () => { - document.removeEventListener("wheel", preventZoom, { capture: true }); - }; - }, [containerRef]); + useEffect(() => () => controller.destroy(), [controller]); return { zoomLevel, - setZoomLevel: wrappedSetZoomLevel, - handleWheel, - saveScrollPosition, + setZoomLevel: controller.setZoomLevel, + handleWheel: controller.handleWheel, + saveScrollPosition: controller.saveScrollPosition, }; } diff --git a/apps/web/src/timeline/playhead-snap-source.ts b/apps/web/src/timeline/playhead-snap-source.ts index 88e21eba..0b41e17b 100644 --- a/apps/web/src/timeline/playhead-snap-source.ts +++ b/apps/web/src/timeline/playhead-snap-source.ts @@ -1,9 +1,10 @@ import type { SnapPoint } from "@/timeline/snapping"; +import type { MediaTime } from "@/wasm"; export function getPlayheadSnapPoints({ playheadTime, }: { - playheadTime: number; + playheadTime: MediaTime; }): SnapPoint[] { return [{ time: playheadTime, type: "playhead" }]; } diff --git a/apps/web/src/timeline/snapping/resolve.ts b/apps/web/src/timeline/snapping/resolve.ts index 2f00d081..dcbc670d 100644 --- a/apps/web/src/timeline/snapping/resolve.ts +++ b/apps/web/src/timeline/snapping/resolve.ts @@ -1,11 +1,12 @@ import type { SnapPoint, SnapResult } from "./types"; +import type { MediaTime } from "@/wasm"; export function resolveTimelineSnap({ targetTime, snapPoints, maxSnapDistance, }: { - targetTime: number; + targetTime: MediaTime; snapPoints: SnapPoint[]; maxSnapDistance: number; }): SnapResult { diff --git a/apps/web/src/timeline/snapping/types.ts b/apps/web/src/timeline/snapping/types.ts index 2a6d0903..7bffd84f 100644 --- a/apps/web/src/timeline/snapping/types.ts +++ b/apps/web/src/timeline/snapping/types.ts @@ -1,3 +1,5 @@ +import type { MediaTime } from "@/wasm"; + export type SnapPointType = | "element-start" | "element-end" @@ -6,14 +8,14 @@ export type SnapPointType = | "keyframe"; export interface SnapPoint { - time: number; + time: MediaTime; type: SnapPointType; elementId?: string; trackId?: string; } export interface SnapResult { - snappedTime: number; + snappedTime: MediaTime; snapPoint: SnapPoint | null; snapDistance: number; } diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts index 2a30a065..99df3128 100644 --- a/apps/web/src/timeline/types.ts +++ b/apps/web/src/timeline/types.ts @@ -284,6 +284,23 @@ export interface ElementDragState { currentMouseY: number; } +export type ElementDragView = + | { readonly kind: "idle" } + | { + readonly kind: "dragging"; + readonly anchorElementId: string; + readonly trackId: string; + readonly memberTimeOffsets: ReadonlyMap; + readonly startMouseX: number; + readonly startMouseY: number; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; + readonly currentTime: MediaTime; + readonly currentMouseX: number; + readonly currentMouseY: number; + readonly dropTarget: DropTarget | null; + }; + export interface DropTarget { trackIndex: number; isNewTrack: boolean; diff --git a/apps/web/src/wasm/media-time.ts b/apps/web/src/wasm/media-time.ts index 7209e81c..82f5378d 100644 --- a/apps/web/src/wasm/media-time.ts +++ b/apps/web/src/wasm/media-time.ts @@ -1,7 +1,13 @@ import { + lastFrameTime as _lastFrameTime, + parseTimecode as _parseTimecode, + roundToFrame as _roundToFrame, + snappedSeekTime as _snappedSeekTime, TICKS_PER_SECOND as _TICKS_PER_SECOND, mediaTimeFromSeconds as _mediaTimeFromSeconds, mediaTimeToSeconds as _mediaTimeToSeconds, + type FrameRate, + type TimeCodeFormat, } from "opencut-wasm"; /** @@ -127,3 +133,58 @@ export function clampMediaTime({ if (time > max) return max; return time; } + +export function roundFrameTime({ + time, + fps, +}: { + time: MediaTime; + fps: FrameRate; +}): MediaTime { + return (_roundToFrame({ time, rate: fps }) ?? time) as MediaTime; +} + +export function roundFrameTicks({ + ticks, + fps, +}: { + ticks: number; + fps: FrameRate; +}): number { + return _roundToFrame({ time: ticks, rate: fps }) ?? ticks; +} + +export function snapSeekMediaTime({ + time, + duration, + fps, +}: { + time: MediaTime; + duration: MediaTime; + fps: FrameRate; +}): MediaTime { + return (_snappedSeekTime({ time, duration, rate: fps }) ?? time) as MediaTime; +} + +export function lastFrameMediaTime({ + duration, + fps, +}: { + duration: MediaTime; + fps: FrameRate; +}): MediaTime { + return (_lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime; +} + +export function parseMediaTimecode({ + timeCode, + format, + fps, +}: { + timeCode: string; + format: TimeCodeFormat; + fps: FrameRate; +}): MediaTime | null { + const parsedTime = _parseTimecode({ timeCode, format, rate: fps }); + return parsedTime == null ? null : (parsedTime as MediaTime); +} diff --git a/bun.lock b/bun.lock index cb36d56e..e342cd5f 100644 --- a/bun.lock +++ b/bun.lock @@ -54,7 +54,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.2.8", + "opencut-wasm": "^0.2.9", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", @@ -785,7 +785,7 @@ "@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="], - "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], "@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="], @@ -875,7 +875,7 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], - "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1359,7 +1359,7 @@ "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="], - "opencut-wasm": ["opencut-wasm@0.2.8", "", {}, "sha512-R1cXB4HuTC5RdbGRYD0Q2n9rWQWDRB7KMwGJnbjrhnnwxBbR5XDu/3tL7hlzb/RZ7VZcaX5OGHZ1QgL6ex/2cQ=="], + "opencut-wasm": ["opencut-wasm@0.2.9", "", {}, "sha512-yZilhRRgNA02XY9Bq2yt6FidbnDQS1OYsvtNczxF6jL+nvl3vRboG0owwpQY0SPIbeJoJjJBuOVy0i1Pp6JS/w=="], "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],