refactor: thinner hooks

This commit is contained in:
Maze Winther 2026-04-26 17:31:28 +02:00
parent e6c6193638
commit 56ca09692a
48 changed files with 5355 additions and 4264 deletions

View File

@ -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,

View File

@ -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);

View File

@ -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 (

View File

@ -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 {

View File

@ -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);
};

View File

@ -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<string, Partial<TimelineElement>>();
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<TimelineElement>;
}>;
}[];
}): void {
let changedOverlayCount = 0;
for (const { elementId, updates: elementUpdates } of updates) {

View File

@ -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<HTMLInputElement>(null);
function containsFiles(dataTransfer: DataTransfer): boolean {
return (
!editor.timeline.dragSource.isActive() &&
dataTransfer.types.includes("Files")
);
}
function openFilePicker() {
if (!inputRef.current) return;

View File

@ -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 (
<div className="flex items-center">

View File

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

View File

@ -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<TimelineElement>;
}
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,
},
},
},
]);
}
}

View File

@ -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<DragState | null>(null);
const pendingGestureRef = useRef<PendingGestureState | null>(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<PreviewInteractionDeps>(deps);
depsRef.current = deps;
const controllerRef = useRef<PreviewInteractionController | null>(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,
};
}

View File

@ -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<HandleType | null>(null);
const scaleStateRef = useRef<ScaleState | null>(null);
const rotationStateRef = useRef<RotationState | null>(null);
const edgeScaleStateRef = useRef<EdgeScaleState | null>(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<TransformHandleDeps>(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<TransformHandleController | null>(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,
};
}

View File

@ -7,6 +7,7 @@ import {
} from "mediabunny";
interface VideoSinkData {
input: Input;
sink: CanvasSink;
iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
currentFrame: WrappedCanvas | null;
@ -20,6 +21,7 @@ export class VideoCache {
private sinks = new Map<string, VideoSinkData>();
private initPromises = new Map<string, Promise<void>>();
private frameChain = new Map<string, Promise<unknown>>();
private seekGenerations = new Map<string, number>();
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<void> {
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 {

View File

@ -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 });
}

View File

@ -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,

View File

@ -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) {

View File

@ -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({

View File

@ -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}
/>
<DragLine
dropTarget={dragDropTarget}
dropTarget={isElementDragging ? dragView.dropTarget : null}
tracks={tracks}
isVisible={dragState.isDragging}
isVisible={isElementDragging}
headerHeight={timelineHeaderHeight}
/>
@ -541,9 +545,7 @@ export function Timeline() {
<TimelineTrackRows
mainTrackId={mainTrackId}
zoomLevel={zoomLevel}
dragState={dragState}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
dragView={dragView}
onResizeStart={handleResizeStart}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
@ -719,9 +721,7 @@ function TrackLabelsPanel({
function TimelineTrackRows({
mainTrackId,
zoomLevel,
dragState,
tracksScrollRef,
lastMouseXRef,
dragView,
onResizeStart,
onElementMouseDown,
onElementClick,
@ -733,9 +733,7 @@ function TimelineTrackRows({
}: {
mainTrackId: string | null;
zoomLevel: number;
dragState: ElementDragState;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
lastMouseXRef: React.RefObject<number>;
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<string, MediaTime> | 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({
<TimelineTrackContent
track={track}
zoomLevel={zoomLevel}
dragState={dragState}
rulerScrollRef={tracksScrollRef}
tracksScrollRef={tracksScrollRef}
lastMouseXRef={lastMouseXRef}
dragView={dragView}
onResizeStart={onResizeStart}
onElementMouseDown={onElementMouseDown}
onElementClick={onElementClick}

View File

@ -34,7 +34,7 @@ import {
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
ElementDragState,
ElementDragView,
VideoElement,
ImageElement,
AudioElement,
@ -49,12 +49,7 @@ import {
import { buildWaveformGainSamples } from "@/timeline/audio-state";
import { getTimelinePixelsPerSecond } from "@/timeline";
import { buildWaveformSourceKey } from "@/media/waveform-summary";
import {
addMediaTime,
type MediaTime,
TICKS_PER_SECOND,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
import {
getActionDefinition,
type TAction,
@ -219,7 +214,7 @@ interface TimelineElementProps {
event: React.MouseEvent,
element: TimelineElementType,
) => 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,
}}

View File

@ -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<HTMLDivElement | null>;
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
lastMouseXRef: React.RefObject<number>;
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 (
<div className="relative size-full">
@ -120,7 +102,7 @@ export function TimelineTrackContent({
onElementClick={(event, element) =>
onElementClick({ event, element, track })
}
dragState={dragState}
dragView={dragView}
isDropTarget={element.id === targetElementId}
/>
);

View File

@ -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<MediaAsset | null>;
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<TimelineDragData, { type: "text" }>;
}): 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<TimelineDragData, { type: "sticker" }>;
}): 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<TimelineDragData, { type: "graphic" }>;
}): 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<TimelineDragData, { type: "media" }>;
}): 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<TimelineDragData, { type: "effect" }>;
}): 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<void> {
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),
};
},
});
}
}

