summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/controllers
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/timeline/controllers')
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts577
-rw-r--r--apps/web/src/timeline/controllers/element-interaction-controller.ts702
-rw-r--r--apps/web/src/timeline/controllers/keyframe-drag-controller.ts358
-rw-r--r--apps/web/src/timeline/controllers/playhead-controller.ts318
-rw-r--r--apps/web/src/timeline/controllers/resize-controller.ts360
-rw-r--r--apps/web/src/timeline/controllers/seek-controller.ts210
-rw-r--r--apps/web/src/timeline/controllers/zoom-controller.ts286
7 files changed, 2811 insertions, 0 deletions
diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts
new file mode 100644
index 00000000..7fca2676
--- /dev/null
+++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts
@@ -0,0 +1,577 @@
+import type { DragEvent } from "react";
+import { processMediaAssets } from "@/media/processing";
+import { showMediaUploadToast } from "@/media/upload-toast";
+import {
+ DEFAULT_NEW_ELEMENT_DURATION,
+ toElementDurationTicks,
+} from "@/timeline/creation";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import type { FrameRate } from "opencut-wasm";
+import {
+ buildTextElement,
+ buildGraphicElement,
+ buildStickerElement,
+ buildElementFromMedia,
+ buildEffectElement,
+} from "@/timeline/element-utils";
+import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline";
+import { BatchCommand } from "@/commands";
+import type { Command } from "@/commands/base-command";
+import { computeDropTarget } from "@/timeline/components/drop-target";
+import type { TimelineDragSource } from "@/timeline/drag-source";
+import type {
+ TrackType,
+ DropTarget,
+ ElementType,
+ SceneTracks,
+ TimelineTrack,
+ CreateTimelineElement,
+} from "@/timeline";
+import type { TimelineDragData } from "@/timeline/drag";
+import type { MediaAsset } from "@/media/types";
+import type { ProcessedMediaAsset } from "@/media/processing";
+import { roundFrameTime, type MediaTime } from "@/wasm";
+
+// --- Config ---
+
+export interface DragDropConfig {
+ zoomLevel: number;
+ getContainerEl: () => HTMLDivElement | null;
+ getHeaderEl: () => HTMLElement | null;
+ getTracksScrollEl: () => HTMLDivElement | null;
+ getActiveProjectFps: () => FrameRate | null;
+ getActiveProjectId: () => string | null;
+ getSceneTracks: () => SceneTracks;
+ getCurrentPlayheadTime: () => MediaTime;
+ getMediaAssets: () => MediaAsset[];
+ dragSource: TimelineDragSource;
+ addMediaAsset: (args: {
+ projectId: string;
+ asset: ProcessedMediaAsset;
+ }) => Promise<MediaAsset | null>;
+ executeCommand: (command: Command) => void;
+ insertElement: (args: {
+ placement: { mode: "explicit"; trackId: string };
+ element: CreateTimelineElement;
+ }) => void;
+ addClipEffect: (args: {
+ trackId: string;
+ elementId: string;
+ effectType: string;
+ }) => void;
+}
+
+export interface DragDropConfigRef {
+ readonly current: DragDropConfig;
+}
+
+// --- State ---
+
+interface DragOverState {
+ kind: "over";
+ dropTarget: DropTarget | null;
+ elementType: ElementType | null;
+}
+
+type DragDropState = { kind: "idle" } | DragOverState;
+
+interface TimelineCoords {
+ mouseX: number;
+ mouseY: number;
+}
+
+// --- Pure helpers ---
+
+function elementTypeFromDrag({
+ dragData,
+}: {
+ dragData: TimelineDragData;
+}): ElementType {
+ switch (dragData.type) {
+ case "text":
+ return "text";
+ case "graphic":
+ return "graphic";
+ case "sticker":
+ return "sticker";
+ case "effect":
+ return "effect";
+ case "media":
+ return dragData.mediaType;
+ }
+}
+
+function getTargetElementTypesForDrag({
+ dragData,
+}: {
+ dragData: TimelineDragData;
+}): string[] | undefined {
+ if (dragData.type === "effect") return dragData.targetElementTypes;
+ if (dragData.type === "media") return dragData.targetElementTypes;
+ return undefined;
+}
+
+function getDurationForDrag({
+ dragData,
+ mediaAssets,
+}: {
+ dragData: TimelineDragData;
+ mediaAssets: MediaAsset[];
+}): MediaTime {
+ if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION;
+ const media = mediaAssets.find((asset) => asset.id === dragData.id);
+ return toElementDurationTicks({ seconds: media?.duration });
+}
+
+function orderedTracks({
+ sceneTracks,
+}: {
+ sceneTracks: SceneTracks;
+}): TimelineTrack[] {
+ return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio];
+}
+
+// --- Controller ---
+
+export class DragDropController {
+ private state: DragDropState = { kind: "idle" };
+ private enterCount = 0;
+ private readonly subscribers = new Set<() => void>();
+ private readonly configRef: DragDropConfigRef;
+
+ constructor(deps: { configRef: DragDropConfigRef }) {
+ this.configRef = deps.configRef;
+ this.onDragEnter = this.onDragEnter.bind(this);
+ this.onDragOver = this.onDragOver.bind(this);
+ this.onDragLeave = this.onDragLeave.bind(this);
+ this.onDrop = this.onDrop.bind(this);
+ }
+
+ private get config(): DragDropConfig {
+ return this.configRef.current;
+ }
+
+ get isDragOver(): boolean {
+ return this.state.kind !== "idle";
+ }
+
+ get dropTarget(): DropTarget | null {
+ return this.state.kind === "over" ? this.state.dropTarget : null;
+ }
+
+ get dragElementType(): ElementType | null {
+ return this.state.kind === "over" ? this.state.elementType : null;
+ }
+
+ subscribe(fn: () => void): () => void {
+ this.subscribers.add(fn);
+ return () => this.subscribers.delete(fn);
+ }
+
+ destroy(): void {
+ this.subscribers.clear();
+ }
+
+ // --- Drag event handlers (bound, stable, passed as React props) ---
+
+ onDragEnter(event: DragEvent): void {
+ event.preventDefault();
+ const hasAsset = this.config.dragSource.isActive();
+ const hasFiles = event.dataTransfer.types.includes("Files");
+ if (!hasAsset && !hasFiles) return;
+
+ this.enterCount += 1;
+ if (this.state.kind === "idle") {
+ this.setOver({ dropTarget: null, elementType: null });
+ }
+ }
+
+ onDragOver(event: DragEvent): void {
+ event.preventDefault();
+
+ const coords = this.getMouseTimelineCoords({ event });
+ if (!coords) return;
+
+ const dragData = this.config.dragSource.getActive();
+ const hasFiles = event.dataTransfer.types.includes("Files");
+ const isExternal = hasFiles && !dragData;
+
+ if (!dragData) {
+ if (hasFiles && isExternal) {
+ this.setOver({ dropTarget: null, elementType: null });
+ }
+ return;
+ }
+
+ const elementType = elementTypeFromDrag({ dragData });
+ const duration = getDurationForDrag({
+ dragData,
+ mediaAssets: this.config.getMediaAssets(),
+ });
+ const targetElementTypes = getTargetElementTypesForDrag({ dragData });
+
+ const sceneTracks = this.config.getSceneTracks();
+ const target = computeDropTarget({
+ elementType,
+ mouseX: coords.mouseX,
+ mouseY: coords.mouseY,
+ tracks: sceneTracks,
+ playheadTime: this.config.getCurrentPlayheadTime(),
+ isExternalDrop: isExternal,
+ elementDuration: duration,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
+ zoomLevel: this.config.zoomLevel,
+ targetElementTypes,
+ });
+
+ const fps = this.config.getActiveProjectFps();
+ target.xPosition = fps
+ ? roundFrameTime({ time: target.xPosition, fps })
+ : target.xPosition;
+
+ this.setOver({ dropTarget: target, elementType });
+ event.dataTransfer.dropEffect = "copy";
+ }
+
+ onDragLeave(event: DragEvent): void {
+ event.preventDefault();
+ if (this.enterCount === 0) return;
+ this.enterCount -= 1;
+ if (this.enterCount === 0) {
+ this.setIdle();
+ }
+ }
+
+ onDrop(event: DragEvent): void {
+ event.preventDefault();
+ this.enterCount = 0;
+
+ const dragData = this.config.dragSource.getActive();
+ const hasFiles = event.dataTransfer.files?.length > 0;
+ if (!dragData && !hasFiles) return;
+
+ const currentTarget = this.dropTarget;
+ this.setIdle();
+
+ try {
+ if (dragData) {
+ if (!currentTarget) return;
+ this.executeAssetDrop({ target: currentTarget, dragData });
+ return;
+ }
+
+ const coords = this.getMouseTimelineCoords({ event });
+ if (!coords) return;
+ this.executeFileDrop({
+ files: Array.from(event.dataTransfer.files),
+ mouseX: coords.mouseX,
+ mouseY: coords.mouseY,
+ }).catch((error) => {
+ console.error("Failed to process file drop:", error);
+ });
+ } catch (error) {
+ console.error("Failed to process drop:", error);
+ }
+ }
+
+ // --- Private ---
+
+ private setOver(state: {
+ dropTarget: DropTarget | null;
+ elementType: ElementType | null;
+ }): void {
+ this.state = { kind: "over", ...state };
+ this.notify();
+ }
+
+ private setIdle(): void {
+ this.state = { kind: "idle" };
+ this.notify();
+ }
+
+ private notify(): void {
+ for (const fn of this.subscribers) fn();
+ }
+
+ private getMouseTimelineCoords({
+ event,
+ }: {
+ event: DragEvent;
+ }): TimelineCoords | null {
+ const scrollContainer = this.config.getTracksScrollEl();
+ const referenceRect =
+ scrollContainer?.getBoundingClientRect() ??
+ this.config.getContainerEl()?.getBoundingClientRect();
+ if (!referenceRect) return null;
+
+ const scrollLeft = scrollContainer?.scrollLeft ?? 0;
+ const scrollTop = scrollContainer?.scrollTop ?? 0;
+ const headerHeight =
+ this.config.getHeaderEl()?.getBoundingClientRect().height ?? 0;
+
+ return {
+ mouseX: event.clientX - referenceRect.left + scrollLeft,
+ mouseY: event.clientY - referenceRect.top + scrollTop - headerHeight,
+ };
+ }
+
+ // Shared insertion logic — new track vs existing track.
+ private insertAtTarget({
+ element,
+ target,
+ trackType,
+ }: {
+ element: CreateTimelineElement;
+ target: DropTarget;
+ trackType: TrackType;
+ }): void {
+ if (target.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
+ this.config.executeCommand(
+ new BatchCommand([
+ addTrackCmd,
+ new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
+ }),
+ ]),
+ );
+ return;
+ }
+
+ const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() });
+ const track = tracks[target.trackIndex];
+ if (!track) return;
+ this.config.insertElement({
+ placement: { mode: "explicit", trackId: track.id },
+ element,
+ });
+ }
+
+ private executeAssetDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: TimelineDragData;
+ }): void {
+ switch (dragData.type) {
+ case "text":
+ this.executeTextDrop({ target, dragData });
+ return;
+ case "graphic":
+ this.executeGraphicDrop({ target, dragData });
+ return;
+ case "sticker":
+ this.executeStickerDrop({ target, dragData });
+ return;
+ case "effect":
+ this.executeEffectDrop({ target, dragData });
+ return;
+ case "media":
+ this.executeMediaDrop({ target, dragData });
+ return;
+ }
+ }
+
+ private executeTextDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract<TimelineDragData, { type: "text" }>;
+ }): void {
+ const element = buildTextElement({
+ raw: { name: dragData.name ?? "", content: dragData.content ?? "" },
+ startTime: target.xPosition,
+ });
+ this.insertAtTarget({ element, target, trackType: "text" });
+ }
+
+ private executeStickerDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract<TimelineDragData, { type: "sticker" }>;
+ }): void {
+ const element = buildStickerElement({
+ stickerId: dragData.stickerId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ });
+ this.insertAtTarget({ element, target, trackType: "graphic" });
+ }
+
+ private executeGraphicDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract<TimelineDragData, { type: "graphic" }>;
+ }): void {
+ const element = buildGraphicElement({
+ definitionId: dragData.definitionId,
+ name: dragData.name,
+ startTime: target.xPosition,
+ params: dragData.params,
+ });
+ this.insertAtTarget({ element, target, trackType: "graphic" });
+ }
+
+ private executeMediaDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract<TimelineDragData, { type: "media" }>;
+ }): void {
+ if (target.targetElement) {
+ // Replace media source — not yet implemented
+ return;
+ }
+
+ const mediaAsset = this.config
+ .getMediaAssets()
+ .find((asset) => asset.id === dragData.id);
+ if (!mediaAsset) return;
+
+ const trackType: TrackType =
+ dragData.mediaType === "audio" ? "audio" : "video";
+ const element = buildElementFromMedia({
+ mediaId: mediaAsset.id,
+ mediaType: mediaAsset.type,
+ name: mediaAsset.name,
+ duration: toElementDurationTicks({ seconds: mediaAsset.duration }),
+ startTime: target.xPosition,
+ });
+ this.insertAtTarget({ element, target, trackType });
+ }
+
+ private executeEffectDrop({
+ target,
+ dragData,
+ }: {
+ target: DropTarget;
+ dragData: Extract<TimelineDragData, { type: "effect" }>;
+ }): void {
+ if (target.targetElement) {
+ this.config.addClipEffect({
+ trackId: target.targetElement.trackId,
+ elementId: target.targetElement.elementId,
+ effectType: dragData.effectType,
+ });
+ return;
+ }
+
+ const element = buildEffectElement({
+ effectType: dragData.effectType,
+ startTime: target.xPosition,
+ });
+
+ const existingEffectTrack = orderedTracks({
+ sceneTracks: this.config.getSceneTracks(),
+ }).find((track) => track.type === "effect");
+
+ if (existingEffectTrack) {
+ this.config.insertElement({
+ placement: { mode: "explicit", trackId: existingEffectTrack.id },
+ element,
+ });
+ return;
+ }
+
+ this.insertAtTarget({ element, target, trackType: "effect" });
+ }
+
+ private async executeFileDrop({
+ files,
+ mouseX,
+ mouseY,
+ }: {
+ files: File[];
+ mouseX: number;
+ mouseY: number;
+ }): Promise<void> {
+ const projectId = this.config.getActiveProjectId();
+ if (!projectId) return;
+
+ await showMediaUploadToast({
+ filesCount: files.length,
+ promise: async () => {
+ const processedAssets = await processMediaAssets({ files });
+
+ // Sequential on purpose: each iteration reads getSceneTracks()
+ // to decide placement (reuse empty main vs new track) and that
+ // decision depends on the effects of prior inserts.
+ for (const asset of processedAssets) {
+ const createdAsset = await this.config.addMediaAsset({
+ projectId,
+ asset,
+ });
+ if (!createdAsset) continue;
+
+ const duration = toElementDurationTicks({
+ seconds: createdAsset.duration,
+ });
+
+ const sceneTracks = this.config.getSceneTracks();
+ const currentTime = this.config.getCurrentPlayheadTime();
+
+ const reuseMainTrackId =
+ createdAsset.type !== "audio" &&
+ sceneTracks.overlay.length === 0 &&
+ sceneTracks.audio.length === 0 &&
+ sceneTracks.main.elements.length === 0
+ ? sceneTracks.main.id
+ : null;
+
+ if (reuseMainTrackId) {
+ this.config.insertElement({
+ placement: { mode: "explicit", trackId: reuseMainTrackId },
+ element: buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: currentTime,
+ }),
+ });
+ continue;
+ }
+
+ const dropTarget = computeDropTarget({
+ elementType: createdAsset.type,
+ mouseX,
+ mouseY,
+ tracks: sceneTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
+ zoomLevel: this.config.zoomLevel,
+ });
+
+ const trackType: TrackType =
+ createdAsset.type === "audio" ? "audio" : "video";
+ this.insertAtTarget({
+ element: buildElementFromMedia({
+ mediaId: createdAsset.id,
+ mediaType: createdAsset.type,
+ name: createdAsset.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ }),
+ target: dropTarget,
+ trackType,
+ });
+ }
+
+ return {
+ uploadedCount: processedAssets.length,
+ assetNames: processedAssets.map((asset) => asset.name),
+ };
+ },
+ });
+ }
+}
diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts
new file mode 100644
index 00000000..d0d6426a
--- /dev/null
+++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts
@@ -0,0 +1,702 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import {
+ buildMoveGroup,
+ resolveGroupMove,
+ snapGroupEdges,
+ type GroupMoveResult,
+ type MoveGroup,
+} from "@/timeline/group-move";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import {
+ maxMediaTime,
+ type MediaTime,
+ mediaTime,
+ roundFrameTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
+import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
+import type { FrameRate } from "opencut-wasm";
+import { computeDropTarget } from "@/timeline/components/drop-target";
+import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
+import { generateUUID } from "@/utils/id";
+import type { SnapPoint } from "@/timeline/snapping";
+import type {
+ DropTarget,
+ ElementRef,
+ ElementDragView,
+ SceneTracks,
+ TimelineElement,
+ TimelineTrack,
+} from "@/timeline";
+
+const MOUSE_BUTTON_RIGHT = 2;
+
+// --- Config ---
+
+export interface ViewportAdapter {
+ getZoomLevel: () => number;
+ getTracksScrollEl: () => HTMLDivElement | null;
+ getTracksContainerEl: () => HTMLDivElement | null;
+ getHeaderEl: () => HTMLElement | null;
+}
+
+export interface InputAdapter {
+ isShiftHeld: () => boolean;
+}
+
+export interface SceneReader {
+ getTracks: () => SceneTracks;
+ getActiveFps: () => FrameRate | null;
+}
+
+export interface ElementSelectionApi {
+ getSelected: () => readonly ElementRef[];
+ isSelected: (ref: ElementRef) => boolean;
+ select: (ref: ElementRef) => void;
+ handleClick: (args: ElementRef & { isMultiKey: boolean }) => void;
+ clearKeyframeSelection: () => void;
+}
+
+export interface PlaybackReader {
+ getCurrentTime: () => MediaTime;
+}
+
+export interface TimelineOps {
+ moveElements: (args: Pick<GroupMoveResult, "moves" | "createTracks">) => void;
+}
+
+export interface SnapConfig {
+ isEnabled: () => boolean;
+ onChange?: (snapPoint: SnapPoint | null) => void;
+}
+
+export interface ElementInteractionDeps {
+ viewport: ViewportAdapter;
+ input: InputAdapter;
+ scene: SceneReader;
+ selection: ElementSelectionApi;
+ playback: PlaybackReader;
+ timeline: TimelineOps;
+ snap: SnapConfig;
+}
+
+export interface ElementInteractionDepsRef {
+ readonly current: ElementInteractionDeps;
+}
+
+// --- Session ---
+
+type Point = { readonly x: number; readonly y: number };
+
+interface MousedownSnapshot {
+ readonly origin: Point;
+ readonly elementId: string;
+ readonly trackId: string;
+ readonly startElementTime: MediaTime;
+ readonly clickOffsetTime: MediaTime;
+ readonly selectedElements: readonly ElementRef[];
+}
+
+interface DragProgress {
+ moveGroup: MoveGroup;
+ // Pre-minted per member so the identity of any "new track" created by
+ // this drag stays stable across mousemove-driven drop-target recomputes.
+ // `resolveGroupMoveForDrop` runs every mousemove and emits a
+ // `createTracks[]` carrying these IDs; downstream consumers (snap
+ // indicator, drop-line, commit path) see the same entity every frame
+ // instead of a churning UUID.
+ reservedNewTrackIds: readonly string[];
+ currentTime: MediaTime;
+ currentMouseX: number;
+ currentMouseY: number;
+ groupMoveResult: GroupMoveResult | null;
+ dropTarget: DropTarget | null;
+}
+
+type Session =
+ | { kind: "idle" }
+ | { kind: "pending"; mousedown: MousedownSnapshot }
+ | { kind: "dragging"; mousedown: MousedownSnapshot; drag: DragProgress };
+
+const IDLE_VIEW: ElementDragView = { kind: "idle" };
+
+// --- Pure helpers ---
+
+function pixelToClickOffsetTime(
+ clientX: number,
+ elementRect: DOMRect,
+ zoomLevel: number,
+): MediaTime {
+ const clickOffsetX = clientX - elementRect.left;
+ const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
+ return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) });
+}
+
+function verticalDirection(
+ startMouseY: number,
+ currentMouseY: number,
+): "up" | "down" | null {
+ if (currentMouseY < startMouseY) return "up";
+ if (currentMouseY > startMouseY) return "down";
+ return null;
+}
+
+function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] {
+ return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio];
+}
+
+function movedPastDragThreshold(current: Point, origin: Point): boolean {
+ return (
+ Math.abs(current.x - origin.x) > TIMELINE_DRAG_THRESHOLD_PX ||
+ Math.abs(current.y - origin.y) > TIMELINE_DRAG_THRESHOLD_PX
+ );
+}
+
+function frameSnappedMouseTime({
+ clientX,
+ scrollContainer,
+ zoomLevel,
+ clickOffsetTime,
+ fps,
+}: {
+ clientX: number;
+ scrollContainer: HTMLDivElement;
+ zoomLevel: number;
+ clickOffsetTime: MediaTime;
+ fps: FrameRate;
+}): MediaTime {
+ const mouseTime = getMouseTimeFromClientX({
+ clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft: scrollContainer.scrollLeft,
+ });
+ const adjusted = maxMediaTime({
+ a: ZERO_MEDIA_TIME,
+ b: subMediaTime({ a: mouseTime, b: clickOffsetTime }),
+ });
+ return roundFrameTime({ time: adjusted, fps });
+}
+
+function resolveDropTarget({
+ clientX,
+ clientY,
+ elementId,
+ trackId,
+ tracks,
+ viewport,
+ zoomLevel,
+ snappedTime,
+ verticalDragDirection,
+}: {
+ clientX: number;
+ clientY: number;
+ elementId: string;
+ trackId: string;
+ tracks: SceneTracks;
+ viewport: ViewportAdapter;
+ zoomLevel: number;
+ snappedTime: MediaTime;
+ verticalDragDirection: "up" | "down" | null;
+}): DropTarget | null {
+ const containerRect = viewport
+ .getTracksContainerEl()
+ ?.getBoundingClientRect();
+ const scrollContainer = viewport.getTracksScrollEl();
+ if (!containerRect || !scrollContainer) return null;
+
+ const sourceTrack = orderedTracks(tracks).find(({ id }) => id === trackId);
+ const movingElement = sourceTrack?.elements.find(
+ ({ id }) => id === elementId,
+ );
+ if (!movingElement) return null;
+
+ const scrollRect = scrollContainer.getBoundingClientRect();
+ const headerHeight =
+ viewport.getHeaderEl()?.getBoundingClientRect().height ?? 0;
+
+ return computeDropTarget({
+ elementType: movingElement.type,
+ mouseX: clientX - scrollRect.left + scrollContainer.scrollLeft,
+ mouseY: clientY - scrollRect.top + scrollContainer.scrollTop - headerHeight,
+ tracks,
+ playheadTime: snappedTime,
+ isExternalDrop: false,
+ elementDuration: movingElement.duration,
+ pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
+ zoomLevel,
+ startTimeOverride: snappedTime,
+ excludeElementId: movingElement.id,
+ verticalDragDirection,
+ });
+}
+
+function resolveGroupMoveForDrop({
+ group,
+ tracks,
+ anchorStartTime,
+ dropTarget,
+ reservedNewTrackIds,
+}: {
+ group: MoveGroup;
+ tracks: SceneTracks;
+ anchorStartTime: MediaTime;
+ dropTarget: DropTarget;
+ reservedNewTrackIds: readonly string[];
+}): GroupMoveResult | null {
+ const newTracksFallback = () =>
+ resolveGroupMove({
+ group,
+ tracks,
+ anchorStartTime,
+ target: {
+ kind: "newTracks",
+ anchorInsertIndex: dropTarget.trackIndex,
+ newTrackIds: [...reservedNewTrackIds],
+ },
+ });
+
+ if (dropTarget.isNewTrack) return newTracksFallback();
+
+ const targetTrack = orderedTracks(tracks)[dropTarget.trackIndex];
+ if (!targetTrack) return null;
+
+ return (
+ resolveGroupMove({
+ group,
+ tracks,
+ anchorStartTime,
+ target: { kind: "existingTrack", anchorTargetTrackId: targetTrack.id },
+ }) ?? newTracksFallback()
+ );
+}
+
+// --- Controller ---
+
+export class ElementInteractionController {
+ private session: Session = { kind: "idle" };
+ // True once the active gesture crossed the drag threshold. Read by
+ // onElementClick, which fires after mouseup — by which point the session
+ // has already returned to idle, so the "was this a drag?" answer must
+ // outlive the session. Reset on the next mousedown.
+ private lastGestureWasDrag = false;
+
+ private readonly subscribers = new Set<() => void>();
+ private readonly depsRef: ElementInteractionDepsRef;
+
+ constructor(args: { depsRef: ElementInteractionDepsRef }) {
+ this.depsRef = args.depsRef;
+ }
+
+ private get deps(): ElementInteractionDeps {
+ return this.depsRef.current;
+ }
+
+ get view(): ElementDragView {
+ if (this.session.kind !== "dragging") return IDLE_VIEW;
+ const { mousedown, drag } = this.session;
+ const memberTimeOffsets = new Map<string, MediaTime>();
+ for (const member of drag.moveGroup.members) {
+ memberTimeOffsets.set(member.elementId, member.timeOffset);
+ }
+ return {
+ kind: "dragging",
+ anchorElementId: mousedown.elementId,
+ trackId: mousedown.trackId,
+ memberTimeOffsets,
+ startMouseX: mousedown.origin.x,
+ startMouseY: mousedown.origin.y,
+ startElementTime: mousedown.startElementTime,
+ clickOffsetTime: mousedown.clickOffsetTime,
+ currentTime: drag.currentTime,
+ currentMouseX: drag.currentMouseX,
+ currentMouseY: drag.currentMouseY,
+ dropTarget: drag.dropTarget,
+ };
+ }
+
+ get isActive(): boolean {
+ return this.session.kind !== "idle";
+ }
+
+ subscribe(fn: () => void): () => void {
+ this.subscribers.add(fn);
+ return () => this.subscribers.delete(fn);
+ }
+
+ cancel = (): void => {
+ this.lastGestureWasDrag = false;
+ this.finishSession();
+ };
+
+ destroy(): void {
+ this.cancel();
+ this.subscribers.clear();
+ }
+
+ onElementMouseDown = ({
+ event,
+ element,
+ track,
+ }: {
+ event: ReactMouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ }): void => {
+ // Right-click must not stopPropagation — ContextMenu needs the bubble.
+ if (event.button === MOUSE_BUTTON_RIGHT) {
+ const ref = { trackId: track.id, elementId: element.id };
+ if (!this.deps.selection.isSelected(ref)) {
+ this.deps.selection.handleClick({ ...ref, isMultiKey: false });
+ }
+ return;
+ }
+
+ event.stopPropagation();
+ this.lastGestureWasDrag = false;
+
+ const ref = { trackId: track.id, elementId: element.id };
+
+ if (event.metaKey || event.ctrlKey || event.shiftKey) {
+ this.deps.selection.handleClick({ ...ref, isMultiKey: true });
+ }
+
+ const selectedElements = this.deps.selection.isSelected(ref)
+ ? this.deps.selection.getSelected()
+ : [ref];
+
+ this.session = {
+ kind: "pending",
+ mousedown: {
+ origin: { x: event.clientX, y: event.clientY },
+ elementId: element.id,
+ trackId: track.id,
+ startElementTime: element.startTime,
+ clickOffsetTime: pixelToClickOffsetTime(
+ event.clientX,
+ event.currentTarget.getBoundingClientRect(),
+ this.deps.viewport.getZoomLevel(),
+ ),
+ selectedElements,
+ },
+ };
+ this.activate();
+ this.notify();
+ };
+
+ onElementClick = ({
+ event,
+ element,
+ track,
+ }: {
+ event: ReactMouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ }): void => {
+ event.stopPropagation();
+
+ if (this.lastGestureWasDrag) {
+ this.lastGestureWasDrag = false;
+ return;
+ }
+
+ if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+
+ const ref = { trackId: track.id, elementId: element.id };
+ if (
+ !this.deps.selection.isSelected(ref) ||
+ this.deps.selection.getSelected().length > 1
+ ) {
+ this.deps.selection.select(ref);
+ return;
+ }
+
+ this.deps.selection.clearKeyframeSelection();
+ };
+
+ private activate(): void {
+ document.addEventListener("mousemove", this.handleMouseMove);
+ document.addEventListener("mouseup", this.handleMouseUp);
+ }
+
+ private deactivate(): void {
+ document.removeEventListener("mousemove", this.handleMouseMove);
+ document.removeEventListener("mouseup", this.handleMouseUp);
+ }
+
+ private notify(): void {
+ for (const fn of this.subscribers) fn();
+ }
+
+ private finishSession(): void {
+ this.session = { kind: "idle" };
+ this.deactivate();
+ this.deps.snap.onChange?.(null);
+ this.notify();
+ }
+
+ private snapResult(
+ frameSnappedTime: MediaTime,
+ group: MoveGroup,
+ ): { snappedTime: MediaTime; snapPoint: SnapPoint | null } {
+ const { snap, input, scene, viewport, playback } = this.deps;
+
+ if (!snap.isEnabled() || input.isShiftHeld()) {
+ return { snappedTime: frameSnappedTime, snapPoint: null };
+ }
+
+ const result = snapGroupEdges({
+ group,
+ anchorStartTime: frameSnappedTime,
+ tracks: scene.getTracks(),
+ playheadTime: playback.getCurrentTime(),
+ zoomLevel: viewport.getZoomLevel(),
+ });
+
+ return {
+ snappedTime: result.snappedAnchorStartTime,
+ snapPoint: result.snapPoint,
+ };
+ }
+
+ private updateDropTarget({
+ clientX,
+ clientY,
+ mousedown,
+ drag,
+ snappedTime,
+ }: {
+ clientX: number;
+ clientY: number;
+ mousedown: MousedownSnapshot;
+ drag: DragProgress;
+ snappedTime: MediaTime;
+ }): void {
+ const { scene, viewport } = this.deps;
+ const tracks = scene.getTracks();
+ const zoomLevel = viewport.getZoomLevel();
+
+ const anchorDropTarget = resolveDropTarget({
+ clientX,
+ clientY,
+ elementId: mousedown.elementId,
+ trackId: mousedown.trackId,
+ tracks,
+ viewport,
+ zoomLevel,
+ snappedTime,
+ verticalDragDirection: verticalDirection(mousedown.origin.y, clientY),
+ });
+
+ const nextGroupMoveResult = anchorDropTarget
+ ? resolveGroupMoveForDrop({
+ group: drag.moveGroup,
+ tracks,
+ anchorStartTime: snappedTime,
+ dropTarget: anchorDropTarget,
+ reservedNewTrackIds: drag.reservedNewTrackIds,
+ })
+ : null;
+
+ drag.groupMoveResult = nextGroupMoveResult;
+ drag.dropTarget =
+ anchorDropTarget && (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
+ ? { ...anchorDropTarget, isNewTrack: true }
+ : null;
+ }
+
+ private handleMouseMove = ({ clientX, clientY }: MouseEvent): void => {
+ const scrollContainer = this.deps.viewport.getTracksScrollEl();
+ if (!scrollContainer) return;
+
+ if (this.session.kind === "pending") {
+ this.beginDragFromPending({
+ mousedown: this.session.mousedown,
+ clientX,
+ clientY,
+ scrollContainer,
+ });
+ return;
+ }
+
+ if (this.session.kind === "dragging") {
+ this.updateActiveDrag({
+ mousedown: this.session.mousedown,
+ drag: this.session.drag,
+ clientX,
+ clientY,
+ scrollContainer,
+ });
+ }
+ };
+
+ private beginDragFromPending({
+ mousedown,
+ clientX,
+ clientY,
+ scrollContainer,
+ }: {
+ mousedown: MousedownSnapshot;
+ clientX: number;
+ clientY: number;
+ scrollContainer: HTMLDivElement;
+ }): void {
+ if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) {
+ return;
+ }
+
+ const fps = this.deps.scene.getActiveFps();
+ if (!fps) return;
+
+ const moveGroup = buildMoveGroup({
+ anchorRef: {
+ trackId: mousedown.trackId,
+ elementId: mousedown.elementId,
+ },
+ selectedElements: [...mousedown.selectedElements],
+ tracks: this.deps.scene.getTracks(),
+ });
+ if (!moveGroup) return;
+
+ const zoomLevel = this.deps.viewport.getZoomLevel();
+ const frameSnappedTime = frameSnappedMouseTime({
+ clientX,
+ scrollContainer,
+ zoomLevel,
+ clickOffsetTime: mousedown.clickOffsetTime,
+ fps,
+ });
+ const { snappedTime, snapPoint } = this.snapResult(
+ frameSnappedTime,
+ moveGroup,
+ );
+
+ // Ensure the anchor is selected before we render the drag — covers the
+ // case where the selection store hasn't committed the mousedown-time
+ // selection click yet.
+ const anchorRef = {
+ trackId: mousedown.trackId,
+ elementId: mousedown.elementId,
+ };
+ if (!this.deps.selection.isSelected(anchorRef)) {
+ this.deps.selection.select(anchorRef);
+ }
+
+ const drag: DragProgress = {
+ moveGroup,
+ reservedNewTrackIds: moveGroup.members.map(() => generateUUID()),
+ currentTime: snappedTime,
+ currentMouseX: clientX,
+ currentMouseY: clientY,
+ groupMoveResult: null,
+ dropTarget: null,
+ };
+
+ this.session = { kind: "dragging", mousedown, drag };
+ this.lastGestureWasDrag = true;
+
+ this.updateDropTarget({
+ clientX,
+ clientY,
+ mousedown,
+ drag,
+ snappedTime,
+ });
+
+ this.deps.snap.onChange?.(snapPoint);
+ this.notify();
+ }
+
+ private updateActiveDrag({
+ mousedown,
+ drag,
+ clientX,
+ clientY,
+ scrollContainer,
+ }: {
+ mousedown: MousedownSnapshot;
+ drag: DragProgress;
+ clientX: number;
+ clientY: number;
+ scrollContainer: HTMLDivElement;
+ }): void {
+ const fps = this.deps.scene.getActiveFps();
+ if (!fps) return;
+
+ const frameSnappedTime = frameSnappedMouseTime({
+ clientX,
+ scrollContainer,
+ zoomLevel: this.deps.viewport.getZoomLevel(),
+ clickOffsetTime: mousedown.clickOffsetTime,
+ fps,
+ });
+ const { snappedTime, snapPoint } = this.snapResult(
+ frameSnappedTime,
+ drag.moveGroup,
+ );
+
+ drag.currentTime = snappedTime;
+ drag.currentMouseX = clientX;
+ drag.currentMouseY = clientY;
+
+ this.updateDropTarget({
+ clientX,
+ clientY,
+ mousedown,
+ drag,
+ snappedTime,
+ });
+
+ this.deps.snap.onChange?.(snapPoint);
+ this.notify();
+ }
+
+ private handleMouseUp = ({ clientX, clientY }: MouseEvent): void => {
+ if (this.session.kind === "pending") {
+ this.finishSession();
+ return;
+ }
+
+ if (this.session.kind !== "dragging") return;
+
+ const { mousedown, drag } = this.session;
+
+ // If the drag returned within the click threshold of its origin, treat
+ // this as a cancel rather than a commit — the user dragged then put the
+ // element back.
+ if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) {
+ this.lastGestureWasDrag = false;
+ this.finishSession();
+ return;
+ }
+
+ const { moveGroup, groupMoveResult } = drag;
+ if (!groupMoveResult) {
+ this.finishSession();
+ return;
+ }
+
+ const didMove = groupMoveResult.moves.some((move) => {
+ const member = moveGroup.members.find(
+ (m) => m.elementId === move.elementId,
+ );
+ const originalStartTime =
+ mousedown.startElementTime + (member?.timeOffset ?? 0);
+ return (
+ member?.trackId !== move.targetTrackId ||
+ originalStartTime !== move.newStartTime
+ );
+ });
+
+ if (didMove || groupMoveResult.createTracks.length > 0) {
+ this.deps.timeline.moveElements({
+ moves: groupMoveResult.moves,
+ createTracks: groupMoveResult.createTracks,
+ });
+ }
+
+ this.finishSession();
+ };
+}
diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
new file mode 100644
index 00000000..29dfc5dd
--- /dev/null
+++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
@@ -0,0 +1,358 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import type { FrameRate } from "opencut-wasm";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import {
+ addMediaTime,
+ clampMediaTime,
+ type MediaTime,
+ mediaTime,
+ roundFrameTicks,
+ snapSeekMediaTime,
+ TICKS_PER_SECOND,
+ ZERO_MEDIA_TIME,
+} from "@/wasm";
+import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
+import { timelineTimeToSnappedPixels } from "@/timeline";
+import { getKeyframeById } from "@/animation";
+import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
+import { BatchCommand } from "@/commands";
+import type { SelectedKeyframeRef } from "@/animation/types";
+import type { TimelineElement } from "@/timeline";
+import type { Command } from "@/commands/base-command";
+
+// --- Session ---
+
+interface PendingSession {
+ kind: "pending";
+ keyframeRefs: SelectedKeyframeRef[];
+ startMouseX: number;
+}
+
+interface ActiveSession {
+ kind: "active";
+ keyframeRefs: SelectedKeyframeRef[];
+ startMouseX: number;
+ deltaTicks: number;
+}
+
+type Session = { kind: "idle" } | PendingSession | ActiveSession;
+
+// --- Public state ---
+
+export interface KeyframeDragState {
+ isDragging: boolean;
+ draggingKeyframeIds: Set<string>;
+ deltaTicks: number;
+}
+
+const IDLE_DRAG_STATE: KeyframeDragState = {
+ isDragging: false,
+ draggingKeyframeIds: new Set(),
+ deltaTicks: 0,
+};
+
+// --- Config ---
+
+export interface KeyframeDragConfig {
+ zoomLevel: number;
+ getFps: () => FrameRate | null;
+ element: TimelineElement;
+ displayedStartTime: MediaTime;
+ selectedKeyframes: SelectedKeyframeRef[];
+ isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean;
+ setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void;
+ toggleKeyframeSelection: (args: {
+ keyframes: SelectedKeyframeRef[];
+ isMultiKey: boolean;
+ }) => void;
+ selectKeyframeRange: (args: {
+ orderedKeyframes: SelectedKeyframeRef[];
+ targetKeyframes: SelectedKeyframeRef[];
+ isAdditive: boolean;
+ }) => void;
+ executeCommand: (command: Command) => void;
+ seek: (args: { time: MediaTime }) => void;
+ getTotalDuration: () => MediaTime;
+}
+
+export interface KeyframeDragConfigRef {
+ readonly current: KeyframeDragConfig;
+}
+
+// --- Controller ---
+
+export class KeyframeDragController {
+ private session: Session = { kind: "idle" };
+ // Persists through mouseup so the click handler can detect drag vs click
+ private mouseDownX: number | null = null;
+ private readonly subscribers = new Set<() => void>();
+ private readonly configRef: KeyframeDragConfigRef;
+
+ constructor(deps: { configRef: KeyframeDragConfigRef }) {
+ this.configRef = deps.configRef;
+ this.onKeyframeMouseDown = this.onKeyframeMouseDown.bind(this);
+ this.onKeyframeClick = this.onKeyframeClick.bind(this);
+ this.getVisualOffsetPx = this.getVisualOffsetPx.bind(this);
+ this.handleMouseMove = this.handleMouseMove.bind(this);
+ this.handleMouseUp = this.handleMouseUp.bind(this);
+ }
+
+ private get config(): KeyframeDragConfig {
+ return this.configRef.current;
+ }
+
+ get isActive(): boolean {
+ return this.session.kind !== "idle";
+ }
+
+ get keyframeDragState(): KeyframeDragState {
+ if (this.session.kind !== "active") return IDLE_DRAG_STATE;
+ return {
+ isDragging: true,
+ draggingKeyframeIds: new Set(
+ this.session.keyframeRefs.map((kf) => kf.keyframeId),
+ ),
+ deltaTicks: this.session.deltaTicks,
+ };
+ }
+
+ subscribe(fn: () => void): () => void {
+ this.subscribers.add(fn);
+ return () => this.subscribers.delete(fn);
+ }
+
+ cancel(): void {
+ this.mouseDownX = null;
+ this.finishSession();
+ }
+
+ destroy(): void {
+ this.deactivate();
+ this.subscribers.clear();
+ }
+
+ onKeyframeMouseDown({
+ event,
+ keyframes,
+ }: {
+ event: ReactMouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }): void {
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.mouseDownX = event.clientX;
+
+ const anySelected = keyframes.some((kf) =>
+ this.config.isKeyframeSelected({ keyframe: kf }),
+ );
+ const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
+
+ if (!anySelected && !isModifierKey) {
+ this.config.setKeyframeSelection({ keyframes });
+ }
+
+ this.session = {
+ kind: "pending",
+ keyframeRefs: anySelected ? this.config.selectedKeyframes : keyframes,
+ startMouseX: event.clientX,
+ };
+ this.activate();
+ this.notify();
+ }
+
+ onKeyframeClick({
+ event,
+ keyframes,
+ orderedKeyframes,
+ indicatorTime,
+ }: {
+ event: ReactMouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: MediaTime;
+ }): void {
+ event.stopPropagation();
+
+ const wasDrag =
+ this.mouseDownX !== null &&
+ Math.abs(event.clientX - this.mouseDownX) > TIMELINE_DRAG_THRESHOLD_PX;
+ this.mouseDownX = null;
+
+ if (wasDrag) return;
+
+ const { displayedStartTime, getFps, getTotalDuration, seek } = this.config;
+ const fps = getFps();
+ const absoluteIndicatorTime = addMediaTime({
+ a: displayedStartTime,
+ b: indicatorTime,
+ });
+ const seekTime =
+ fps != null
+ ? snapSeekMediaTime({
+ time: absoluteIndicatorTime,
+ duration: getTotalDuration(),
+ fps,
+ })
+ : absoluteIndicatorTime;
+ seek({ time: seekTime });
+
+ if (event.shiftKey) {
+ this.config.selectKeyframeRange({
+ orderedKeyframes,
+ targetKeyframes: keyframes,
+ isAdditive: event.metaKey || event.ctrlKey,
+ });
+ return;
+ }
+
+ this.config.toggleKeyframeSelection({
+ keyframes,
+ isMultiKey: event.metaKey || event.ctrlKey,
+ });
+ }
+
+ getVisualOffsetPx({
+ indicatorTime,
+ indicatorOffsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ }: {
+ indicatorTime: MediaTime;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: MediaTime;
+ elementLeft: number;
+ }): number {
+ if (!isBeingDragged || this.session.kind !== "active")
+ return indicatorOffsetPx;
+ const deltaTime = mediaTime({ ticks: this.session.deltaTicks });
+ const clampedTime = clampMediaTime({
+ time: addMediaTime({ a: indicatorTime, b: deltaTime }),
+ min: ZERO_MEDIA_TIME,
+ max: this.config.element.duration,
+ });
+ return (
+ timelineTimeToSnappedPixels({
+ time: addMediaTime({ a: displayedStartTime, b: clampedTime }),
+ zoomLevel: this.config.zoomLevel,
+ }) - elementLeft
+ );
+ }
+
+ private activate(): void {
+ document.addEventListener("mousemove", this.handleMouseMove);
+ document.addEventListener("mouseup", this.handleMouseUp);
+ }
+
+ private deactivate(): void {
+ document.removeEventListener("mousemove", this.handleMouseMove);
+ document.removeEventListener("mouseup", this.handleMouseUp);
+ }
+
+ private notify(): void {
+ for (const fn of this.subscribers) fn();
+ }
+
+ private finishSession(): void {
+ this.session = { kind: "idle" };
+ this.deactivate();
+ this.notify();
+ }
+
+ private commitDrag({
+ keyframeRefs,
+ deltaTicks,
+ }: {
+ keyframeRefs: SelectedKeyframeRef[];
+ deltaTicks: number;
+ }): void {
+ const { element } = this.config;
+ const commands: Command[] = keyframeRefs.flatMap((ref) => {
+ const keyframe = getKeyframeById({
+ animations: element.animations,
+ propertyPath: ref.propertyPath,
+ keyframeId: ref.keyframeId,
+ });
+ if (!keyframe) return [];
+ return [
+ new RetimeKeyframeCommand({
+ trackId: ref.trackId,
+ elementId: ref.elementId,
+ propertyPath: ref.propertyPath,
+ keyframeId: ref.keyframeId,
+ nextTime: clampMediaTime({
+ time: addMediaTime({
+ a: keyframe.time,
+ b: mediaTime({ ticks: deltaTicks }),
+ }),
+ min: ZERO_MEDIA_TIME,
+ max: element.duration,
+ }),
+ }),
+ ];
+ });
+
+ const [first, ...rest] = commands;
+ if (!first) return;
+ if (rest.length === 0) {
+ this.config.executeCommand(first);
+ } else {
+ this.config.executeCommand(new BatchCommand([first, ...rest]));
+ }
+ }
+
+ private handleMouseMove({ clientX }: MouseEvent): void {
+ if (this.session.kind === "pending") {
+ const deltaX = Math.abs(clientX - this.session.startMouseX);
+ if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
+
+ this.session = {
+ kind: "active",
+ keyframeRefs: this.session.keyframeRefs,
+ startMouseX: this.session.startMouseX,
+ deltaTicks: 0,
+ };
+ this.notify();
+ return;
+ }
+
+ if (this.session.kind !== "active") return;
+
+ const fps = this.config.getFps();
+ if (!fps) return;
+
+ const pixelsPerSecond =
+ BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel;
+ const rawDeltaTicks = Math.round(
+ ((clientX - this.session.startMouseX) / pixelsPerSecond) *
+ TICKS_PER_SECOND,
+ );
+ this.session.deltaTicks =
+ roundFrameTicks({ ticks: rawDeltaTicks, fps });
+ this.notify();
+ }
+
+ private handleMouseUp(): void {
+ if (this.session.kind === "pending") {
+ this.finishSession();
+ return;
+ }
+
+ if (this.session.kind !== "active") return;
+
+ const { selectedKeyframes, element } = this.config;
+ const { keyframeRefs, deltaTicks } = this.session;
+ const draggingIds = new Set(keyframeRefs.map((r) => r.keyframeId));
+ const draggingRefs = selectedKeyframes.filter(
+ (kf) => kf.elementId === element.id && draggingIds.has(kf.keyframeId),
+ );
+
+ if (draggingRefs.length > 0 && deltaTicks !== 0) {
+ this.commitDrag({ keyframeRefs: draggingRefs, deltaTicks });
+ }
+
+ this.finishSession();
+ }
+}
diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts
new file mode 100644
index 00000000..b96b868b
--- /dev/null
+++ b/apps/web/src/timeline/controllers/playhead-controller.ts
@@ -0,0 +1,318 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import type { FrameRate } from "opencut-wasm";
+import {
+ mediaTime,
+ snapSeekMediaTime,
+ TICKS_PER_SECOND,
+ type MediaTime,
+} from "@/wasm";
+import {
+ buildTimelineSnapPoints,
+ getTimelineSnapThresholdInTicks,
+ resolveTimelineSnap,
+} from "@/timeline/snapping";
+import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
+import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import {
+ getCenteredLineLeft,
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/timeline";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import type { Bookmark, SceneTracks } from "@/timeline";
+
+// --- Session ---
+
+interface ScrubSession {
+ kind: "scrubbing";
+ /** True when scrub started from a ruler click (not the playhead handle). */
+ didStartFromRuler: boolean;
+ /** True once the mouse has moved during a ruler drag. */
+ hasMoved: boolean;
+ /** Most recent frame-snapped time set by scrub(). */
+ currentTime: MediaTime | null;
+}
+
+type Session = { kind: "idle" } | ScrubSession;
+
+// --- Config ---
+
+export interface PlayheadConfig {
+ zoomLevel: number;
+ duration: MediaTime;
+ getActiveProjectFps: () => FrameRate | null;
+ isShiftHeld: () => boolean;
+ getIsPlaying: () => boolean;
+ getRulerEl: () => HTMLDivElement | null;
+ getRulerScrollEl: () => HTMLDivElement | null;
+ getTracksScrollEl: () => HTMLDivElement | null;
+ getPlayheadEl: () => HTMLDivElement | null;
+ getSceneTracks: () => SceneTracks;
+ getSceneBookmarks: () => Bookmark[];
+ seek: (time: MediaTime) => void;
+ setScrubbing: (isScrubbing: boolean) => void;
+ setTimelineViewState: (viewState: {
+ zoomLevel: number;
+ scrollLeft: number;
+ playheadTime: MediaTime;
+ }) => void;
+}
+
+export interface PlayheadConfigRef {
+ readonly current: PlayheadConfig;
+}
+
+// --- Pure helpers (px → logical) ---
+
+function pixelToTime({
+ clientX,
+ rulerEl,
+ zoomLevel,
+ duration,
+}: {
+ clientX: number;
+ rulerEl: HTMLDivElement;
+ zoomLevel: number;
+ duration: MediaTime;
+}): MediaTime {
+ const rulerRect = rulerEl.getBoundingClientRect();
+ const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
+ const clampedX = Math.max(
+ 0,
+ Math.min(contentWidth, clientX - rulerRect.left),
+ );
+ const seconds = Math.max(
+ 0,
+ Math.min(
+ duration / TICKS_PER_SECOND,
+ clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
+ ),
+ );
+ return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) });
+}
+
+// --- Controller ---
+
+export class PlayheadController {
+ private lastMouseClientX = 0;
+
+ private session: Session = { kind: "idle" };
+ private readonly configRef: PlayheadConfigRef;
+
+ constructor(deps: { configRef: PlayheadConfigRef }) {
+ this.configRef = deps.configRef;
+ this.onPlayheadMouseDown = this.onPlayheadMouseDown.bind(this);
+ this.onRulerMouseDown = this.onRulerMouseDown.bind(this);
+ this.handleMouseMove = this.handleMouseMove.bind(this);
+ this.handleMouseUp = this.handleMouseUp.bind(this);
+ }
+
+ private get config(): PlayheadConfig {
+ return this.configRef.current;
+ }
+
+ get isActive(): boolean {
+ return this.session.kind !== "idle";
+ }
+
+ getLastMouseClientX(): number {
+ return this.lastMouseClientX;
+ }
+
+ destroy(): void {
+ this.deactivate();
+ }
+
+ // --- Public event handlers (bound, stable references) ---
+
+ onPlayheadMouseDown(event: ReactMouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+ this.session = {
+ kind: "scrubbing",
+ didStartFromRuler: false,
+ hasMoved: false,
+ currentTime: null,
+ };
+ this.config.setScrubbing(true);
+ this.scrub({ event, isElementSnappingEnabled: true });
+ this.activate();
+ }
+
+ onRulerMouseDown(event: ReactMouseEvent): void {
+ if (event.button !== 0) return;
+ if (this.config.getPlayheadEl()?.contains(event.target as Node)) return;
+
+ event.preventDefault();
+ this.session = {
+ kind: "scrubbing",
+ didStartFromRuler: true,
+ hasMoved: false,
+ currentTime: null,
+ };
+ this.config.setScrubbing(true);
+ // No element-edge snapping on initial ruler click — avoids a jarring jump.
+ this.scrub({ event, isElementSnappingEnabled: false });
+ this.activate();
+ }
+
+ // --- Public non-session methods ---
+
+ /**
+ * Imperatively updates the playhead DOM element's `left` style.
+ * Called on scroll and playback events to avoid React re-renders
+ * during animation frame updates.
+ */
+ updatePlayheadLeft(time: MediaTime): void {
+ const playheadEl = this.config.getPlayheadEl();
+ if (!playheadEl) return;
+
+ const centerPixel = timelineTimeToSnappedPixels({
+ time,
+ zoomLevel: this.config.zoomLevel,
+ });
+ const scrollLeft = this.config.getRulerScrollEl()?.scrollLeft ?? 0;
+ playheadEl.style.left = `${getCenteredLineLeft({ centerPixel }) - scrollLeft}px`;
+ }
+
+ /**
+ * Updates the playhead position and auto-scrolls to keep the playhead
+ * visible during playback.
+ */
+ handlePlaybackUpdate(time: MediaTime): void {
+ this.updatePlayheadLeft(time);
+
+ // Auto-scroll only during playback, not while scrubbing.
+ if (!this.config.getIsPlaying() || this.session.kind === "scrubbing")
+ return;
+
+ const rulerViewport = this.config.getRulerScrollEl();
+ const tracksViewport = this.config.getTracksScrollEl();
+ if (!rulerViewport || !tracksViewport) return;
+
+ const playheadPixels = timelineTimeToPixels({
+ time,
+ zoomLevel: this.config.zoomLevel,
+ });
+ const viewportWidth = rulerViewport.clientWidth;
+ const isOutOfView =
+ playheadPixels < rulerViewport.scrollLeft ||
+ playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+
+ if (isOutOfView) {
+ const desiredScroll = Math.max(
+ 0,
+ Math.min(
+ rulerViewport.scrollWidth - viewportWidth,
+ playheadPixels - viewportWidth / 2,
+ ),
+ );
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ }
+
+ // --- Private ---
+
+ private activate(): void {
+ window.addEventListener("mousemove", this.handleMouseMove);
+ window.addEventListener("mouseup", this.handleMouseUp);
+ }
+
+ private deactivate(): void {
+ window.removeEventListener("mousemove", this.handleMouseMove);
+ window.removeEventListener("mouseup", this.handleMouseUp);
+ }
+
+ /**
+ * Converts pointer position to a frame-snapped timeline time and seeks.
+ * `isElementSnappingEnabled` controls element-edge snapping; frame-level snapping
+ * is always applied.
+ */
+ private scrub({
+ event,
+ isElementSnappingEnabled,
+ }: {
+ event: MouseEvent | ReactMouseEvent;
+ isElementSnappingEnabled: boolean;
+ }): void {
+ const ruler = this.config.getRulerEl();
+ if (!ruler) return;
+
+ const fps = this.config.getActiveProjectFps();
+ if (!fps) return;
+
+ const { zoomLevel, duration } = this.config;
+ const rawTime = pixelToTime({
+ clientX: event.clientX,
+ rulerEl: ruler,
+ zoomLevel,
+ duration,
+ });
+ const frameTime = snapSeekMediaTime({ time: rawTime, duration, fps });
+
+ const time = (() => {
+ if (!isElementSnappingEnabled || this.config.isShiftHeld())
+ return frameTime;
+
+ const snapPoints = buildTimelineSnapPoints({
+ sources: [
+ () =>
+ getElementEdgeSnapPoints({ tracks: this.config.getSceneTracks() }),
+ () =>
+ getBookmarkSnapPoints({
+ bookmarks: this.config.getSceneBookmarks(),
+ }),
+ () =>
+ getAnimationKeyframeSnapPointsForTimeline({
+ tracks: this.config.getSceneTracks(),
+ }),
+ ],
+ });
+ const result = resolveTimelineSnap({
+ targetTime: frameTime,
+ snapPoints,
+ maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
+ });
+ return result.snapPoint ? result.snappedTime : frameTime;
+ })();
+
+ if (this.session.kind === "scrubbing") {
+ this.session.currentTime = time;
+ }
+ this.config.seek(time);
+ this.lastMouseClientX = event.clientX;
+ }
+
+ private handleMouseMove(event: MouseEvent): void {
+ if (this.session.kind !== "scrubbing") return;
+ this.scrub({ event, isElementSnappingEnabled: true });
+ if (this.session.didStartFromRuler) {
+ this.session.hasMoved = true;
+ }
+ }
+
+ private handleMouseUp(event: MouseEvent): void {
+ if (this.session.kind !== "scrubbing") return;
+
+ const session = this.session;
+ this.config.setScrubbing(false);
+
+ if (session.currentTime !== null) {
+ this.config.seek(session.currentTime);
+ this.config.setTimelineViewState({
+ zoomLevel: this.config.zoomLevel,
+ scrollLeft: this.config.getTracksScrollEl()?.scrollLeft ?? 0,
+ playheadTime: session.currentTime,
+ });
+ }
+
+ // Ruler click without drag: snap to clicked position on mouseup.
+ if (session.didStartFromRuler && !session.hasMoved) {
+ this.scrub({ event, isElementSnappingEnabled: false });
+ }
+
+ this.session = { kind: "idle" };
+ this.deactivate();
+ }
+}
diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts
new file mode 100644
index 00000000..8b0119a0
--- /dev/null
+++ b/apps/web/src/timeline/controllers/resize-controller.ts
@@ -0,0 +1,360 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import {
+ addMediaTime,
+ maxMediaTime,
+ type MediaTime,
+ mediaTime,
+ minMediaTime,
+ subMediaTime,
+ TICKS_PER_SECOND,
+} from "@/wasm";
+import {
+ computeGroupResize,
+ type GroupResizeMember,
+ type GroupResizeResult,
+ type GroupResizeUpdate,
+ type ResizeSide,
+} from "@/timeline/group-resize";
+import {
+ buildTimelineSnapPoints,
+ getTimelineSnapThresholdInTicks,
+ resolveTimelineSnap,
+ type SnapPoint,
+} from "@/timeline/snapping";
+import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
+import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import {
+ isRetimableElement,
+ type SceneTracks,
+ type TimelineElement,
+ type TimelineTrack,
+} from "@/timeline";
+import type { ElementRef } from "@/timeline/types";
+import type { FrameRate } from "opencut-wasm";
+
+// --- Session ---
+
+interface ResizeSession {
+ kind: "active";
+ side: ResizeSide;
+ startX: number;
+ fps: FrameRate;
+ members: GroupResizeMember[];
+ result: GroupResizeResult | null;
+}
+
+type Session = { kind: "idle" } | ResizeSession;
+
+// --- Config ---
+
+export interface ResizeConfig {
+ zoomLevel: number;
+ snappingEnabled: boolean;
+ isShiftHeld: () => boolean;
+ getSceneTracks: () => SceneTracks;
+ getCurrentPlayheadTime: () => MediaTime;
+ getActiveProjectFps: () => FrameRate | null;
+ selectedElements: ElementRef[];
+ discardPreview: () => void;
+ previewElements: (updates: GroupResizeUpdate[]) => void;
+ commitElements: (updates: GroupResizeUpdate[]) => void;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}
+
+export interface ResizeConfigRef {
+ readonly current: ResizeConfig;
+}
+
+// --- Pure helpers ---
+
+export function buildResizeMembers({
+ tracks,
+ selectedElements,
+}: {
+ tracks: SceneTracks;
+ selectedElements: ElementRef[];
+}): GroupResizeMember[] {
+ const selectedElementIds = new Set(
+ selectedElements.map((el) => el.elementId),
+ );
+ const trackMap = new Map(
+ [...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
+ track.id,
+ track,
+ ]),
+ );
+
+ return selectedElements.flatMap(({ trackId, elementId }) => {
+ const track = trackMap.get(trackId);
+ const element = track?.elements.find((el) => el.id === elementId);
+ if (!track || !element) return [];
+
+ const otherElements = track.elements.filter(
+ (el) => !selectedElementIds.has(el.id),
+ );
+ const leftNeighborBound = otherElements
+ .filter(
+ (el) =>
+ addMediaTime({ a: el.startTime, b: el.duration }) <=
+ element.startTime,
+ )
+ .reduce<MediaTime | null>((bound, el) => {
+ const elementEnd = addMediaTime({
+ a: el.startTime,
+ b: el.duration,
+ });
+ return bound === null
+ ? elementEnd
+ : maxMediaTime({ a: bound, b: elementEnd });
+ }, null);
+ const rightNeighborBound = otherElements
+ .filter(
+ (el) =>
+ el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }),
+ )
+ .reduce<MediaTime | null>(
+ (bound, el) =>
+ bound === null
+ ? el.startTime
+ : minMediaTime({ a: bound, b: el.startTime }),
+ null,
+ );
+
+ return [
+ {
+ trackId,
+ elementId,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ sourceDuration: element.sourceDuration,
+ retime: isRetimableElement(element) ? element.retime : undefined,
+ leftNeighborBound,
+ rightNeighborBound,
+ },
+ ];
+ });
+}
+
+function hasResizeChanges({
+ members,
+ result,
+}: {
+ members: GroupResizeMember[];
+ result: GroupResizeResult;
+}): boolean {
+ return result.updates.some((update) => {
+ const member = members.find((m) => m.elementId === update.elementId);
+ return (
+ member?.trimStart !== update.patch.trimStart ||
+ member?.trimEnd !== update.patch.trimEnd ||
+ member?.startTime !== update.patch.startTime ||
+ member?.duration !== update.patch.duration
+ );
+ });
+}
+
+// --- Controller ---
+
+export class ResizeController {
+ private session: Session = { kind: "idle" };
+ private readonly subscribers = new Set<() => void>();
+ private readonly configRef: ResizeConfigRef;
+
+ constructor(deps: { configRef: ResizeConfigRef }) {
+ this.configRef = deps.configRef;
+ this.onResizeStart = this.onResizeStart.bind(this);
+ this.handleMouseMove = this.handleMouseMove.bind(this);
+ this.handleMouseUp = this.handleMouseUp.bind(this);
+ }
+
+ private get config(): ResizeConfig {
+ return this.configRef.current;
+ }
+
+ get isResizing(): boolean {
+ return this.session.kind === "active";
+ }
+
+ subscribe(fn: () => void): () => void {
+ this.subscribers.add(fn);
+ return () => this.subscribers.delete(fn);
+ }
+
+ cancel(): void {
+ this.config.discardPreview();
+ this.finishSession();
+ }
+
+ destroy(): void {
+ this.deactivate();
+ this.subscribers.clear();
+ }
+
+ onResizeStart({
+ event,
+ element,
+ track,
+ side,
+ }: {
+ event: ReactMouseEvent;
+ element: TimelineElement;
+ track: TimelineTrack;
+ side: ResizeSide;
+ }): void {
+ event.stopPropagation();
+ event.preventDefault();
+
+ // UI should prevent this, but be explicit: a new resize start
+ // means the previous one is abandoned, not silently replaced.
+ if (this.session.kind === "active") this.cancel();
+
+ const fps = this.config.getActiveProjectFps();
+ if (!fps) return;
+
+ const ref = { trackId: track.id, elementId: element.id };
+ const activeSelection = this.config.selectedElements.some(
+ (el) => el.trackId === track.id && el.elementId === element.id,
+ )
+ ? this.config.selectedElements
+ : [ref];
+
+ const members = buildResizeMembers({
+ tracks: this.config.getSceneTracks(),
+ selectedElements: activeSelection,
+ });
+ if (members.length === 0) return;
+
+ this.config.discardPreview();
+
+ this.session = {
+ kind: "active",
+ side,
+ startX: event.clientX,
+ fps,
+ members,
+ result: null,
+ };
+ this.activate();
+ this.notify();
+ }
+
+ private activate(): void {
+ document.addEventListener("mousemove", this.handleMouseMove);
+ document.addEventListener("mouseup", this.handleMouseUp);
+ }
+
+ private deactivate(): void {
+ document.removeEventListener("mousemove", this.handleMouseMove);
+ document.removeEventListener("mouseup", this.handleMouseUp);
+ }
+
+ private notify(): void {
+ for (const fn of this.subscribers) fn();
+ }
+
+ private finishSession(): void {
+ this.session = { kind: "idle" };
+ this.deactivate();
+ this.config.onSnapPointChange?.(null);
+ this.notify();
+ }
+
+ private snappedDelta(
+ session: ResizeSession,
+ rawDeltaTime: MediaTime,
+ ): MediaTime {
+ const { snappingEnabled, isShiftHeld, zoomLevel } = this.config;
+
+ if (!snappingEnabled || isShiftHeld()) {
+ this.config.onSnapPointChange?.(null);
+ return rawDeltaTime;
+ }
+
+ const tracks = this.config.getSceneTracks();
+ const playheadTime = this.config.getCurrentPlayheadTime();
+ const excludeElementIds = new Set(session.members.map((m) => m.elementId));
+
+ const snapPoints = buildTimelineSnapPoints({
+ sources: [
+ () => getElementEdgeSnapPoints({ tracks, excludeElementIds }),
+ () => getPlayheadSnapPoints({ playheadTime }),
+ () =>
+ getAnimationKeyframeSnapPointsForTimeline({
+ tracks,
+ excludeElementIds,
+ }),
+ ],
+ });
+ const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel });
+
+ let closestSnapPoint: SnapPoint | null = null;
+ let closestSnapDistance = Infinity;
+ let deltaTime = rawDeltaTime;
+
+ for (const member of session.members) {
+ const baseEdgeTime =
+ session.side === "left"
+ ? member.startTime
+ : addMediaTime({ a: member.startTime, b: member.duration });
+ const snapResult = resolveTimelineSnap({
+ targetTime: addMediaTime({ a: baseEdgeTime, b: rawDeltaTime }),
+ snapPoints,
+ maxSnapDistance,
+ });
+ if (
+ snapResult.snapPoint &&
+ snapResult.snapDistance < closestSnapDistance
+ ) {
+ closestSnapDistance = snapResult.snapDistance;
+ closestSnapPoint = snapResult.snapPoint;
+ deltaTime = subMediaTime({ a: snapResult.snappedTime, b: baseEdgeTime });
+ }
+ }
+
+ this.config.onSnapPointChange?.(closestSnapPoint);
+ return deltaTime;
+ }
+
+ private handleMouseMove({ clientX }: MouseEvent): void {
+ if (this.session.kind !== "active") return;
+ const session = this.session;
+
+ const rawDeltaTime = mediaTime({
+ ticks: Math.round(
+ ((clientX - session.startX) /
+ (BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
+ TICKS_PER_SECOND,
+ ),
+ });
+ const deltaTime = this.snappedDelta(session, rawDeltaTime);
+ const result = computeGroupResize({
+ members: session.members,
+ side: session.side,
+ deltaTime,
+ fps: session.fps,
+ });
+
+ session.result = result;
+ this.config.previewElements(result.updates);
+ }
+
+ private handleMouseUp(): void {
+ if (this.session.kind !== "active") return;
+ const session = this.session;
+
+ this.config.discardPreview();
+
+ if (
+ session.result &&
+ hasResizeChanges({ members: session.members, result: session.result })
+ ) {
+ this.config.commitElements(session.result.updates);
+ }
+
+ this.finishSession();
+ }
+}
diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts
new file mode 100644
index 00000000..67dc6e96
--- /dev/null
+++ b/apps/web/src/timeline/controllers/seek-controller.ts
@@ -0,0 +1,210 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import type { FrameRate } from "opencut-wasm";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import { mediaTime, snapSeekMediaTime, TICKS_PER_SECOND, type MediaTime } from "@/wasm";
+
+type SeekSource = "ruler" | "tracks";
+
+interface PendingSeekSession {
+ kind: "pending";
+ source: SeekSource;
+ downX: number;
+ downY: number;
+ downTime: number;
+}
+
+type Session = { kind: "idle" } | PendingSeekSession;
+
+export interface SeekConfig {
+ zoomLevel: number;
+ duration: MediaTime;
+ isSelecting: boolean;
+ getPlayheadEl: () => HTMLDivElement | null;
+ getTrackLabelsEl: () => HTMLDivElement | null;
+ getRulerScrollEl: () => HTMLDivElement | null;
+ getTracksScrollEl: () => HTMLDivElement | null;
+ getActiveProjectFps: () => FrameRate | null;
+ clearSelectedElements: () => void;
+ seek: (time: MediaTime) => void;
+ setTimelineViewState: (viewState: {
+ zoomLevel: number;
+ scrollLeft: number;
+ playheadTime: MediaTime;
+ }) => void;
+}
+
+export interface SeekConfigRef {
+ readonly current: SeekConfig;
+}
+
+function pixelToTime({
+ clientX,
+ scrollContainer,
+ zoomLevel,
+ duration,
+}: {
+ clientX: number;
+ scrollContainer: HTMLDivElement;
+ zoomLevel: number;
+ duration: MediaTime;
+}): MediaTime {
+ const rect = scrollContainer.getBoundingClientRect();
+ const mouseX = clientX - rect.left;
+ const scrollLeft = scrollContainer.scrollLeft;
+
+ const rawTimeSeconds = Math.max(
+ 0,
+ Math.min(
+ duration / TICKS_PER_SECOND,
+ (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
+ ),
+ );
+
+ return mediaTime({ ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND) });
+}
+
+function isClickGesture({
+ event,
+ session,
+}: {
+ event: ReactMouseEvent;
+ session: PendingSeekSession;
+}): boolean {
+ const deltaX = Math.abs(event.clientX - session.downX);
+ const deltaY = Math.abs(event.clientY - session.downY);
+ const deltaTime = event.timeStamp - session.downTime;
+
+ return deltaX <= 5 && deltaY <= 5 && deltaTime <= 500;
+}
+
+export class SeekController {
+ private session: Session = { kind: "idle" };
+ private readonly configRef: SeekConfigRef;
+
+ constructor(deps: { configRef: SeekConfigRef }) {
+ this.configRef = deps.configRef;
+ this.onTracksMouseDown = this.onTracksMouseDown.bind(this);
+ this.onRulerMouseDown = this.onRulerMouseDown.bind(this);
+ this.onTracksClick = this.onTracksClick.bind(this);
+ this.onRulerClick = this.onRulerClick.bind(this);
+ }
+
+ private get config(): SeekConfig {
+ return this.configRef.current;
+ }
+
+ destroy(): void {
+ this.session = { kind: "idle" };
+ }
+
+ onTracksMouseDown(event: ReactMouseEvent): void {
+ this.beginPendingSeek({ event, source: "tracks" });
+ }
+
+ onRulerMouseDown(event: ReactMouseEvent): void {
+ this.beginPendingSeek({ event, source: "ruler" });
+ }
+
+ onTracksClick(event: ReactMouseEvent): void {
+ this.handleClick({ event, source: "tracks" });
+ }
+
+ onRulerClick(event: ReactMouseEvent): void {
+ this.handleClick({ event, source: "ruler" });
+ }
+
+ private beginPendingSeek({
+ event,
+ source,
+ }: {
+ event: ReactMouseEvent;
+ source: SeekSource;
+ }): void {
+ if (event.button !== 0) return;
+
+ this.session = {
+ kind: "pending",
+ source,
+ downX: event.clientX,
+ downY: event.clientY,
+ downTime: event.timeStamp,
+ };
+ }
+
+ private handleClick({
+ event,
+ source,
+ }: {
+ event: ReactMouseEvent;
+ source: SeekSource;
+ }): void {
+ const shouldProcess = this.shouldProcessClick({ event, source });
+ this.session = { kind: "idle" };
+
+ if (!shouldProcess) return;
+
+ this.config.clearSelectedElements();
+ this.seekFromEvent({ event, source });
+ }
+
+ private shouldProcessClick({
+ event,
+ source,
+ }: {
+ event: ReactMouseEvent;
+ source: SeekSource;
+ }): boolean {
+ if (this.session.kind !== "pending") return false;
+ if (this.session.source !== source) return false;
+ if (!isClickGesture({ event, session: this.session })) return false;
+ if (this.config.isSelecting) return false;
+
+ const target = event.target as HTMLElement;
+ if (this.config.getPlayheadEl()?.contains(target)) return false;
+
+ if (this.config.getTrackLabelsEl()?.contains(target)) {
+ this.config.clearSelectedElements();
+ return false;
+ }
+
+ return true;
+ }
+
+ private seekFromEvent({
+ event,
+ source,
+ }: {
+ event: ReactMouseEvent;
+ source: SeekSource;
+ }): void {
+ const scrollContainer =
+ source === "ruler"
+ ? this.config.getRulerScrollEl()
+ : this.config.getTracksScrollEl();
+ if (!scrollContainer) return;
+
+ const rawTime = pixelToTime({
+ clientX: event.clientX,
+ scrollContainer,
+ zoomLevel: this.config.zoomLevel,
+ duration: this.config.duration,
+ });
+
+ const fps = this.config.getActiveProjectFps();
+ const time =
+ fps != null
+ ? snapSeekMediaTime({
+ time: rawTime,
+ duration: this.config.duration,
+ fps,
+ })
+ : rawTime;
+
+ this.config.seek(time);
+ this.config.setTimelineViewState({
+ zoomLevel: this.config.zoomLevel,
+ scrollLeft: scrollContainer.scrollLeft,
+ playheadTime: time,
+ });
+ }
+}
diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts
new file mode 100644
index 00000000..c5090e16
--- /dev/null
+++ b/apps/web/src/timeline/controllers/zoom-controller.ts
@@ -0,0 +1,286 @@
+import type { WheelEvent as ReactWheelEvent } from "react";
+import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction";
+import { timelineTimeToPixels } from "@/timeline/pixel-utils";
+import { TIMELINE_ZOOM_MAX } from "@/timeline/scale";
+import { zoomToSlider } from "@/timeline/zoom-utils";
+import type { MediaTime } from "@/wasm";
+
+type ZoomUpdater = number | ((prev: number) => number);
+
+export interface ZoomConfig {
+ minZoom: number;
+ getContainerEl: () => HTMLDivElement | null;
+ getTracksScrollEl: () => HTMLDivElement | null;
+ getRulerScrollEl: () => HTMLDivElement | null;
+ getCurrentPlayheadTime: () => MediaTime;
+ seek: (time: MediaTime) => void;
+ setTimelineViewState: (viewState: {
+ zoomLevel: number;
+ scrollLeft: number;
+ playheadTime: MediaTime;
+ }) => void;
+}
+
+export interface ZoomConfigRef {
+ readonly current: ZoomConfig;
+}
+
+function clampZoom(zoomLevel: number, minZoom: number): number {
+ return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel));
+}
+
+export class ZoomController {
+ private readonly configRef: ZoomConfigRef;
+ private readonly subscribers = new Set<() => void>();
+
+ private zoomLevelValue: number;
+ private hasInitialized = false;
+ private hasRestoredPlayhead = false;
+ private hasRestoredScroll = false;
+ private previousZoom: number;
+ private preZoomScrollLeft = 0;
+ private prePlayheadAnchorScrollLeft = 0;
+ private isInPlayheadAnchorMode = false;
+ private scrollSaveTimeout: ReturnType<typeof setTimeout> | null = null;
+
+ constructor(deps: { configRef: ZoomConfigRef; initialZoom?: number }) {
+ this.configRef = deps.configRef;
+
+ const minZoom = this.config.minZoom;
+ this.zoomLevelValue =
+ deps.initialZoom !== undefined
+ ? clampZoom(deps.initialZoom, minZoom)
+ : minZoom;
+ this.previousZoom = this.zoomLevelValue;
+ this.hasInitialized = deps.initialZoom !== undefined;
+
+ this.setZoomLevel = this.setZoomLevel.bind(this);
+ this.handleWheel = this.handleWheel.bind(this);
+ this.saveScrollPosition = this.saveScrollPosition.bind(this);
+ }
+
+ private get config(): ZoomConfig {
+ return this.configRef.current;
+ }
+
+ get zoomLevel(): number {
+ return this.zoomLevelValue;
+ }
+
+ subscribe(fn: () => void): () => void {
+ this.subscribers.add(fn);
+ return () => this.subscribers.delete(fn);
+ }
+
+ destroy(): void {
+ if (this.scrollSaveTimeout) {
+ clearTimeout(this.scrollSaveTimeout);
+ this.scrollSaveTimeout = null;
+ }
+ }
+
+ setZoomLevel(zoomLevelOrUpdater: ZoomUpdater): void {
+ const scrollElement = this.config.getTracksScrollEl();
+ if (scrollElement) {
+ this.preZoomScrollLeft = scrollElement.scrollLeft;
+ }
+
+ const nextZoomRaw =
+ typeof zoomLevelOrUpdater === "function"
+ ? zoomLevelOrUpdater(this.zoomLevelValue)
+ : zoomLevelOrUpdater;
+ const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom);
+ if (nextZoom === this.zoomLevelValue) return;
+
+ this.zoomLevelValue = nextZoom;
+ this.notify();
+ }
+
+ handleWheel(event: ReactWheelEvent): void {
+ const isZoomGesture = event.ctrlKey || event.metaKey;
+ const isHorizontalScrollGesture =
+ event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
+
+ if (isHorizontalScrollGesture) {
+ return;
+ }
+
+ if (isZoomGesture) {
+ const normalizedDelta =
+ event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
+ const cappedDelta =
+ Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
+ const zoomFactor = Math.exp(-cappedDelta / 300);
+ this.setZoomLevel((prev) => prev * zoomFactor);
+ }
+ }
+
+ reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void {
+ if (initialZoom !== undefined && !this.hasInitialized) {
+ this.hasInitialized = true;
+ this.setZoomLevel(clampZoom(initialZoom, minZoom));
+ return;
+ }
+
+ if (this.zoomLevelValue < minZoom) {
+ this.setZoomLevel(minZoom);
+ }
+ }
+
+ applyZoomLayout(zoomLevel: number): void {
+ const previousZoom = this.previousZoom;
+ if (previousZoom === zoomLevel) return;
+
+ const scrollElement = this.config.getTracksScrollEl();
+ if (!scrollElement) {
+ this.previousZoom = zoomLevel;
+ return;
+ }
+
+ const currentScrollLeft = this.preZoomScrollLeft;
+ const playheadTime = this.config.getCurrentPlayheadTime();
+ const sliderPercent = zoomToSlider({
+ zoomLevel,
+ minZoom: this.config.minZoom,
+ });
+ const previousSliderPercent = zoomToSlider({
+ zoomLevel: previousZoom,
+ minZoom: this.config.minZoom,
+ });
+ const isCrossingThresholdUp =
+ previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+ const isCrossingThresholdDown =
+ previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+
+ const syncScroll = (scrollLeft: number) => {
+ scrollElement.scrollLeft = scrollLeft;
+ const ruler = this.config.getRulerScrollEl();
+ if (ruler) {
+ ruler.scrollLeft = scrollLeft;
+ }
+ };
+
+ const clampScrollLeft = (scrollLeft: number) => {
+ const maxScrollLeft =
+ scrollElement.scrollWidth - scrollElement.clientWidth;
+ return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
+ };
+
+ if (isCrossingThresholdUp) {
+ this.prePlayheadAnchorScrollLeft = currentScrollLeft;
+ this.isInPlayheadAnchorMode = true;
+ }
+
+ if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
+ const playheadPixelsBefore = timelineTimeToPixels({
+ time: playheadTime,
+ zoomLevel: previousZoom,
+ });
+ const playheadPixelsAfter = timelineTimeToPixels({
+ time: playheadTime,
+ zoomLevel,
+ });
+ const viewportOffset = playheadPixelsBefore - currentScrollLeft;
+ const nextScrollLeft = playheadPixelsAfter - viewportOffset;
+ syncScroll(clampScrollLeft(nextScrollLeft));
+ } else if (isCrossingThresholdDown && this.isInPlayheadAnchorMode) {
+ syncScroll(clampScrollLeft(this.prePlayheadAnchorScrollLeft));
+ this.isInPlayheadAnchorMode = false;
+ }
+
+ this.previousZoom = zoomLevel;
+
+ this.config.setTimelineViewState({
+ zoomLevel,
+ scrollLeft: scrollElement.scrollLeft,
+ playheadTime,
+ });
+ }
+
+ saveScrollPosition(): void {
+ if (this.scrollSaveTimeout) {
+ clearTimeout(this.scrollSaveTimeout);
+ }
+
+ this.scrollSaveTimeout = setTimeout(() => {
+ const scrollElement = this.config.getTracksScrollEl();
+ if (!scrollElement) return;
+
+ this.config.setTimelineViewState({
+ zoomLevel: this.zoomLevelValue,
+ scrollLeft: scrollElement.scrollLeft,
+ playheadTime: this.config.getCurrentPlayheadTime(),
+ });
+ }, 300);
+ }
+
+ restoreInitialScrollIfNeeded(
+ initialScrollLeft?: number,
+ ): (() => void) | undefined {
+ if (initialScrollLeft === undefined) return;
+ if (this.hasRestoredScroll) return;
+
+ const scrollElement = this.config.getTracksScrollEl();
+ if (!scrollElement) return;
+
+ const restoreScroll = () => {
+ scrollElement.scrollLeft = initialScrollLeft;
+ const ruler = this.config.getRulerScrollEl();
+ if (ruler) {
+ ruler.scrollLeft = initialScrollLeft;
+ }
+ this.hasRestoredScroll = true;
+ this.preZoomScrollLeft = initialScrollLeft;
+ };
+
+ if (scrollElement.scrollWidth > 0) {
+ restoreScroll();
+ return;
+ }
+
+ const observer = new ResizeObserver(() => {
+ if (scrollElement.scrollWidth > 0) {
+ restoreScroll();
+ observer.disconnect();
+ }
+ });
+ observer.observe(scrollElement);
+ return () => observer.disconnect();
+ }
+
+ restoreInitialPlayheadIfNeeded(initialPlayheadTime?: MediaTime): void {
+ if (initialPlayheadTime === undefined) return;
+ if (this.hasRestoredPlayhead) return;
+
+ this.hasRestoredPlayhead = true;
+ this.config.seek(initialPlayheadTime);
+ }
+
+ bindPreventBrowserZoom(): () => void {
+ const preventZoom = (event: WheelEvent) => {
+ const isZoomKeyPressed = event.ctrlKey || event.metaKey;
+ const container = this.config.getContainerEl();
+ const isInContainer = container?.contains(event.target as Node) ?? false;
+ if (isZoomKeyPressed && isInContainer) {
+ event.preventDefault();
+ }
+ };
+
+ document.addEventListener("wheel", preventZoom, {
+ passive: false,
+ capture: true,
+ });
+
+ return () => {
+ document.removeEventListener("wheel", preventZoom, { capture: true });
+ };
+ }
+
+ private notify(): void {
+ for (const fn of this.subscribers) {
+ fn();
+ }
+ }
+}