summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
commit6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 (patch)
tree976d60dbe1ac8e44d6a9259f9d6e20fe6530c698 /apps/web/src/timeline
parent0dffefdd59d8c70fece63436e8556f8ec1e43a49 (diff)
downloadopencut-6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692.tar.gz
refactor: centralize editor interaction state
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events. This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync. Made-with: Cursor
Diffstat (limited to 'apps/web/src/timeline')
-rw-r--r--apps/web/src/timeline/components/index.tsx69
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx23
-rw-r--r--apps/web/src/timeline/components/timeline-track.tsx26
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts577
-rw-r--r--apps/web/src/timeline/controllers/element-interaction-controller.ts691
-rw-r--r--apps/web/src/timeline/controllers/keyframe-drag-controller.ts342
-rw-r--r--apps/web/src/timeline/controllers/playhead-controller.ts314
-rw-r--r--apps/web/src/timeline/controllers/resize-controller.ts329
-rw-r--r--apps/web/src/timeline/controllers/seek-controller.ts210
-rw-r--r--apps/web/src/timeline/controllers/zoom-controller.ts285
-rw-r--r--apps/web/src/timeline/creation.ts17
-rw-r--r--apps/web/src/timeline/drag-data.ts48
-rw-r--r--apps/web/src/timeline/drag-source.ts40
-rw-r--r--apps/web/src/timeline/group-resize/compute-resize.ts58
-rw-r--r--apps/web/src/timeline/group-resize/types.ts4
-rw-r--r--apps/web/src/timeline/hooks/element/use-element-interaction.ts775
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-drag.ts334
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-drag-drop.ts659
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-playhead.ts327
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-resize.ts353
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-seek.ts195
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-zoom.ts277
-rw-r--r--apps/web/src/timeline/types.ts631
23 files changed, 3520 insertions, 3064 deletions
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}
/>
<DragLine
- dropTarget={dragDropTarget}
+ dropTarget={isElementDragging ? dragView.dropTarget : null}
tracks={tracks}
- isVisible={dragState.isDragging}
+ isVisible={isElementDragging}
headerHeight={timelineHeaderHeight}
/>
@@ -540,9 +544,7 @@ export function Timeline() {
<TimelineTrackRows
mainTrackId={mainTrackId}
zoomLevel={zoomLevel}
- dragState={dragState}
- tracksScrollRef={tracksScrollRef}
- lastMouseXRef={lastMouseXRef}
+ dragView={dragView}
onResizeStart={handleResizeStart}
onElementMouseDown={handleElementMouseDown}
onElementClick={handleElementClick}
@@ -718,9 +720,7 @@ function TrackLabelsPanel({
function TimelineTrackRows({
mainTrackId,
zoomLevel,
- dragState,
- tracksScrollRef,
- lastMouseXRef,
+ dragView,
onResizeStart,
onElementMouseDown,
onElementClick,
@@ -732,9 +732,7 @@ function TimelineTrackRows({
}: {
mainTrackId: string | null;
zoomLevel: number;
- dragState: ElementDragState;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- lastMouseXRef: React.RefObject<number>;
+ dragView: ElementDragView;
onResizeStart: React.ComponentProps<
typeof TimelineTrackContent
>["onResizeStart"];
@@ -776,8 +774,16 @@ function TimelineTrackRows({
[tracks, expandedElementIds],
);
+ const draggingElementIds = useMemo(
+ () =>
+ dragView.kind === "dragging"
+ ? dragView.memberTimeOffsets
+ : (null as ReadonlyMap<string, number> | 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({
<TimelineTrackContent
track={track}
zoomLevel={zoomLevel}
- dragState={dragState}
- rulerScrollRef={tracksScrollRef}
- tracksScrollRef={tracksScrollRef}
- lastMouseXRef={lastMouseXRef}
+ dragView={dragView}
onResizeStart={onResizeStart}
onElementMouseDown={onElementMouseDown}
onElementClick={onElementClick}
diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx
index 74c34607..35785332 100644
--- a/apps/web/src/timeline/components/timeline-element.tsx
+++ b/apps/web/src/timeline/components/timeline-element.tsx
@@ -34,7 +34,7 @@ import {
import type {
TimelineElement as TimelineElementType,
TimelineTrack,
- ElementDragState,
+ ElementDragView,
VideoElement,
ImageElement,
AudioElement,
@@ -214,7 +214,7 @@ interface TimelineElementProps {
event: React.MouseEvent,
element: TimelineElementType,
) => 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<HTMLDivElement | null>;
- tracksScrollRef: React.RefObject<HTMLDivElement | null>;
- lastMouseXRef: React.RefObject<number>;
+ dragView: ElementDragView;
onResizeStart: (params: {
event: React.MouseEvent;
element: TimelineElementType;
@@ -42,10 +36,7 @@ interface TimelineTrackContentProps {
export function TimelineTrackContent({
track,
zoomLevel,
- dragState,
- rulerScrollRef,
- tracksScrollRef,
- lastMouseXRef,
+ dragView,
onResizeStart,
onElementMouseDown,
onElementClick,
@@ -55,15 +46,6 @@ export function TimelineTrackContent({
targetElementId = null,
}: TimelineTrackContentProps) {
const { isElementSelected } = useElementSelection();
- const duration = useEditor((e) => e.timeline.getTotalDuration());
-
- useEdgeAutoScroll({
- isActive: dragState.isDragging,
- getMouseClientX: () => lastMouseXRef.current ?? 0,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
- });
return (
<div className="relative size-full">
@@ -120,7 +102,7 @@ export function TimelineTrackContent({
onElementClick={(event, element) =>
onElementClick({ event, element, track })
}
- dragState={dragState}
+ dragView={dragView}
isDropTarget={element.id === targetElementId}
/>
);
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<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[];
+}): 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<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..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<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: 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<string, number>();
+ 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<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: 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<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?: 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 { registerCanceller } from "@/editor/cancel-interaction";
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";
+ ElementInteractionController,
+ type ElementInteractionDeps,
+ type ElementInteractionDepsRef,
+} from "@/timeline/controllers/element-interaction-controller";
import type { SnapPoint } from "@/timeline/snapping";
-import { registerCanceller } from "@/editor/cancel-interaction";
-import type {
- DropTarget,
- ElementRef,
- ElementDragState,
- SceneTracks,
- TimelineElement,
- TimelineTrack,
-} from "@/timeline";
interface UseElementInteractionProps {
zoomLevel: number;
- timelineRef: RefObject<HTMLDivElement | null>;
tracksContainerRef: RefObject<HTMLDivElement | null>;
tracksScrollRef: RefObject<HTMLDivElement | null>;
headerRef?: RefObject<HTMLElement | null>;
@@ -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<HTMLDivElement | null>;
- tracksScrollRef: RefObject<HTMLDivElement | null>;
- headerRef?: RefObject<HTMLElement | null>;
- 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<ElementDragState>(initialDragState);
- const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
- const [isPendingDrag, setIsPendingDrag] = useState(false);
- const pendingDragRef = useRef<PendingDragState | null>(null);
- const moveGroupRef = useRef<MoveGroup | null>(null);
- const newTrackIdsRef = useRef<string[]>([]);
- const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
- const lastMouseXRef = useRef(0);
- const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
-
- const startDrag = useCallback(
- ({
- elementId,
- dragElementIds,
- dragTimeOffsets,
- trackId,
- startMouseX,
- startMouseY,
- startElementTime,
- clickOffsetTime,
- initialCurrentTime,
- initialCurrentMouseY,
- }: StartDragParams) => {
- setDragState({
- isDragging: true,
- elementId,
- dragElementIds,
- dragTimeOffsets,
- trackId,
- startMouseX,
- startMouseY,
- startElementTime,
- clickOffsetTime,
- currentTime: initialCurrentTime,
- currentMouseY: initialCurrentMouseY,
- });
+ const deps: ElementInteractionDeps = {
+ viewport: {
+ getZoomLevel: () => zoomLevel,
+ getTracksScrollEl: () => tracksScrollRef.current,
+ getTracksContainerEl: () => tracksContainerRef.current,
+ getHeaderEl: () => headerRef?.current ?? null,
},
- [],
- );
-
- const endDrag = useCallback(() => {
- moveGroupRef.current = null;
- newTrackIdsRef.current = [];
- groupMoveResultRef.current = null;
- setDragState(initialDragState);
- setDragDropTarget(null);
- }, []);
-
- const cancelCurrentDrag = useCallback(() => {
- pendingDragRef.current = null;
- mouseDownLocationRef.current = null;
- setIsPendingDrag(false);
- endDrag();
- onSnapPointChange?.(null);
- }, [endDrag, onSnapPointChange]);
-
- const resolveGroupDragMove = useCallback(
- ({
- group,
- snappedTime,
- dropTarget,
- }: {
- group: MoveGroup;
- snappedTime: 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],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- 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,
- };
+ scene: {
+ getTracks: () => editor.scenes.getActiveScene().tracks,
+ getActiveFps: () => editor.project.getActive()?.settings.fps ?? null,
},
- [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<string, number> = {};
- 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);
+ 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),
},
- [zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
- );
+ snap: {
+ isEnabled: () => snappingEnabled,
+ onChange: onSnapPointChange,
+ },
+ };
- const handleElementClick = useCallback(
- ({
- event,
- element,
- track,
- }: {
- event: ReactMouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- }) => {
- event.stopPropagation();
+ const depsRef = useRef<ElementInteractionDeps>(deps);
+ depsRef.current = deps;
- 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;
- }
- }
+ const controllerRef = useRef<ElementInteractionController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ElementInteractionController({
+ depsRef: depsRef as ElementInteractionDepsRef,
+ });
+ }
+ const controller = controllerRef.current;
- // modifier keys already handled in mousedown
- if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- const alreadySelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
- if (!alreadySelected || selectedElements.length > 1) {
- selectElement({ trackId: track.id, elementId: element.id });
- return;
- }
+ useEffect(() => {
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isActive, controller]);
- 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<string>;
- deltaTime: number;
-}
-
-const initialDragState: KeyframeDragState = {
- isDragging: false,
- draggingKeyframeIds: new Set(),
- deltaTime: 0,
-};
+import {
+ KeyframeDragController,
+ type KeyframeDragConfig,
+ type KeyframeDragState,
+} from "@/timeline/controllers/keyframe-drag-controller";
+import type { TimelineElement } from "@/timeline";
-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<KeyframeDragState>(initialDragState);
- const [isPendingDrag, setIsPendingDrag] = useState(false);
-
- const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
- const mouseDownXRef = useRef<number | null>(null);
-
- const activeProject = editor.project.getActive();
- const fps = activeProject.settings.fps;
-
- const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
-
- const endDrag = useCallback(() => {
- setDragState(initialDragState);
- }, []);
-
- const cancelDrag = useCallback(() => {
- pendingDragRef.current = null;
- mouseDownXRef.current = null;
- setIsPendingDrag(false);
- endDrag();
- }, [endDrag]);
-
- const commitDrag = useCallback(
- ({
- keyframeRefs,
- deltaTime,
- }: {
- keyframeRefs: SelectedKeyframeRef[];
- deltaTime: 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,
- ]);
-
- useEffect(() => {
- if (!isPendingDrag) return;
-
- 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;
+ isKeyframeSelected,
+ setKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ executeCommand: (command) => editor.command.execute({ command }),
+ seek: (args) => editor.playback.seek(args),
+ getTotalDuration: () => editor.timeline.getTotalDuration(),
+ };
- if (wasDrag) return;
+ const configRef = useRef<KeyframeDragConfig>(config);
+ configRef.current = config;
- const duration = editor.timeline.getTotalDuration();
- const seekTime =
- snappedSeekTime({
- time: displayedStartTime + indicatorTime,
- duration,
- rate: fps,
- }) ?? displayedStartTime + indicatorTime;
- editor.playback.seek({ time: seekTime });
+ const controllerRef = useRef<KeyframeDragController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new KeyframeDragController({ configRef });
+ }
+ const controller = controllerRef.current;
- if (event.shiftKey) {
- selectKeyframeRange({
- orderedKeyframes,
- targetKeyframes: keyframes,
- isAdditive: event.metaKey || event.ctrlKey,
- });
- return;
- }
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- toggleKeyframeSelection({
- keyframes,
- isMultiKey: event.metaKey || event.ctrlKey,
- });
- },
- [
- toggleKeyframeSelection,
- selectKeyframeRange,
- editor,
- displayedStartTime,
- fps,
- ],
- );
+ useEffect(() => {
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isActive, controller]);
- 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<HTMLDivElement | null>;
@@ -40,609 +19,47 @@ export function useTimelineDragDrop({
zoomLevel,
}: UseTimelineDragDropProps) {
const editor = useEditor();
- const [isDragOver, setIsDragOver] = useState(false);
- const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
- const [dragElementType, setElementType] = useState<ElementType | null>(null);
- const getSnappedTime = useCallback(
- ({ time }: { time: number }) => {
- const projectFps = editor.project.getActive().settings.fps;
- return roundToFrame({ time, rate: projectFps }) ?? time;
- },
- [editor],
- );
-
- const getElementType = useCallback(
- ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
- const dragData = getDragData({ dataTransfer });
- if (!dragData) return null;
-
- 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 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 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 currentTarget = dropTarget;
- setIsDragOver(false);
- setDropTarget(null);
- setElementType(null);
+ const configRef = useRef<DragDropConfig>(config);
+ configRef.current = config;
- try {
- if (hasAsset) {
- if (!currentTarget) return;
- const dragData = getDragData({ dataTransfer: e.dataTransfer });
- if (!dragData) return;
+ const controllerRef = useRef<DragDropController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new DragDropController({ configRef });
+ }
+ const controller = controllerRef.current;
- 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);
-
- 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<number | null>(null);
- const isDraggingRulerRef = useRef(false);
- const hasDraggedRulerRef = useRef(false);
- const lastMouseXRef = useRef<number>(0);
-
- const handleScrub = useCallback(
- ({
- event,
- snappingEnabled = true,
- }: {
- event: MouseEvent | React.MouseEvent;
- snappingEnabled?: boolean;
- }) => {
- const ruler = rulerRef.current;
- if (!ruler) return;
- const rulerRect = ruler.getBoundingClientRect();
- const relativeMouseX = event.clientX - rulerRect.left;
-
- const timelineContentWidth = timelineTimeToPixels({
- time: duration,
- zoomLevel,
- });
-
- const clampedMouseX = Math.max(
- 0,
- Math.min(timelineContentWidth, relativeMouseX),
- );
-
- const rawTimeSeconds = Math.max(
- 0,
- Math.min(
- duration / TICKS_PER_SECOND,
- clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
- ),
- );
- const rawTime = 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 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;
- 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<PlayheadController | null>(null);
+ if (!ctrlRef.current) {
+ ctrlRef.current = new PlayheadController({ configRef });
+ }
+ const ctrl = ctrlRef.current;
+ // Scroll → keep playhead position in sync with scroll offset.
useEffect(() => {
const scrollEl = rulerScrollRef.current;
if (!scrollEl) return;
+ const handler = () =>
+ ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
+ scrollEl.addEventListener("scroll", handler, { passive: true });
+ return () => scrollEl.removeEventListener("scroll", handler);
+ }, [ctrl, editor.playback, rulerScrollRef]);
- const handleScroll = () => {
- updatePlayheadLeft(editor.playback.getCurrentTime());
- };
-
- scrollEl.addEventListener("scroll", handleScroll, { passive: true });
- return () => scrollEl.removeEventListener("scroll", handleScroll);
- }, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
-
+ // Playback events → update playhead position and auto-scroll during playback.
useEffect(() => {
- const 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 handler = (time: number) => ctrl.handlePlaybackUpdate(time);
+ ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
+ const unsubscribeUpdate = editor.playback.onUpdate(handler);
+ const unsubscribeSeek = editor.playback.onSeek(handler);
+ return () => {
+ unsubscribeUpdate();
+ unsubscribeSeek();
};
+ }, [ctrl, editor.playback]);
- const initialTime = editor.playback.getCurrentTime();
- handlePlaybackUpdate({
- detail: { time: initialTime },
- } as CustomEvent<{ time: number }>);
+ useEdgeAutoScroll({
+ isActive: isScrubbing,
+ getMouseClientX: () => ctrl.getLastMouseClientX(),
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: timelineTimeToPixels({
+ time: editor.timeline.getTotalDuration(),
+ zoomLevel,
+ }),
+ });
- window.addEventListener("playback-update", handlePlaybackUpdate);
- window.addEventListener("playback-seek", handlePlaybackUpdate);
- return () => {
- window.removeEventListener("playback-update", handlePlaybackUpdate);
- window.removeEventListener("playback-seek", handlePlaybackUpdate);
- };
- }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
+ 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";
+
+export type { ResizeSide };
-interface ResizeInteractionState {
- side: ResizeSide;
- startX: number;
- members: GroupResizeMember[];
+interface UseTimelineResizeProps {
+ zoomLevel: number;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}
export function useTimelineResize({
zoomLevel,
onSnapPointChange,
-}: {
- zoomLevel: number;
- onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
-}) {
+}: UseTimelineResizeProps) {
const editor = useEditor();
const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const { selectedElements } = useElementSelection();
- const [resizeState, setResizeState] = useState<ResizeInteractionState | null>(
- null,
- );
- const latestResultRef = useRef<GroupResizeResult | null>(null);
-
- const cancelResize = useCallback(() => {
- editor.timeline.discardPreview();
- setResizeState(null);
- latestResultRef.current = null;
- onSnapPointChange?.(null);
- }, [editor.timeline, onSnapPointChange]);
- useEffect(() => {
- if (!resizeState) {
- return;
- }
-
- return registerCanceller({ fn: cancelResize });
- }, [resizeState, cancelResize]);
-
- const handleResizeStart = useCallback(
- ({
- event,
- element,
- track,
- side,
- }: {
- event: React.MouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- side: ResizeSide;
- }) => {
- event.stopPropagation();
- event.preventDefault();
-
- const elementRef = {
- trackId: track.id,
- elementId: element.id,
- };
- const activeSelection = selectedElements.some(
- (selectedElement) =>
- selectedElement.trackId === track.id &&
- selectedElement.elementId === element.id,
- )
- ? selectedElements
- : [elementRef];
- const members = buildResizeMembers({
- tracks: editor.scenes.getActiveScene().tracks,
- selectedElements: activeSelection,
- });
- if (members.length === 0) {
- return;
- }
-
- editor.timeline.discardPreview();
- latestResultRef.current = null;
- setResizeState({
- side,
- startX: event.clientX,
- members,
- });
- },
- [selectedElements, editor],
- );
-
- useEffect(() => {
- if (!resizeState) {
- return;
- }
-
- const handleMouseMove = ({ clientX }: MouseEvent) => {
- const deltaX = clientX - resizeState.startX;
- const rawDeltaTime = Math.round(
- (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) *
- TICKS_PER_SECOND,
- );
- const snappedDeltaTime = getSnappedResizeDelta({
- editor,
- resizeState,
- rawDeltaTime,
- zoomLevel,
- shouldSnap: snappingEnabled && !isShiftHeldRef.current,
- onSnapPointChange,
- });
- const fps = editor.project.getActive().settings.fps;
- const result = computeGroupResize({
- members: resizeState.members,
- side: resizeState.side,
- deltaTime: snappedDeltaTime.deltaTime,
- fps,
- });
-
- latestResultRef.current = result;
+ const config: ResizeConfig = {
+ zoomLevel,
+ snappingEnabled,
+ isShiftHeld: () => isShiftHeldRef.current,
+ getSceneTracks: () => editor.scenes.getActiveScene().tracks,
+ getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
+ getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
+ selectedElements,
+ discardPreview: () => editor.timeline.discardPreview(),
+ previewElements: (updates) =>
editor.timeline.previewElements({
- updates: result.updates.map(({ trackId, elementId, patch }) => ({
+ updates: updates.map(({ trackId, elementId, patch }) => ({
trackId,
elementId,
updates: patch,
})),
- });
- };
-
- 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,
- ]);
-
- return {
- isResizing: resizeState !== null,
- handleResizeStart,
};
-}
-
-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);
+ const configRef = useRef(config);
+ configRef.current = config;
- 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 controllerRef = useRef<ResizeController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ResizeController({ configRef });
}
+ const controller = controllerRef.current;
- 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;
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- 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;
- }
- }
+ useEffect(() => {
+ if (!controller.isResizing) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isResizing, controller]);
- onSnapPointChange?.(closestSnapPoint);
- return { deltaTime };
-}
+ useEffect(() => () => controller.destroy(), [controller]);
-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
- );
- });
+ return {
+ isResizing: controller.isResizing,
+ handleResizeStart: controller.onResizeStart,
+ };
}
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<HTMLDivElement | null>;
@@ -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 mouseTrackingRef = useRef({
- isMouseDown: false,
- downX: 0,
- downY: 0,
- downTime: 0,
- });
-
- const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
- if (event.button !== 0) return;
- setMouseTracking({ mouseTrackingRef, event });
- }, []);
-
- const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
- if (event.button !== 0) return;
- setMouseTracking({ mouseTrackingRef, event });
- }, []);
-
- const shouldProcessTimelineClick = useCallback(
- ({ event }: { event: React.MouseEvent }) => {
- const target = event.target as HTMLElement;
- const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
- const deltaX = Math.abs(event.clientX - downX);
- const deltaY = Math.abs(event.clientY - downY);
- const deltaTime = event.timeStamp - downTime;
- const isPlayhead = !!playheadRef.current?.contains(target);
- const isTrackLabels = !!trackLabelsRef.current?.contains(target);
- const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
-
- if (!isMouseDown) return false;
- if (shouldBlockForDrag) return false;
- if (isSelecting) return false;
- if (isPlayhead) return false;
- if (isTrackLabels) {
- clearSelectedElements();
- return false;
- }
-
- return true;
- },
- [isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
- );
-
- const handleTimelineSeek = useCallback(
- ({
- event,
- source,
- }: {
- event: React.MouseEvent;
- source: "ruler" | "tracks";
- }) => {
- const scrollContainer =
- source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
-
- if (!scrollContainer) return;
-
- const rect = scrollContainer.getBoundingClientRect();
- const mouseX = event.clientX - rect.left;
- const scrollLeft = scrollContainer.scrollLeft;
-
- const rawTimeSeconds = Math.max(
- 0,
- Math.min(
- duration / TICKS_PER_SECOND,
- (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
- ),
- );
- const rawTime = 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 });
+ 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 }),
+ };
- if (shouldProcess) {
- clearSelectedElements();
- handleTimelineSeek({ event, source: "tracks" });
- }
- },
- [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
- );
+ const configRef = useRef(config);
+ configRef.current = config;
- const handleRulerClick = useCallback(
- (event: React.MouseEvent) => {
- const shouldProcess = shouldProcessTimelineClick({ event });
- resetMouseTracking({ mouseTrackingRef });
+ const controllerRef = useRef<SeekController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new SeekController({ configRef });
+ }
+ const controller = controllerRef.current;
- 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<HTMLDivElement | null>;
@@ -40,252 +40,53 @@ export function useTimelineZoom({
rulerScrollRef,
}: UseTimelineZoomProps): UseTimelineZoomReturn {
const editor = useEditor();
- const hasInitializedRef = useRef(false);
- const hasRestoredPlayheadRef = useRef(false);
- const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
- null,
- );
-
- const [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 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);
+ 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;
- if (isHorizontalScrollGesture) {
- return;
- }
+ const controllerRef = useRef<ZoomController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ZoomController({ configRef, initialZoom });
+ }
+ const controller = controllerRef.current;
+ const zoomLevel = controller.zoomLevel;
- // 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<T extends ElementType[]>(...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<UploadAudioElement, "id">;
-export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
-export type CreateAudioElement =
- | CreateUploadAudioElement
- | CreateLibraryAudioElement;
-export type CreateVideoElement = Omit<VideoElement, "id">;
-export type CreateImageElement = Omit<ImageElement, "id">;
-export type CreateTextElement = Omit<TextElement, "id">;
-export type CreateStickerElement = Omit<StickerElement, "id">;
-export type CreateGraphicElement = Omit<GraphicElement, "id">;
-export type CreateEffectElement = Omit<EffectElement, "id">;
-export type CreateTimelineElement =
- | CreateAudioElement
- | CreateVideoElement
- | CreateImageElement
- | CreateTextElement
- | CreateStickerElement
- | CreateGraphicElement
- | CreateEffectElement;
-
-export interface ElementDragState {
- isDragging: boolean;
- elementId: string | null;
- dragElementIds: string[];
- dragTimeOffsets: Record<string, number>;
- 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<T extends ElementType[]>(...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<UploadAudioElement, "id">;
+export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
+export type CreateAudioElement =
+ | CreateUploadAudioElement
+ | CreateLibraryAudioElement;
+export type CreateVideoElement = Omit<VideoElement, "id">;
+export type CreateImageElement = Omit<ImageElement, "id">;
+export type CreateTextElement = Omit<TextElement, "id">;
+export type CreateStickerElement = Omit<StickerElement, "id">;
+export type CreateGraphicElement = Omit<GraphicElement, "id">;
+export type CreateEffectElement = Omit<EffectElement, "id">;
+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<string, number>;
+ 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;
+}