diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-25 03:56:22 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-25 03:56:22 +0200 |
| commit | 6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 (patch) | |
| tree | 976d60dbe1ac8e44d6a9259f9d6e20fe6530c698 /apps/web/src/timeline/controllers | |
| parent | 0dffefdd59d8c70fece63436e8556f8ec1e43a49 (diff) | |
| download | opencut-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/controllers')
7 files changed, 2748 insertions, 0 deletions
diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts new file mode 100644 index 00000000..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(); + } + } +} |