View File

@ -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<GroupMoveResult, "moves" | "createTracks">) => 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<string, MediaTime>();
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();
};
}

View File

@ -0,0 +1,358 @@
import type { MouseEvent as ReactMouseEvent } from "react";
import type { FrameRate } from "opencut-wasm";
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
import {
addMediaTime,
clampMediaTime,
type MediaTime,
mediaTime,
roundFrameTicks,
snapSeekMediaTime,
TICKS_PER_SECOND,
ZERO_MEDIA_TIME,
} from "@/wasm";
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
import { timelineTimeToSnappedPixels } from "@/timeline";
import { getKeyframeById } from "@/animation";
import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
import { BatchCommand } from "@/commands";
import type { SelectedKeyframeRef } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
import type { Command } from "@/commands/base-command";
// --- Session ---
interface PendingSession {
kind: "pending";
keyframeRefs: SelectedKeyframeRef[];
startMouseX: number;
}
interface ActiveSession {
kind: "active";
keyframeRefs: SelectedKeyframeRef[];
startMouseX: number;
deltaTicks: number;
}
type Session = { kind: "idle" } | PendingSession | ActiveSession;
// --- Public state ---
export interface KeyframeDragState {
isDragging: boolean;
draggingKeyframeIds: Set<string>;
deltaTicks: number;
}
const IDLE_DRAG_STATE: KeyframeDragState = {
isDragging: false,
draggingKeyframeIds: new Set(),
deltaTicks: 0,
};
// --- Config ---
export interface KeyframeDragConfig {
zoomLevel: number;
getFps: () => FrameRate | null;
element: TimelineElement;
displayedStartTime: MediaTime;
selectedKeyframes: SelectedKeyframeRef[];
isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean;
setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void;
toggleKeyframeSelection: (args: {
keyframes: SelectedKeyframeRef[];
isMultiKey: boolean;
}) => void;
selectKeyframeRange: (args: {
orderedKeyframes: SelectedKeyframeRef[];
targetKeyframes: SelectedKeyframeRef[];
isAdditive: boolean;
}) => void;
executeCommand: (command: Command) => void;
seek: (args: { time: MediaTime }) => void;
getTotalDuration: () => MediaTime;
}
export interface KeyframeDragConfigRef {
readonly current: KeyframeDragConfig;
}
// --- Controller ---
export class KeyframeDragController {
private session: Session = { kind: "idle" };
// Persists through mouseup so the click handler can detect drag vs click
private mouseDownX: number | null = null;
private readonly subscribers = new Set<() => void>();
private readonly configRef: KeyframeDragConfigRef;
constructor(deps: { configRef: KeyframeDragConfigRef }) {
this.configRef = deps.configRef;
this.onKeyframeMouseDown = this.onKeyframeMouseDown.bind(this);
this.onKeyframeClick = this.onKeyframeClick.bind(this);
this.getVisualOffsetPx = this.getVisualOffsetPx.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
}
private get config(): KeyframeDragConfig {
return this.configRef.current;
}
get isActive(): boolean {
return this.session.kind !== "idle";
}
get keyframeDragState(): KeyframeDragState {
if (this.session.kind !== "active") return IDLE_DRAG_STATE;
return {
isDragging: true,
draggingKeyframeIds: new Set(
this.session.keyframeRefs.map((kf) => kf.keyframeId),
),
deltaTicks: this.session.deltaTicks,
};
}
subscribe(fn: () => void): () => void {
this.subscribers.add(fn);
return () => this.subscribers.delete(fn);
}
cancel(): void {
this.mouseDownX = null;
this.finishSession();
}
destroy(): void {
this.deactivate();
this.subscribers.clear();
}
onKeyframeMouseDown({
event,
keyframes,
}: {
event: ReactMouseEvent;
keyframes: SelectedKeyframeRef[];
}): void {
event.preventDefault();
event.stopPropagation();
this.mouseDownX = event.clientX;
const anySelected = keyframes.some((kf) =>
this.config.isKeyframeSelected({ keyframe: kf }),
);
const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
if (!anySelected && !isModifierKey) {
this.config.setKeyframeSelection({ keyframes });
}
this.session = {
kind: "pending",
keyframeRefs: anySelected ? this.config.selectedKeyframes : keyframes,
startMouseX: event.clientX,
};
this.activate();
this.notify();
}
onKeyframeClick({
event,
keyframes,
orderedKeyframes,
indicatorTime,
}: {
event: ReactMouseEvent;
keyframes: SelectedKeyframeRef[];
orderedKeyframes: SelectedKeyframeRef[];
indicatorTime: MediaTime;
}): void {
event.stopPropagation();
const wasDrag =
this.mouseDownX !== null &&
Math.abs(event.clientX - this.mouseDownX) > TIMELINE_DRAG_THRESHOLD_PX;
this.mouseDownX = null;
if (wasDrag) return;
const { displayedStartTime, getFps, getTotalDuration, seek } = this.config;
const fps = getFps();
const absoluteIndicatorTime = addMediaTime({
a: displayedStartTime,
b: indicatorTime,
});
const seekTime =
fps != null
? snapSeekMediaTime({
time: absoluteIndicatorTime,
duration: getTotalDuration(),
fps,
})
: absoluteIndicatorTime;
seek({ time: seekTime });
if (event.shiftKey) {
this.config.selectKeyframeRange({
orderedKeyframes,
targetKeyframes: keyframes,
isAdditive: event.metaKey || event.ctrlKey,
});
return;
}
this.config.toggleKeyframeSelection({
keyframes,
isMultiKey: event.metaKey || event.ctrlKey,
});
}
getVisualOffsetPx({
indicatorTime,
indicatorOffsetPx,
isBeingDragged,
displayedStartTime,
elementLeft,
}: {
indicatorTime: MediaTime;
indicatorOffsetPx: number;
isBeingDragged: boolean;
displayedStartTime: MediaTime;
elementLeft: number;
}): number {
if (!isBeingDragged || this.session.kind !== "active")
return indicatorOffsetPx;
const deltaTime = mediaTime({ ticks: this.session.deltaTicks });
const clampedTime = clampMediaTime({
time: addMediaTime({ a: indicatorTime, b: deltaTime }),
min: ZERO_MEDIA_TIME,
max: this.config.element.duration,
});
return (
timelineTimeToSnappedPixels({
time: addMediaTime({ a: displayedStartTime, b: clampedTime }),
zoomLevel: this.config.zoomLevel,
}) - elementLeft
);
}
private activate(): void {
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("mouseup", this.handleMouseUp);
}
private deactivate(): void {
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("mouseup", this.handleMouseUp);
}
private notify(): void {
for (const fn of this.subscribers) fn();
}
private finishSession(): void {
this.session = { kind: "idle" };
this.deactivate();
this.notify();
}
private commitDrag({
keyframeRefs,
deltaTicks,
}: {
keyframeRefs: SelectedKeyframeRef[];
deltaTicks: number;
}): void {
const { element } = this.config;
const commands: Command[] = keyframeRefs.flatMap((ref) => {
const keyframe = getKeyframeById({
animations: element.animations,
propertyPath: ref.propertyPath,
keyframeId: ref.keyframeId,
});
if (!keyframe) return [];
return [
new RetimeKeyframeCommand({
trackId: ref.trackId,
elementId: ref.elementId,
propertyPath: ref.propertyPath,
keyframeId: ref.keyframeId,
nextTime: clampMediaTime({
time: addMediaTime({
a: keyframe.time,
b: mediaTime({ ticks: deltaTicks }),
}),
min: ZERO_MEDIA_TIME,
max: element.duration,
}),
}),
];
});
const [first, ...rest] = commands;
if (!first) return;
if (rest.length === 0) {
this.config.executeCommand(first);
} else {
this.config.executeCommand(new BatchCommand([first, ...rest]));
}
}
private handleMouseMove({ clientX }: MouseEvent): void {
if (this.session.kind === "pending") {
const deltaX = Math.abs(clientX - this.session.startMouseX);
if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
this.session = {
kind: "active",
keyframeRefs: this.session.keyframeRefs,
startMouseX: this.session.startMouseX,
deltaTicks: 0,
};
this.notify();
return;
}
if (this.session.kind !== "active") return;
const fps = this.config.getFps();
if (!fps) return;
const pixelsPerSecond =
BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel;
const rawDeltaTicks = Math.round(
((clientX - this.session.startMouseX) / pixelsPerSecond) *
TICKS_PER_SECOND,
);
this.session.deltaTicks =
roundFrameTicks({ ticks: rawDeltaTicks, fps });
this.notify();
}
private handleMouseUp(): void {
if (this.session.kind === "pending") {
this.finishSession();
return;
}
if (this.session.kind !== "active") return;
const { selectedKeyframes, element } = this.config;
const { keyframeRefs, deltaTicks } = this.session;
const draggingIds = new Set(keyframeRefs.map((r) => r.keyframeId));
const draggingRefs = selectedKeyframes.filter(
(kf) => kf.elementId === element.id && draggingIds.has(kf.keyframeId),
);
if (draggingRefs.length > 0 && deltaTicks !== 0) {
this.commitDrag({ keyframeRefs: draggingRefs, deltaTicks });
}
this.finishSession();
}
}

