diff options
Diffstat (limited to 'apps/web/src/timeline/controllers/element-interaction-controller.ts')
| -rw-r--r-- | apps/web/src/timeline/controllers/element-interaction-controller.ts | 126 |
1 files changed, 83 insertions, 43 deletions
diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts index cc19daaa..5afc3a93 100644 --- a/apps/web/src/timeline/controllers/element-interaction-controller.ts +++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts @@ -7,9 +7,17 @@ import { type MoveGroup, } from "@/timeline/group-move"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; -import { TICKS_PER_SECOND } from "@/wasm"; +import { + maxMediaTime, + type MediaTime, + mediaTime, + roundFrameTime, + subMediaTime, + TICKS_PER_SECOND, + ZERO_MEDIA_TIME, +} from "@/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction"; -import { roundToFrame, type FrameRate } from "opencut-wasm"; +import type { FrameRate } from "opencut-wasm"; import { computeDropTarget } from "@/timeline/components/drop-target"; import { getMouseTimeFromClientX } from "@/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; @@ -52,7 +60,7 @@ export interface ElementSelectionApi { } export interface PlaybackReader { - getCurrentTime: () => number; + getCurrentTime: () => MediaTime; } export interface TimelineOps { @@ -86,8 +94,8 @@ interface MousedownSnapshot { readonly origin: Point; readonly elementId: string; readonly trackId: string; - readonly startElementTime: number; - readonly clickOffsetTime: number; + readonly startElementTime: MediaTime; + readonly clickOffsetTime: MediaTime; readonly selectedElements: readonly ElementRef[]; } @@ -100,7 +108,7 @@ interface DragProgress { // indicator, drop-line, commit path) see the same entity every frame // instead of a churning UUID. reservedNewTrackIds: readonly string[]; - currentTime: number; + currentTime: MediaTime; currentMouseX: number; currentMouseY: number; groupMoveResult: GroupMoveResult | null; @@ -116,20 +124,27 @@ const IDLE_VIEW: ElementDragView = { kind: "idle" }; // --- Pure helpers --- -function pixelToClickOffsetTime( - clientX: number, - elementRect: DOMRect, - zoomLevel: number, -): number { +function pixelToClickOffsetTime({ + clientX, + elementRect, + zoomLevel, +}: { + clientX: number; + elementRect: DOMRect; + zoomLevel: number; +}): MediaTime { const clickOffsetX = clientX - elementRect.left; const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - return Math.round(seconds * TICKS_PER_SECOND); + return mediaTime({ ticks: Math.round(seconds * TICKS_PER_SECOND) }); } -function verticalDirection( - startMouseY: number, - currentMouseY: number, -): "up" | "down" | null { +function verticalDirection({ + startMouseY, + currentMouseY, +}: { + startMouseY: number; + currentMouseY: number; +}): "up" | "down" | null { if (currentMouseY < startMouseY) return "up"; if (currentMouseY > startMouseY) return "down"; return null; @@ -139,7 +154,13 @@ function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] { return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio]; } -function movedPastDragThreshold(current: Point, origin: Point): boolean { +function movedPastDragThreshold({ + current, + origin, +}: { + 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 @@ -156,17 +177,20 @@ function frameSnappedMouseTime({ clientX: number; scrollContainer: HTMLDivElement; zoomLevel: number; - clickOffsetTime: number; + clickOffsetTime: MediaTime; fps: FrameRate; -}): number { +}): MediaTime { 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; + const adjusted = maxMediaTime({ + a: ZERO_MEDIA_TIME, + b: subMediaTime({ a: mouseTime, b: clickOffsetTime }), + }); + return roundFrameTime({ time: adjusted, fps }); } function resolveDropTarget({ @@ -187,7 +211,7 @@ function resolveDropTarget({ tracks: SceneTracks; viewport: ViewportAdapter; zoomLevel: number; - snappedTime: number; + snappedTime: MediaTime; verticalDragDirection: "up" | "down" | null; }): DropTarget | null { const containerRect = viewport @@ -231,7 +255,7 @@ function resolveGroupMoveForDrop({ }: { group: MoveGroup; tracks: SceneTracks; - anchorStartTime: number; + anchorStartTime: MediaTime; dropTarget: DropTarget; reservedNewTrackIds: readonly string[]; }): GroupMoveResult | null { @@ -286,7 +310,7 @@ export class ElementInteractionController { get view(): ElementDragView { if (this.session.kind !== "dragging") return IDLE_VIEW; const { mousedown, drag } = this.session; - const memberTimeOffsets = new Map<string, number>(); + const memberTimeOffsets = new Map<string, MediaTime>(); for (const member of drag.moveGroup.members) { memberTimeOffsets.set(member.elementId, member.timeOffset); } @@ -363,11 +387,11 @@ export class ElementInteractionController { elementId: element.id, trackId: track.id, startElementTime: element.startTime, - clickOffsetTime: pixelToClickOffsetTime( - event.clientX, - event.currentTarget.getBoundingClientRect(), - this.deps.viewport.getZoomLevel(), - ), + clickOffsetTime: pixelToClickOffsetTime({ + clientX: event.clientX, + elementRect: event.currentTarget.getBoundingClientRect(), + zoomLevel: this.deps.viewport.getZoomLevel(), + }), selectedElements, }, }; @@ -426,10 +450,13 @@ export class ElementInteractionController { this.notify(); } - private snapResult( - frameSnappedTime: number, - group: MoveGroup, - ): { snappedTime: number; snapPoint: SnapPoint | null } { + private snapResult({ + frameSnappedTime, + group, + }: { + frameSnappedTime: MediaTime; + group: MoveGroup; + }): { snappedTime: MediaTime; snapPoint: SnapPoint | null } { const { snap, input, scene, viewport, playback } = this.deps; if (!snap.isEnabled() || input.isShiftHeld()) { @@ -461,7 +488,7 @@ export class ElementInteractionController { clientY: number; mousedown: MousedownSnapshot; drag: DragProgress; - snappedTime: number; + snappedTime: MediaTime; }): void { const { scene, viewport } = this.deps; const tracks = scene.getTracks(); @@ -476,7 +503,10 @@ export class ElementInteractionController { viewport, zoomLevel, snappedTime, - verticalDragDirection: verticalDirection(mousedown.origin.y, clientY), + verticalDragDirection: verticalDirection({ + startMouseY: mousedown.origin.y, + currentMouseY: clientY, + }), }); const nextGroupMoveResult = anchorDropTarget @@ -532,7 +562,12 @@ export class ElementInteractionController { clientY: number; scrollContainer: HTMLDivElement; }): void { - if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) { + if ( + !movedPastDragThreshold({ + current: { x: clientX, y: clientY }, + origin: mousedown.origin, + }) + ) { return; } @@ -557,10 +592,10 @@ export class ElementInteractionController { clickOffsetTime: mousedown.clickOffsetTime, fps, }); - const { snappedTime, snapPoint } = this.snapResult( + const { snappedTime, snapPoint } = this.snapResult({ frameSnappedTime, - moveGroup, - ); + group: moveGroup, + }); // Ensure the anchor is selected before we render the drag — covers the // case where the selection store hasn't committed the mousedown-time @@ -621,10 +656,10 @@ export class ElementInteractionController { clickOffsetTime: mousedown.clickOffsetTime, fps, }); - const { snappedTime, snapPoint } = this.snapResult( + const { snappedTime, snapPoint } = this.snapResult({ frameSnappedTime, - drag.moveGroup, - ); + group: drag.moveGroup, + }); drag.currentTime = snappedTime; drag.currentMouseX = clientX; @@ -655,7 +690,12 @@ export class ElementInteractionController { // 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)) { + if ( + !movedPastDragThreshold({ + current: { x: clientX, y: clientY }, + origin: mousedown.origin, + }) + ) { this.lastGestureWasDrag = false; this.finishSession(); return; |
