From 56ca09692a308b22455420d11376ff8d41244c47 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 26 Apr 2026 17:31:28 +0200 Subject: refactor: thinner hooks --- .../src/timeline/controllers/seek-controller.ts | 210 +++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 apps/web/src/timeline/controllers/seek-controller.ts (limited to 'apps/web/src/timeline/controllers/seek-controller.ts') diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts new file mode 100644 index 00000000..67dc6e96 --- /dev/null +++ b/apps/web/src/timeline/controllers/seek-controller.ts @@ -0,0 +1,210 @@ +import type { MouseEvent as ReactMouseEvent } from "react"; +import type { FrameRate } from "opencut-wasm"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale"; +import { mediaTime, snapSeekMediaTime, TICKS_PER_SECOND, type MediaTime } from "@/wasm"; + +type SeekSource = "ruler" | "tracks"; + +interface PendingSeekSession { + kind: "pending"; + source: SeekSource; + downX: number; + downY: number; + downTime: number; +} + +type Session = { kind: "idle" } | PendingSeekSession; + +export interface SeekConfig { + zoomLevel: number; + duration: MediaTime; + isSelecting: boolean; + getPlayheadEl: () => HTMLDivElement | null; + getTrackLabelsEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getActiveProjectFps: () => FrameRate | null; + clearSelectedElements: () => void; + seek: (time: MediaTime) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: MediaTime; + }) => void; +} + +export interface SeekConfigRef { + readonly current: SeekConfig; +} + +function pixelToTime({ + clientX, + scrollContainer, + zoomLevel, + duration, +}: { + clientX: number; + scrollContainer: HTMLDivElement; + zoomLevel: number; + duration: MediaTime; +}): MediaTime { + const rect = scrollContainer.getBoundingClientRect(); + const mouseX = clientX - rect.left; + const scrollLeft = scrollContainer.scrollLeft; + + const rawTimeSeconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + (mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + + return mediaTime({ ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND) }); +} + +function isClickGesture({ + event, + session, +}: { + event: ReactMouseEvent; + session: PendingSeekSession; +}): boolean { + const deltaX = Math.abs(event.clientX - session.downX); + const deltaY = Math.abs(event.clientY - session.downY); + const deltaTime = event.timeStamp - session.downTime; + + return deltaX <= 5 && deltaY <= 5 && deltaTime <= 500; +} + +export class SeekController { + private session: Session = { kind: "idle" }; + private readonly configRef: SeekConfigRef; + + constructor(deps: { configRef: SeekConfigRef }) { + this.configRef = deps.configRef; + this.onTracksMouseDown = this.onTracksMouseDown.bind(this); + this.onRulerMouseDown = this.onRulerMouseDown.bind(this); + this.onTracksClick = this.onTracksClick.bind(this); + this.onRulerClick = this.onRulerClick.bind(this); + } + + private get config(): SeekConfig { + return this.configRef.current; + } + + destroy(): void { + this.session = { kind: "idle" }; + } + + onTracksMouseDown(event: ReactMouseEvent): void { + this.beginPendingSeek({ event, source: "tracks" }); + } + + onRulerMouseDown(event: ReactMouseEvent): void { + this.beginPendingSeek({ event, source: "ruler" }); + } + + onTracksClick(event: ReactMouseEvent): void { + this.handleClick({ event, source: "tracks" }); + } + + onRulerClick(event: ReactMouseEvent): void { + this.handleClick({ event, source: "ruler" }); + } + + private beginPendingSeek({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + if (event.button !== 0) return; + + this.session = { + kind: "pending", + source, + downX: event.clientX, + downY: event.clientY, + downTime: event.timeStamp, + }; + } + + private handleClick({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + const shouldProcess = this.shouldProcessClick({ event, source }); + this.session = { kind: "idle" }; + + if (!shouldProcess) return; + + this.config.clearSelectedElements(); + this.seekFromEvent({ event, source }); + } + + private shouldProcessClick({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): boolean { + if (this.session.kind !== "pending") return false; + if (this.session.source !== source) return false; + if (!isClickGesture({ event, session: this.session })) return false; + if (this.config.isSelecting) return false; + + const target = event.target as HTMLElement; + if (this.config.getPlayheadEl()?.contains(target)) return false; + + if (this.config.getTrackLabelsEl()?.contains(target)) { + this.config.clearSelectedElements(); + return false; + } + + return true; + } + + private seekFromEvent({ + event, + source, + }: { + event: ReactMouseEvent; + source: SeekSource; + }): void { + const scrollContainer = + source === "ruler" + ? this.config.getRulerScrollEl() + : this.config.getTracksScrollEl(); + if (!scrollContainer) return; + + const rawTime = pixelToTime({ + clientX: event.clientX, + scrollContainer, + zoomLevel: this.config.zoomLevel, + duration: this.config.duration, + }); + + const fps = this.config.getActiveProjectFps(); + const time = + fps != null + ? snapSeekMediaTime({ + time: rawTime, + duration: this.config.duration, + fps, + }) + : rawTime; + + this.config.seek(time); + this.config.setTimelineViewState({ + zoomLevel: this.config.zoomLevel, + scrollLeft: scrollContainer.scrollLeft, + playheadTime: time, + }); + } +} -- cgit v1.3.1