View File

@ -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();
}
}

View File

@ -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<MediaTime | null>((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<MediaTime | null>(
(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();
}
}

View File

@ -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,
});
}
}

View File

@ -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<typeof setTimeout> | 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();
}
}
}

View File

@ -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 });
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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,
});
}

View File

@ -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;
}

View File

@ -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<HTMLDivElement | null>;
tracksContainerRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
@ -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<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
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<ElementDragState>(initialDragState);
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingDragState | null>(null);
const moveGroupRef = useRef<MoveGroup | null>(null);
const newTrackIdsRef = useRef<string[]>([]);
const groupMoveResultRef = useRef<GroupMoveResult | null>(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<ElementInteractionDeps>(deps);
depsRef.current = deps;
const controllerRef = useRef<ElementInteractionController | null>(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<string, MediaTime> = {};
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,
};
}

View File

@ -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<string>;
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<KeyframeDragState>(initialDragState);
const [isPendingDrag, setIsPendingDrag] = useState(false);
const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
const mouseDownXRef = useRef<number | null>(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<KeyframeDragConfig>(config);
configRef.current = config;
const controllerRef = useRef<KeyframeDragController | null>(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,
};
}

View File

@ -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<HTMLDivElement | null>;
@ -40,609 +19,47 @@ export function useTimelineDragDrop({
zoomLevel,
}: UseTimelineDragDropProps) {
const editor = useEditor();
const [isDragOver, setIsDragOver] = useState(false);
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
const [dragElementType, setElementType] = useState<ElementType | null>(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<DragDropConfig>(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<DragDropController | null>(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,
},
};
}

View File

@ -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<MediaTime | null>(null);
const isDraggingRulerRef = useRef(false);
const hasDraggedRulerRef = useRef(false);
const lastMouseXRef = useRef<number>(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<PlayheadController | null>(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,
};
}

View File

@ -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<ResizeInteractionState | null>(
null,
);
const latestResultRef = useRef<GroupResizeResult | null>(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<TimelineElement>,
})),
});
};
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<TimelineElement>,
})),
});
}
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<TimelineElement>,
})),
}),
onSnapPointChange,
]);
};
const configRef = useRef(config);
configRef.current = config;
const controllerRef = useRef<ResizeController | null>(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
);
});
}

View File

@ -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<HTMLDivElement | null>;
@ -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<SeekController | null>(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,
};
}

View File

@ -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<ReturnType<typeof setTimeout> | 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<ZoomController | null>(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,
};
}

View File

@ -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" }];
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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<string, MediaTime>;
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;

View File

@ -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);
}

View File

@ -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=="],