@@ -72,15 +65,13 @@ function TimecodeDisplay() {
);
useEffect(() => {
- const handler = (e: Event) =>
- setCurrentTime((e as CustomEvent<{ time: number }>).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 (
@@ -94,7 +85,11 @@ function TimecodeDisplay() {
/>
/
- {formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })}
+ {formatTimecode({
+ time: totalDuration,
+ format: "HH:MM:SS:FF",
+ rate: fps,
+ })}
);
diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts
new file mode 100644
index 00000000..00573662
--- /dev/null
+++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts
@@ -0,0 +1,583 @@
+import type {
+ MouseEvent as ReactMouseEvent,
+ PointerEvent as ReactPointerEvent,
+} from "react";
+import type { MediaAsset } from "@/media/types";
+import {
+ getVisibleElementsWithBounds,
+ type ElementWithBounds,
+} from "@/preview/element-bounds";
+import {
+ getHitElements,
+ hitTest,
+ resolvePreferredHit,
+} from "@/preview/hit-test";
+import {
+ SNAP_THRESHOLD_SCREEN_PIXELS,
+ snapPosition,
+ type SnapLine,
+} from "@/preview/preview-snap";
+import type { TCanvasSize } from "@/project/types";
+import type { Transform } from "@/rendering";
+import { isVisualElement } from "@/timeline/element-utils";
+import type {
+ ElementRef,
+ SceneTracks,
+ TextElement,
+ TimelineElement,
+ TimelineTrack,
+ VisualElement,
+} from "@/timeline";
+
+const MIN_DRAG_DISTANCE = 0.5;
+const PRIMARY_POINTER_BUTTON = 0;
+
+type Point = { readonly x: number; readonly y: number };
+
+interface CapturedPointerState {
+ readonly pointerId: number;
+ readonly captureTarget: HTMLElement;
+}
+
+interface PendingGesture extends CapturedPointerState {
+ readonly kind: "pending";
+ readonly origin: Point;
+ readonly topmostHit: ElementWithBounds | null;
+ readonly selectedHit: ElementWithBounds | null;
+ readonly selectedElements: readonly ElementRef[];
+}
+
+interface DragElementSnapshot {
+ readonly trackId: string;
+ readonly elementId: string;
+ readonly initialTransform: Transform;
+}
+
+interface DraggingGesture extends CapturedPointerState {
+ readonly kind: "dragging";
+ readonly origin: Point;
+ readonly bounds: {
+ readonly width: number;
+ readonly height: number;
+ readonly rotation: number;
+ };
+ readonly elements: readonly DragElementSnapshot[];
+}
+
+type GestureSession =
+ | { readonly kind: "idle" }
+ | PendingGesture
+ | DraggingGesture;
+
+const IDLE_GESTURE: GestureSession = { kind: "idle" };
+
+export interface EditingTextState {
+ readonly trackId: string;
+ readonly elementId: string;
+ readonly element: TextElement;
+}
+
+export interface PreviewViewportAdapter {
+ screenToCanvas: ({
+ clientX,
+ clientY,
+ }: {
+ clientX: number;
+ clientY: number;
+ }) => Point | null;
+ screenPixelsToLogicalThreshold: ({
+ screenPixels,
+ }: {
+ screenPixels: number;
+ }) => Point;
+}
+
+export interface InputAdapter {
+ isShiftHeld: () => boolean;
+}
+
+export interface SceneReader {
+ getTracks: () => SceneTracks;
+ getCurrentTime: () => number;
+ getMediaAssets: () => MediaAsset[];
+ getCanvasSize: () => TCanvasSize;
+}
+
+export interface SelectionApi {
+ getSelected: () => readonly ElementRef[];
+ setSelected: (elements: readonly ElementRef[]) => void;
+ clearSelection: () => void;
+}
+
+export interface TimelinePreviewUpdate {
+ readonly trackId: string;
+ readonly elementId: string;
+ readonly updates: Partial
;
+}
+
+export interface TimelineOps {
+ getElementsWithTracks: ({
+ elements,
+ }: {
+ elements: readonly ElementRef[];
+ }) => Array<{ track: TimelineTrack; element: TimelineElement }>;
+ previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
+ commitPreview: () => void;
+ discardPreview: () => void;
+}
+
+export interface PlaybackApi {
+ getIsPlaying: () => boolean;
+ subscribe: (listener: () => void) => () => void;
+}
+
+export interface PreviewOptions {
+ isMaskMode: () => boolean;
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}
+
+export interface PreviewInteractionDeps {
+ viewport: PreviewViewportAdapter;
+ input: InputAdapter;
+ scene: SceneReader;
+ selection: SelectionApi;
+ timeline: TimelineOps;
+ playback: PlaybackApi;
+ preview: PreviewOptions;
+}
+
+export interface PreviewInteractionDepsRef {
+ readonly current: PreviewInteractionDeps;
+}
+
+function isSameElementRef({
+ left,
+ right,
+}: {
+ left: ElementRef;
+ right: ElementRef;
+}): boolean {
+ return left.trackId === right.trackId && left.elementId === right.elementId;
+}
+
+function buildDragSelection({
+ selectedElements,
+ dragTarget,
+}: {
+ selectedElements: readonly ElementRef[];
+ dragTarget: ElementWithBounds;
+}): ElementRef[] {
+ const dragTargetRef = {
+ trackId: dragTarget.trackId,
+ elementId: dragTarget.elementId,
+ };
+
+ if (
+ !selectedElements.some((selectedElement) =>
+ isSameElementRef({ left: selectedElement, right: dragTargetRef }),
+ )
+ ) {
+ return [dragTargetRef];
+ }
+
+ return [
+ dragTargetRef,
+ ...selectedElements.filter(
+ (selectedElement) =>
+ !isSameElementRef({ left: selectedElement, right: dragTargetRef }),
+ ),
+ ];
+}
+
+function movedPastDragThreshold({
+ current,
+ origin,
+}: {
+ current: Point;
+ origin: Point;
+}): boolean {
+ return (
+ Math.abs(current.x - origin.x) > MIN_DRAG_DISTANCE ||
+ Math.abs(current.y - origin.y) > MIN_DRAG_DISTANCE
+ );
+}
+
+function toDragElementSnapshots({
+ elementsWithTracks,
+}: {
+ elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>;
+}): DragElementSnapshot[] {
+ const isVisualTrackedElement = (value: {
+ track: TimelineTrack;
+ element: TimelineElement;
+ }): value is { track: TimelineTrack; element: VisualElement } =>
+ isVisualElement(value.element);
+
+ return elementsWithTracks
+ .filter(isVisualTrackedElement)
+ .map(({ track, element }) => ({
+ trackId: track.id,
+ elementId: element.id,
+ initialTransform: element.transform,
+ }));
+}
+
+export class PreviewInteractionController {
+ private readonly depsRef: PreviewInteractionDepsRef;
+ private readonly subscribers = new Set<() => void>();
+
+ private gesture: GestureSession = IDLE_GESTURE;
+ private editingTextState: EditingTextState | null = null;
+ private wasPlaying: boolean;
+ private unsubscribePlayback: (() => void) | null = null;
+
+ constructor({ depsRef }: { depsRef: PreviewInteractionDepsRef }) {
+ this.depsRef = depsRef;
+ this.wasPlaying = this.deps.playback.getIsPlaying();
+
+ this.onDoubleClick = this.onDoubleClick.bind(this);
+ this.onPointerDown = this.onPointerDown.bind(this);
+ this.onPointerMove = this.onPointerMove.bind(this);
+ this.onPointerUp = this.onPointerUp.bind(this);
+ this.commitTextEdit = this.commitTextEdit.bind(this);
+ this.handlePlaybackChange = this.handlePlaybackChange.bind(this);
+
+ this.unsubscribePlayback = this.deps.playback.subscribe(
+ this.handlePlaybackChange,
+ );
+ }
+
+ private get deps(): PreviewInteractionDeps {
+ return this.depsRef.current;
+ }
+
+ get isDragging(): boolean {
+ return this.gesture.kind === "dragging";
+ }
+
+ get editingText(): EditingTextState | null {
+ return this.editingTextState;
+ }
+
+ subscribe({ listener }: { listener: () => void }): () => void {
+ this.subscribers.add(listener);
+ return () => this.subscribers.delete(listener);
+ }
+
+ destroy(): void {
+ this.unsubscribePlayback?.();
+ this.unsubscribePlayback = null;
+ this.abortActiveGesture();
+ this.editingTextState = null;
+ this.subscribers.clear();
+ }
+
+ cancel(): void {
+ if (this.gesture.kind === "idle") return;
+ this.abortActiveGesture();
+ this.notify();
+ }
+
+ private abortActiveGesture(): void {
+ if (this.gesture.kind === "idle") return;
+
+ if (this.gesture.kind === "dragging") {
+ this.deps.timeline.discardPreview();
+ }
+
+ this.releaseCapturedPointer({ pointerState: this.gesture });
+ this.gesture = IDLE_GESTURE;
+ this.clearSnapLines();
+ }
+
+ commitTextEdit(): void {
+ if (!this.editingTextState) return;
+
+ this.editingTextState = null;
+ this.deps.timeline.commitPreview();
+ this.notify();
+ }
+
+ onDoubleClick({ clientX, clientY }: ReactMouseEvent): void {
+ if (this.editingTextState || this.deps.preview.isMaskMode()) return;
+
+ const startPos = this.deps.viewport.screenToCanvas({
+ clientX,
+ clientY,
+ });
+ if (!startPos) return;
+
+ const hit = hitTest({
+ canvasX: startPos.x,
+ canvasY: startPos.y,
+ elementsWithBounds: this.getVisibleElementsWithBounds(),
+ });
+
+ if (!hit || hit.element.type !== "text") return;
+
+ this.editingTextState = {
+ trackId: hit.trackId,
+ elementId: hit.elementId,
+ element: hit.element,
+ };
+ this.notify();
+ }
+
+ onPointerDown({
+ clientX,
+ clientY,
+ currentTarget,
+ pointerId,
+ button,
+ }: ReactPointerEvent): void {
+ if (this.editingTextState) return;
+ if (this.deps.preview.isMaskMode()) return;
+ if (button !== PRIMARY_POINTER_BUTTON) return;
+
+ const startPos = this.deps.viewport.screenToCanvas({
+ clientX,
+ clientY,
+ });
+ if (!startPos) return;
+
+ const hits = getHitElements({
+ canvasX: startPos.x,
+ canvasY: startPos.y,
+ elementsWithBounds: this.getVisibleElementsWithBounds(),
+ });
+ const selectedElements = this.deps.selection.getSelected();
+
+ this.gesture = {
+ kind: "pending",
+ origin: startPos,
+ pointerId,
+ captureTarget: currentTarget as HTMLElement,
+ topmostHit: hits[0] ?? null,
+ selectedHit: resolvePreferredHit({
+ hits,
+ preferredElements: [...selectedElements],
+ }),
+ selectedElements,
+ };
+
+ currentTarget.setPointerCapture(pointerId);
+ }
+
+ onPointerMove({ clientX, clientY }: ReactPointerEvent): void {
+ const currentPos = this.deps.viewport.screenToCanvas({
+ clientX,
+ clientY,
+ });
+ if (!currentPos) return;
+
+ if (this.gesture.kind === "pending") {
+ const pending = this.gesture;
+ if (
+ !movedPastDragThreshold({
+ current: currentPos,
+ origin: pending.origin,
+ })
+ ) {
+ this.clearSnapLines();
+ return;
+ }
+
+ this.beginDragFromPending({ pending });
+ }
+
+ if (this.gesture.kind !== "dragging") return;
+
+ this.updateDragPreview({
+ drag: this.gesture,
+ currentPos,
+ });
+ }
+
+ onPointerUp({ type }: ReactPointerEvent): void {
+ if (this.gesture.kind === "dragging") {
+ const drag = this.gesture;
+
+ if (type === "pointercancel") {
+ this.deps.timeline.discardPreview();
+ } else {
+ this.deps.timeline.commitPreview();
+ }
+
+ this.gesture = IDLE_GESTURE;
+ this.clearSnapLines();
+ this.releaseCapturedPointer({ pointerState: drag });
+ this.notify();
+ return;
+ }
+
+ if (this.gesture.kind !== "pending") return;
+
+ const pending = this.gesture;
+
+ if (type !== "pointercancel") {
+ const clickTarget = pending.topmostHit;
+ if (!clickTarget) {
+ this.deps.selection.clearSelection();
+ } else {
+ this.deps.selection.setSelected([
+ {
+ trackId: clickTarget.trackId,
+ elementId: clickTarget.elementId,
+ },
+ ]);
+ }
+ }
+
+ this.gesture = IDLE_GESTURE;
+ this.clearSnapLines();
+ this.releaseCapturedPointer({ pointerState: pending });
+ }
+
+ private notify(): void {
+ for (const listener of this.subscribers) listener();
+ }
+
+ private clearSnapLines(): void {
+ this.deps.preview.onSnapLinesChange?.([]);
+ }
+
+ private releaseCapturedPointer({
+ pointerState,
+ }: {
+ pointerState: CapturedPointerState | null;
+ }): void {
+ if (!pointerState) return;
+
+ if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) {
+ return;
+ }
+
+ pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
+ }
+
+ private getVisibleElementsWithBounds(): ElementWithBounds[] {
+ return getVisibleElementsWithBounds({
+ tracks: this.deps.scene.getTracks(),
+ currentTime: this.deps.scene.getCurrentTime(),
+ canvasSize: this.deps.scene.getCanvasSize(),
+ mediaAssets: this.deps.scene.getMediaAssets(),
+ });
+ }
+
+ private handlePlaybackChange(): void {
+ const isPlaying = this.deps.playback.getIsPlaying();
+ if (isPlaying && !this.wasPlaying && this.editingTextState) {
+ this.commitTextEdit();
+ }
+ this.wasPlaying = isPlaying;
+ }
+
+ private beginDragFromPending({ pending }: { pending: PendingGesture }): void {
+ const dragTarget = pending.selectedHit ?? pending.topmostHit;
+ if (!dragTarget) {
+ this.gesture = IDLE_GESTURE;
+ this.clearSnapLines();
+ this.releaseCapturedPointer({ pointerState: pending });
+ return;
+ }
+
+ const dragSelection = buildDragSelection({
+ selectedElements: pending.selectedElements,
+ dragTarget,
+ });
+ const draggableElements = toDragElementSnapshots({
+ elementsWithTracks: this.deps.timeline.getElementsWithTracks({
+ elements: dragSelection,
+ }),
+ });
+
+ if (draggableElements.length === 0) {
+ this.gesture = IDLE_GESTURE;
+ this.clearSnapLines();
+ this.releaseCapturedPointer({ pointerState: pending });
+ return;
+ }
+
+ if (pending.selectedHit === null) {
+ this.deps.selection.setSelected([
+ {
+ trackId: dragTarget.trackId,
+ elementId: dragTarget.elementId,
+ },
+ ]);
+ }
+
+ this.gesture = {
+ kind: "dragging",
+ origin: pending.origin,
+ pointerId: pending.pointerId,
+ captureTarget: pending.captureTarget,
+ bounds: {
+ width: dragTarget.bounds.width,
+ height: dragTarget.bounds.height,
+ rotation: dragTarget.bounds.rotation,
+ },
+ elements: draggableElements,
+ };
+ this.notify();
+ }
+
+ private updateDragPreview({
+ drag,
+ currentPos,
+ }: {
+ drag: DraggingGesture;
+ currentPos: Point;
+ }): void {
+ const firstElement = drag.elements[0];
+ if (!firstElement) return;
+
+ const deltaX = currentPos.x - drag.origin.x;
+ const deltaY = currentPos.y - drag.origin.y;
+
+ const proposedPosition = {
+ x: firstElement.initialTransform.position.x + deltaX,
+ y: firstElement.initialTransform.position.y + deltaY,
+ };
+
+ const shouldSnap = !this.deps.input.isShiftHeld();
+ const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { snappedPosition, activeLines } = shouldSnap
+ ? snapPosition({
+ proposedPosition,
+ canvasSize: this.deps.scene.getCanvasSize(),
+ elementSize: drag.bounds,
+ rotation: drag.bounds.rotation,
+ snapThreshold,
+ })
+ : {
+ snappedPosition: proposedPosition,
+ activeLines: [] as SnapLine[],
+ };
+
+ this.deps.preview.onSnapLinesChange?.(activeLines);
+
+ const deltaSnappedX =
+ snappedPosition.x - firstElement.initialTransform.position.x;
+ const deltaSnappedY =
+ snappedPosition.y - firstElement.initialTransform.position.y;
+
+ this.deps.timeline.previewElements(
+ drag.elements.map(({ trackId, elementId, initialTransform }) => ({
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ position: {
+ x: initialTransform.position.x + deltaSnappedX,
+ y: initialTransform.position.y + deltaSnappedY,
+ },
+ },
+ },
+ })),
+ );
+ }
+}
diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts
new file mode 100644
index 00000000..e9d251e3
--- /dev/null
+++ b/apps/web/src/preview/controllers/transform-handle-controller.ts
@@ -0,0 +1,753 @@
+import type { PointerEvent as ReactPointerEvent } from "react";
+import type { MediaAsset } from "@/media/types";
+import {
+ getVisibleElementsWithBounds,
+ type Corner,
+ type Edge,
+ type ElementBounds,
+ type ElementWithBounds,
+} from "@/preview/element-bounds";
+import {
+ MIN_SCALE,
+ SNAP_THRESHOLD_SCREEN_PIXELS,
+ snapRotation,
+ snapScale,
+ snapScaleAxes,
+ type ScaleEdgePreference,
+ type SnapLine,
+} from "@/preview/preview-snap";
+import { isVisualElement } from "@/timeline/element-utils";
+import {
+ getElementLocalTime,
+ hasKeyframesForPath,
+ resolveTransformAtTime,
+ setChannel,
+} from "@/animation";
+import type { ElementAnimations } from "@/animation/types";
+import type { Transform } from "@/rendering";
+import type {
+ ElementRef,
+ SceneTracks,
+ TimelineElement,
+ VisualElement,
+} from "@/timeline";
+
+type Point = { readonly x: number; readonly y: number };
+type CanvasSize = { readonly width: number; readonly height: number };
+type HandleType = Corner | Edge | "rotation";
+
+interface CapturedPointerState {
+ readonly pointerId: number;
+ readonly captureTarget: HTMLElement;
+}
+
+interface CornerScaleSession extends CapturedPointerState {
+ readonly kind: "corner-scale";
+ readonly corner: Corner;
+ readonly trackId: string;
+ readonly elementId: string;
+ readonly initialTransform: Transform;
+ readonly initialDistance: number;
+ readonly initialBoundsCx: number;
+ readonly initialBoundsCy: number;
+ readonly baseWidth: number;
+ readonly baseHeight: number;
+ readonly shouldClearScaleAnimation: boolean;
+ readonly animationsWithoutScale: ElementAnimations | undefined;
+}
+
+interface EdgeScaleSession extends CapturedPointerState {
+ readonly kind: "edge-scale";
+ readonly edge: Edge;
+ readonly trackId: string;
+ readonly elementId: string;
+ readonly initialTransform: Transform;
+ readonly initialBoundsCx: number;
+ readonly initialBoundsCy: number;
+ readonly baseWidth: number;
+ readonly baseHeight: number;
+ readonly rotationRad: number;
+ readonly shouldClearScaleAnimation: boolean;
+ readonly animationsWithoutScale: ElementAnimations | undefined;
+}
+
+interface RotationSession extends CapturedPointerState {
+ readonly kind: "rotation";
+ readonly trackId: string;
+ readonly elementId: string;
+ readonly initialTransform: Transform;
+ readonly initialAngle: number;
+ readonly initialBoundsCx: number;
+ readonly initialBoundsCy: number;
+}
+
+type TransformSession =
+ | { readonly kind: "idle" }
+ | CornerScaleSession
+ | EdgeScaleSession
+ | RotationSession;
+
+const IDLE_SESSION: TransformSession = { kind: "idle" };
+
+interface VisualSelectionContext {
+ readonly trackId: string;
+ readonly elementId: string;
+ readonly element: VisualElement;
+ readonly bounds: ElementBounds;
+ readonly resolvedTransform: Transform;
+}
+
+export interface PreviewViewportAdapter {
+ screenToCanvas: ({
+ clientX,
+ clientY,
+ }: {
+ clientX: number;
+ clientY: number;
+ }) => Point | null;
+ screenPixelsToLogicalThreshold: ({
+ screenPixels,
+ }: {
+ screenPixels: number;
+ }) => Point;
+}
+
+export interface InputAdapter {
+ isShiftHeld: () => boolean;
+}
+
+export interface SceneReader {
+ getSelectedElements: () => readonly ElementRef[];
+ getTracks: () => SceneTracks;
+ getCurrentTime: () => number;
+ getMediaAssets: () => MediaAsset[];
+ getCanvasSize: () => CanvasSize;
+}
+
+export interface TimelinePreviewUpdate {
+ readonly trackId: string;
+ readonly elementId: string;
+ readonly updates: Partial;
+}
+
+export interface TimelineOps {
+ previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
+ commitPreview: () => void;
+ discardPreview: () => void;
+}
+
+export interface PreviewOptions {
+ onSnapLinesChange?: (lines: SnapLine[]) => void;
+}
+
+export interface TransformHandleDeps {
+ viewport: PreviewViewportAdapter;
+ input: InputAdapter;
+ scene: SceneReader;
+ timeline: TimelineOps;
+ preview: PreviewOptions;
+}
+
+export interface TransformHandleDepsRef {
+ readonly current: TransformHandleDeps;
+}
+
+function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference {
+ return edge === "right"
+ ? { right: true }
+ : edge === "left"
+ ? { left: true }
+ : { bottom: true };
+}
+
+function clampScaleNonZero(scale: number): number {
+ if (Math.abs(scale) < MIN_SCALE) {
+ return scale < 0 ? -MIN_SCALE : MIN_SCALE;
+ }
+ return scale;
+}
+
+function getCornerDistance({
+ bounds,
+ corner,
+}: {
+ bounds: ElementBounds;
+ corner: Corner;
+}): number {
+ const halfWidth = bounds.width / 2;
+ const halfHeight = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+
+ const localX =
+ corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
+ const localY =
+ corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
+
+ const rotatedX = localX * cos - localY * sin;
+ const rotatedY = localX * sin + localY * cos;
+ return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
+}
+
+function buildSelectedWithBounds({
+ selectedElements,
+ elementsWithBounds,
+}: {
+ selectedElements: readonly ElementRef[];
+ elementsWithBounds: readonly ElementWithBounds[];
+}): ElementWithBounds | null {
+ if (selectedElements.length !== 1) return null;
+
+ return (
+ elementsWithBounds.find(
+ (entry) =>
+ entry.trackId === selectedElements[0].trackId &&
+ entry.elementId === selectedElements[0].elementId,
+ ) ?? null
+ );
+}
+
+function buildCornerScaleAnimationReset({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): {
+ shouldClearScaleAnimation: boolean;
+ animationsWithoutScale: ElementAnimations | undefined;
+} {
+ const shouldClearScaleAnimation =
+ hasKeyframesForPath({
+ animations,
+ propertyPath: "transform.scaleX",
+ }) ||
+ hasKeyframesForPath({
+ animations,
+ propertyPath: "transform.scaleY",
+ });
+
+ return {
+ shouldClearScaleAnimation,
+ animationsWithoutScale: shouldClearScaleAnimation
+ ? setChannel({
+ animations: setChannel({
+ animations,
+ propertyPath: "transform.scaleX",
+ channel: undefined,
+ }),
+ propertyPath: "transform.scaleY",
+ channel: undefined,
+ })
+ : animations,
+ };
+}
+
+function buildEdgeScaleAnimationReset({
+ animations,
+ edge,
+}: {
+ animations: ElementAnimations | undefined;
+ edge: Edge;
+}): {
+ shouldClearScaleAnimation: boolean;
+ animationsWithoutScale: ElementAnimations | undefined;
+} {
+ const propertyPath =
+ edge === "right" || edge === "left"
+ ? "transform.scaleX"
+ : "transform.scaleY";
+
+ const shouldClearScaleAnimation = hasKeyframesForPath({
+ animations,
+ propertyPath,
+ });
+
+ return {
+ shouldClearScaleAnimation,
+ animationsWithoutScale: shouldClearScaleAnimation
+ ? setChannel({
+ animations,
+ propertyPath,
+ channel: undefined,
+ })
+ : animations,
+ };
+}
+
+export class TransformHandleController {
+ private readonly depsRef: TransformHandleDepsRef;
+ private readonly subscribers = new Set<() => void>();
+
+ private session: TransformSession = IDLE_SESSION;
+
+ constructor({ depsRef }: { depsRef: TransformHandleDepsRef }) {
+ this.depsRef = depsRef;
+
+ this.onCornerPointerDown = this.onCornerPointerDown.bind(this);
+ this.onEdgePointerDown = this.onEdgePointerDown.bind(this);
+ this.onRotationPointerDown = this.onRotationPointerDown.bind(this);
+ this.onPointerMove = this.onPointerMove.bind(this);
+ this.onPointerUp = this.onPointerUp.bind(this);
+ }
+
+ private get deps(): TransformHandleDeps {
+ return this.depsRef.current;
+ }
+
+ get selectedWithBounds(): ElementWithBounds | null {
+ return buildSelectedWithBounds({
+ selectedElements: this.deps.scene.getSelectedElements(),
+ elementsWithBounds: this.getVisibleElementsWithBounds(),
+ });
+ }
+
+ get activeHandle(): HandleType | null {
+ switch (this.session.kind) {
+ case "corner-scale":
+ return this.session.corner;
+ case "edge-scale":
+ return this.session.edge;
+ case "rotation":
+ return "rotation";
+ default:
+ return null;
+ }
+ }
+
+ get isActive(): boolean {
+ return this.session.kind !== "idle";
+ }
+
+ subscribe(fn: () => void): () => void {
+ this.subscribers.add(fn);
+ return () => this.subscribers.delete(fn);
+ }
+
+ destroy(): void {
+ if (this.session.kind !== "idle") {
+ const session = this.session;
+ this.session = IDLE_SESSION;
+ this.deps.timeline.discardPreview();
+ this.clearSnapLines();
+ this.releaseCapturedPointer(session);
+ }
+
+ this.subscribers.clear();
+ }
+
+ cancel(): void {
+ if (this.session.kind === "idle") return;
+
+ const session = this.session;
+ this.session = IDLE_SESSION;
+ this.deps.timeline.discardPreview();
+ this.clearSnapLines();
+ this.releaseCapturedPointer(session);
+ this.notify();
+ }
+
+ onCornerPointerDown({
+ event,
+ corner,
+ }: {
+ event: ReactPointerEvent;
+ corner: Corner;
+ }): void {
+ const context = this.getSelectedVisualContext();
+ if (!context) return;
+
+ event.stopPropagation();
+
+ const { shouldClearScaleAnimation, animationsWithoutScale } =
+ buildCornerScaleAnimationReset({
+ animations: context.element.animations,
+ });
+
+ this.session = {
+ kind: "corner-scale",
+ corner,
+ trackId: context.trackId,
+ elementId: context.elementId,
+ initialTransform: context.resolvedTransform,
+ initialDistance: getCornerDistance({
+ bounds: context.bounds,
+ corner,
+ }),
+ initialBoundsCx: context.bounds.cx,
+ initialBoundsCy: context.bounds.cy,
+ baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
+ baseHeight: context.bounds.height / context.resolvedTransform.scaleY,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ pointerId: event.pointerId,
+ captureTarget: this.capturePointer({
+ target: event.currentTarget as HTMLElement,
+ pointerId: event.pointerId,
+ }),
+ };
+
+ this.notify();
+ }
+
+ onRotationPointerDown({ event }: { event: ReactPointerEvent }): void {
+ const context = this.getSelectedVisualContext();
+ if (!context) return;
+
+ event.stopPropagation();
+
+ const position = this.deps.viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!position) return;
+
+ const deltaX = position.x - context.bounds.cx;
+ const deltaY = position.y - context.bounds.cy;
+ const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+
+ this.session = {
+ kind: "rotation",
+ trackId: context.trackId,
+ elementId: context.elementId,
+ initialTransform: context.resolvedTransform,
+ initialAngle,
+ initialBoundsCx: context.bounds.cx,
+ initialBoundsCy: context.bounds.cy,
+ pointerId: event.pointerId,
+ captureTarget: this.capturePointer({
+ target: event.currentTarget as HTMLElement,
+ pointerId: event.pointerId,
+ }),
+ };
+
+ this.notify();
+ }
+
+ onEdgePointerDown({
+ event,
+ edge,
+ }: {
+ event: ReactPointerEvent;
+ edge: Edge;
+ }): void {
+ const context = this.getSelectedVisualContext();
+ if (!context) return;
+
+ event.stopPropagation();
+
+ const { shouldClearScaleAnimation, animationsWithoutScale } =
+ buildEdgeScaleAnimationReset({
+ animations: context.element.animations,
+ edge,
+ });
+
+ this.session = {
+ kind: "edge-scale",
+ edge,
+ trackId: context.trackId,
+ elementId: context.elementId,
+ initialTransform: context.resolvedTransform,
+ initialBoundsCx: context.bounds.cx,
+ initialBoundsCy: context.bounds.cy,
+ baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
+ baseHeight: context.bounds.height / context.resolvedTransform.scaleY,
+ rotationRad: (context.bounds.rotation * Math.PI) / 180,
+ shouldClearScaleAnimation,
+ animationsWithoutScale,
+ pointerId: event.pointerId,
+ captureTarget: this.capturePointer({
+ target: event.currentTarget as HTMLElement,
+ pointerId: event.pointerId,
+ }),
+ };
+
+ this.notify();
+ }
+
+ onPointerMove({ event }: { event: ReactPointerEvent }): void {
+ if (this.session.kind === "idle") return;
+
+ const position = this.deps.viewport.screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ if (!position) return;
+
+ switch (this.session.kind) {
+ case "corner-scale":
+ this.previewCornerScale({
+ session: this.session,
+ position,
+ });
+ return;
+ case "edge-scale":
+ this.previewEdgeScale({
+ session: this.session,
+ position,
+ });
+ return;
+ case "rotation":
+ this.previewRotation({
+ session: this.session,
+ position,
+ });
+ return;
+ default:
+ return;
+ }
+ }
+
+ onPointerUp(): void {
+ if (this.session.kind === "idle") return;
+
+ const session = this.session;
+ this.session = IDLE_SESSION;
+ this.deps.timeline.commitPreview();
+ this.clearSnapLines();
+ this.releaseCapturedPointer(session);
+ this.notify();
+ }
+
+ private notify(): void {
+ for (const fn of this.subscribers) fn();
+ }
+
+ private clearSnapLines(): void {
+ this.deps.preview.onSnapLinesChange?.([]);
+ }
+
+ private capturePointer({
+ target,
+ pointerId,
+ }: {
+ target: HTMLElement;
+ pointerId: number;
+ }): HTMLElement {
+ target.setPointerCapture(pointerId);
+ return target;
+ }
+
+ private releaseCapturedPointer(pointerState: CapturedPointerState): void {
+ if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) {
+ return;
+ }
+
+ pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
+ }
+
+ private getVisibleElementsWithBounds(): ElementWithBounds[] {
+ return getVisibleElementsWithBounds({
+ tracks: this.deps.scene.getTracks(),
+ currentTime: this.deps.scene.getCurrentTime(),
+ canvasSize: this.deps.scene.getCanvasSize(),
+ mediaAssets: this.deps.scene.getMediaAssets(),
+ });
+ }
+
+ private getSelectedVisualContext(): VisualSelectionContext | null {
+ const selectedWithBounds = this.selectedWithBounds;
+ if (!selectedWithBounds) return null;
+ if (!isVisualElement(selectedWithBounds.element)) return null;
+
+ const localTime = getElementLocalTime({
+ timelineTime: this.deps.scene.getCurrentTime(),
+ elementStartTime: selectedWithBounds.element.startTime,
+ elementDuration: selectedWithBounds.element.duration,
+ });
+
+ return {
+ trackId: selectedWithBounds.trackId,
+ elementId: selectedWithBounds.elementId,
+ element: selectedWithBounds.element,
+ bounds: selectedWithBounds.bounds,
+ resolvedTransform: resolveTransformAtTime({
+ baseTransform: selectedWithBounds.element.transform,
+ animations: selectedWithBounds.element.animations,
+ localTime,
+ }),
+ };
+ }
+
+ private previewCornerScale({
+ session,
+ position,
+ }: {
+ session: CornerScaleSession;
+ position: Point;
+ }): void {
+ const deltaX = position.x - session.initialBoundsCx;
+ const deltaY = position.y - session.initialBoundsCy;
+ const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
+ const scaleFactor = currentDistance / session.initialDistance;
+
+ // Use actual element dimensions (base * current scale) so snapping is
+ // computed from the rendered geometry when scaleX != scaleY.
+ const effectiveWidth = session.baseWidth * session.initialTransform.scaleX;
+ const effectiveHeight =
+ session.baseHeight * session.initialTransform.scaleY;
+
+ const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { snappedScale, activeLines } = this.deps.input.isShiftHeld()
+ ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
+ : snapScale({
+ proposedScale: scaleFactor,
+ position: session.initialTransform.position,
+ baseWidth: effectiveWidth,
+ baseHeight: effectiveHeight,
+ rotation: session.initialTransform.rotate,
+ canvasSize: this.deps.scene.getCanvasSize(),
+ snapThreshold,
+ });
+
+ this.deps.preview.onSnapLinesChange?.(activeLines);
+
+ this.deps.timeline.previewElements([
+ {
+ trackId: session.trackId,
+ elementId: session.elementId,
+ updates: {
+ transform: {
+ ...session.initialTransform,
+ scaleX: clampScaleNonZero(
+ session.initialTransform.scaleX * snappedScale,
+ ),
+ scaleY: clampScaleNonZero(
+ session.initialTransform.scaleY * snappedScale,
+ ),
+ },
+ ...(session.shouldClearScaleAnimation && {
+ animations: session.animationsWithoutScale,
+ }),
+ },
+ },
+ ]);
+ }
+
+ private previewEdgeScale({
+ session,
+ position,
+ }: {
+ session: EdgeScaleSession;
+ position: Point;
+ }): void {
+ const deltaX = position.x - session.initialBoundsCx;
+ const deltaY = position.y - session.initialBoundsCy;
+ const xProjection =
+ deltaX * Math.cos(session.rotationRad) +
+ deltaY * Math.sin(session.rotationRad);
+ const yProjection =
+ -deltaX * Math.sin(session.rotationRad) +
+ deltaY * Math.cos(session.rotationRad);
+ const projection =
+ session.edge === "right"
+ ? xProjection
+ : session.edge === "left"
+ ? -xProjection
+ : yProjection;
+
+ const baseAxisHalf =
+ session.edge === "right" || session.edge === "left"
+ ? session.baseWidth / 2
+ : session.baseHeight / 2;
+ const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
+
+ const proposedScaleX =
+ session.edge === "right" || session.edge === "left"
+ ? proposedScale
+ : session.initialTransform.scaleX;
+ const proposedScaleY =
+ session.edge === "bottom"
+ ? proposedScale
+ : session.initialTransform.scaleY;
+
+ const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
+ screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
+ });
+ const { x: xSnap, y: ySnap } = this.deps.input.isShiftHeld()
+ ? {
+ x: {
+ snappedScale: proposedScaleX,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ y: {
+ snappedScale: proposedScaleY,
+ snapDistance: Infinity,
+ activeLines: [] as SnapLine[],
+ },
+ }
+ : snapScaleAxes({
+ proposedScaleX,
+ proposedScaleY,
+ position: session.initialTransform.position,
+ baseWidth: session.baseWidth,
+ baseHeight: session.baseHeight,
+ rotation: session.initialTransform.rotate,
+ canvasSize: this.deps.scene.getCanvasSize(),
+ snapThreshold,
+ preferredEdges: getPreferredEdge({ edge: session.edge }),
+ });
+
+ const relevantSnap =
+ session.edge === "right" || session.edge === "left" ? xSnap : ySnap;
+ this.deps.preview.onSnapLinesChange?.(relevantSnap.activeLines);
+
+ this.deps.timeline.previewElements([
+ {
+ trackId: session.trackId,
+ elementId: session.elementId,
+ updates: {
+ transform: {
+ ...session.initialTransform,
+ scaleX:
+ session.edge === "right" || session.edge === "left"
+ ? xSnap.snappedScale
+ : session.initialTransform.scaleX,
+ scaleY:
+ session.edge === "bottom"
+ ? ySnap.snappedScale
+ : session.initialTransform.scaleY,
+ },
+ ...(session.shouldClearScaleAnimation && {
+ animations: session.animationsWithoutScale,
+ }),
+ },
+ },
+ ]);
+ }
+
+ private previewRotation({
+ session,
+ position,
+ }: {
+ session: RotationSession;
+ position: Point;
+ }): void {
+ const deltaX = position.x - session.initialBoundsCx;
+ const deltaY = position.y - session.initialBoundsCy;
+ const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
+ let deltaAngle = currentAngle - session.initialAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+
+ const newRotate = session.initialTransform.rotate + deltaAngle;
+ const { snappedRotation } = this.deps.input.isShiftHeld()
+ ? { snappedRotation: newRotate }
+ : snapRotation({ proposedRotation: newRotate });
+
+ this.deps.timeline.previewElements([
+ {
+ trackId: session.trackId,
+ elementId: session.elementId,
+ updates: {
+ transform: {
+ ...session.initialTransform,
+ rotate: snappedRotation,
+ },
+ },
+ },
+ ]);
+ }
+}
diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts
index 78212e74..d093dcf2 100644
--- a/apps/web/src/preview/hooks/use-preview-interaction.ts
+++ b/apps/web/src/preview/hooks/use-preview-interaction.ts
@@ -1,97 +1,17 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useEffect, useReducer, useRef } from "react";
import { useEditor } from "@/editor/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
-import type { Transform } from "@/rendering";
-import type { ElementRef, TextElement } from "@/timeline";
-import {
- getVisibleElementsWithBounds,
- type ElementWithBounds,
-} from "@/preview/element-bounds";
-import {
- getHitElements,
- hitTest,
- resolvePreferredHit,
-} from "@/preview/hit-test";
-import { isVisualElement } from "@/timeline/element-utils";
-import {
- SNAP_THRESHOLD_SCREEN_PIXELS,
- snapPosition,
- type SnapLine,
-} from "@/preview/preview-snap";
+import type { SnapLine } from "@/preview/preview-snap";
import { registerCanceller } from "@/editor/cancel-interaction";
+import {
+ PreviewInteractionController,
+ type PreviewInteractionDeps,
+ type PreviewInteractionDepsRef,
+} from "@/preview/controllers/preview-interaction-controller";
export type OnSnapLinesChange = (lines: SnapLine[]) => void;
-const MIN_DRAG_DISTANCE = 0.5;
-
-interface CapturedPointerState {
- pointerId: number;
- captureTarget: HTMLElement;
-}
-
-interface PendingGestureState extends CapturedPointerState {
- startX: number;
- startY: number;
- topmostHit: ElementWithBounds | null;
- selectedHit: ElementWithBounds | null;
- selectedElements: ElementRef[];
-}
-
-interface DragState extends CapturedPointerState {
- startX: number;
- startY: number;
- bounds: {
- width: number;
- height: number;
- rotation: number;
- };
- elements: Array<{
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- }>;
-}
-
-function isSameElementRef({
- left,
- right,
-}: {
- left: ElementRef;
- right: ElementRef;
-}): boolean {
- return left.trackId === right.trackId && left.elementId === right.elementId;
-}
-
-function buildDragSelection({
- selectedElements,
- dragTarget,
-}: {
- selectedElements: ElementRef[];
- dragTarget: ElementWithBounds;
-}): ElementRef[] {
- const dragTargetRef = {
- trackId: dragTarget.trackId,
- elementId: dragTarget.elementId,
- };
-
- if (
- !selectedElements.some((selectedElement) =>
- isSameElementRef({ left: selectedElement, right: dragTargetRef }),
- )
- ) {
- return [dragTargetRef];
- }
-
- return [
- dragTargetRef,
- ...selectedElements.filter(
- (selectedElement) =>
- !isSameElementRef({ left: selectedElement, right: dragTargetRef }),
- ),
- ];
-}
-
export function usePreviewInteraction({
onSnapLinesChange,
isMaskMode = false,
@@ -102,355 +22,74 @@ export function usePreviewInteraction({
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const viewport = usePreviewViewport();
- const [isDragging, setIsDragging] = useState(false);
- const [editingText, setEditingText] = useState<{
- trackId: string;
- elementId: string;
- element: TextElement;
- originalOpacity: number;
- } | null>(null);
- const dragStateRef = useRef(null);
- const pendingGestureRef = useRef(null);
- const wasPlayingRef = useRef(editor.playback.getIsPlaying());
- const editingTextRef = useRef(editingText);
- editingTextRef.current = editingText;
-
- const releaseCapturedPointer = useCallback(
- (pointerState: CapturedPointerState | null) => {
- if (!pointerState) return;
-
- if (
- !pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)
- ) {
- return;
- }
-
- pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
+ const deps: PreviewInteractionDeps = {
+ viewport: {
+ screenToCanvas: viewport.screenToCanvas,
+ screenPixelsToLogicalThreshold: viewport.screenPixelsToLogicalThreshold,
},
- [],
- );
+ input: {
+ isShiftHeld: () => isShiftHeldRef.current,
+ },
+ scene: {
+ getTracks: () => editor.scenes.getActiveScene().tracks,
+ getCurrentTime: () => editor.playback.getCurrentTime(),
+ getMediaAssets: () => editor.media.getAssets(),
+ getCanvasSize: () => editor.project.getActive().settings.canvasSize,
+ },
+ selection: {
+ getSelected: () => editor.selection.getSelectedElements(),
+ setSelected: (elements) =>
+ editor.selection.setSelectedElements({ elements: [...elements] }),
+ clearSelection: () => editor.selection.clearSelection(),
+ },
+ timeline: {
+ getElementsWithTracks: ({ elements }) =>
+ editor.timeline.getElementsWithTracks({ elements: [...elements] }),
+ previewElements: (updates) =>
+ editor.timeline.previewElements({ updates: [...updates] }),
+ commitPreview: () => editor.timeline.commitPreview(),
+ discardPreview: () => editor.timeline.discardPreview(),
+ },
+ playback: {
+ getIsPlaying: () => editor.playback.getIsPlaying(),
+ subscribe: (listener) => editor.playback.subscribe(listener),
+ },
+ preview: {
+ isMaskMode: () => isMaskMode,
+ onSnapLinesChange,
+ },
+ };
- const commitTextEdit = useCallback(() => {
- const current = editingTextRef.current;
- if (!current) return;
- editingTextRef.current = null;
- editor.timeline.commitPreview();
- setEditingText(null);
- }, [editor.timeline]);
+ const depsRef = useRef(deps);
+ depsRef.current = deps;
+
+ const controllerRef = useRef(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new PreviewInteractionController({
+ depsRef: depsRef as PreviewInteractionDepsRef,
+ });
+ }
+ const controller = controllerRef.current;
+
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(
+ () => controller.subscribe({ listener: rerender }),
+ [controller],
+ );
useEffect(() => {
- const unsubscribe = editor.playback.subscribe(() => {
- const isPlaying = editor.playback.getIsPlaying();
- if (isPlaying && !wasPlayingRef.current && editingTextRef.current) {
- commitTextEdit();
- }
- wasPlayingRef.current = isPlaying;
- });
- return unsubscribe;
- }, [editor.playback, commitTextEdit]);
+ if (!controller.isDragging) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isDragging, controller]);
- useEffect(() => {
- if (!isDragging) return;
-
- return registerCanceller({
- fn: () => {
- const dragState = dragStateRef.current;
- if (!dragState) return;
-
- editor.timeline.discardPreview();
- dragStateRef.current = null;
- pendingGestureRef.current = null;
- setIsDragging(false);
- onSnapLinesChange?.([]);
- releaseCapturedPointer(dragState);
- },
- });
- }, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]);
-
- const handleDoubleClick = useCallback(
- ({ clientX, clientY }: React.MouseEvent) => {
- if (editingText || isMaskMode) return;
-
- const tracks = editor.scenes.getActiveScene().tracks;
- const currentTime = editor.playback.getCurrentTime();
- const mediaAssets = editor.media.getAssets();
- const canvasSize = editor.project.getActive().settings.canvasSize;
-
- const startPos = viewport.screenToCanvas({
- clientX,
- clientY,
- });
- if (!startPos) return;
-
- const elementsWithBounds = getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
- });
-
- const hit = hitTest({
- canvasX: startPos.x,
- canvasY: startPos.y,
- elementsWithBounds,
- });
-
- if (!hit || hit.element.type !== "text") return;
-
- const textElement = hit.element as TextElement;
- setEditingText({
- trackId: hit.trackId,
- elementId: hit.elementId,
- element: textElement,
- originalOpacity: textElement.opacity,
- });
- },
- [editor, editingText, isMaskMode, viewport],
- );
-
- const handlePointerDown = useCallback(
- ({
- clientX,
- clientY,
- currentTarget,
- pointerId,
- button,
- }: React.PointerEvent) => {
- if (editingText) return;
- if (isMaskMode) return;
- if (button !== 0) return;
-
- const tracks = editor.scenes.getActiveScene().tracks;
- const currentTime = editor.playback.getCurrentTime();
- const mediaAssets = editor.media.getAssets();
- const canvasSize = editor.project.getActive().settings.canvasSize;
-
- const startPos = viewport.screenToCanvas({
- clientX,
- clientY,
- });
- if (!startPos) return;
-
- const elementsWithBounds = getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
- });
-
- const hits = getHitElements({
- canvasX: startPos.x,
- canvasY: startPos.y,
- elementsWithBounds,
- });
- const selectedElements = editor.selection.getSelectedElements();
- const topmostHit = hits[0] ?? null;
-
- pendingGestureRef.current = {
- startX: startPos.x,
- startY: startPos.y,
- pointerId,
- captureTarget: currentTarget as HTMLElement,
- topmostHit,
- selectedHit: resolvePreferredHit({
- hits,
- preferredElements: selectedElements,
- }),
- selectedElements,
- };
- currentTarget.setPointerCapture(pointerId);
- },
- [editor, editingText, isMaskMode, viewport],
- );
-
- const handlePointerMove = useCallback(
- ({ clientX, clientY }: React.PointerEvent) => {
- const canvasSize = editor.project.getActive().settings.canvasSize;
-
- const currentPos = viewport.screenToCanvas({
- clientX,
- clientY,
- });
- if (!currentPos) return;
-
- let dragState = dragStateRef.current;
-
- if (!dragState) {
- const pendingGesture = pendingGestureRef.current;
- if (!pendingGesture) return;
-
- const deltaX = currentPos.x - pendingGesture.startX;
- const deltaY = currentPos.y - pendingGesture.startY;
- const hasMovement =
- Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
- Math.abs(deltaY) > MIN_DRAG_DISTANCE;
-
- if (!hasMovement) {
- onSnapLinesChange?.([]);
- return;
- }
-
- const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
- if (!dragTarget) {
- pendingGestureRef.current = null;
- onSnapLinesChange?.([]);
- releaseCapturedPointer(pendingGesture);
- return;
- }
-
- const dragSelection = buildDragSelection({
- selectedElements: pendingGesture.selectedElements,
- dragTarget,
- });
- const elementsWithTracks = editor.timeline.getElementsWithTracks({
- elements: dragSelection,
- });
- const draggableElements = elementsWithTracks.filter(({ element }) =>
- isVisualElement(element),
- );
-
- if (draggableElements.length === 0) {
- pendingGestureRef.current = null;
- onSnapLinesChange?.([]);
- releaseCapturedPointer(pendingGesture);
- return;
- }
-
- if (pendingGesture.selectedHit === null) {
- editor.selection.setSelectedElements({
- elements: [
- {
- trackId: dragTarget.trackId,
- elementId: dragTarget.elementId,
- },
- ],
- });
- }
-
- dragState = {
- startX: pendingGesture.startX,
- startY: pendingGesture.startY,
- pointerId: pendingGesture.pointerId,
- captureTarget: pendingGesture.captureTarget,
- bounds: {
- width: dragTarget.bounds.width,
- height: dragTarget.bounds.height,
- rotation: dragTarget.bounds.rotation,
- },
- elements: draggableElements.map(({ track, element }) => ({
- trackId: track.id,
- elementId: element.id,
- initialTransform: (element as { transform: Transform }).transform,
- })),
- };
- dragStateRef.current = dragState;
- pendingGestureRef.current = null;
- setIsDragging(true);
- }
-
- const deltaX = currentPos.x - dragState.startX;
- const deltaY = currentPos.y - dragState.startY;
- const firstElement = dragState.elements[0];
- const proposedPosition = {
- x: firstElement.initialTransform.position.x + deltaX,
- y: firstElement.initialTransform.position.y + deltaY,
- };
-
- const shouldSnap = !isShiftHeldRef.current;
- const snapThreshold = viewport.screenPixelsToLogicalThreshold({
- screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
- });
- const { snappedPosition, activeLines } = shouldSnap
- ? snapPosition({
- proposedPosition,
- canvasSize,
- elementSize: dragState.bounds,
- rotation: dragState.bounds.rotation,
- snapThreshold,
- })
- : {
- snappedPosition: proposedPosition,
- activeLines: [] as SnapLine[],
- };
-
- onSnapLinesChange?.(activeLines);
-
- const deltaSnappedX =
- snappedPosition.x - firstElement.initialTransform.position.x;
- const deltaSnappedY =
- snappedPosition.y - firstElement.initialTransform.position.y;
-
- const updates = dragState.elements.map(
- ({ trackId, elementId, initialTransform }) => ({
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- position: {
- x: initialTransform.position.x + deltaSnappedX,
- y: initialTransform.position.y + deltaSnappedY,
- },
- },
- },
- }),
- );
-
- editor.timeline.previewElements({ updates });
- },
- [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
- );
-
- const handlePointerUp = useCallback(
- ({ type }: React.PointerEvent) => {
- const dragState = dragStateRef.current;
- if (dragState) {
- if (type === "pointercancel") {
- editor.timeline.discardPreview();
- } else {
- editor.timeline.commitPreview();
- }
-
- dragStateRef.current = null;
- pendingGestureRef.current = null;
- setIsDragging(false);
- onSnapLinesChange?.([]);
- releaseCapturedPointer(dragState);
- return;
- }
-
- const pendingGesture = pendingGestureRef.current;
- if (!pendingGesture) return;
-
- if (type !== "pointercancel") {
- const clickTarget = pendingGesture.topmostHit;
- if (!clickTarget) {
- editor.selection.clearSelection();
- } else {
- editor.selection.setSelectedElements({
- elements: [
- {
- trackId: clickTarget.trackId,
- elementId: clickTarget.elementId,
- },
- ],
- });
- }
- }
-
- pendingGestureRef.current = null;
- onSnapLinesChange?.([]);
- releaseCapturedPointer(pendingGesture);
- },
- [editor, onSnapLinesChange, releaseCapturedPointer],
- );
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- onPointerDown: handlePointerDown,
- onPointerMove: handlePointerMove,
- onPointerUp: handlePointerUp,
- onDoubleClick: handleDoubleClick,
- editingText,
- commitTextEdit,
+ onPointerDown: controller.onPointerDown,
+ onPointerMove: controller.onPointerMove,
+ onPointerUp: controller.onPointerUp,
+ onDoubleClick: controller.onDoubleClick,
+ editingText: controller.editingText,
+ commitTextEdit: controller.commitTextEdit,
};
}
diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts
index 030e026c..f4b7e306 100644
--- a/apps/web/src/preview/hooks/use-transform-handles.ts
+++ b/apps/web/src/preview/hooks/use-transform-handles.ts
@@ -1,629 +1,84 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useEffect, useReducer, useRef } from "react";
import { usePreviewViewport } from "@/preview/components/preview-viewport";
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
import { useEditor } from "@/editor/use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
-import {
- getVisibleElementsWithBounds,
- type ElementWithBounds,
-} from "@/preview/element-bounds";
-import {
- MIN_SCALE,
- SNAP_THRESHOLD_SCREEN_PIXELS,
- snapRotation,
- snapScale,
- snapScaleAxes,
- type ScaleEdgePreference,
- type SnapLine,
-} from "@/preview/preview-snap";
-import { isVisualElement } from "@/timeline/element-utils";
-import {
- getElementLocalTime,
- hasKeyframesForPath,
- resolveTransformAtTime,
- setChannel,
-} from "@/animation";
-import type { Transform } from "@/rendering";
-import type { ElementAnimations } from "@/animation/types";
import { registerCanceller } from "@/editor/cancel-interaction";
-
-type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
-type Edge = "right" | "left" | "bottom";
-type HandleType = Corner | Edge | "rotation";
-
-function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference {
- return edge === "right"
- ? { right: true }
- : edge === "left"
- ? { left: true }
- : { bottom: true };
-}
-
-interface ScaleState {
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- initialDistance: number;
- initialBoundsCx: number;
- initialBoundsCy: number;
- baseWidth: number;
- baseHeight: number;
- shouldClearScaleAnimation: boolean;
- animationsWithoutScale: ElementAnimations | undefined;
-}
-
-interface RotationState {
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- initialAngle: number;
- initialBoundsCx: number;
- initialBoundsCy: number;
-}
-
-interface EdgeScaleState {
- trackId: string;
- elementId: string;
- initialTransform: Transform;
- initialBoundsCx: number;
- initialBoundsCy: number;
- baseWidth: number;
- baseHeight: number;
- edge: Edge;
- rotationRad: number;
- shouldClearScaleAnimation: boolean;
- animationsWithoutScale: ElementAnimations | undefined;
-}
-
-function clampScaleNonZero(scale: number): number {
- if (Math.abs(scale) < MIN_SCALE) {
- return scale < 0 ? -MIN_SCALE : MIN_SCALE;
- }
- return scale;
-}
-
-function getCornerDistance({
- bounds,
- corner,
-}: {
- bounds: {
- cx: number;
- cy: number;
- width: number;
- height: number;
- rotation: number;
- };
- corner: Corner;
-}): number {
- const halfWidth = bounds.width / 2;
- const halfHeight = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
-
- const localX =
- corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
- const localY =
- corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
-
- const rotatedX = localX * cos - localY * sin;
- const rotatedY = localX * sin + localY * cos;
- return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
-}
+import {
+ TransformHandleController,
+ type TransformHandleDeps,
+} from "@/preview/controllers/transform-handle-controller";
export function useTransformHandles({
onSnapLinesChange,
}: {
onSnapLinesChange?: OnSnapLinesChange;
}) {
+ const viewport = usePreviewViewport();
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
- const viewport = usePreviewViewport();
- const [activeHandle, setActiveHandle] = useState(null);
- const scaleStateRef = useRef(null);
- const rotationStateRef = useRef(null);
- const edgeScaleStateRef = useRef(null);
- const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
- null,
- );
-
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
const tracks = useEditor(
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
);
const currentTime = useEditor((e) => e.playback.getCurrentTime());
- const currentTimeRef = useRef(currentTime);
- currentTimeRef.current = currentTime;
const mediaAssets = useEditor((e) => e.media.getAssets());
const canvasSize = useEditor(
(e) => e.project.getActive().settings.canvasSize,
);
+ const deps: TransformHandleDeps = {
+ viewport,
+ input: {
+ isShiftHeld: () => isShiftHeldRef.current,
+ },
+ scene: {
+ getSelectedElements: () => selectedElements,
+ getTracks: () => tracks,
+ getCurrentTime: () => currentTime,
+ getMediaAssets: () => mediaAssets,
+ getCanvasSize: () => canvasSize,
+ },
+ timeline: {
+ previewElements: (updates) =>
+ editor.timeline.previewElements({ updates }),
+ commitPreview: () => editor.timeline.commitPreview(),
+ discardPreview: () => editor.timeline.discardPreview(),
+ },
+ preview: {
+ onSnapLinesChange,
+ },
+ };
- const elementsWithBounds = getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
- });
+ const depsRef = useRef(deps);
+ depsRef.current = deps;
- const selectedWithBounds: ElementWithBounds | null =
- selectedElements.length === 1
- ? (elementsWithBounds.find(
- (entry) =>
- entry.trackId === selectedElements[0].trackId &&
- entry.elementId === selectedElements[0].elementId,
- ) ?? null)
- : null;
+ const controllerRef = useRef(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new TransformHandleController({ depsRef });
+ }
+ const controller = controllerRef.current;
- const hasVisualSelection =
- selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
-
- const clearActiveHandleState = useCallback(() => {
- scaleStateRef.current = null;
- rotationStateRef.current = null;
- edgeScaleStateRef.current = null;
- setActiveHandle(null);
- onSnapLinesChange?.([]);
- }, [onSnapLinesChange]);
-
- const releaseCapturedPointer = useCallback(() => {
- const capture = captureRef.current;
- if (!capture) return;
-
- if (capture.element.hasPointerCapture(capture.pointerId)) {
- capture.element.releasePointerCapture(capture.pointerId);
- }
-
- captureRef.current = null;
- }, []);
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
useEffect(() => {
- if (!activeHandle) return;
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller, controller.isActive]);
- return registerCanceller({
- fn: () => {
- editor.timeline.discardPreview();
- clearActiveHandleState();
- releaseCapturedPointer();
- },
- });
- }, [
- activeHandle,
- clearActiveHandleState,
- editor.timeline,
- releaseCapturedPointer,
- ]);
+ useEffect(() => () => controller.destroy(), [controller]);
- const handleCornerPointerDown = useCallback(
- ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
- if (!selectedWithBounds) return;
- event.stopPropagation();
-
- const { bounds, trackId, elementId, element } = selectedWithBounds;
- if (!isVisualElement(element)) return;
-
- const localTime = getElementLocalTime({
- timelineTime: currentTimeRef.current,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const initialDistance = getCornerDistance({ bounds, corner });
- const baseWidth = bounds.width / resolvedTransform.scaleX;
- const baseHeight = bounds.height / resolvedTransform.scaleY;
- const shouldClearScaleAnimation =
- hasKeyframesForPath({
- animations: element.animations,
- propertyPath: "transform.scaleX",
- }) ||
- hasKeyframesForPath({
- animations: element.animations,
- propertyPath: "transform.scaleY",
- });
- const animationsWithoutScale = shouldClearScaleAnimation
- ? setChannel({
- animations: setChannel({
- animations: element.animations,
- propertyPath: "transform.scaleX",
- channel: undefined,
- }),
- propertyPath: "transform.scaleY",
- channel: undefined,
- })
- : element.animations;
-
- scaleStateRef.current = {
- trackId,
- elementId,
- initialTransform: resolvedTransform,
- initialDistance,
- initialBoundsCx: bounds.cx,
- initialBoundsCy: bounds.cy,
- baseWidth,
- baseHeight,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- };
- setActiveHandle(corner);
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [selectedWithBounds],
- );
-
- const handleRotationPointerDown = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (!selectedWithBounds) return;
- event.stopPropagation();
-
- const { bounds, trackId, elementId, element } = selectedWithBounds;
- if (!isVisualElement(element)) return;
-
- const localTime = getElementLocalTime({
- timelineTime: currentTimeRef.current,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const position = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!position) return;
- const deltaX = position.x - bounds.cx;
- const deltaY = position.y - bounds.cy;
- const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
-
- rotationStateRef.current = {
- trackId,
- elementId,
- initialTransform: resolvedTransform,
- initialAngle,
- initialBoundsCx: bounds.cx,
- initialBoundsCy: bounds.cy,
- };
- setActiveHandle("rotation");
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [selectedWithBounds, viewport],
- );
-
- const handleEdgePointerDown = useCallback(
- ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
- if (!selectedWithBounds) return;
- event.stopPropagation();
-
- const { bounds, trackId, elementId, element } = selectedWithBounds;
- if (!isVisualElement(element)) return;
-
- const localTime = getElementLocalTime({
- timelineTime: currentTimeRef.current,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const baseWidth = bounds.width / resolvedTransform.scaleX;
- const baseHeight = bounds.height / resolvedTransform.scaleY;
- const rotationRad = (bounds.rotation * Math.PI) / 180;
-
- const propertyPath =
- edge === "right" || edge === "left"
- ? "transform.scaleX"
- : "transform.scaleY";
- const shouldClearScaleAnimation = hasKeyframesForPath({
- animations: element.animations,
- propertyPath,
- });
- const animationsWithoutScale = shouldClearScaleAnimation
- ? setChannel({
- animations: element.animations,
- propertyPath,
- channel: undefined,
- })
- : element.animations;
-
- edgeScaleStateRef.current = {
- trackId,
- elementId,
- initialTransform: resolvedTransform,
- initialBoundsCx: bounds.cx,
- initialBoundsCy: bounds.cy,
- baseWidth,
- baseHeight,
- edge,
- rotationRad,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- };
- setActiveHandle(edge);
- const captureTarget = event.currentTarget as HTMLElement;
- captureTarget.setPointerCapture(event.pointerId);
- captureRef.current = {
- element: captureTarget,
- pointerId: event.pointerId,
- };
- },
- [selectedWithBounds],
- );
-
- const handlePointerMove = useCallback(
- ({ event }: { event: React.PointerEvent }) => {
- if (
- !scaleStateRef.current &&
- !rotationStateRef.current &&
- !edgeScaleStateRef.current
- )
- return;
-
- const position = viewport.screenToCanvas({
- clientX: event.clientX,
- clientY: event.clientY,
- });
- if (!position) return;
-
- if (
- scaleStateRef.current &&
- activeHandle &&
- activeHandle !== "rotation"
- ) {
- const {
- trackId,
- elementId,
- initialTransform,
- initialDistance,
- initialBoundsCx,
- initialBoundsCy,
- baseWidth,
- baseHeight,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- } = scaleStateRef.current;
-
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentDistance =
- Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
- const scaleFactor = currentDistance / initialDistance;
-
- // Use actual element dimensions (base * current scale) so snap
- // computes the correct edges when scaleX ≠ scaleY
- const effectiveWidth = baseWidth * initialTransform.scaleX;
- const effectiveHeight = baseHeight * initialTransform.scaleY;
-
- const snapThreshold = viewport.screenPixelsToLogicalThreshold({
- screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
- });
- const { snappedScale: snappedFactor, activeLines } =
- isShiftHeldRef.current
- ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
- : snapScale({
- proposedScale: scaleFactor,
- position: initialTransform.position,
- baseWidth: effectiveWidth,
- baseHeight: effectiveHeight,
- rotation: initialTransform.rotate,
- canvasSize,
- snapThreshold,
- });
-
- onSnapLinesChange?.(activeLines);
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- scaleX: clampScaleNonZero(
- initialTransform.scaleX * snappedFactor,
- ),
- scaleY: clampScaleNonZero(
- initialTransform.scaleY * snappedFactor,
- ),
- },
- ...(shouldClearScaleAnimation && {
- animations: animationsWithoutScale,
- }),
- },
- },
- ],
- });
- return;
- }
-
- if (
- edgeScaleStateRef.current &&
- (activeHandle === "right" ||
- activeHandle === "left" ||
- activeHandle === "bottom")
- ) {
- const {
- trackId,
- elementId,
- initialTransform,
- initialBoundsCx,
- initialBoundsCy,
- baseWidth,
- baseHeight,
- edge,
- rotationRad,
- shouldClearScaleAnimation,
- animationsWithoutScale,
- } = edgeScaleStateRef.current;
-
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const xProjection =
- deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
- const yProjection =
- -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
- const projection =
- edge === "right"
- ? xProjection
- : edge === "left"
- ? -xProjection
- : yProjection;
-
- const baseAxisHalf =
- edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
- const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
-
- const proposedScaleX =
- edge === "right" || edge === "left"
- ? proposedScale
- : initialTransform.scaleX;
- const proposedScaleY =
- edge === "bottom" ? proposedScale : initialTransform.scaleY;
-
- const snapThreshold = viewport.screenPixelsToLogicalThreshold({
- screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
- });
- const { x: xSnap, y: ySnap } = isShiftHeldRef.current
- ? {
- x: {
- snappedScale: proposedScaleX,
- snapDistance: Infinity,
- activeLines: [] as SnapLine[],
- },
- y: {
- snappedScale: proposedScaleY,
- snapDistance: Infinity,
- activeLines: [] as SnapLine[],
- },
- }
- : snapScaleAxes({
- proposedScaleX,
- proposedScaleY,
- position: initialTransform.position,
- baseWidth,
- baseHeight,
- rotation: initialTransform.rotate,
- canvasSize,
- snapThreshold,
- preferredEdges: getPreferredEdge({ edge }),
- });
-
- const relevantSnap =
- edge === "right" || edge === "left" ? xSnap : ySnap;
- onSnapLinesChange?.(relevantSnap.activeLines);
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- scaleX:
- edge === "right" || edge === "left"
- ? xSnap.snappedScale
- : initialTransform.scaleX,
- scaleY:
- edge === "bottom"
- ? ySnap.snappedScale
- : initialTransform.scaleY,
- },
- ...(shouldClearScaleAnimation && {
- animations: animationsWithoutScale,
- }),
- },
- },
- ],
- });
- return;
- }
-
- if (rotationStateRef.current && activeHandle === "rotation") {
- const {
- trackId,
- elementId,
- initialTransform,
- initialAngle,
- initialBoundsCx,
- initialBoundsCy,
- } = rotationStateRef.current;
-
- const deltaX = position.x - initialBoundsCx;
- const deltaY = position.y - initialBoundsCy;
- const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
- let deltaAngle = currentAngle - initialAngle;
- if (deltaAngle > 180) deltaAngle -= 360;
- if (deltaAngle < -180) deltaAngle += 360;
- const newRotate = initialTransform.rotate + deltaAngle;
- const { snappedRotation } = isShiftHeldRef.current
- ? { snappedRotation: newRotate }
- : snapRotation({ proposedRotation: newRotate });
-
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: { ...initialTransform, rotate: snappedRotation },
- },
- },
- ],
- });
- }
- },
- [
- activeHandle,
- canvasSize,
- editor,
- isShiftHeldRef,
- onSnapLinesChange,
- viewport,
- ],
- );
-
- const handlePointerUp = useCallback(() => {
- if (
- scaleStateRef.current ||
- rotationStateRef.current ||
- edgeScaleStateRef.current
- ) {
- editor.timeline.commitPreview();
- clearActiveHandleState();
- }
- releaseCapturedPointer();
- }, [clearActiveHandleState, editor, releaseCapturedPointer]);
+ const selectedWithBounds = controller.selectedWithBounds;
+ const hasVisualSelection = selectedWithBounds !== null;
return {
selectedWithBounds,
hasVisualSelection,
- activeHandle,
- handleCornerPointerDown,
- handleEdgePointerDown,
- handleRotationPointerDown,
- handlePointerMove,
- handlePointerUp,
+ activeHandle: controller.activeHandle,
+ handleCornerPointerDown: controller.onCornerPointerDown,
+ handleEdgePointerDown: controller.onEdgePointerDown,
+ handleRotationPointerDown: controller.onRotationPointerDown,
+ handlePointerMove: controller.onPointerMove,
+ handlePointerUp: controller.onPointerUp,
};
}
diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts
index 31467065..cad4fc31 100644
--- a/apps/web/src/services/video-cache/service.ts
+++ b/apps/web/src/services/video-cache/service.ts
@@ -21,6 +21,7 @@ export class VideoCache {
private sinks = new Map();
private initPromises = new Map>();
private frameChain = new Map>();
+ private seekGenerations = new Map();
async getFrameAt({
mediaId,
@@ -36,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;
}
@@ -173,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) {
@@ -313,6 +312,8 @@ export class VideoCache {
}
this.initPromises.delete(mediaId);
+ this.frameChain.delete(mediaId);
+ this.seekGenerations.delete(mediaId);
}
clearAll(): void {
diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx
index e6a9df49..88fb93b7 100644
--- a/apps/web/src/timeline/components/index.tsx
+++ b/apps/web/src/timeline/components/index.tsx
@@ -29,7 +29,7 @@ import {
useState,
type ReactNode,
} from "react";
-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";
@@ -287,20 +287,15 @@ export function Timeline() {
isReady: tracks.length > 0,
});
- const {
- dragState,
- dragDropTarget,
- handleElementMouseDown,
- handleElementClick,
- lastMouseXRef,
- } = useElementInteraction({
- zoomLevel,
- timelineRef,
- tracksContainerRef,
- tracksScrollRef,
- snappingEnabled,
- onSnapPointChange: handleSnapPointChange,
- });
+ const { dragView, handleElementMouseDown, handleElementClick } =
+ useElementInteraction({
+ zoomLevel,
+ tracksContainerRef,
+ tracksScrollRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
+ const isElementDragging = dragView.kind === "dragging";
const {
dragState: bookmarkDragState,
@@ -391,10 +386,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,
@@ -457,9 +461,9 @@ export function Timeline() {
headerHeight={timelineHeaderHeight}
/>
@@ -540,9 +544,7 @@ export function Timeline() {
;
- lastMouseXRef: React.RefObject;
+ dragView: ElementDragView;
onResizeStart: React.ComponentProps<
typeof TimelineTrackContent
>["onResizeStart"];
@@ -776,8 +774,16 @@ function TimelineTrackRows({
[tracks, expandedElementIds],
);
+ const draggingElementIds = useMemo(
+ () =>
+ dragView.kind === "dragging"
+ ? dragView.memberTimeOffsets
+ : (null as ReadonlyMap | null),
+ [dragView],
+ );
const sortedTracks = useMemo(() => {
- const draggingElementIds = new Set(dragState.dragElementIds);
+ if (!draggingElementIds)
+ return tracks.map((track, index) => ({ track, index }));
return [...tracks]
.map((track, index) => ({ track, index }))
.sort((a, b) => {
@@ -791,7 +797,7 @@ function TimelineTrackRows({
if (bHasDragged) return -1;
return 0;
});
- }, [tracks, dragState.dragElementIds]);
+ }, [tracks, draggingElementIds]);
return (
<>
@@ -811,10 +817,7 @@ function TimelineTrackRows({
void;
- dragState: ElementDragState;
+ dragView: ElementDragView;
isDropTarget?: boolean;
}
@@ -226,7 +226,7 @@ export function TimelineElement({
onResizeStart,
onElementMouseDown,
onElementClick,
- dragState,
+ dragView,
isDropTarget = false,
}: TimelineElementProps) {
const mediaAssets = useEditor((e) => e.media.getAssets());
@@ -252,15 +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] ?? 0;
const elementStartTime =
- isBeingDragged && dragState.isDragging
- ? dragState.currentTime + dragTimeOffset
+ isDragging && isBeingDragged
+ ? dragView.currentTime + dragTimeOffset
: renderElement.startTime;
const displayedStartTime = elementStartTime;
const displayedDuration = renderElement.duration;
@@ -382,7 +385,7 @@ export function TimelineElement({
? `${baseTrackHeight + expansionHeight}px`
: "100%",
transform:
- isBeingDragged && dragState.isDragging
+ isBeingDragged && isDragging
? `translate3d(0, ${dragOffsetY}px, 0)`
: undefined,
}}
diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx
index 6c40637e..7608e6df 100644
--- a/apps/web/src/timeline/components/timeline-track.tsx
+++ b/apps/web/src/timeline/components/timeline-track.tsx
@@ -5,18 +5,12 @@ import { TimelineElement } from "./timeline-element";
import type { TimelineTrack } from "@/timeline";
import type { TimelineElement as TimelineElementType } from "@/timeline";
import { TIMELINE_LAYERS } from "./layers";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
-import type { ElementDragState } from "@/timeline";
-import { useEditor } from "@/editor/use-editor";
+import type { ElementDragView } from "@/timeline";
interface TimelineTrackContentProps {
track: TimelineTrack;
zoomLevel: number;
- dragState: ElementDragState;
- rulerScrollRef: React.RefObject;
- tracksScrollRef: React.RefObject;
- lastMouseXRef: React.RefObject;
+ dragView: ElementDragView;
onResizeStart: (params: {
event: React.MouseEvent;
element: TimelineElementType;
@@ -42,10 +36,7 @@ interface TimelineTrackContentProps {
export function TimelineTrackContent({
track,
zoomLevel,
- dragState,
- rulerScrollRef,
- tracksScrollRef,
- lastMouseXRef,
+ dragView,
onResizeStart,
onElementMouseDown,
onElementClick,
@@ -55,15 +46,6 @@ export function TimelineTrackContent({
targetElementId = null,
}: TimelineTrackContentProps) {
const { isElementSelected } = useElementSelection();
- const duration = useEditor((e) => e.timeline.getTotalDuration());
-
- useEdgeAutoScroll({
- isActive: dragState.isDragging,
- getMouseClientX: () => lastMouseXRef.current ?? 0,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
- });
return (
@@ -120,7 +102,7 @@ export function TimelineTrackContent({
onElementClick={(event, element) =>
onElementClick({ event, element, track })
}
- dragState={dragState}
+ dragView={dragView}
isDropTarget={element.id === targetElementId}
/>
);
diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts
new file mode 100644
index 00000000..cd03300e
--- /dev/null
+++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts
@@ -0,0 +1,577 @@
+import type { DragEvent } from "react";
+import { processMediaAssets } from "@/media/processing";
+import { showMediaUploadToast } from "@/media/upload-toast";
+import {
+ DEFAULT_NEW_ELEMENT_DURATION,
+ toElementDurationTicks,
+} from "@/timeline/creation";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { roundToFrame, 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";
+
+// --- Config ---
+
+export interface DragDropConfig {
+ zoomLevel: number;
+ getContainerEl: () => HTMLDivElement | null;
+ getHeaderEl: () => HTMLElement | null;
+ getTracksScrollEl: () => HTMLDivElement | null;
+ getActiveProjectFps: () => FrameRate | null;
+ getActiveProjectId: () => string | null;
+ getSceneTracks: () => SceneTracks;
+ getCurrentPlayheadTime: () => number;
+ getMediaAssets: () => MediaAsset[];
+ dragSource: TimelineDragSource;
+ addMediaAsset: (args: {
+ projectId: string;
+ asset: ProcessedMediaAsset;
+ }) => Promise
;
+ executeCommand: (command: Command) => void;
+ insertElement: (args: {
+ placement: { mode: "explicit"; trackId: string };
+ element: CreateTimelineElement;
+ }) => void;
+ addClipEffect: (args: {
+ trackId: string;
+ elementId: string;
+ effectType: string;
+ }) => void;
+}
+
+export interface DragDropConfigRef {
+ readonly current: DragDropConfig;
+}
+
+// --- State ---
+
+interface DragOverState {
+ kind: "over";
+ dropTarget: DropTarget | null;
+ elementType: ElementType | null;
+}
+
+type DragDropState = { kind: "idle" } | DragOverState;
+
+interface TimelineCoords {
+ mouseX: number;
+ mouseY: number;
+}
+
+// --- Pure helpers ---
+
+function elementTypeFromDrag({
+ dragData,
+}: {
+ dragData: TimelineDragData;
+}): ElementType {
+ switch (dragData.type) {
+ case "text":
+ return "text";
+ case "graphic":
+ return "graphic";
+ case "sticker":
+ return "sticker";
+ case "effect":
+ return "effect";
+ case "media":
+ return dragData.mediaType;
+ }
+}
+
+function getTargetElementTypesForDrag({
+ dragData,
+}: {
+ dragData: TimelineDragData;
+}): string[] | undefined {
+ if (dragData.type === "effect") return dragData.targetElementTypes;
+ if (dragData.type === "media") return dragData.targetElementTypes;
+ return undefined;
+}
+
+function getDurationForDrag({
+ dragData,
+ mediaAssets,
+}: {
+ dragData: TimelineDragData;
+ mediaAssets: MediaAsset[];
+}): number {
+ 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
+ ? (roundToFrame({ time: target.xPosition, rate: fps }) ??
+ target.xPosition)
+ : target.xPosition;
+
+ this.setOver({ dropTarget: target, elementType });
+ event.dataTransfer.dropEffect = "copy";
+ }
+
+ onDragLeave(event: DragEvent): void {
+ event.preventDefault();
+ if (this.enterCount === 0) return;
+ this.enterCount -= 1;
+ if (this.enterCount === 0) {
+ this.setIdle();
+ }
+ }
+
+ onDrop(event: DragEvent): void {
+ event.preventDefault();
+ this.enterCount = 0;
+
+ const dragData = this.config.dragSource.getActive();
+ const hasFiles = event.dataTransfer.files?.length > 0;
+ if (!dragData && !hasFiles) return;
+
+ const currentTarget = this.dropTarget;
+ this.setIdle();
+
+ try {
+ if (dragData) {
+ if (!currentTarget) return;
+ this.executeAssetDrop({ target: currentTarget, dragData });
+ return;
+ }
+
+ const coords = this.getMouseTimelineCoords({ event });
+ if (!coords) return;
+ this.executeFileDrop({
+ files: Array.from(event.dataTransfer.files),
+ mouseX: coords.mouseX,
+ mouseY: coords.mouseY,
+ }).catch((error) => {
+ console.error("Failed to process file drop:", error);
+ });
+ } catch (error) {
+ console.error("Failed to process drop:", error);
+ }
+ }
+
+ // --- Private ---
+
+ private setOver(state: {
+ dropTarget: DropTarget | null;
+ elementType: ElementType | null;
+ }): void {
+ this.state = { kind: "over", ...state };
+ this.notify();
+ }
+
+ private setIdle(): void {
+ this.state = { kind: "idle" };
+ this.notify();
+ }
+
+ private notify(): void {
+ for (const fn of this.subscribers) fn();
+ }
+
+ private getMouseTimelineCoords({
+ event,
+ }: {
+ event: DragEvent;
+ }): TimelineCoords | null {
+ const scrollContainer = this.config.getTracksScrollEl();
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ this.config.getContainerEl()?.getBoundingClientRect();
+ if (!referenceRect) return null;
+
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+ const headerHeight =
+ this.config.getHeaderEl()?.getBoundingClientRect().height ?? 0;
+
+ return {
+ mouseX: event.clientX - referenceRect.left + scrollLeft,
+ mouseY: event.clientY - referenceRect.top + scrollTop - headerHeight,
+ };
+ }
+
+ // Shared insertion logic — new track vs existing track.
+ private insertAtTarget({
+ element,
+ target,
+ trackType,
+ }: {
+ element: CreateTimelineElement;
+ target: DropTarget;
+ trackType: TrackType;
+ }): void {
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
+ this.config.executeCommand(
+ new BatchCommand([
+ addTrackCmd,
+ new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ }),
+ ]),
+ );
+ return;
+ }
+
+ const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() });
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ this.config.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ }
+
+ private executeAssetDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: TimelineDragData;
+ }): void {
+ switch (dragData.type) {
+ case "text":
+ this.executeTextDrop({ target, dragData });
+ return;
+ case "graphic":
+ this.executeGraphicDrop({ target, dragData });
+ return;
+ case "sticker":
+ this.executeStickerDrop({ target, dragData });
+ return;
+ case "effect":
+ this.executeEffectDrop({ target, dragData });
+ return;
+ case "media":
+ this.executeMediaDrop({ target, dragData });
+ return;
+ }
+ }
+
+ private executeTextDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract;
+ }): void {
+ const element = buildTextElement({
+ raw: { name: dragData.name ?? "", content: dragData.content ?? "" },
+ startTime: target.xPosition,
+ });
+ this.insertAtTarget({ element, target, trackType: "text" });
+ }
+
+ private executeStickerDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract;
+ }): void {
+ const element = buildStickerElement({
+ stickerId: dragData.stickerId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ });
+ this.insertAtTarget({ element, target, trackType: "graphic" });
+ }
+
+ private executeGraphicDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract;
+ }): void {
+ const element = buildGraphicElement({
+ definitionId: dragData.definitionId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ params: dragData.params,
+ });
+ this.insertAtTarget({ element, target, trackType: "graphic" });
+ }
+
+ private executeMediaDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract;
+ }): void {
+ if (target.targetElement) {
+ // Replace media source — not yet implemented
+ return;
+ }
+
+ const mediaAsset = this.config
+ .getMediaAssets()
+ .find((asset) => asset.id === dragData.id);
+ if (!mediaAsset) return;
+
+ const trackType: TrackType =
+ dragData.mediaType === "audio" ? "audio" : "video";
+ const element = buildElementFromMedia({
+ mediaId: mediaAsset.id,
+ mediaType: mediaAsset.type,
+ name: mediaAsset.name,
+ duration: toElementDurationTicks({ seconds: mediaAsset.duration }),
+ startTime: target.xPosition,
+ });
+ this.insertAtTarget({ element, target, trackType });
+ }
+
+ private executeEffectDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract;
+ }): void {
+ if (target.targetElement) {
+ this.config.addClipEffect({
+ trackId: target.targetElement.trackId,
+ elementId: target.targetElement.elementId,
+ effectType: dragData.effectType,
+ });
+ return;
+ }
+
+ const element = buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ });
+
+ const existingEffectTrack = orderedTracks({
+ sceneTracks: this.config.getSceneTracks(),
+ }).find((track) => track.type === "effect");
+
+ if (existingEffectTrack) {
+ this.config.insertElement({
+ placement: { mode: "explicit", trackId: existingEffectTrack.id },
+ element,
+ });
+ return;
+ }
+
+ this.insertAtTarget({ element, target, trackType: "effect" });
+ }
+
+ private async executeFileDrop({
+ files,
+ mouseX,
+ mouseY,
+ }: {
+ files: File[];
+ mouseX: number;
+ mouseY: number;
+ }): Promise {
+ const projectId = this.config.getActiveProjectId();
+ if (!projectId) return;
+
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+
+ // Sequential on purpose: each iteration reads getSceneTracks()
+ // to decide placement (reuse empty main vs new track) and that
+ // decision depends on the effects of prior inserts.
+ for (const asset of processedAssets) {
+ const createdAsset = await this.config.addMediaAsset({
+ projectId,
+ asset,
+ });
+ if (!createdAsset) continue;
+
+ const duration = toElementDurationTicks({
+ seconds: createdAsset.duration,
+ });
+
+ const sceneTracks = this.config.getSceneTracks();
+ const currentTime = this.config.getCurrentPlayheadTime();
+
+ const reuseMainTrackId =
+ createdAsset.type !== "audio" &&
+ sceneTracks.overlay.length === 0 &&
+ sceneTracks.audio.length === 0 &&
+ sceneTracks.main.elements.length === 0
+ ? sceneTracks.main.id
+ : null;
+
+ if (reuseMainTrackId) {
+ this.config.insertElement({
+ placement: { mode: "explicit", trackId: reuseMainTrackId },
+ element: buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: currentTime,
+ }),
+ });
+ continue;
+ }
+
+ const dropTarget = computeDropTarget({
+ elementType: createdAsset.type,
+ mouseX,
+ mouseY,
+ tracks: sceneTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
+ zoomLevel: this.config.zoomLevel,
+ });
+
+ const trackType: TrackType =
+ createdAsset.type === "audio" ? "audio" : "video";
+ this.insertAtTarget({
+ element: buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ }),
+ target: dropTarget,
+ trackType,
+ });
+ }
+
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
+ }
+}
diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts
new file mode 100644
index 00000000..cc19daaa
--- /dev/null
+++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts
@@ -0,0 +1,691 @@
+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 { TICKS_PER_SECOND } from "@/wasm";
+import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
+import { roundToFrame, 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: () => number;
+}
+
+export interface TimelineOps {
+ moveElements: (args: Pick) => void;
+}
+
+export interface SnapConfig {
+ isEnabled: () => boolean;
+ onChange?: (snapPoint: SnapPoint | null) => void;
+}
+
+export interface ElementInteractionDeps {
+ viewport: ViewportAdapter;
+ input: InputAdapter;
+ scene: SceneReader;
+ selection: ElementSelectionApi;
+ playback: PlaybackReader;
+ timeline: TimelineOps;
+ snap: SnapConfig;
+}
+
+export interface ElementInteractionDepsRef {
+ readonly current: ElementInteractionDeps;
+}
+
+// --- Session ---
+
+type Point = { readonly x: number; readonly y: number };
+
+interface MousedownSnapshot {
+ readonly origin: Point;
+ readonly elementId: string;
+ readonly trackId: string;
+ readonly startElementTime: number;
+ readonly clickOffsetTime: number;
+ 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: number;
+ 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,
+): number {
+ const clickOffsetX = clientX - elementRect.left;
+ const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
+ return 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: number;
+ fps: FrameRate;
+}): number {
+ const mouseTime = getMouseTimeFromClientX({
+ clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft: scrollContainer.scrollLeft,
+ });
+ const adjusted = Math.max(0, mouseTime - clickOffsetTime);
+ return roundToFrame({ time: adjusted, rate: fps }) ?? adjusted;
+}
+
+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: number;
+ 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: number;
+ dropTarget: DropTarget;
+ reservedNewTrackIds: readonly string[];
+}): GroupMoveResult | null {
+ const newTracksFallback = () =>
+ resolveGroupMove({
+ group,
+ tracks,
+ anchorStartTime,
+ target: {
+ kind: "newTracks",
+ anchorInsertIndex: dropTarget.trackIndex,
+ newTrackIds: [...reservedNewTrackIds],
+ },
+ });
+
+ if (dropTarget.isNewTrack) return newTracksFallback();
+
+ const targetTrack = orderedTracks(tracks)[dropTarget.trackIndex];
+ if (!targetTrack) return null;
+
+ return (
+ resolveGroupMove({
+ group,
+ tracks,
+ anchorStartTime,
+ target: { kind: "existingTrack", anchorTargetTrackId: targetTrack.id },
+ }) ?? newTracksFallback()
+ );
+}
+
+// --- Controller ---
+
+export class ElementInteractionController {
+ private session: Session = { kind: "idle" };
+ // True once the active gesture crossed the drag threshold. Read by
+ // onElementClick, which fires after mouseup — by which point the session
+ // has already returned to idle, so the "was this a drag?" answer must
+ // outlive the session. Reset on the next mousedown.
+ private lastGestureWasDrag = false;
+
+ private readonly subscribers = new Set<() => void>();
+ private readonly depsRef: ElementInteractionDepsRef;
+
+ constructor(args: { depsRef: ElementInteractionDepsRef }) {
+ this.depsRef = args.depsRef;
+ }
+
+ private get deps(): ElementInteractionDeps {
+ return this.depsRef.current;
+ }
+
+ get view(): ElementDragView {
+ if (this.session.kind !== "dragging") return IDLE_VIEW;
+ const { mousedown, drag } = this.session;
+ const memberTimeOffsets = new Map();
+ for (const member of drag.moveGroup.members) {
+ memberTimeOffsets.set(member.elementId, member.timeOffset);
+ }
+ return {
+ kind: "dragging",
+ anchorElementId: mousedown.elementId,
+ trackId: mousedown.trackId,
+ memberTimeOffsets,
+ startMouseX: mousedown.origin.x,
+ startMouseY: mousedown.origin.y,
+ startElementTime: mousedown.startElementTime,
+ clickOffsetTime: mousedown.clickOffsetTime,
+ currentTime: drag.currentTime,
+ currentMouseX: drag.currentMouseX,
+ currentMouseY: drag.currentMouseY,
+ dropTarget: drag.dropTarget,
+ };
+ }
+
+ get isActive(): boolean {
+ return this.session.kind !== "idle";
+ }
+
+ subscribe(fn: () => void): () => void {
+ this.subscribers.add(fn);
+ return () => this.subscribers.delete(fn);
+ }
+
+ cancel = (): void => {
+ this.lastGestureWasDrag = false;
+ this.finishSession();
+ };
+
+ destroy(): void {
+ this.cancel();
+ this.subscribers.clear();
+ }
+
+ onElementMouseDown = ({
+ event,
+ element,
+ track,
+ }: {
+ event: ReactMouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ }): void => {
+ // Right-click must not stopPropagation — ContextMenu needs the bubble.
+ if (event.button === MOUSE_BUTTON_RIGHT) {
+ const ref = { trackId: track.id, elementId: element.id };
+ if (!this.deps.selection.isSelected(ref)) {
+ this.deps.selection.handleClick({ ...ref, isMultiKey: false });
+ }
+ return;
+ }
+
+ event.stopPropagation();
+ this.lastGestureWasDrag = false;
+
+ const ref = { trackId: track.id, elementId: element.id };
+
+ if (event.metaKey || event.ctrlKey || event.shiftKey) {
+ this.deps.selection.handleClick({ ...ref, isMultiKey: true });
+ }
+
+ const selectedElements = this.deps.selection.isSelected(ref)
+ ? this.deps.selection.getSelected()
+ : [ref];
+
+ this.session = {
+ kind: "pending",
+ mousedown: {
+ origin: { x: event.clientX, y: event.clientY },
+ elementId: element.id,
+ trackId: track.id,
+ startElementTime: element.startTime,
+ clickOffsetTime: pixelToClickOffsetTime(
+ event.clientX,
+ event.currentTarget.getBoundingClientRect(),
+ this.deps.viewport.getZoomLevel(),
+ ),
+ selectedElements,
+ },
+ };
+ this.activate();
+ this.notify();
+ };
+
+ onElementClick = ({
+ event,
+ element,
+ track,
+ }: {
+ event: ReactMouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ }): void => {
+ event.stopPropagation();
+
+ if (this.lastGestureWasDrag) {
+ this.lastGestureWasDrag = false;
+ return;
+ }
+
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+
+ const ref = { trackId: track.id, elementId: element.id };
+ if (
+ !this.deps.selection.isSelected(ref) ||
+ this.deps.selection.getSelected().length > 1
+ ) {
+ this.deps.selection.select(ref);
+ return;
+ }
+
+ this.deps.selection.clearKeyframeSelection();
+ };
+
+ private activate(): void {
+ document.addEventListener("mousemove", this.handleMouseMove);
+ document.addEventListener("mouseup", this.handleMouseUp);
+ }
+
+ private deactivate(): void {
+ document.removeEventListener("mousemove", this.handleMouseMove);
+ document.removeEventListener("mouseup", this.handleMouseUp);
+ }
+
+ private notify(): void {
+ for (const fn of this.subscribers) fn();
+ }
+
+ private finishSession(): void {
+ this.session = { kind: "idle" };
+ this.deactivate();
+ this.deps.snap.onChange?.(null);
+ this.notify();
+ }
+
+ private snapResult(
+ frameSnappedTime: number,
+ group: MoveGroup,
+ ): { snappedTime: number; 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: number;
+ }): void {
+ const { scene, viewport } = this.deps;
+ const tracks = scene.getTracks();
+ const zoomLevel = viewport.getZoomLevel();
+
+ const anchorDropTarget = resolveDropTarget({
+ clientX,
+ clientY,
+ elementId: mousedown.elementId,
+ trackId: mousedown.trackId,
+ tracks,
+ viewport,
+ zoomLevel,
+ snappedTime,
+ verticalDragDirection: verticalDirection(mousedown.origin.y, clientY),
+ });
+
+ const nextGroupMoveResult = anchorDropTarget
+ ? resolveGroupMoveForDrop({
+ group: drag.moveGroup,
+ tracks,
+ anchorStartTime: snappedTime,
+ dropTarget: anchorDropTarget,
+ reservedNewTrackIds: drag.reservedNewTrackIds,
+ })
+ : null;
+
+ drag.groupMoveResult = nextGroupMoveResult;
+ drag.dropTarget =
+ anchorDropTarget && (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
+ ? { ...anchorDropTarget, isNewTrack: true }
+ : null;
+ }
+
+ private handleMouseMove = ({ clientX, clientY }: MouseEvent): void => {
+ const scrollContainer = this.deps.viewport.getTracksScrollEl();
+ if (!scrollContainer) return;
+
+ if (this.session.kind === "pending") {
+ this.beginDragFromPending({
+ mousedown: this.session.mousedown,
+ clientX,
+ clientY,
+ scrollContainer,
+ });
+ return;
+ }
+
+ if (this.session.kind === "dragging") {
+ this.updateActiveDrag({
+ mousedown: this.session.mousedown,
+ drag: this.session.drag,
+ clientX,
+ clientY,
+ scrollContainer,
+ });
+ }
+ };
+
+ private beginDragFromPending({
+ mousedown,
+ clientX,
+ clientY,
+ scrollContainer,
+ }: {
+ mousedown: MousedownSnapshot;
+ clientX: number;
+ clientY: number;
+ scrollContainer: HTMLDivElement;
+ }): void {
+ if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) {
+ return;
+ }
+
+ const fps = this.deps.scene.getActiveFps();
+ if (!fps) return;
+
+ const moveGroup = buildMoveGroup({
+ anchorRef: {
+ trackId: mousedown.trackId,
+ elementId: mousedown.elementId,
+ },
+ selectedElements: [...mousedown.selectedElements],
+ tracks: this.deps.scene.getTracks(),
+ });
+ if (!moveGroup) return;
+
+ const zoomLevel = this.deps.viewport.getZoomLevel();
+ const frameSnappedTime = frameSnappedMouseTime({
+ clientX,
+ scrollContainer,
+ zoomLevel,
+ clickOffsetTime: mousedown.clickOffsetTime,
+ fps,
+ });
+ const { snappedTime, snapPoint } = this.snapResult(
+ frameSnappedTime,
+ moveGroup,
+ );
+
+ // Ensure the anchor is selected before we render the drag — covers the
+ // case where the selection store hasn't committed the mousedown-time
+ // selection click yet.
+ const anchorRef = {
+ trackId: mousedown.trackId,
+ elementId: mousedown.elementId,
+ };
+ if (!this.deps.selection.isSelected(anchorRef)) {
+ this.deps.selection.select(anchorRef);
+ }
+
+ const drag: DragProgress = {
+ moveGroup,
+ reservedNewTrackIds: moveGroup.members.map(() => generateUUID()),
+ currentTime: snappedTime,
+ currentMouseX: clientX,
+ currentMouseY: clientY,
+ groupMoveResult: null,
+ dropTarget: null,
+ };
+
+ this.session = { kind: "dragging", mousedown, drag };
+ this.lastGestureWasDrag = true;
+
+ this.updateDropTarget({
+ clientX,
+ clientY,
+ mousedown,
+ drag,
+ snappedTime,
+ });
+
+ this.deps.snap.onChange?.(snapPoint);
+ this.notify();
+ }
+
+ private updateActiveDrag({
+ mousedown,
+ drag,
+ clientX,
+ clientY,
+ scrollContainer,
+ }: {
+ mousedown: MousedownSnapshot;
+ drag: DragProgress;
+ clientX: number;
+ clientY: number;
+ scrollContainer: HTMLDivElement;
+ }): void {
+ const fps = this.deps.scene.getActiveFps();
+ if (!fps) return;
+
+ const frameSnappedTime = frameSnappedMouseTime({
+ clientX,
+ scrollContainer,
+ zoomLevel: this.deps.viewport.getZoomLevel(),
+ clickOffsetTime: mousedown.clickOffsetTime,
+ fps,
+ });
+ const { snappedTime, snapPoint } = this.snapResult(
+ frameSnappedTime,
+ drag.moveGroup,
+ );
+
+ drag.currentTime = snappedTime;
+ drag.currentMouseX = clientX;
+ drag.currentMouseY = clientY;
+
+ this.updateDropTarget({
+ clientX,
+ clientY,
+ mousedown,
+ drag,
+ snappedTime,
+ });
+
+ this.deps.snap.onChange?.(snapPoint);
+ this.notify();
+ }
+
+ private handleMouseUp = ({ clientX, clientY }: MouseEvent): void => {
+ if (this.session.kind === "pending") {
+ this.finishSession();
+ return;
+ }
+
+ if (this.session.kind !== "dragging") return;
+
+ const { mousedown, drag } = this.session;
+
+ // If the drag returned within the click threshold of its origin, treat
+ // this as a cancel rather than a commit — the user dragged then put the
+ // element back.
+ if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) {
+ this.lastGestureWasDrag = false;
+ this.finishSession();
+ return;
+ }
+
+ const { moveGroup, groupMoveResult } = drag;
+ if (!groupMoveResult) {
+ this.finishSession();
+ return;
+ }
+
+ const didMove = groupMoveResult.moves.some((move) => {
+ const member = moveGroup.members.find(
+ (m) => m.elementId === move.elementId,
+ );
+ const originalStartTime =
+ mousedown.startElementTime + (member?.timeOffset ?? 0);
+ return (
+ member?.trackId !== move.targetTrackId ||
+ originalStartTime !== move.newStartTime
+ );
+ });
+
+ if (didMove || groupMoveResult.createTracks.length > 0) {
+ this.deps.timeline.moveElements({
+ moves: groupMoveResult.moves,
+ createTracks: groupMoveResult.createTracks,
+ });
+ }
+
+ this.finishSession();
+ };
+}
diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
new file mode 100644
index 00000000..51c60dee
--- /dev/null
+++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
@@ -0,0 +1,342 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import { roundToFrame, snappedSeekTime, type FrameRate } from "opencut-wasm";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { TICKS_PER_SECOND } from "@/wasm";
+import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
+import { timelineTimeToSnappedPixels } from "@/timeline";
+import { getKeyframeById } from "@/animation";
+import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
+import { BatchCommand } from "@/commands";
+import type { SelectedKeyframeRef } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
+import type { Command } from "@/commands/base-command";
+
+// --- Session ---
+
+interface PendingSession {
+ kind: "pending";
+ keyframeRefs: SelectedKeyframeRef[];
+ startMouseX: number;
+}
+
+interface ActiveSession {
+ kind: "active";
+ keyframeRefs: SelectedKeyframeRef[];
+ startMouseX: number;
+ deltaTicks: number;
+}
+
+type Session = { kind: "idle" } | PendingSession | ActiveSession;
+
+// --- Public state ---
+
+export interface KeyframeDragState {
+ isDragging: boolean;
+ draggingKeyframeIds: Set;
+ deltaTicks: number;
+}
+
+const IDLE_DRAG_STATE: KeyframeDragState = {
+ isDragging: false,
+ draggingKeyframeIds: new Set(),
+ deltaTicks: 0,
+};
+
+// --- Config ---
+
+export interface KeyframeDragConfig {
+ zoomLevel: number;
+ getFps: () => FrameRate | null;
+ element: TimelineElement;
+ displayedStartTime: number;
+ 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: number }) => void;
+ getTotalDuration: () => number;
+}
+
+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: number;
+ }): 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 seekTime =
+ fps != null
+ ? (snappedSeekTime({
+ time: displayedStartTime + indicatorTime,
+ duration: getTotalDuration(),
+ rate: fps,
+ }) ?? displayedStartTime + indicatorTime)
+ : displayedStartTime + indicatorTime;
+ 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: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }): number {
+ if (!isBeingDragged || this.session.kind !== "active")
+ return indicatorOffsetPx;
+ const clampedTime = Math.max(
+ 0,
+ Math.min(
+ this.config.element.duration,
+ indicatorTime + this.session.deltaTicks,
+ ),
+ );
+ return (
+ timelineTimeToSnappedPixels({
+ time: displayedStartTime + 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: Math.max(
+ 0,
+ Math.min(element.duration, keyframe.time + deltaTicks),
+ ),
+ }),
+ ];
+ });
+
+ 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 =
+ roundToFrame({ time: rawDeltaTicks, rate: fps }) ?? rawDeltaTicks;
+ this.notify();
+ }
+
+ private handleMouseUp(): void {
+ if (this.session.kind === "pending") {
+ this.finishSession();
+ return;
+ }
+
+ if (this.session.kind !== "active") return;
+
+ const { selectedKeyframes, element } = this.config;
+ const { keyframeRefs, deltaTicks } = this.session;
+ const draggingIds = new Set(keyframeRefs.map((r) => r.keyframeId));
+ const draggingRefs = selectedKeyframes.filter(
+ (kf) => kf.elementId === element.id && draggingIds.has(kf.keyframeId),
+ );
+
+ if (draggingRefs.length > 0 && deltaTicks !== 0) {
+ this.commitDrag({ keyframeRefs: draggingRefs, deltaTicks });
+ }
+
+ this.finishSession();
+ }
+}
diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts
new file mode 100644
index 00000000..5260c046
--- /dev/null
+++ b/apps/web/src/timeline/controllers/playhead-controller.ts
@@ -0,0 +1,314 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import { snappedSeekTime, type FrameRate } from "opencut-wasm";
+import { TICKS_PER_SECOND } 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: number | null;
+}
+
+type Session = { kind: "idle" } | ScrubSession;
+
+// --- Config ---
+
+export interface PlayheadConfig {
+ zoomLevel: number;
+ duration: number;
+ 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: number) => void;
+ setScrubbing: (isScrubbing: boolean) => void;
+ setTimelineViewState: (viewState: {
+ zoomLevel: number;
+ scrollLeft: number;
+ playheadTime: number;
+ }) => void;
+}
+
+export interface PlayheadConfigRef {
+ readonly current: PlayheadConfig;
+}
+
+// --- Pure helpers (px → logical) ---
+
+function pixelToTime({
+ clientX,
+ rulerEl,
+ zoomLevel,
+ duration,
+}: {
+ clientX: number;
+ rulerEl: HTMLDivElement;
+ zoomLevel: number;
+ duration: number;
+}): number {
+ 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 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: number): 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: number): 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 =
+ snappedSeekTime({ time: rawTime, duration, rate: fps }) ?? rawTime;
+
+ const time = (() => {
+ if (!isElementSnappingEnabled || this.config.isShiftHeld())
+ return frameTime;
+
+ const snapPoints = buildTimelineSnapPoints({
+ sources: [
+ () =>
+ getElementEdgeSnapPoints({ tracks: this.config.getSceneTracks() }),
+ () =>
+ getBookmarkSnapPoints({
+ bookmarks: this.config.getSceneBookmarks(),
+ }),
+ () =>
+ getAnimationKeyframeSnapPointsForTimeline({
+ tracks: this.config.getSceneTracks(),
+ }),
+ ],
+ });
+ const result = resolveTimelineSnap({
+ targetTime: frameTime,
+ snapPoints,
+ maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
+ });
+ return result.snapPoint ? result.snappedTime : frameTime;
+ })();
+
+ if (this.session.kind === "scrubbing") {
+ this.session.currentTime = time;
+ }
+ this.config.seek(time);
+ this.lastMouseClientX = event.clientX;
+ }
+
+ private handleMouseMove(event: MouseEvent): void {
+ if (this.session.kind !== "scrubbing") return;
+ this.scrub({ event, isElementSnappingEnabled: true });
+ if (this.session.didStartFromRuler) {
+ this.session.hasMoved = true;
+ }
+ }
+
+ private handleMouseUp(event: MouseEvent): void {
+ if (this.session.kind !== "scrubbing") return;
+
+ const session = this.session;
+ this.config.setScrubbing(false);
+
+ if (session.currentTime !== null) {
+ this.config.seek(session.currentTime);
+ this.config.setTimelineViewState({
+ zoomLevel: this.config.zoomLevel,
+ scrollLeft: this.config.getTracksScrollEl()?.scrollLeft ?? 0,
+ playheadTime: session.currentTime,
+ });
+ }
+
+ // Ruler click without drag: snap to clicked position on mouseup.
+ if (session.didStartFromRuler && !session.hasMoved) {
+ this.scrub({ event, isElementSnappingEnabled: false });
+ }
+
+ this.session = { kind: "idle" };
+ this.deactivate();
+ }
+}
diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts
new file mode 100644
index 00000000..8677dd15
--- /dev/null
+++ b/apps/web/src/timeline/controllers/resize-controller.ts
@@ -0,0 +1,329 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { 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: () => number;
+ 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) => el.startTime + el.duration <= element.startTime)
+ .reduce(
+ (bound, el) => Math.max(bound, el.startTime + el.duration),
+ -Infinity,
+ );
+ const rightNeighborBound = otherElements
+ .filter((el) => el.startTime >= element.startTime + element.duration)
+ .reduce((bound, el) => Math.min(bound, el.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 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, rawDeltaTicks: number): number {
+ const { snappingEnabled, isShiftHeld, zoomLevel } = this.config;
+
+ if (!snappingEnabled || isShiftHeld()) {
+ this.config.onSnapPointChange?.(null);
+ return rawDeltaTicks;
+ }
+
+ 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 deltaTicks = rawDeltaTicks;
+
+ for (const member of session.members) {
+ const baseEdgeTime =
+ session.side === "left"
+ ? member.startTime
+ : member.startTime + member.duration;
+ const snapResult = resolveTimelineSnap({
+ targetTime: baseEdgeTime + rawDeltaTicks,
+ snapPoints,
+ maxSnapDistance,
+ });
+ if (
+ snapResult.snapPoint &&
+ snapResult.snapDistance < closestSnapDistance
+ ) {
+ closestSnapDistance = snapResult.snapDistance;
+ closestSnapPoint = snapResult.snapPoint;
+ deltaTicks = snapResult.snappedTime - baseEdgeTime;
+ }
+ }
+
+ this.config.onSnapPointChange?.(closestSnapPoint);
+ return deltaTicks;
+ }
+
+ private handleMouseMove({ clientX }: MouseEvent): void {
+ if (this.session.kind !== "active") return;
+ const session = this.session;
+
+ const rawDeltaTicks = Math.round(
+ ((clientX - session.startX) /
+ (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
+ TICKS_PER_SECOND,
+ );
+ const deltaTicks = this.snappedDelta(session, rawDeltaTicks);
+ const result = computeGroupResize({
+ members: session.members,
+ side: session.side,
+ deltaTicks,
+ fps: session.fps,
+ });
+
+ session.result = result;
+ this.config.previewElements(result.updates);
+ }
+
+ private handleMouseUp(): void {
+ if (this.session.kind !== "active") return;
+ const session = this.session;
+
+ this.config.discardPreview();
+
+ if (
+ session.result &&
+ hasResizeChanges({ members: session.members, result: session.result })
+ ) {
+ this.config.commitElements(session.result.updates);
+ }
+
+ this.finishSession();
+ }
+}
diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts
new file mode 100644
index 00000000..f32c855e
--- /dev/null
+++ b/apps/web/src/timeline/controllers/seek-controller.ts
@@ -0,0 +1,210 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import { snappedSeekTime, type FrameRate } from "opencut-wasm";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { TICKS_PER_SECOND } 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: number;
+ isSelecting: boolean;
+ getPlayheadEl: () => HTMLDivElement | null;
+ getTrackLabelsEl: () => HTMLDivElement | null;
+ getRulerScrollEl: () => HTMLDivElement | null;
+ getTracksScrollEl: () => HTMLDivElement | null;
+ getActiveProjectFps: () => FrameRate | null;
+ clearSelectedElements: () => void;
+ seek: (time: number) => void;
+ setTimelineViewState: (viewState: {
+ zoomLevel: number;
+ scrollLeft: number;
+ playheadTime: number;
+ }) => void;
+}
+
+export interface SeekConfigRef {
+ readonly current: SeekConfig;
+}
+
+function pixelToTime({
+ clientX,
+ scrollContainer,
+ zoomLevel,
+ duration,
+}: {
+ clientX: number;
+ scrollContainer: HTMLDivElement;
+ zoomLevel: number;
+ duration: number;
+}): number {
+ 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 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
+ ? (snappedSeekTime({
+ time: rawTime,
+ duration: this.config.duration,
+ rate: fps,
+ }) ?? rawTime)
+ : rawTime;
+
+ this.config.seek(time);
+ this.config.setTimelineViewState({
+ zoomLevel: this.config.zoomLevel,
+ scrollLeft: scrollContainer.scrollLeft,
+ playheadTime: time,
+ });
+ }
+}
diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts
new file mode 100644
index 00000000..8e0af4fe
--- /dev/null
+++ b/apps/web/src/timeline/controllers/zoom-controller.ts
@@ -0,0 +1,285 @@
+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";
+
+type ZoomUpdater = number | ((prev: number) => number);
+
+export interface ZoomConfig {
+ minZoom: number;
+ getContainerEl: () => HTMLDivElement | null;
+ getTracksScrollEl: () => HTMLDivElement | null;
+ getRulerScrollEl: () => HTMLDivElement | null;
+ getCurrentPlayheadTime: () => number;
+ seek: (time: number) => void;
+ setTimelineViewState: (viewState: {
+ zoomLevel: number;
+ scrollLeft: number;
+ playheadTime: number;
+ }) => void;
+}
+
+export interface ZoomConfigRef {
+ readonly current: ZoomConfig;
+}
+
+function clampZoom(zoomLevel: number, minZoom: number): number {
+ return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel));
+}
+
+export class ZoomController {
+ private readonly configRef: ZoomConfigRef;
+ private readonly subscribers = new Set<() => void>();
+
+ private zoomLevelValue: number;
+ private hasInitialized = false;
+ private hasRestoredPlayhead = false;
+ private hasRestoredScroll = false;
+ private previousZoom: number;
+ private preZoomScrollLeft = 0;
+ private prePlayheadAnchorScrollLeft = 0;
+ private isInPlayheadAnchorMode = false;
+ private scrollSaveTimeout: ReturnType | null = null;
+
+ constructor(deps: { configRef: ZoomConfigRef; initialZoom?: number }) {
+ this.configRef = deps.configRef;
+
+ const minZoom = this.config.minZoom;
+ this.zoomLevelValue =
+ deps.initialZoom !== undefined
+ ? clampZoom(deps.initialZoom, minZoom)
+ : minZoom;
+ this.previousZoom = this.zoomLevelValue;
+ this.hasInitialized = deps.initialZoom !== undefined;
+
+ this.setZoomLevel = this.setZoomLevel.bind(this);
+ this.handleWheel = this.handleWheel.bind(this);
+ this.saveScrollPosition = this.saveScrollPosition.bind(this);
+ }
+
+ private get config(): ZoomConfig {
+ return this.configRef.current;
+ }
+
+ get zoomLevel(): number {
+ return this.zoomLevelValue;
+ }
+
+ subscribe(fn: () => void): () => void {
+ this.subscribers.add(fn);
+ return () => this.subscribers.delete(fn);
+ }
+
+ destroy(): void {
+ if (this.scrollSaveTimeout) {
+ clearTimeout(this.scrollSaveTimeout);
+ this.scrollSaveTimeout = null;
+ }
+ }
+
+ setZoomLevel(zoomLevelOrUpdater: ZoomUpdater): void {
+ const scrollElement = this.config.getTracksScrollEl();
+ if (scrollElement) {
+ this.preZoomScrollLeft = scrollElement.scrollLeft;
+ }
+
+ const nextZoomRaw =
+ typeof zoomLevelOrUpdater === "function"
+ ? zoomLevelOrUpdater(this.zoomLevelValue)
+ : zoomLevelOrUpdater;
+ const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom);
+ if (nextZoom === this.zoomLevelValue) return;
+
+ this.zoomLevelValue = nextZoom;
+ this.notify();
+ }
+
+ handleWheel(event: ReactWheelEvent): void {
+ const isZoomGesture = event.ctrlKey || event.metaKey;
+ const isHorizontalScrollGesture =
+ event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
+
+ if (isHorizontalScrollGesture) {
+ return;
+ }
+
+ if (isZoomGesture) {
+ const normalizedDelta =
+ event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
+ const cappedDelta =
+ Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+ this.setZoomLevel((prev) => prev * zoomFactor);
+ }
+ }
+
+ reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void {
+ if (initialZoom !== undefined && !this.hasInitialized) {
+ this.hasInitialized = true;
+ this.setZoomLevel(clampZoom(initialZoom, minZoom));
+ return;
+ }
+
+ if (this.zoomLevelValue < minZoom) {
+ this.setZoomLevel(minZoom);
+ }
+ }
+
+ applyZoomLayout(zoomLevel: number): void {
+ const previousZoom = this.previousZoom;
+ if (previousZoom === zoomLevel) return;
+
+ const scrollElement = this.config.getTracksScrollEl();
+ if (!scrollElement) {
+ this.previousZoom = zoomLevel;
+ return;
+ }
+
+ const currentScrollLeft = this.preZoomScrollLeft;
+ const playheadTime = this.config.getCurrentPlayheadTime();
+ const sliderPercent = zoomToSlider({
+ zoomLevel,
+ minZoom: this.config.minZoom,
+ });
+ const previousSliderPercent = zoomToSlider({
+ zoomLevel: previousZoom,
+ minZoom: this.config.minZoom,
+ });
+ const isCrossingThresholdUp =
+ previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+ const isCrossingThresholdDown =
+ previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+
+ const syncScroll = (scrollLeft: number) => {
+ scrollElement.scrollLeft = scrollLeft;
+ const ruler = this.config.getRulerScrollEl();
+ if (ruler) {
+ ruler.scrollLeft = scrollLeft;
+ }
+ };
+
+ const clampScrollLeft = (scrollLeft: number) => {
+ const maxScrollLeft =
+ scrollElement.scrollWidth - scrollElement.clientWidth;
+ return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
+ };
+
+ if (isCrossingThresholdUp) {
+ this.prePlayheadAnchorScrollLeft = currentScrollLeft;
+ this.isInPlayheadAnchorMode = true;
+ }
+
+ if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
+ const playheadPixelsBefore = timelineTimeToPixels({
+ time: playheadTime,
+ zoomLevel: previousZoom,
+ });
+ const playheadPixelsAfter = timelineTimeToPixels({
+ time: playheadTime,
+ zoomLevel,
+ });
+ const viewportOffset = playheadPixelsBefore - currentScrollLeft;
+ const nextScrollLeft = playheadPixelsAfter - viewportOffset;
+ syncScroll(clampScrollLeft(nextScrollLeft));
+ } else if (isCrossingThresholdDown && this.isInPlayheadAnchorMode) {
+ syncScroll(clampScrollLeft(this.prePlayheadAnchorScrollLeft));
+ this.isInPlayheadAnchorMode = false;
+ }
+
+ this.previousZoom = zoomLevel;
+
+ this.config.setTimelineViewState({
+ zoomLevel,
+ scrollLeft: scrollElement.scrollLeft,
+ playheadTime,
+ });
+ }
+
+ saveScrollPosition(): void {
+ if (this.scrollSaveTimeout) {
+ clearTimeout(this.scrollSaveTimeout);
+ }
+
+ this.scrollSaveTimeout = setTimeout(() => {
+ const scrollElement = this.config.getTracksScrollEl();
+ if (!scrollElement) return;
+
+ this.config.setTimelineViewState({
+ zoomLevel: this.zoomLevelValue,
+ scrollLeft: scrollElement.scrollLeft,
+ playheadTime: this.config.getCurrentPlayheadTime(),
+ });
+ }, 300);
+ }
+
+ restoreInitialScrollIfNeeded(
+ initialScrollLeft?: number,
+ ): (() => void) | undefined {
+ if (initialScrollLeft === undefined) return;
+ if (this.hasRestoredScroll) return;
+
+ const scrollElement = this.config.getTracksScrollEl();
+ if (!scrollElement) return;
+
+ const restoreScroll = () => {
+ scrollElement.scrollLeft = initialScrollLeft;
+ const ruler = this.config.getRulerScrollEl();
+ if (ruler) {
+ ruler.scrollLeft = initialScrollLeft;
+ }
+ this.hasRestoredScroll = true;
+ this.preZoomScrollLeft = initialScrollLeft;
+ };
+
+ if (scrollElement.scrollWidth > 0) {
+ restoreScroll();
+ return;
+ }
+
+ const observer = new ResizeObserver(() => {
+ if (scrollElement.scrollWidth > 0) {
+ restoreScroll();
+ observer.disconnect();
+ }
+ });
+ observer.observe(scrollElement);
+ return () => observer.disconnect();
+ }
+
+ restoreInitialPlayheadIfNeeded(initialPlayheadTime?: number): void {
+ if (initialPlayheadTime === undefined) return;
+ if (this.hasRestoredPlayhead) return;
+
+ this.hasRestoredPlayhead = true;
+ this.config.seek(initialPlayheadTime);
+ }
+
+ bindPreventBrowserZoom(): () => void {
+ const preventZoom = (event: WheelEvent) => {
+ const isZoomKeyPressed = event.ctrlKey || event.metaKey;
+ const container = this.config.getContainerEl();
+ const isInContainer = container?.contains(event.target as Node) ?? false;
+ if (isZoomKeyPressed && isInContainer) {
+ event.preventDefault();
+ }
+ };
+
+ document.addEventListener("wheel", preventZoom, {
+ passive: false,
+ capture: true,
+ });
+
+ return () => {
+ document.removeEventListener("wheel", preventZoom, { capture: true });
+ };
+ }
+
+ private notify(): void {
+ for (const fn of this.subscribers) {
+ fn();
+ }
+ }
+}
diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts
index 2ac7f246..2dec3186 100644
--- a/apps/web/src/timeline/creation.ts
+++ b/apps/web/src/timeline/creation.ts
@@ -1,3 +1,20 @@
import { TICKS_PER_SECOND } from "@/wasm/ticks";
export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND;
+
+/**
+ * Resolves the duration in ticks that a newly created timeline element
+ * should have, given an optional source duration in seconds.
+ *
+ * Falls back to {@link DEFAULT_NEW_ELEMENT_DURATION} when the source has
+ * no known duration (e.g. stickers, graphics, text, or media whose
+ * probe failed).
+ */
+export function toElementDurationTicks({
+ seconds,
+}: {
+ seconds: number | null | undefined;
+}): number {
+ if (seconds == null) return DEFAULT_NEW_ELEMENT_DURATION;
+ return Math.round(seconds * TICKS_PER_SECOND);
+}
diff --git a/apps/web/src/timeline/drag-data.ts b/apps/web/src/timeline/drag-data.ts
deleted file mode 100644
index 1fc9ce9a..00000000
--- a/apps/web/src/timeline/drag-data.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import type { TimelineDragData } from "@/timeline/drag";
-
-const MIME_TYPE = "application/x-timeline-drag";
-let lastDragData: TimelineDragData | null = null;
-
-export function setDragData({
- dataTransfer,
- dragData,
-}: {
- dataTransfer: DataTransfer;
- dragData: TimelineDragData;
-}): void {
- dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
- dataTransfer.setData("text/plain", JSON.stringify(dragData));
- lastDragData = dragData;
-}
-
-export function getDragData({
- dataTransfer,
-}: {
- dataTransfer: DataTransfer;
-}): TimelineDragData | null {
- const data = dataTransfer.getData(MIME_TYPE);
- if (data) return JSON.parse(data) as TimelineDragData;
-
- const textData = dataTransfer.getData("text/plain");
- if (textData) {
- try {
- return JSON.parse(textData) as TimelineDragData;
- } catch {
- return lastDragData;
- }
- }
-
- return lastDragData;
-}
-
-export function hasDragData({
- dataTransfer,
-}: {
- dataTransfer: DataTransfer;
-}): boolean {
- return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null;
-}
-
-export function clearDragData(): void {
- lastDragData = null;
-}
diff --git a/apps/web/src/timeline/drag-source.ts b/apps/web/src/timeline/drag-source.ts
new file mode 100644
index 00000000..503c547b
--- /dev/null
+++ b/apps/web/src/timeline/drag-source.ts
@@ -0,0 +1,40 @@
+import type { TimelineDragData } from "@/timeline/drag";
+
+const TIMELINE_DRAG_MIME = "application/x-timeline-drag";
+
+/**
+ * Owns the state of an in-progress timeline drag session.
+ *
+ * Exists because browsers restrict `DataTransfer.getData()` to the `drop`
+ * event for security — during `dragover`/`dragenter` only `types` is
+ * readable. The drop target needs the payload (element type, target
+ * element types, source duration) while the pointer is hovering, so we
+ * keep a live copy here and hand it out via {@link getActive}.
+ */
+export class TimelineDragSource {
+ private active: TimelineDragData | null = null;
+
+ begin({
+ dataTransfer,
+ dragData,
+ }: {
+ dataTransfer: DataTransfer;
+ dragData: TimelineDragData;
+ }): void {
+ dataTransfer.setData(TIMELINE_DRAG_MIME, JSON.stringify(dragData));
+ dataTransfer.effectAllowed = "copy";
+ this.active = dragData;
+ }
+
+ end(): void {
+ this.active = null;
+ }
+
+ getActive(): TimelineDragData | null {
+ return this.active;
+ }
+
+ isActive(): boolean {
+ return this.active !== null;
+ }
+}
diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts
index dd542967..d22b6891 100644
--- a/apps/web/src/timeline/group-resize/compute-resize.ts
+++ b/apps/web/src/timeline/group-resize/compute-resize.ts
@@ -15,63 +15,63 @@ import type {
export function computeGroupResize({
members,
side,
- deltaTime,
+ deltaTicks,
fps,
}: ComputeGroupResizeArgs): GroupResizeResult {
const minDuration = Math.round(
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
);
- const minimumDeltaTime = Math.max(
+ const minimumDeltaTicks = Math.max(
...members.map((member) =>
- getMinimumAllowedDeltaTime({
+ getMinimumAllowedDeltaTicks({
member,
side,
minDuration,
}),
),
);
- const maximumDeltaTime = Math.min(
+ const maximumDeltaTicks = Math.min(
...members.map((member) =>
- getMaximumAllowedDeltaTime({
+ getMaximumAllowedDeltaTicks({
member,
side,
minDuration,
}),
),
);
- const clampedDeltaTime =
- minimumDeltaTime > maximumDeltaTime
- ? minimumDeltaTime
- : Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
+ const clampedDeltaTicks =
+ minimumDeltaTicks > maximumDeltaTicks
+ ? minimumDeltaTicks
+ : Math.min(maximumDeltaTicks, Math.max(minimumDeltaTicks, deltaTicks));
// Snap the drag delta to a frame exactly once, then derive every patch
// field from that single snapped value. This keeps the invariant
// `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same
// delta is added on one side of the element and removed from the other,
- // 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;
+ // so the rounding cancels by construction. Rounding each field
+ // independently would break this — the individual rounds don't compose
+ // when `sourceDuration` isn't frame-aligned.
+ const snappedDeltaTicks =
+ roundToFrame({ time: clampedDeltaTicks, rate: fps }) ?? clampedDeltaTicks;
// 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
+ const finalDeltaTicks =
+ minimumDeltaTicks > maximumDeltaTicks
+ ? minimumDeltaTicks
: Math.min(
- maximumDeltaTime,
- Math.max(minimumDeltaTime, snappedDeltaTime),
+ maximumDeltaTicks,
+ Math.max(minimumDeltaTicks, snappedDeltaTicks),
);
return {
- deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime,
+ deltaTicks: Object.is(finalDeltaTicks, -0) ? 0 : finalDeltaTicks,
updates: members.map((member) =>
buildResizeUpdate({
member,
side,
- deltaTime: finalDeltaTime,
+ deltaTicks: finalDeltaTicks,
}),
),
};
@@ -80,15 +80,15 @@ export function computeGroupResize({
function buildResizeUpdate({
member,
side,
- deltaTime,
+ deltaTicks,
}: {
member: GroupResizeMember;
side: ResizeSide;
- deltaTime: number;
+ deltaTicks: number;
}): GroupResizeUpdate {
const sourceDelta = getSourceDeltaForClipDelta({
member,
- clipDelta: deltaTime,
+ clipDelta: deltaTicks,
});
if (side === "left") {
@@ -98,8 +98,8 @@ function buildResizeUpdate({
patch: {
trimStart: Math.max(0, member.trimStart + sourceDelta),
trimEnd: member.trimEnd,
- startTime: member.startTime + deltaTime,
- duration: member.duration - deltaTime,
+ startTime: member.startTime + deltaTicks,
+ duration: member.duration - deltaTicks,
},
};
}
@@ -111,12 +111,12 @@ function buildResizeUpdate({
trimStart: member.trimStart,
trimEnd: Math.max(0, member.trimEnd - sourceDelta),
startTime: member.startTime,
- duration: member.duration + deltaTime,
+ duration: member.duration + deltaTicks,
},
};
}
-function getMinimumAllowedDeltaTime({
+function getMinimumAllowedDeltaTicks({
member,
side,
minDuration,
@@ -148,7 +148,7 @@ function getMinimumAllowedDeltaTime({
return Math.max(leftNeighborFloor, -maximumSourceExtension);
}
-function getMaximumAllowedDeltaTime({
+function getMaximumAllowedDeltaTicks({
member,
side,
minDuration,
diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts
index b07e6fe6..511358ba 100644
--- a/apps/web/src/timeline/group-resize/types.ts
+++ b/apps/web/src/timeline/group-resize/types.ts
@@ -24,13 +24,13 @@ export interface GroupResizeUpdate extends ElementRef {
}
export interface GroupResizeResult {
- deltaTime: number;
+ deltaTicks: number;
updates: GroupResizeUpdate[];
}
export interface ComputeGroupResizeArgs {
members: GroupResizeMember[];
side: ResizeSide;
- deltaTime: number;
+ deltaTicks: number;
fps: FrameRate;
}
diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts
index 621259b9..26a21f85 100644
--- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts
+++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts
@@ -1,42 +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 { TICKS_PER_SECOND } from "@/wasm";
-import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
-import { roundToFrame } from "opencut-wasm";
-import { computeDropTarget } from "@/timeline/components/drop-target";
-import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
-import { generateUUID } from "@/utils/id";
-import type { SnapPoint } from "@/timeline/snapping";
import { registerCanceller } from "@/editor/cancel-interaction";
-import type {
- DropTarget,
- ElementRef,
- ElementDragState,
- SceneTracks,
- TimelineElement,
- TimelineTrack,
-} from "@/timeline";
+import {
+ ElementInteractionController,
+ type ElementInteractionDeps,
+ type ElementInteractionDepsRef,
+} from "@/timeline/controllers/element-interaction-controller";
+import type { SnapPoint } from "@/timeline/snapping";
interface UseElementInteractionProps {
zoomLevel: number;
- timelineRef: RefObject;
tracksContainerRef: RefObject;
tracksScrollRef: RefObject;
headerRef?: RefObject;
@@ -44,131 +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: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- currentMouseY: 0,
-};
-
-interface PendingDragState {
- elementId: string;
- trackId: string;
- selectedElements: ElementRef[];
- startMouseX: number;
- startMouseY: number;
- startElementTime: number;
- clickOffsetTime: number;
-}
-
-function getClickOffsetTime({
- clientX,
- elementRect,
- zoomLevel,
-}: {
- clientX: number;
- elementRect: DOMRect;
- zoomLevel: number;
-}): number {
- const clickOffsetX = clientX - elementRect.left;
- const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
- return Math.round(seconds * TICKS_PER_SECOND);
-}
-
-function getVerticalDragDirection({
- startMouseY,
- currentMouseY,
-}: {
- startMouseY: number;
- currentMouseY: number;
-}): "up" | "down" | null {
- if (currentMouseY < startMouseY) return "up";
- if (currentMouseY > startMouseY) return "down";
- return null;
-}
-
-function getDragDropTarget({
- clientX,
- clientY,
- elementId,
- trackId,
- tracks,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- zoomLevel,
- snappedTime,
- verticalDragDirection,
-}: {
- clientX: number;
- clientY: number;
- elementId: string;
- trackId: string;
- tracks: SceneTracks;
- tracksContainerRef: RefObject;
- tracksScrollRef: RefObject;
- headerRef?: RefObject;
- zoomLevel: number;
- snappedTime: number;
- 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: number;
- initialCurrentMouseY: number;
-}
-
export function useElementInteraction({
zoomLevel,
- timelineRef,
tracksContainerRef,
tracksScrollRef,
headerRef,
@@ -177,584 +29,65 @@ export function useElementInteraction({
}: UseElementInteractionProps) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
- const sceneTracks = editor.scenes.getActiveScene().tracks;
- const {
- selectedElements,
- isElementSelected,
- selectElement,
- handleElementClick: handleSelectionClick,
- } = useElementSelection();
+ const selection = useElementSelection();
- const [dragState, setDragState] =
- useState(initialDragState);
- const [dragDropTarget, setDragDropTarget] = useState(null);
- const [isPendingDrag, setIsPendingDrag] = useState(false);
- const pendingDragRef = useRef(null);
- const moveGroupRef = useRef(null);
- const newTrackIdsRef = useRef([]);
- const groupMoveResultRef = useRef(null);
- const lastMouseXRef = useRef(0);
- const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
-
- const startDrag = useCallback(
- ({
- elementId,
- dragElementIds,
- dragTimeOffsets,
- trackId,
- startMouseX,
- startMouseY,
- startElementTime,
- clickOffsetTime,
- initialCurrentTime,
- initialCurrentMouseY,
- }: StartDragParams) => {
- setDragState({
- isDragging: true,
- elementId,
- dragElementIds,
- dragTimeOffsets,
- trackId,
- startMouseX,
- startMouseY,
- startElementTime,
- clickOffsetTime,
- currentTime: initialCurrentTime,
- currentMouseY: initialCurrentMouseY,
- });
+ const deps: ElementInteractionDeps = {
+ viewport: {
+ getZoomLevel: () => zoomLevel,
+ getTracksScrollEl: () => tracksScrollRef.current,
+ getTracksContainerEl: () => tracksContainerRef.current,
+ getHeaderEl: () => headerRef?.current ?? null,
},
- [],
- );
-
- const endDrag = useCallback(() => {
- moveGroupRef.current = null;
- newTrackIdsRef.current = [];
- groupMoveResultRef.current = null;
- setDragState(initialDragState);
- setDragDropTarget(null);
- }, []);
-
- const cancelCurrentDrag = useCallback(() => {
- pendingDragRef.current = null;
- mouseDownLocationRef.current = null;
- setIsPendingDrag(false);
- endDrag();
- onSnapPointChange?.(null);
- }, [endDrag, onSnapPointChange]);
-
- const resolveGroupDragMove = useCallback(
- ({
- group,
- snappedTime,
- dropTarget,
- }: {
- group: MoveGroup;
- snappedTime: number;
- dropTarget: DropTarget | null;
- }): GroupMoveResult | null => {
- if (!dropTarget) {
- return null;
- }
-
- if (dropTarget.isNewTrack) {
- return resolveGroupMove({
- group,
- tracks: sceneTracks,
- anchorStartTime: snappedTime,
- target: {
- kind: "newTracks",
- anchorInsertIndex: dropTarget.trackIndex,
- newTrackIds: newTrackIdsRef.current,
- },
- });
- }
-
- const orderedTracks = [
- ...sceneTracks.overlay,
- sceneTracks.main,
- ...sceneTracks.audio,
- ];
- const targetTrack = orderedTracks[dropTarget.trackIndex];
- if (!targetTrack) {
- return null;
- }
-
- const existingTrackResult = resolveGroupMove({
- group,
- tracks: sceneTracks,
- anchorStartTime: snappedTime,
- target: {
- kind: "existingTrack",
- anchorTargetTrackId: targetTrack.id,
- },
- });
- if (existingTrackResult) {
- return existingTrackResult;
- }
-
- return resolveGroupMove({
- group,
- tracks: sceneTracks,
- anchorStartTime: snappedTime,
- target: {
- kind: "newTracks",
- anchorInsertIndex: dropTarget.trackIndex,
- newTrackIds: newTrackIdsRef.current,
- },
- });
+ input: {
+ isShiftHeld: () => isShiftHeldRef.current,
},
- [sceneTracks],
- );
+ scene: {
+ getTracks: () => editor.scenes.getActiveScene().tracks,
+ getActiveFps: () => editor.project.getActive()?.settings.fps ?? null,
+ },
+ selection: {
+ getSelected: () => selection.selectedElements,
+ isSelected: selection.isElementSelected,
+ select: selection.selectElement,
+ handleClick: selection.handleElementClick,
+ clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(),
+ },
+ playback: {
+ getCurrentTime: () => editor.playback.getCurrentTime(),
+ },
+ timeline: {
+ moveElements: (args) => editor.timeline.moveElements(args),
+ },
+ snap: {
+ isEnabled: () => snappingEnabled,
+ onChange: onSnapPointChange,
+ },
+ };
+
+ const depsRef = useRef(deps);
+ depsRef.current = deps;
+
+ const controllerRef = useRef(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ElementInteractionController({
+ depsRef: depsRef as ElementInteractionDepsRef,
+ });
+ }
+ const controller = controllerRef.current;
+
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isActive, controller]);
- return registerCanceller({ fn: cancelCurrentDrag });
- }, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
-
- const getDragSnapResult = useCallback(
- ({
- frameSnappedTime,
- group,
- }: {
- frameSnappedTime: number;
- 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 = Math.max(
- 0,
- mouseTime - pendingDragRef.current.clickOffsetTime,
- );
- const snappedTime =
- roundToFrame({
- time: adjustedTime,
- rate: activeProject.settings.fps,
- }) ?? adjustedTime;
- const moveGroup = buildMoveGroup({
- anchorRef: {
- trackId: pendingDragRef.current.trackId,
- elementId: pendingDragRef.current.elementId,
- },
- selectedElements: pendingDragRef.current.selectedElements,
- tracks: sceneTracks,
- });
- if (!moveGroup) {
- return;
- }
-
- moveGroupRef.current = moveGroup;
- newTrackIdsRef.current = moveGroup.members.map(() => generateUUID());
- const dragTimeOffsets: Record = {};
- for (const member of moveGroup.members) {
- dragTimeOffsets[member.elementId] = member.timeOffset;
- }
- const {
- snappedTime: initialSnappedTime,
- snapPoint: initialSnapPoint,
- } = getDragSnapResult({
- frameSnappedTime: snappedTime,
- group: moveGroup,
- });
- const verticalDragDirection = getVerticalDragDirection({
- startMouseY: pendingDragRef.current.startMouseY,
- currentMouseY: clientY,
- });
- const anchorDropTarget = getDragDropTarget({
- clientX,
- clientY,
- elementId: pendingDragRef.current.elementId,
- trackId: pendingDragRef.current.trackId,
- tracks: sceneTracks,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- zoomLevel,
- snappedTime: initialSnappedTime,
- verticalDragDirection,
- });
- const nextGroupMoveResult =
- anchorDropTarget != null
- ? resolveGroupDragMove({
- group: moveGroup,
- snappedTime: initialSnappedTime,
- dropTarget: anchorDropTarget,
- })
- : null;
- groupMoveResultRef.current = nextGroupMoveResult;
- setDragDropTarget(
- anchorDropTarget &&
- (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
- ? {
- ...anchorDropTarget,
- isNewTrack: true,
- }
- : null,
- );
- startDrag({
- ...pendingDragRef.current,
- dragElementIds: moveGroup.members.map((member) => member.elementId),
- dragTimeOffsets,
- initialCurrentTime: initialSnappedTime,
- initialCurrentMouseY: clientY,
- });
- onSnapPointChange?.(initialSnapPoint);
- startedDragThisEvent = true;
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- } else {
- return;
- }
- }
-
- if (startedDragThisEvent) {
- return;
- }
-
- if (dragState.elementId && dragState.trackId) {
- const alreadySelected = isElementSelected({
- trackId: dragState.trackId,
- elementId: dragState.elementId,
- });
- if (!alreadySelected) {
- selectElement({
- trackId: dragState.trackId,
- elementId: dragState.elementId,
- });
- }
- }
-
- const activeProject = editor.project.getActive();
- if (!activeProject) return;
-
- const scrollLeft = scrollContainer.scrollLeft;
- const mouseTime = getMouseTimeFromClientX({
- clientX,
- containerRect: scrollContainer.getBoundingClientRect(),
- zoomLevel,
- scrollLeft,
- });
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- const fps = activeProject.settings.fps;
- const frameSnappedTime =
- roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
-
- 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 =
- dragState.startElementTime + (currentMember?.timeOffset ?? 0);
- return (
- currentMember?.trackId !== move.targetTrackId ||
- originalStartTime !== move.newStartTime
- );
- });
- if (!didMove && groupMoveResult.createTracks.length === 0) {
- endDrag();
- onSnapPointChange?.(null);
- return;
- }
-
- editor.timeline.moveElements({
- moves: groupMoveResult.moves,
- createTracks: groupMoveResult.createTracks,
- });
- endDrag();
- onSnapPointChange?.(null);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [
- dragState.isDragging,
- dragState.elementId,
- dragState.startElementTime,
- dragState.trackId,
- endDrag,
- onSnapPointChange,
- editor.timeline,
- ]);
-
- useEffect(() => {
- if (!isPendingDrag) return;
-
- const handleMouseUp = () => {
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- onSnapPointChange?.(null);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [isPendingDrag, onSnapPointChange]);
-
- const handleElementMouseDown = useCallback(
- ({
- event,
- element,
- track,
- }: {
- event: ReactMouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- }) => {
- const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
-
- // right-click: don't stop propagation so ContextMenu can open
- if (isRightClick) {
- const alreadySelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
- if (!alreadySelected) {
- handleSelectionClick({
- trackId: track.id,
- elementId: element.id,
- isMultiKey: false,
- });
- }
- return;
- }
-
- event.stopPropagation();
- mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
-
- const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
-
- if (isMultiSelect) {
- handleSelectionClick({
- trackId: track.id,
- elementId: element.id,
- isMultiKey: true,
- });
- }
-
- const clickOffsetTime = getClickOffsetTime({
- clientX: event.clientX,
- elementRect: event.currentTarget.getBoundingClientRect(),
- zoomLevel,
- });
- const elementRef = {
- trackId: track.id,
- elementId: element.id,
- };
- const pendingSelectedElements = isElementSelected(elementRef)
- ? selectedElements
- : [elementRef];
- pendingDragRef.current = {
- elementId: element.id,
- trackId: track.id,
- selectedElements: pendingSelectedElements,
- startMouseX: event.clientX,
- startMouseY: event.clientY,
- startElementTime: element.startTime,
- clickOffsetTime,
- };
- setIsPendingDrag(true);
- },
- [zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
- );
-
- const handleElementClick = useCallback(
- ({
- event,
- element,
- track,
- }: {
- event: ReactMouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- }) => {
- event.stopPropagation();
-
- if (mouseDownLocationRef.current) {
- const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
- const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
- if (
- deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
- deltaY > TIMELINE_DRAG_THRESHOLD_PX
- ) {
- mouseDownLocationRef.current = null;
- return;
- }
- }
-
- // modifier keys already handled in mousedown
- if (event.metaKey || event.ctrlKey || event.shiftKey) return;
-
- const alreadySelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
- if (!alreadySelected || selectedElements.length > 1) {
- selectElement({ trackId: track.id, elementId: element.id });
- return;
- }
-
- editor.selection.clearKeyframeSelection();
- },
- [editor.selection, isElementSelected, selectElement, selectedElements],
- );
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- dragState,
- dragDropTarget,
- handleElementMouseDown,
- handleElementClick,
- lastMouseXRef,
+ dragView: controller.view,
+ handleElementMouseDown: controller.onElementMouseDown,
+ handleElementClick: controller.onElementClick,
};
}
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
index 92b6f0d8..5a1ff1f0 100644
--- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
+++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
@@ -1,40 +1,15 @@
-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 { TICKS_PER_SECOND } from "@/wasm";
-import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
-import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
-import { BatchCommand } from "@/commands";
-import type { SelectedKeyframeRef } from "@/animation/types";
-import type { TimelineElement } from "@/timeline";
-import type { Command } from "@/commands/base-command";
import { registerCanceller } from "@/editor/cancel-interaction";
-export interface KeyframeDragState {
- isDragging: boolean;
- draggingKeyframeIds: Set;
- deltaTime: number;
-}
+import {
+ KeyframeDragController,
+ type KeyframeDragConfig,
+ type KeyframeDragState,
+} from "@/timeline/controllers/keyframe-drag-controller";
+import type { TimelineElement } from "@/timeline";
-const initialDragState: KeyframeDragState = {
- isDragging: false,
- draggingKeyframeIds: new Set(),
- deltaTime: 0,
-};
-
-interface PendingKeyframeDrag {
- keyframeRefs: SelectedKeyframeRef[];
- startMouseX: number;
-}
+export type { KeyframeDragState };
export function useKeyframeDrag({
zoomLevel,
@@ -54,275 +29,44 @@ export function useKeyframeDrag({
selectKeyframeRange,
} = useKeyframeSelection();
- const [dragState, setDragState] =
- useState(initialDragState);
- const [isPendingDrag, setIsPendingDrag] = useState(false);
-
- const pendingDragRef = useRef(null);
- const mouseDownXRef = useRef(null);
-
- const activeProject = editor.project.getActive();
- const fps = activeProject.settings.fps;
-
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
-
- const endDrag = useCallback(() => {
- setDragState(initialDragState);
- }, []);
-
- const cancelDrag = useCallback(() => {
- pendingDragRef.current = null;
- mouseDownXRef.current = null;
- setIsPendingDrag(false);
- endDrag();
- }, [endDrag]);
-
- const commitDrag = useCallback(
- ({
- keyframeRefs,
- deltaTime,
- }: {
- keyframeRefs: SelectedKeyframeRef[];
- deltaTime: number;
- }) => {
- const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
- const keyframe = getKeyframeById({
- animations: element.animations,
- propertyPath: keyframeRef.propertyPath,
- keyframeId: keyframeRef.keyframeId,
- });
- if (!keyframe) return [];
- const nextTime = Math.max(
- 0,
- Math.min(element.duration, keyframe.time + 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: 0,
- });
- return;
- }
-
- if (!dragState.isDragging) return;
-
- const startX = mouseDownXRef.current ?? clientX;
- const rawDelta = Math.round(
- ((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND,
- );
- const snappedDelta =
- roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
-
- 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: (args) => editor.playback.seek(args),
+ getTotalDuration: () => editor.timeline.getTotalDuration(),
+ };
+
+ const configRef = useRef(config);
+ configRef.current = config;
+
+ const controllerRef = useRef(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new KeyframeDragController({ configRef });
+ }
+ const controller = controllerRef.current;
+
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
useEffect(() => {
- if (!isPendingDrag) return;
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isActive, controller]);
- const handleMouseUp = () => {
- pendingDragRef.current = null;
- setIsPendingDrag(false);
- };
-
- document.addEventListener("mouseup", handleMouseUp);
- return () => document.removeEventListener("mouseup", handleMouseUp);
- }, [isPendingDrag]);
-
- const handleKeyframeMouseDown = useCallback(
- ({
- event,
- keyframes,
- }: {
- event: ReactMouseEvent;
- keyframes: SelectedKeyframeRef[];
- }) => {
- event.preventDefault();
- event.stopPropagation();
-
- mouseDownXRef.current = event.clientX;
-
- const anySelected = keyframes.some((keyframe) =>
- isKeyframeSelected({ keyframe }),
- );
-
- const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
- if (!anySelected && !isModifierKey) {
- setKeyframeSelection({ keyframes });
- }
-
- const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
-
- pendingDragRef.current = {
- keyframeRefs: keyframeRefsToTrack,
- startMouseX: event.clientX,
- };
- setIsPendingDrag(true);
- },
- [isKeyframeSelected, selectedKeyframes, setKeyframeSelection],
- );
-
- const handleKeyframeClick = useCallback(
- ({
- event,
- keyframes,
- orderedKeyframes,
- indicatorTime,
- }: {
- event: ReactMouseEvent;
- keyframes: SelectedKeyframeRef[];
- orderedKeyframes: SelectedKeyframeRef[];
- indicatorTime: number;
- }) => {
- 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 seekTime =
- snappedSeekTime({
- time: displayedStartTime + indicatorTime,
- duration,
- rate: fps,
- }) ?? displayedStartTime + indicatorTime;
- editor.playback.seek({ time: seekTime });
-
- if (event.shiftKey) {
- selectKeyframeRange({
- orderedKeyframes,
- targetKeyframes: keyframes,
- isAdditive: event.metaKey || event.ctrlKey,
- });
- return;
- }
-
- toggleKeyframeSelection({
- keyframes,
- isMultiKey: event.metaKey || event.ctrlKey,
- });
- },
- [
- toggleKeyframeSelection,
- selectKeyframeRange,
- editor,
- displayedStartTime,
- fps,
- ],
- );
-
- const getVisualOffsetPx = useCallback(
- ({
- indicatorTime,
- indicatorOffsetPx,
- isBeingDragged,
- displayedStartTime,
- elementLeft,
- }: {
- indicatorTime: number;
- indicatorOffsetPx: number;
- isBeingDragged: boolean;
- displayedStartTime: number;
- elementLeft: number;
- }): number => {
- if (!isBeingDragged) return indicatorOffsetPx;
- const clampedTime = Math.max(
- 0,
- Math.min(element.duration, indicatorTime + dragState.deltaTime),
- );
- return (
- timelineTimeToSnappedPixels({
- time: displayedStartTime + clampedTime,
- zoomLevel,
- }) - elementLeft
- );
- },
- [dragState.deltaTime, element.duration, zoomLevel],
- );
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- keyframeDragState: dragState,
- handleKeyframeMouseDown,
- handleKeyframeClick,
- getVisualOffsetPx,
+ keyframeDragState: controller.keyframeDragState,
+ handleKeyframeMouseDown: controller.onKeyframeMouseDown,
+ handleKeyframeClick: controller.onKeyframeClick,
+ getVisualOffsetPx: controller.getVisualOffsetPx,
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
index 775b7337..4ec2b80b 100644
--- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
@@ -1,30 +1,9 @@
-import { useState, useCallback, type RefObject } from "react";
+import { useEffect, useReducer, useRef, type RefObject } from "react";
import { useEditor } from "@/editor/use-editor";
-import { processMediaAssets } from "@/media/processing";
-import { toast } from "sonner";
-import { showMediaUploadToast } from "@/media/upload-toast";
-import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
-import { TICKS_PER_SECOND } from "@/wasm";
-import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
-import { roundToFrame } from "opencut-wasm";
import {
- buildTextElement,
- buildGraphicElement,
- buildStickerElement,
- buildElementFromMedia,
- buildEffectElement,
-} from "@/timeline/element-utils";
-import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline";
-import { BatchCommand } from "@/commands";
-import { computeDropTarget } from "@/timeline/components/drop-target";
-import { getDragData, hasDragData } from "@/timeline/drag-data";
-import type { TrackType, DropTarget, ElementType } from "@/timeline";
-import type {
- MediaDragData,
- GraphicDragData,
- StickerDragData,
- EffectDragData,
-} from "@/timeline/drag";
+ DragDropController,
+ type DragDropConfig,
+} from "@/timeline/controllers/drag-drop-controller";
interface UseTimelineDragDropProps {
containerRef: RefObject;
@@ -40,609 +19,47 @@ export function useTimelineDragDrop({
zoomLevel,
}: UseTimelineDragDropProps) {
const editor = useEditor();
- const [isDragOver, setIsDragOver] = useState(false);
- const [dropTarget, setDropTarget] = useState(null);
- const [dragElementType, setElementType] = useState(null);
- const getSnappedTime = useCallback(
- ({ time }: { time: number }) => {
- const projectFps = editor.project.getActive().settings.fps;
- return roundToFrame({ time, rate: projectFps }) ?? time;
- },
- [editor],
- );
+ const config: DragDropConfig = {
+ zoomLevel,
+ getContainerEl: () => containerRef.current,
+ getHeaderEl: () => headerRef?.current ?? null,
+ getTracksScrollEl: () => tracksScrollRef?.current ?? null,
+ getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
+ getActiveProjectId: () =>
+ editor.project.getActiveOrNull()?.metadata.id ?? null,
+ getSceneTracks: () => editor.scenes.getActiveScene().tracks,
+ getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
+ getMediaAssets: () => editor.media.getAssets(),
+ dragSource: editor.timeline.dragSource,
+ addMediaAsset: (args) => editor.media.addMediaAsset(args),
+ executeCommand: (command) => editor.command.execute({ command }),
+ insertElement: (args) => editor.timeline.insertElement(args),
+ addClipEffect: (args) => editor.timeline.addClipEffect(args),
+ };
- const getElementType = useCallback(
- ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
- const dragData = getDragData({ dataTransfer });
- if (!dragData) return null;
+ const configRef = useRef(config);
+ configRef.current = config;
- if (dragData.type === "text") return "text";
- if (dragData.type === "graphic") return "graphic";
- if (dragData.type === "sticker") return "sticker";
- if (dragData.type === "effect") return "effect";
- if (dragData.type === "media") {
- return dragData.mediaType;
- }
- return null;
- },
- [],
- );
+ const controllerRef = useRef(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new DragDropController({ configRef });
+ }
+ const controller = controllerRef.current;
- const getElementDuration = useCallback(
- ({
- elementType,
- mediaId,
- }: {
- elementType: ElementType;
- mediaId?: string;
- }): number => {
- 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
- ? Math.round(media.duration * TICKS_PER_SECOND)
- : 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
- ? Math.round(mediaAsset.duration * TICKS_PER_SECOND)
- : 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
- ? Math.round(createdAsset.duration * TICKS_PER_SECOND)
- : DEFAULT_NEW_ELEMENT_DURATION;
- const sceneTracks = editor.scenes.getActiveScene().tracks;
- const currentTime = editor.playback.getCurrentTime();
- const reuseMainTrackId =
- createdAsset.type !== "audio" &&
- sceneTracks.overlay.length === 0 &&
- sceneTracks.audio.length === 0 &&
- sceneTracks.main.elements.length === 0
- ? sceneTracks.main.id
- : null;
- const dropTarget = reuseMainTrackId
- ? null
- : computeDropTarget({
- elementType: createdAsset.type,
- mouseX,
- mouseY,
- tracks: sceneTracks,
- playheadTime: currentTime,
- isExternalDrop: true,
- elementDuration: duration,
- pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
- zoomLevel,
- });
-
- const trackType: TrackType =
- createdAsset.type === "audio" ? "audio" : "video";
-
- const startTime = dropTarget?.xPosition ?? currentTime;
- const element = buildElementFromMedia({
- mediaId: createdAsset.id,
- mediaType: createdAsset.type,
- name: createdAsset.name,
- duration,
- startTime,
- });
-
- if (reuseMainTrackId) {
- editor.command.execute({
- command: new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId: reuseMainTrackId },
- }),
- });
- } else {
- if (!dropTarget) continue;
- if (dropTarget.isNewTrack) {
- const addTrackCmd = new AddTrackCommand(
- trackType,
- dropTarget.trackIndex,
- );
- editor.command.execute({
- command: new BatchCommand([
- addTrackCmd,
- new InsertElementCommand({
- element,
- placement: {
- mode: "explicit",
- trackId: addTrackCmd.getTrackId(),
- },
- }),
- ]),
- });
- } else {
- const trackId = [
- ...sceneTracks.overlay,
- sceneTracks.main,
- ...sceneTracks.audio,
- ][dropTarget.trackIndex]?.id;
- if (!trackId) continue;
- editor.command.execute({
- command: new InsertElementCommand({
- element,
- placement: { mode: "explicit", trackId },
- }),
- });
- }
- }
- }
-
- return {
- uploadedCount: processedAssets.length,
- assetNames: processedAssets.map((asset) => asset.name),
- };
- },
- });
- },
- [editor, zoomLevel],
- );
-
- const handleDrop = useCallback(
- async (e: React.DragEvent) => {
- e.preventDefault();
-
- const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
- const hasFiles = e.dataTransfer.files?.length > 0;
-
- if (!hasAsset && !hasFiles) return;
-
- const currentTarget = dropTarget;
- setIsDragOver(false);
- setDropTarget(null);
- setElementType(null);
-
- try {
- if (hasAsset) {
- if (!currentTarget) return;
- const dragData = getDragData({ dataTransfer: e.dataTransfer });
- if (!dragData) return;
-
- if (dragData.type === "text") {
- executeTextDrop({ target: currentTarget, dragData });
- } else if (dragData.type === "graphic") {
- executeGraphicDrop({
- target: currentTarget,
- dragData: dragData as GraphicDragData,
- });
- } else if (dragData.type === "sticker") {
- executeStickerDrop({ target: currentTarget, dragData });
- } else if (dragData.type === "effect") {
- executeEffectDrop({
- target: currentTarget,
- dragData: dragData as EffectDragData,
- });
- } else {
- executeMediaDrop({ target: currentTarget, dragData });
- }
- } else if (hasFiles) {
- const scrollContainer = tracksScrollRef?.current;
- const referenceRect =
- scrollContainer?.getBoundingClientRect() ??
- containerRef.current?.getBoundingClientRect();
- if (!referenceRect) return;
- const scrollLeft = scrollContainer?.scrollLeft ?? 0;
- const scrollTop = scrollContainer?.scrollTop ?? 0;
- const mouseX = e.clientX - referenceRect.left + scrollLeft;
- const headerHeight =
- headerRef?.current?.getBoundingClientRect().height ?? 0;
- const mouseY =
- e.clientY - referenceRect.top + scrollTop - headerHeight;
- await executeFileDrop({
- files: Array.from(e.dataTransfer.files),
- mouseX,
- mouseY,
- });
- }
- } catch (err) {
- console.error("Failed to process drop:", err);
- }
- },
- [
- dropTarget,
- executeTextDrop,
- executeGraphicDrop,
- executeStickerDrop,
- executeMediaDrop,
- executeEffectDrop,
- executeFileDrop,
- containerRef,
- headerRef,
- tracksScrollRef,
- ],
- );
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- isDragOver,
- dropTarget,
- dragElementType,
+ isDragOver: controller.isDragOver,
+ dropTarget: controller.dropTarget,
+ dragElementType: controller.dragElementType,
dragProps: {
- onDragEnter: handleDragEnter,
- onDragOver: handleDragOver,
- onDragLeave: handleDragLeave,
- onDrop: handleDrop,
+ onDragEnter: controller.onDragEnter,
+ onDragOver: controller.onDragOver,
+ onDragLeave: controller.onDragLeave,
+ onDrop: controller.onDrop,
},
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
index 7d9cf447..12bfe76a 100644
--- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
@@ -1,23 +1,12 @@
-import { snappedSeekTime } from "opencut-wasm";
-import { 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";
interface UseTimelinePlayheadProps {
zoomLevel: number;
@@ -35,267 +24,75 @@ 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: (viewState) =>
+ editor.project.setTimelineViewState({ viewState }),
+ };
+ 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: number }) => editor.playback.seek({ time }),
- [editor.playback],
- );
-
- const scrubTimeRef = useRef(null);
- const isDraggingRulerRef = useRef(false);
- const hasDraggedRulerRef = useRef(false);
- const lastMouseXRef = useRef(0);
-
- const handleScrub = useCallback(
- ({
- event,
- snappingEnabled = true,
- }: {
- event: MouseEvent | React.MouseEvent;
- snappingEnabled?: boolean;
- }) => {
- const ruler = rulerRef.current;
- if (!ruler) return;
- const rulerRect = ruler.getBoundingClientRect();
- const relativeMouseX = event.clientX - rulerRect.left;
-
- const timelineContentWidth = timelineTimeToPixels({
- time: duration,
- zoomLevel,
- });
-
- const clampedMouseX = Math.max(
- 0,
- Math.min(timelineContentWidth, relativeMouseX),
- );
-
- const rawTimeSeconds = Math.max(
- 0,
- Math.min(
- duration / TICKS_PER_SECOND,
- clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
- ),
- );
- const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
-
- const rate = activeProject.settings.fps;
- const frameTime =
- snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
-
- const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
- const time = (() => {
- 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 : 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: number) => {
- const playheadEl = playheadRef?.current;
- if (!playheadEl) return;
- const centerPosition = timelineTimeToSnappedPixels({
- time,
- zoomLevel: zoomLevelRef.current,
- });
- const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
- const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0;
- playheadEl.style.left = `${leftPosition - scrollLeft}px`;
- },
- [playheadRef, rulerScrollRef],
- );
+ const ctrlRef = useRef(null);
+ if (!ctrlRef.current) {
+ ctrlRef.current = new PlayheadController({ configRef });
+ }
+ const ctrl = ctrlRef.current;
+ // Scroll → keep playhead position in sync with scroll offset.
useEffect(() => {
const scrollEl = rulerScrollRef.current;
if (!scrollEl) return;
+ const handler = () =>
+ ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
+ scrollEl.addEventListener("scroll", handler, { passive: true });
+ return () => scrollEl.removeEventListener("scroll", handler);
+ }, [ctrl, editor.playback, rulerScrollRef]);
- const handleScroll = () => {
- updatePlayheadLeft(editor.playback.getCurrentTime());
- };
-
- scrollEl.addEventListener("scroll", handleScroll, { passive: true });
- return () => scrollEl.removeEventListener("scroll", handleScroll);
- }, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
-
+ // Playback events → update playhead position and auto-scroll during playback.
useEffect(() => {
- const handlePlaybackUpdate = (e: Event) => {
- const time = (e as CustomEvent<{ time: number }>).detail.time;
- 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 initialTime = editor.playback.getCurrentTime();
- handlePlaybackUpdate({
- detail: { time: initialTime },
- } as CustomEvent<{ time: number }>);
-
- window.addEventListener("playback-update", handlePlaybackUpdate);
- window.addEventListener("playback-seek", handlePlaybackUpdate);
+ const handler = (time: number) => ctrl.handlePlaybackUpdate(time);
+ ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
+ const unsubscribeUpdate = editor.playback.onUpdate(handler);
+ const unsubscribeSeek = editor.playback.onSeek(handler);
return () => {
- window.removeEventListener("playback-update", handlePlaybackUpdate);
- window.removeEventListener("playback-seek", handlePlaybackUpdate);
+ unsubscribeUpdate();
+ unsubscribeSeek();
};
- }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
+ }, [ctrl, editor.playback]);
+
+ useEdgeAutoScroll({
+ isActive: isScrubbing,
+ getMouseClientX: () => ctrl.getLastMouseClientX(),
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: timelineTimeToPixels({
+ time: editor.timeline.getTotalDuration(),
+ zoomLevel,
+ }),
+ });
+
+ useEffect(() => () => ctrl.destroy(), [ctrl]);
return {
- handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
- handleRulerMouseDown: handleRulerMouseDownEvent,
+ handlePlayheadMouseDown: ctrl.onPlayheadMouseDown,
+ handleRulerMouseDown: ctrl.onRulerMouseDown,
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts
index fb8da291..46363fb6 100644
--- a/apps/web/src/timeline/hooks/use-timeline-resize.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts
@@ -1,332 +1,81 @@
-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";
-interface ResizeInteractionState {
- side: ResizeSide;
- startX: number;
- members: GroupResizeMember[];
+export type { ResizeSide };
+
+interface UseTimelineResizeProps {
+ zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
export function useTimelineResize({
zoomLevel,
onSnapPointChange,
-}: {
- zoomLevel: number;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}) {
+}: UseTimelineResizeProps) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const { selectedElements } = useElementSelection();
- const [resizeState, setResizeState] = useState(
- null,
- );
- const latestResultRef = useRef(null);
- const cancelResize = useCallback(() => {
- editor.timeline.discardPreview();
- setResizeState(null);
- latestResultRef.current = null;
- onSnapPointChange?.(null);
- }, [editor.timeline, onSnapPointChange]);
-
- useEffect(() => {
- if (!resizeState) {
- return;
- }
-
- return registerCanceller({ fn: cancelResize });
- }, [resizeState, cancelResize]);
-
- const handleResizeStart = useCallback(
- ({
- event,
- element,
- track,
- side,
- }: {
- event: React.MouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- side: ResizeSide;
- }) => {
- event.stopPropagation();
- event.preventDefault();
-
- const elementRef = {
- trackId: track.id,
- elementId: element.id,
- };
- const activeSelection = selectedElements.some(
- (selectedElement) =>
- selectedElement.trackId === track.id &&
- selectedElement.elementId === element.id,
- )
- ? selectedElements
- : [elementRef];
- const members = buildResizeMembers({
- tracks: editor.scenes.getActiveScene().tracks,
- selectedElements: activeSelection,
- });
- if (members.length === 0) {
- return;
- }
-
- editor.timeline.discardPreview();
- latestResultRef.current = null;
- setResizeState({
- side,
- startX: event.clientX,
- members,
- });
- },
- [selectedElements, editor],
- );
-
- useEffect(() => {
- if (!resizeState) {
- return;
- }
-
- const handleMouseMove = ({ clientX }: MouseEvent) => {
- const deltaX = clientX - resizeState.startX;
- const rawDeltaTime = Math.round(
- (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) *
- TICKS_PER_SECOND,
- );
- const snappedDeltaTime = getSnappedResizeDelta({
- editor,
- resizeState,
- rawDeltaTime,
- zoomLevel,
- shouldSnap: snappingEnabled && !isShiftHeldRef.current,
- onSnapPointChange,
- });
- const fps = editor.project.getActive().settings.fps;
- const result = computeGroupResize({
- members: resizeState.members,
- side: resizeState.side,
- deltaTime: snappedDeltaTime.deltaTime,
- fps,
- });
-
- latestResultRef.current = result;
+ const config: ResizeConfig = {
+ zoomLevel,
+ snappingEnabled,
+ isShiftHeld: () => isShiftHeldRef.current,
+ getSceneTracks: () => editor.scenes.getActiveScene().tracks,
+ getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
+ getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
+ selectedElements,
+ discardPreview: () => editor.timeline.discardPreview(),
+ previewElements: (updates) =>
editor.timeline.previewElements({
- updates: result.updates.map(({ trackId, elementId, patch }) => ({
+ updates: updates.map(({ trackId, elementId, patch }) => ({
trackId,
elementId,
updates: patch,
})),
- });
- };
-
- 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,
- })),
- });
- }
-
- 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,
+ })),
+ }),
onSnapPointChange,
- ]);
+ };
+
+ const configRef = useRef(config);
+ configRef.current = config;
+
+ const controllerRef = useRef(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ResizeController({ configRef });
+ }
+ const controller = controllerRef.current;
+
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
+
+ useEffect(() => {
+ if (!controller.isResizing) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isResizing, controller]);
+
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- isResizing: resizeState !== null,
- handleResizeStart,
+ isResizing: controller.isResizing,
+ handleResizeStart: controller.onResizeStart,
};
}
-
-function buildResizeMembers({
- tracks,
- selectedElements,
-}: {
- tracks: SceneTracks;
- selectedElements: Array<{ trackId: string; elementId: string }>;
-}): GroupResizeMember[] {
- const selectedElementIds = new Set(
- selectedElements.map((selectedElement) => selectedElement.elementId),
- );
- const trackMap = new Map(
- [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
- track.id,
- track,
- ]),
- );
-
- return selectedElements.flatMap(({ trackId, elementId }) => {
- const track = trackMap.get(trackId);
- const element = track?.elements.find(
- (trackElement) => trackElement.id === elementId,
- );
- if (!track || !element) {
- return [];
- }
-
- const otherElements = track.elements.filter(
- (trackElement) => !selectedElementIds.has(trackElement.id),
- );
- const leftNeighborBound = otherElements
- .filter(
- (trackElement) =>
- trackElement.startTime + trackElement.duration <= element.startTime,
- )
- .reduce((bound, trackElement) => {
- return Math.max(bound, trackElement.startTime + trackElement.duration);
- }, -Infinity);
- const rightNeighborBound = otherElements
- .filter(
- (trackElement) =>
- trackElement.startTime >= element.startTime + element.duration,
- )
- .reduce((bound, trackElement) => {
- return Math.min(bound, trackElement.startTime);
- }, Infinity);
-
- return [
- {
- trackId,
- elementId,
- startTime: element.startTime,
- duration: element.duration,
- trimStart: element.trimStart,
- trimEnd: element.trimEnd,
- sourceDuration: element.sourceDuration,
- retime: isRetimableElement(element) ? element.retime : undefined,
- leftNeighborBound,
- rightNeighborBound,
- },
- ];
- });
-}
-
-function getSnappedResizeDelta({
- editor,
- resizeState,
- rawDeltaTime,
- zoomLevel,
- shouldSnap,
- onSnapPointChange,
-}: {
- editor: EditorCore;
- resizeState: ResizeInteractionState;
- rawDeltaTime: number;
- zoomLevel: number;
- shouldSnap: boolean;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}): { deltaTime: number } {
- if (!shouldSnap) {
- onSnapPointChange?.(null);
- return { deltaTime: rawDeltaTime };
- }
-
- const tracks = editor.scenes.getActiveScene().tracks;
- const playheadTime = editor.playback.getCurrentTime();
- const excludeElementIds = new Set(
- resizeState.members.map((member) => member.elementId),
- );
- const snapPoints = buildTimelineSnapPoints({
- sources: [
- () => getElementEdgeSnapPoints({ tracks, excludeElementIds }),
- () => getPlayheadSnapPoints({ playheadTime }),
- () =>
- getAnimationKeyframeSnapPointsForTimeline({
- tracks,
- excludeElementIds,
- }),
- ],
- });
- const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel });
- let closestSnapPoint: SnapPoint | null = null;
- let closestSnapDistance = Infinity;
- let deltaTime = rawDeltaTime;
-
- for (const member of resizeState.members) {
- const baseEdgeTime =
- resizeState.side === "left"
- ? member.startTime
- : member.startTime + member.duration;
- const snapResult = resolveTimelineSnap({
- targetTime: baseEdgeTime + rawDeltaTime,
- snapPoints,
- maxSnapDistance,
- });
- if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) {
- closestSnapDistance = snapResult.snapDistance;
- closestSnapPoint = snapResult.snapPoint;
- deltaTime = snapResult.snappedTime - baseEdgeTime;
- }
- }
-
- onSnapPointChange?.(closestSnapPoint);
- return { deltaTime };
-}
-
-function hasResizeChanges({
- members,
- result,
-}: {
- members: GroupResizeMember[];
- result: GroupResizeResult;
-}): boolean {
- return result.updates.some((update) => {
- const member = members.find(
- (candidateMember) => candidateMember.elementId === update.elementId,
- );
- return (
- member?.trimStart !== update.patch.trimStart ||
- member?.trimEnd !== update.patch.trimEnd ||
- member?.startTime !== update.patch.startTime ||
- member?.duration !== update.patch.duration
- );
- });
-}
diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts
index 889177c9..56f68ed6 100644
--- a/apps/web/src/timeline/hooks/use-timeline-seek.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts
@@ -1,9 +1,9 @@
-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 { 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";
interface UseTimelineSeekProps {
playheadRef: RefObject;
@@ -17,44 +17,6 @@ interface UseTimelineSeekProps {
seek: (time: number) => 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,131 +29,36 @@ export function useTimelineSeek({
seek,
}: UseTimelineSeekProps) {
const editor = useEditor();
- const activeProject = editor.project.getActive();
+ 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: (viewState) =>
+ editor.project.setTimelineViewState({ viewState }),
+ };
- const mouseTrackingRef = useRef({
- isMouseDown: false,
- downX: 0,
- downY: 0,
- downTime: 0,
- });
+ const configRef = useRef(config);
+ configRef.current = config;
- const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
- if (event.button !== 0) return;
- setMouseTracking({ mouseTrackingRef, event });
- }, []);
+ const controllerRef = useRef(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new SeekController({ configRef });
+ }
+ const controller = controllerRef.current;
- 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 = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
-
- const rate = activeProject?.settings.fps;
- const time = rate
- ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime)
- : rawTime;
- seek(time);
- editor.project.setTimelineViewState({
- viewState: {
- zoomLevel,
- scrollLeft: scrollContainer.scrollLeft,
- playheadTime: time,
- },
- });
- },
- [
- 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 });
-
- if (shouldProcess) {
- clearSelectedElements();
- handleTimelineSeek({ event, source: "tracks" });
- }
- },
- [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
- );
-
- const handleRulerClick = useCallback(
- (event: React.MouseEvent) => {
- const shouldProcess = shouldProcessTimelineClick({ event });
- resetMouseTracking({ mouseTrackingRef });
-
- if (shouldProcess) {
- clearSelectedElements();
- handleTimelineSeek({ event, source: "ruler" });
- }
- },
- [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
- );
+ useEffect(() => () => controller.destroy(), [controller]);
return {
- handleTracksMouseDown,
- handleTracksClick,
- handleRulerMouseDown,
- handleRulerClick,
+ handleTracksMouseDown: controller.onTracksMouseDown,
+ handleTracksClick: controller.onTracksClick,
+ handleRulerMouseDown: controller.onRulerMouseDown,
+ handleRulerClick: controller.onRulerClick,
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
index 7929d495..a8825a37 100644
--- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
@@ -1,17 +1,17 @@
import {
type WheelEvent as ReactWheelEvent,
type RefObject,
- useCallback,
useEffect,
useLayoutEffect,
+ useReducer,
useRef,
- useState,
} from "react";
-import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction";
-import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale";
-import { timelineTimeToPixels } from "@/timeline/pixel-utils";
import { useEditor } from "@/editor/use-editor";
-import { zoomToSlider } from "@/timeline/zoom-utils";
+import { TIMELINE_ZOOM_MIN } from "@/timeline/scale";
+import {
+ ZoomController,
+ type ZoomConfig,
+} from "@/timeline/controllers/zoom-controller";
interface UseTimelineZoomProps {
containerRef: RefObject;
@@ -40,252 +40,53 @@ export function useTimelineZoom({
rulerScrollRef,
}: UseTimelineZoomProps): UseTimelineZoomReturn {
const editor = useEditor();
- const hasInitializedRef = useRef(false);
- const hasRestoredPlayheadRef = useRef(false);
- const scrollSaveTimeoutRef = useRef | null>(
- null,
- );
+ const config: ZoomConfig = {
+ minZoom,
+ getContainerEl: () => containerRef.current,
+ getTracksScrollEl: () => tracksScrollRef.current,
+ getRulerScrollEl: () => rulerScrollRef.current,
+ getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
+ seek: (time) => editor.playback.seek({ time }),
+ setTimelineViewState: (viewState) =>
+ editor.project.setTimelineViewState({ viewState }),
+ };
+ const configRef = useRef(config);
+ configRef.current = config;
- const [zoomLevel, setZoomLevelRaw] = useState(() => {
- if (initialZoom !== undefined) {
- hasInitializedRef.current = true;
- return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom));
- }
- return minZoom;
- });
- const previousZoomRef = useRef(zoomLevel);
- const hasRestoredScrollRef = useRef(false);
- const preZoomScrollLeftRef = useRef(0);
- const prePlayheadAnchorScrollLeftRef = useRef(0);
- const isInPlayheadAnchorModeRef = useRef(false);
+ const controllerRef = useRef(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ZoomController({ configRef, initialZoom });
+ }
+ const controller = controllerRef.current;
+ const zoomLevel = controller.zoomLevel;
- const setZoomLevel = useCallback(
- (updater: number | ((prev: number) => number)) => {
- const scrollElement = tracksScrollRef.current;
- if (scrollElement) {
- preZoomScrollLeftRef.current = scrollElement.scrollLeft;
- }
- setZoomLevelRaw(updater);
- },
- [tracksScrollRef],
- );
-
- const handleWheel = useCallback(
- (event: ReactWheelEvent) => {
- const isZoomGesture = event.ctrlKey || event.metaKey;
- const isHorizontalScrollGesture =
- event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
-
- if (isHorizontalScrollGesture) {
- return;
- }
-
- // pinch-zoom (ctrl/meta + wheel)
- if (isZoomGesture) {
- const normalizedDelta =
- event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
- const cappedDelta =
- Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
- const zoomFactor = Math.exp(-cappedDelta / 300);
- setZoomLevel((prev) => {
- const nextZoom = Math.max(
- minZoom,
- Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
- );
- return nextZoom;
- });
- return;
- }
- },
- [minZoom, setZoomLevel],
- );
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
useEffect(() => {
- if (initialZoom !== undefined && !hasInitializedRef.current) {
- hasInitializedRef.current = true;
- setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)));
- return;
- }
- setZoomLevel((prev) => {
- if (prev < minZoom) {
- return minZoom;
- }
- return prev;
- });
- }, [minZoom, initialZoom, setZoomLevel]);
-
- const wrappedSetZoomLevel = useCallback(
- (zoomLevelOrUpdater: number | ((prev: number) => number)) => {
- setZoomLevel((prev) => {
- const nextZoom =
- typeof zoomLevelOrUpdater === "function"
- ? zoomLevelOrUpdater(prev)
- : zoomLevelOrUpdater;
- const clampedZoom = Math.max(
- minZoom,
- Math.min(TIMELINE_ZOOM_MAX, nextZoom),
- );
- return clampedZoom;
- });
- },
- [minZoom, setZoomLevel],
- );
+ controller.reconcileInitialAndMinZoom(minZoom, initialZoom);
+ }, [controller, minZoom, initialZoom]);
useLayoutEffect(() => {
- const previousZoom = previousZoomRef.current;
- if (previousZoom === zoomLevel) return;
-
- const scrollElement = tracksScrollRef.current;
- if (!scrollElement) {
- previousZoomRef.current = zoomLevel;
- return;
- }
-
- const currentScrollLeft = preZoomScrollLeftRef.current;
- const playheadTime = editor.playback.getCurrentTime();
- const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
- const previousSliderPercent = zoomToSlider({
- zoomLevel: previousZoom,
- minZoom,
- });
- const isCrossingThresholdUp =
- previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
- sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
- const isCrossingThresholdDown =
- previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
- sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
-
- const syncScroll = (scrollLeft: number) => {
- scrollElement.scrollLeft = scrollLeft;
- if (rulerScrollRef.current) {
- rulerScrollRef.current.scrollLeft = scrollLeft;
- }
- };
-
- const clampScrollLeft = (scrollLeft: number) => {
- const maxScrollLeft =
- scrollElement.scrollWidth - scrollElement.clientWidth;
- return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
- };
-
- if (isCrossingThresholdUp) {
- prePlayheadAnchorScrollLeftRef.current = currentScrollLeft;
- isInPlayheadAnchorModeRef.current = true;
- }
-
- if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
- const playheadPixelsBefore = timelineTimeToPixels({
- time: playheadTime,
- zoomLevel: previousZoom,
- });
- const playheadPixelsAfter = timelineTimeToPixels({
- time: playheadTime,
- zoomLevel,
- });
-
- const viewportOffset = playheadPixelsBefore - currentScrollLeft;
- const newScrollLeft = playheadPixelsAfter - viewportOffset;
-
- syncScroll(clampScrollLeft(newScrollLeft));
- } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) {
- syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current));
- isInPlayheadAnchorModeRef.current = false;
- }
-
- previousZoomRef.current = zoomLevel;
-
- editor.project.setTimelineViewState({
- viewState: {
- zoomLevel,
- scrollLeft: scrollElement.scrollLeft,
- playheadTime,
- },
- });
- }, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]);
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
- const saveScrollPosition = useCallback(() => {
- if (scrollSaveTimeoutRef.current) {
- clearTimeout(scrollSaveTimeoutRef.current);
- }
- scrollSaveTimeoutRef.current = setTimeout(() => {
- const scrollElement = tracksScrollRef.current;
- if (scrollElement) {
- editor.project.setTimelineViewState({
- viewState: {
- zoomLevel,
- scrollLeft: scrollElement.scrollLeft,
- playheadTime: editor.playback.getCurrentTime(),
- },
- });
- }
- }, 300);
- }, [zoomLevel, editor]);
-
- // biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable
- useEffect(() => {
- if (initialScrollLeft === undefined) return;
- if (hasRestoredScrollRef.current) return;
- const scrollElement = tracksScrollRef.current;
- if (!scrollElement) return;
-
- const restoreScroll = () => {
- scrollElement.scrollLeft = initialScrollLeft;
- if (rulerScrollRef.current) {
- rulerScrollRef.current.scrollLeft = initialScrollLeft;
- }
- hasRestoredScrollRef.current = true;
- };
-
- if (scrollElement.scrollWidth > 0) {
- restoreScroll();
- } else {
- const observer = new ResizeObserver(() => {
- if (scrollElement.scrollWidth > 0) {
- restoreScroll();
- observer.disconnect();
- }
- });
- observer.observe(scrollElement);
- return () => observer.disconnect();
- }
- }, [initialScrollLeft]);
+ controller.applyZoomLayout(zoomLevel);
+ }, [controller, zoomLevel]);
useEffect(() => {
- if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
- hasRestoredPlayheadRef.current = true;
- editor.playback.seek({ time: initialPlayheadTime });
- }
- }, [initialPlayheadTime, editor]);
+ return controller.restoreInitialScrollIfNeeded(initialScrollLeft);
+ }, [controller, initialScrollLeft]);
- // prevent browser zoom in the timeline
useEffect(() => {
- const preventZoom = (event: WheelEvent) => {
- const isZoomKeyPressed = event.ctrlKey || event.metaKey;
- const isInContainer = containerRef.current?.contains(
- event.target as Node,
- );
- // only check isInContainer, not isInTimeline state - the state check
- // causes race conditions where the closure captures stale state
- if (isZoomKeyPressed && isInContainer) {
- event.preventDefault();
- }
- };
+ controller.restoreInitialPlayheadIfNeeded(initialPlayheadTime);
+ }, [controller, initialPlayheadTime]);
- document.addEventListener("wheel", preventZoom, {
- passive: false,
- capture: true,
- });
+ useEffect(() => controller.bindPreventBrowserZoom(), [controller]);
- return () => {
- document.removeEventListener("wheel", preventZoom, { capture: true });
- };
- }, [containerRef]);
+ useEffect(() => () => controller.destroy(), [controller]);
return {
zoomLevel,
- setZoomLevel: wrappedSetZoomLevel,
- handleWheel,
- saveScrollPosition,
+ setZoomLevel: controller.setZoomLevel,
+ handleWheel: controller.handleWheel,
+ saveScrollPosition: controller.saveScrollPosition,
};
}
diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts
index cd543cdf..338da9d9 100644
--- a/apps/web/src/timeline/types.ts
+++ b/apps/web/src/timeline/types.ts
@@ -1,314 +1,317 @@
-import type { ElementAnimations } from "@/animation/types";
-import type { Effect } from "@/effects/types";
-import type { Mask } from "@/masks/types";
-import type { ParamValues } from "@/params";
-import type { BlendMode, Transform } from "@/rendering";
-
-export type ElementRef = {
- trackId: string;
- elementId: string;
-};
-
-export interface Bookmark {
- time: number;
- note?: string;
- color?: string;
- duration?: number;
-}
-
-export interface TScene {
- id: string;
- name: string;
- isMain: boolean;
- tracks: SceneTracks;
- bookmarks: Bookmark[];
- createdAt: Date;
- updatedAt: Date;
-}
-
-export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
-
-interface BaseTrack {
- id: string;
- name: string;
-}
-
-export interface VideoTrack extends BaseTrack {
- type: "video";
- elements: (VideoElement | ImageElement)[];
- muted: boolean;
- hidden: boolean;
-}
-
-export interface TextTrack extends BaseTrack {
- type: "text";
- elements: TextElement[];
- hidden: boolean;
-}
-
-export interface AudioTrack extends BaseTrack {
- type: "audio";
- elements: AudioElement[];
- muted: boolean;
-}
-
-export interface GraphicTrack extends BaseTrack {
- type: "graphic";
- elements: (StickerElement | GraphicElement)[];
- hidden: boolean;
-}
-
-export interface EffectTrack extends BaseTrack {
- type: "effect";
- elements: EffectElement[];
- hidden: boolean;
-}
-
-export type TimelineTrack =
- | VideoTrack
- | TextTrack
- | AudioTrack
- | GraphicTrack
- | EffectTrack;
-
-export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
-
-export interface SceneTracks {
- overlay: OverlayTrack[];
- main: VideoTrack;
- audio: AudioTrack[];
-}
-
-export interface RetimeConfig {
- rate: number;
- maintainPitch?: boolean;
-}
-
-interface BaseAudioElement extends BaseTimelineElement {
- type: "audio";
- volume: number;
- muted?: boolean;
- buffer?: AudioBuffer;
- retime?: RetimeConfig;
-}
-
-export interface UploadAudioElement extends BaseAudioElement {
- sourceType: "upload";
- mediaId: string;
-}
-
-export interface LibraryAudioElement extends BaseAudioElement {
- sourceType: "library";
- sourceUrl: string;
-}
-
-export type AudioElement = UploadAudioElement | LibraryAudioElement;
-
-interface BaseTimelineElement {
- id: string;
- name: string;
- duration: number;
- startTime: number;
- trimStart: number;
- trimEnd: number;
- sourceDuration?: number;
- animations?: ElementAnimations;
-}
-
-export interface VideoElement extends BaseTimelineElement {
- type: "video";
- mediaId: string;
- volume?: number;
- muted?: boolean;
- isSourceAudioEnabled?: boolean;
- hidden?: boolean;
- retime?: RetimeConfig;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface ImageElement extends BaseTimelineElement {
- type: "image";
- mediaId: string;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface TextBackground {
- enabled: boolean;
- color: string;
- cornerRadius?: number;
- paddingX?: number;
- paddingY?: number;
- offsetX?: number;
- offsetY?: number;
-}
-
-export interface TextElement extends BaseTimelineElement {
- type: "text";
- content: string;
- fontSize: number;
- fontFamily: string;
- color: string;
- background: TextBackground;
- textAlign: "left" | "center" | "right";
- fontWeight: "normal" | "bold";
- fontStyle: "normal" | "italic";
- textDecoration: "none" | "underline" | "line-through";
- letterSpacing?: number;
- lineHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface StickerElement extends BaseTimelineElement {
- type: "sticker";
- stickerId: string;
- /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
- intrinsicWidth?: number;
- intrinsicHeight?: number;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
-}
-
-export interface GraphicElement extends BaseTimelineElement {
- type: "graphic";
- definitionId: string;
- params: ParamValues;
- hidden?: boolean;
- transform: Transform;
- opacity: number;
- blendMode?: BlendMode;
- effects?: Effect[];
- masks?: Mask[];
-}
-
-export interface EffectElement extends BaseTimelineElement {
- type: "effect";
- effectType: string;
- params: ParamValues;
-}
-
-export type ElementUpdatePatch =
- | { transform: Transform }
- | { opacity: number }
- | { volume: number };
-
-export type TimelineElement =
- | AudioElement
- | VideoElement
- | ImageElement
- | TextElement
- | StickerElement
- | GraphicElement
- | EffectElement;
-
-export type ElementType = TimelineElement["type"];
-
-function elementTypes(...types: T): T {
- return types;
-}
-
-export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
-
-export type MaskableElement = Extract<
- TimelineElement,
- { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
->;
-
-export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
-
-export type RetimableElement = Extract<
- TimelineElement,
- { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
->;
-
-export const VISUAL_ELEMENT_TYPES = elementTypes(
- "video",
- "image",
- "text",
- "sticker",
- "graphic",
-);
-
-export type VisualElement = Extract<
- TimelineElement,
- { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
->;
-
-export type CreateUploadAudioElement = Omit;
-export type CreateLibraryAudioElement = Omit;
-export type CreateAudioElement =
- | CreateUploadAudioElement
- | CreateLibraryAudioElement;
-export type CreateVideoElement = Omit;
-export type CreateImageElement = Omit;
-export type CreateTextElement = Omit;
-export type CreateStickerElement = Omit;
-export type CreateGraphicElement = Omit;
-export type CreateEffectElement = Omit;
-export type CreateTimelineElement =
- | CreateAudioElement
- | CreateVideoElement
- | CreateImageElement
- | CreateTextElement
- | CreateStickerElement
- | CreateGraphicElement
- | CreateEffectElement;
-
-export interface ElementDragState {
- isDragging: boolean;
- elementId: string | null;
- dragElementIds: string[];
- dragTimeOffsets: Record;
- trackId: string | null;
- startMouseX: number;
- startMouseY: number;
- startElementTime: number;
- clickOffsetTime: number;
- currentTime: number;
- currentMouseY: number;
-}
-
-export interface DropTarget {
- trackIndex: number;
- isNewTrack: boolean;
- insertPosition: "above" | "below" | null;
- xPosition: number;
- targetElement: { elementId: string; trackId: string } | null;
-}
-
-export interface ComputeDropTargetParams {
- elementType: ElementType;
- mouseX: number;
- mouseY: number;
- tracks: SceneTracks;
- playheadTime: number;
- isExternalDrop: boolean;
- elementDuration: number;
- pixelsPerSecond: number;
- zoomLevel: number;
- verticalDragDirection?: "up" | "down" | null;
- startTimeOverride?: number;
- excludeElementId?: string;
- targetElementTypes?: string[];
-}
-
-export interface ClipboardItem {
- trackId: string;
- trackType: TrackType;
- element: CreateTimelineElement;
-}
+import type { ElementAnimations } from "@/animation/types";
+import type { Effect } from "@/effects/types";
+import type { Mask } from "@/masks/types";
+import type { ParamValues } from "@/params";
+import type { BlendMode, Transform } from "@/rendering";
+
+export type ElementRef = {
+ trackId: string;
+ elementId: string;
+};
+
+export interface Bookmark {
+ time: number;
+ note?: string;
+ color?: string;
+ duration?: number;
+}
+
+export interface TScene {
+ id: string;
+ name: string;
+ isMain: boolean;
+ tracks: SceneTracks;
+ bookmarks: Bookmark[];
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
+
+interface BaseTrack {
+ id: string;
+ name: string;
+}
+
+export interface VideoTrack extends BaseTrack {
+ type: "video";
+ elements: (VideoElement | ImageElement)[];
+ muted: boolean;
+ hidden: boolean;
+}
+
+export interface TextTrack extends BaseTrack {
+ type: "text";
+ elements: TextElement[];
+ hidden: boolean;
+}
+
+export interface AudioTrack extends BaseTrack {
+ type: "audio";
+ elements: AudioElement[];
+ muted: boolean;
+}
+
+export interface GraphicTrack extends BaseTrack {
+ type: "graphic";
+ elements: (StickerElement | GraphicElement)[];
+ hidden: boolean;
+}
+
+export interface EffectTrack extends BaseTrack {
+ type: "effect";
+ elements: EffectElement[];
+ hidden: boolean;
+}
+
+export type TimelineTrack =
+ | VideoTrack
+ | TextTrack
+ | AudioTrack
+ | GraphicTrack
+ | EffectTrack;
+
+export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
+
+export interface SceneTracks {
+ overlay: OverlayTrack[];
+ main: VideoTrack;
+ audio: AudioTrack[];
+}
+
+export interface RetimeConfig {
+ rate: number;
+ maintainPitch?: boolean;
+}
+
+interface BaseAudioElement extends BaseTimelineElement {
+ type: "audio";
+ volume: number;
+ muted?: boolean;
+ buffer?: AudioBuffer;
+ retime?: RetimeConfig;
+}
+
+export interface UploadAudioElement extends BaseAudioElement {
+ sourceType: "upload";
+ mediaId: string;
+}
+
+export interface LibraryAudioElement extends BaseAudioElement {
+ sourceType: "library";
+ sourceUrl: string;
+}
+
+export type AudioElement = UploadAudioElement | LibraryAudioElement;
+
+interface BaseTimelineElement {
+ id: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ trimStart: number;
+ trimEnd: number;
+ sourceDuration?: number;
+ animations?: ElementAnimations;
+}
+
+export interface VideoElement extends BaseTimelineElement {
+ type: "video";
+ mediaId: string;
+ volume?: number;
+ muted?: boolean;
+ isSourceAudioEnabled?: boolean;
+ hidden?: boolean;
+ retime?: RetimeConfig;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface ImageElement extends BaseTimelineElement {
+ type: "image";
+ mediaId: string;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface TextBackground {
+ enabled: boolean;
+ color: string;
+ cornerRadius?: number;
+ paddingX?: number;
+ paddingY?: number;
+ offsetX?: number;
+ offsetY?: number;
+}
+
+export interface TextElement extends BaseTimelineElement {
+ type: "text";
+ content: string;
+ fontSize: number;
+ fontFamily: string;
+ color: string;
+ background: TextBackground;
+ textAlign: "left" | "center" | "right";
+ fontWeight: "normal" | "bold";
+ fontStyle: "normal" | "italic";
+ textDecoration: "none" | "underline" | "line-through";
+ letterSpacing?: number;
+ lineHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface StickerElement extends BaseTimelineElement {
+ type: "sticker";
+ stickerId: string;
+ /** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
+ intrinsicWidth?: number;
+ intrinsicHeight?: number;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+}
+
+export interface GraphicElement extends BaseTimelineElement {
+ type: "graphic";
+ definitionId: string;
+ params: ParamValues;
+ hidden?: boolean;
+ transform: Transform;
+ opacity: number;
+ blendMode?: BlendMode;
+ effects?: Effect[];
+ masks?: Mask[];
+}
+
+export interface EffectElement extends BaseTimelineElement {
+ type: "effect";
+ effectType: string;
+ params: ParamValues;
+}
+
+export type ElementUpdatePatch =
+ | { transform: Transform }
+ | { opacity: number }
+ | { volume: number };
+
+export type TimelineElement =
+ | AudioElement
+ | VideoElement
+ | ImageElement
+ | TextElement
+ | StickerElement
+ | GraphicElement
+ | EffectElement;
+
+export type ElementType = TimelineElement["type"];
+
+function elementTypes(...types: T): T {
+ return types;
+}
+
+export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
+
+export type MaskableElement = Extract<
+ TimelineElement,
+ { type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
+
+export type RetimableElement = Extract<
+ TimelineElement,
+ { type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
+>;
+
+export const VISUAL_ELEMENT_TYPES = elementTypes(
+ "video",
+ "image",
+ "text",
+ "sticker",
+ "graphic",
+);
+
+export type VisualElement = Extract<
+ TimelineElement,
+ { type: (typeof VISUAL_ELEMENT_TYPES)[number] }
+>;
+
+export type CreateUploadAudioElement = Omit;
+export type CreateLibraryAudioElement = Omit;
+export type CreateAudioElement =
+ | CreateUploadAudioElement
+ | CreateLibraryAudioElement;
+export type CreateVideoElement = Omit;
+export type CreateImageElement = Omit;
+export type CreateTextElement = Omit;
+export type CreateStickerElement = Omit;
+export type CreateGraphicElement = Omit;
+export type CreateEffectElement = Omit;
+export type CreateTimelineElement =
+ | CreateAudioElement
+ | CreateVideoElement
+ | CreateImageElement
+ | CreateTextElement
+ | CreateStickerElement
+ | CreateGraphicElement
+ | CreateEffectElement;
+
+export type ElementDragView =
+ | { readonly kind: "idle" }
+ | {
+ readonly kind: "dragging";
+ readonly anchorElementId: string;
+ readonly trackId: string;
+ readonly memberTimeOffsets: ReadonlyMap;
+ readonly startMouseX: number;
+ readonly startMouseY: number;
+ readonly startElementTime: number;
+ readonly clickOffsetTime: number;
+ readonly currentTime: number;
+ readonly currentMouseX: number;
+ readonly currentMouseY: number;
+ readonly dropTarget: DropTarget | null;
+ };
+
+export interface DropTarget {
+ trackIndex: number;
+ isNewTrack: boolean;
+ insertPosition: "above" | "below" | null;
+ xPosition: number;
+ targetElement: { elementId: string; trackId: string } | null;
+}
+
+export interface ComputeDropTargetParams {
+ elementType: ElementType;
+ mouseX: number;
+ mouseY: number;
+ tracks: SceneTracks;
+ playheadTime: number;
+ isExternalDrop: boolean;
+ elementDuration: number;
+ pixelsPerSecond: number;
+ zoomLevel: number;
+ verticalDragDirection?: "up" | "down" | null;
+ startTimeOverride?: number;
+ excludeElementId?: string;
+ targetElementTypes?: string[];
+}
+
+export interface ClipboardItem {
+ trackId: string;
+ trackType: TrackType;
+ element: CreateTimelineElement;
+}
diff --git a/rust/crates/compositor/src/compositor.rs b/rust/crates/compositor/src/compositor.rs
index 84bcceae..99f0e7e9 100644
--- a/rust/crates/compositor/src/compositor.rs
+++ b/rust/crates/compositor/src/compositor.rs
@@ -348,7 +348,6 @@ impl Compositor {
) -> Result<(), CompositorError> {
let frame = options.frame;
self.texture_pool.recycle_frame();
- context.configure_surface(options.surface, frame.width, frame.height)?;
let surface_texture = context.acquire_surface_texture(options.surface)?;
let surface_view = surface_texture
.texture
diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs
index 0fcb2e8c..da835280 100644
--- a/rust/crates/gpu/src/context.rs
+++ b/rust/crates/gpu/src/context.rs
@@ -1,5 +1,8 @@
use wgpu::util::DeviceExt;
+#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+use std::cell::RefCell;
+
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
use wasm_bindgen::JsCast;
@@ -17,6 +20,12 @@ impl wgpu::rwh::HasDisplayHandle for WebDisplay {
}
}
+#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+struct CachedCanvasSurface {
+ surface: wgpu::Surface<'static>,
+ size: (u32, u32),
+}
+
const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl");
const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [
@@ -44,6 +53,8 @@ pub struct GpuContext {
/// fallback path. Used by render_texture_via_gl_canvas to output frames on WebGL.
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
gl_canvas: Option,
+ #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+ gl_surface: RefCell>,
}
impl GpuContext {
@@ -170,6 +181,8 @@ impl GpuContext {
supports_external_texture_copies,
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
gl_canvas,
+ #[cfg(all(feature = "wasm", target_arch = "wasm32"))]
+ gl_surface: RefCell::new(None),
})
}
@@ -184,17 +197,15 @@ impl GpuContext {
),
GpuError,
> {
- // Temporary fix: force the wasm renderer onto the WebGL backend even when
- // WebGPU is available while a WebGPU bug is being investigated.
- // let instance = wgpu::util::new_instance_with_webgpu_detection(
- // wgpu::InstanceDescriptor::new_without_display_handle(),
- // )
- // .await;
+ let instance = wgpu::util::new_instance_with_webgpu_detection(
+ wgpu::InstanceDescriptor::new_without_display_handle(),
+ )
+ .await;
- // match Self::try_request_device(&instance, None).await {
- // Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)),
- // Err(_) => {}
- // }
+ match Self::try_request_device(&instance, None).await {
+ Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)),
+ Err(_) => {}
+ }
let (gl_instance, adapter, device, queue, canvas) = Self::try_gl_fallback().await?;
Ok((gl_instance, adapter, device, queue, Some(canvas)))
}
@@ -369,6 +380,14 @@ impl GpuContext {
height: u32,
) -> Result<(), GpuError> {
self.configure_surface(surface, width, height)?;
+ self.present_texture_to_surface(texture, surface)
+ }
+
+ pub fn present_texture_to_surface(
+ &self,
+ texture: &wgpu::Texture,
+ surface: &wgpu::Surface<'_>,
+ ) -> Result<(), GpuError> {
let surface_texture = self.acquire_surface_texture(surface)?;
let target_view = surface_texture
.texture
@@ -390,16 +409,38 @@ impl GpuContext {
width: u32,
height: u32,
) -> Result<(), GpuError> {
- let Some(config) = surface.get_default_config(&self.adapter, width, height) else {
- return Err(GpuError::UnsupportedSurfaceFormat);
- };
- if config.format != self.texture_format {
- return Err(GpuError::UnsupportedSurfaceFormat);
- }
+ let config = self.build_surface_configuration(surface, width, height)?;
surface.configure(&self.device, &config);
Ok(())
}
+ fn build_surface_configuration(
+ &self,
+ surface: &wgpu::Surface<'_>,
+ width: u32,
+ height: u32,
+ ) -> Result {
+ let caps = surface.get_capabilities(&self.adapter);
+ if !caps.formats.contains(&self.texture_format) {
+ return Err(GpuError::UnsupportedSurfaceFormat);
+ }
+
+ Ok(wgpu::SurfaceConfiguration {
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+ format: self.texture_format,
+ width,
+ height,
+ present_mode: wgpu::PresentMode::Fifo,
+ alpha_mode: caps
+ .alpha_modes
+ .first()
+ .copied()
+ .unwrap_or(wgpu::CompositeAlphaMode::Auto),
+ view_formats: vec![],
+ desired_maximum_frame_latency: 2,
+ })
+ }
+
pub fn acquire_surface_texture(
&self,
surface: &wgpu::Surface<'_>,
@@ -593,57 +634,29 @@ impl GpuContext {
gl_canvas.set_width(width);
gl_canvas.set_height(height);
- let surface = self
- .instance
- .create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?;
-
- let caps = surface.get_capabilities(&self.adapter);
- let surface_format = if caps.formats.contains(&self.texture_format) {
- self.texture_format
- } else if !caps.formats.is_empty() {
- caps.formats[0]
- } else {
- return Err(GpuError::UnsupportedSurfaceFormat);
+ let mut cached_surface = self.gl_surface.borrow_mut();
+ let cached_surface = match cached_surface.as_mut() {
+ Some(cached_surface) => cached_surface,
+ None => {
+ let surface = self
+ .instance
+ .create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?;
+ cached_surface.replace(CachedCanvasSurface {
+ surface,
+ size: (0, 0),
+ });
+ cached_surface
+ .as_mut()
+ .expect("gl_surface cache should exist after initialization")
+ }
};
- if surface_format != self.texture_format {
- return Err(GpuError::UnsupportedSurfaceFormat);
+ if cached_surface.size != (width, height) {
+ self.configure_surface(&cached_surface.surface, width, height)?;
+ cached_surface.size = (width, height);
}
- let config = wgpu::SurfaceConfiguration {
- usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
- format: surface_format,
- width,
- height,
- present_mode: wgpu::PresentMode::Fifo,
- alpha_mode: caps
- .alpha_modes
- .first()
- .copied()
- .unwrap_or(wgpu::CompositeAlphaMode::Auto),
- view_formats: vec![],
- desired_maximum_frame_latency: 2,
- };
- surface.configure(&self.device, &config);
-
- let surface_texture = self.acquire_surface_texture(&surface)?;
- let surface_view = surface_texture
- .texture
- .create_view(&wgpu::TextureViewDescriptor::default());
-
- let mut encoder = self
- .device
- .create_command_encoder(&wgpu::CommandEncoderDescriptor {
- label: Some("gpu-gl-canvas-blit-encoder"),
- });
- self.encode_texture_blit_to_view(
- &mut encoder,
- texture,
- &surface_view,
- "gpu-gl-canvas-blit",
- );
- self.queue.submit([encoder.finish()]);
- surface_texture.present();
+ self.present_texture_to_surface(texture, &cached_surface.surface)?;
Ok(gl_canvas)
}
diff --git a/rust/wasm/src/compositor.rs b/rust/wasm/src/compositor.rs
index 84467842..7652b3f3 100644
--- a/rust/wasm/src/compositor.rs
+++ b/rust/wasm/src/compositor.rs
@@ -16,6 +16,8 @@ use crate::perf;
struct CompositorRuntime {
canvas: web_sys::HtmlCanvasElement,
compositor: Compositor,
+ surface: wgpu::Surface<'static>,
+ surface_size: (u32, u32),
}
thread_local! {
@@ -44,9 +46,23 @@ pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> {
canvas.set_height(height);
let compositor = Compositor::new(&gpu_runtime.context);
+ let surface = gpu_runtime
+ .context
+ .instance()
+ .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ gpu_runtime
+ .context
+ .configure_surface(&surface, width, height)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
COMPOSITOR_RUNTIME.with(|runtime| {
- runtime.replace(Some(CompositorRuntime { canvas, compositor }));
+ runtime.replace(Some(CompositorRuntime {
+ canvas,
+ compositor,
+ surface,
+ surface_size: (width, height),
+ }));
});
Ok(())
@@ -55,16 +71,25 @@ pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> {
#[wasm_bindgen(js_name = resizeCompositor)]
pub fn resize_compositor(width: u32, height: u32) -> Result<(), JsValue> {
- COMPOSITOR_RUNTIME.with(|runtime| {
- let mut borrow = runtime.borrow_mut();
- let Some(runtime) = borrow.as_mut() else {
- return Err(JsValue::from_str(
- "Compositor is not initialized. Call initCompositor() first.",
- ));
- };
- runtime.canvas.set_width(width);
- runtime.canvas.set_height(height);
- Ok(())
+ with_gpu_runtime(|gpu_runtime| {
+ COMPOSITOR_RUNTIME.with(|runtime| {
+ let mut borrow = runtime.borrow_mut();
+ let Some(runtime) = borrow.as_mut() else {
+ return Err(JsValue::from_str(
+ "Compositor is not initialized. Call initCompositor() first.",
+ ));
+ };
+ runtime.canvas.set_width(width);
+ runtime.canvas.set_height(height);
+ if runtime.surface_size != (width, height) {
+ gpu_runtime
+ .context
+ .configure_surface(&runtime.surface, width, height)
+ .map_err(|error| JsValue::from_str(&error.to_string()))?;
+ runtime.surface_size = (width, height);
+ }
+ Ok(())
+ })
})
}
@@ -144,15 +169,19 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
));
};
- if gpu_runtime.context.supports_surface_rendering() {
+ if runtime.surface_size != (frame.width, frame.height) {
+ runtime.canvas.set_width(frame.width);
+ runtime.canvas.set_height(frame.height);
let t_surface = perf::now_ms();
- let surface = gpu_runtime
+ gpu_runtime
.context
- .instance()
- .create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone()))
+ .configure_surface(&runtime.surface, frame.width, frame.height)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
- perf::record("wasm.surfaceCreate", perf::now_ms() - t_surface);
+ perf::record("wasm.surfaceConfigure", perf::now_ms() - t_surface);
+ runtime.surface_size = (frame.width, frame.height);
+ }
+ if gpu_runtime.context.supports_surface_rendering() {
let t_render = perf::now_ms();
let result = runtime
.compositor
@@ -160,17 +189,15 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
&gpu_runtime.context,
RenderFrameOptions {
frame: &frame,
- surface: &surface,
+ surface: &runtime.surface,
},
)
.map_err(|error| JsValue::from_str(&error.to_string()));
perf::record("wasm.renderFrameToSurface", perf::now_ms() - t_render);
result
} else {
- // WebGL surface-renders to the canvas its GL context was created
- // on; `init_compositor` already pointed `runtime.canvas` at that
- // same canvas, so presenting writes directly to the canvas the
- // UI mounts. No intermediate copy.
+ // WebGL still needs a separate composition pass, but the output
+ // surface is now persistent just like the WebGPU path.
let t_composite = perf::now_ms();
let texture = runtime
.compositor
@@ -181,9 +208,9 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
let t_present = perf::now_ms();
gpu_runtime
.context
- .render_texture_to_gl_canvas_surface(&texture, frame.width, frame.height)
+ .present_texture_to_surface(&texture, &runtime.surface)
.map_err(|error| JsValue::from_str(&error.to_string()))?;
- perf::record("wasm.presentToGlCanvas", perf::now_ms() - t_present);
+ perf::record("wasm.presentToSurface", perf::now_ms() - t_present);
Ok(())
}