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/zoom-controller.ts | 286 +++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 apps/web/src/timeline/controllers/zoom-controller.ts (limited to 'apps/web/src/timeline/controllers/zoom-controller.ts') diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts new file mode 100644 index 00000000..c5090e16 --- /dev/null +++ b/apps/web/src/timeline/controllers/zoom-controller.ts @@ -0,0 +1,286 @@ +import type { WheelEvent as ReactWheelEvent } from "react"; +import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction"; +import { timelineTimeToPixels } from "@/timeline/pixel-utils"; +import { TIMELINE_ZOOM_MAX } from "@/timeline/scale"; +import { zoomToSlider } from "@/timeline/zoom-utils"; +import type { MediaTime } from "@/wasm"; + +type ZoomUpdater = number | ((prev: number) => number); + +export interface ZoomConfig { + minZoom: number; + getContainerEl: () => HTMLDivElement | null; + getTracksScrollEl: () => HTMLDivElement | null; + getRulerScrollEl: () => HTMLDivElement | null; + getCurrentPlayheadTime: () => MediaTime; + seek: (time: MediaTime) => void; + setTimelineViewState: (viewState: { + zoomLevel: number; + scrollLeft: number; + playheadTime: MediaTime; + }) => void; +} + +export interface ZoomConfigRef { + readonly current: ZoomConfig; +} + +function clampZoom(zoomLevel: number, minZoom: number): number { + return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel)); +} + +export class ZoomController { + private readonly configRef: ZoomConfigRef; + private readonly subscribers = new Set<() => void>(); + + private zoomLevelValue: number; + private hasInitialized = false; + private hasRestoredPlayhead = false; + private hasRestoredScroll = false; + private previousZoom: number; + private preZoomScrollLeft = 0; + private prePlayheadAnchorScrollLeft = 0; + private isInPlayheadAnchorMode = false; + private scrollSaveTimeout: ReturnType | null = null; + + constructor(deps: { configRef: ZoomConfigRef; initialZoom?: number }) { + this.configRef = deps.configRef; + + const minZoom = this.config.minZoom; + this.zoomLevelValue = + deps.initialZoom !== undefined + ? clampZoom(deps.initialZoom, minZoom) + : minZoom; + this.previousZoom = this.zoomLevelValue; + this.hasInitialized = deps.initialZoom !== undefined; + + this.setZoomLevel = this.setZoomLevel.bind(this); + this.handleWheel = this.handleWheel.bind(this); + this.saveScrollPosition = this.saveScrollPosition.bind(this); + } + + private get config(): ZoomConfig { + return this.configRef.current; + } + + get zoomLevel(): number { + return this.zoomLevelValue; + } + + subscribe(fn: () => void): () => void { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); + } + + destroy(): void { + if (this.scrollSaveTimeout) { + clearTimeout(this.scrollSaveTimeout); + this.scrollSaveTimeout = null; + } + } + + setZoomLevel(zoomLevelOrUpdater: ZoomUpdater): void { + const scrollElement = this.config.getTracksScrollEl(); + if (scrollElement) { + this.preZoomScrollLeft = scrollElement.scrollLeft; + } + + const nextZoomRaw = + typeof zoomLevelOrUpdater === "function" + ? zoomLevelOrUpdater(this.zoomLevelValue) + : zoomLevelOrUpdater; + const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom); + if (nextZoom === this.zoomLevelValue) return; + + this.zoomLevelValue = nextZoom; + this.notify(); + } + + handleWheel(event: ReactWheelEvent): void { + const isZoomGesture = event.ctrlKey || event.metaKey; + const isHorizontalScrollGesture = + event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); + + if (isHorizontalScrollGesture) { + return; + } + + if (isZoomGesture) { + const normalizedDelta = + event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY; + const cappedDelta = + Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30); + const zoomFactor = Math.exp(-cappedDelta / 300); + this.setZoomLevel((prev) => prev * zoomFactor); + } + } + + reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void { + if (initialZoom !== undefined && !this.hasInitialized) { + this.hasInitialized = true; + this.setZoomLevel(clampZoom(initialZoom, minZoom)); + return; + } + + if (this.zoomLevelValue < minZoom) { + this.setZoomLevel(minZoom); + } + } + + applyZoomLayout(zoomLevel: number): void { + const previousZoom = this.previousZoom; + if (previousZoom === zoomLevel) return; + + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) { + this.previousZoom = zoomLevel; + return; + } + + const currentScrollLeft = this.preZoomScrollLeft; + const playheadTime = this.config.getCurrentPlayheadTime(); + const sliderPercent = zoomToSlider({ + zoomLevel, + minZoom: this.config.minZoom, + }); + const previousSliderPercent = zoomToSlider({ + zoomLevel: previousZoom, + minZoom: this.config.minZoom, + }); + const isCrossingThresholdUp = + previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + const isCrossingThresholdDown = + previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD && + sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD; + + const syncScroll = (scrollLeft: number) => { + scrollElement.scrollLeft = scrollLeft; + const ruler = this.config.getRulerScrollEl(); + if (ruler) { + ruler.scrollLeft = scrollLeft; + } + }; + + const clampScrollLeft = (scrollLeft: number) => { + const maxScrollLeft = + scrollElement.scrollWidth - scrollElement.clientWidth; + return Math.max(0, Math.min(maxScrollLeft, scrollLeft)); + }; + + if (isCrossingThresholdUp) { + this.prePlayheadAnchorScrollLeft = currentScrollLeft; + this.isInPlayheadAnchorMode = true; + } + + if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { + const playheadPixelsBefore = timelineTimeToPixels({ + time: playheadTime, + zoomLevel: previousZoom, + }); + const playheadPixelsAfter = timelineTimeToPixels({ + time: playheadTime, + zoomLevel, + }); + const viewportOffset = playheadPixelsBefore - currentScrollLeft; + const nextScrollLeft = playheadPixelsAfter - viewportOffset; + syncScroll(clampScrollLeft(nextScrollLeft)); + } else if (isCrossingThresholdDown && this.isInPlayheadAnchorMode) { + syncScroll(clampScrollLeft(this.prePlayheadAnchorScrollLeft)); + this.isInPlayheadAnchorMode = false; + } + + this.previousZoom = zoomLevel; + + this.config.setTimelineViewState({ + zoomLevel, + scrollLeft: scrollElement.scrollLeft, + playheadTime, + }); + } + + saveScrollPosition(): void { + if (this.scrollSaveTimeout) { + clearTimeout(this.scrollSaveTimeout); + } + + this.scrollSaveTimeout = setTimeout(() => { + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) return; + + this.config.setTimelineViewState({ + zoomLevel: this.zoomLevelValue, + scrollLeft: scrollElement.scrollLeft, + playheadTime: this.config.getCurrentPlayheadTime(), + }); + }, 300); + } + + restoreInitialScrollIfNeeded( + initialScrollLeft?: number, + ): (() => void) | undefined { + if (initialScrollLeft === undefined) return; + if (this.hasRestoredScroll) return; + + const scrollElement = this.config.getTracksScrollEl(); + if (!scrollElement) return; + + const restoreScroll = () => { + scrollElement.scrollLeft = initialScrollLeft; + const ruler = this.config.getRulerScrollEl(); + if (ruler) { + ruler.scrollLeft = initialScrollLeft; + } + this.hasRestoredScroll = true; + this.preZoomScrollLeft = initialScrollLeft; + }; + + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + return; + } + + const observer = new ResizeObserver(() => { + if (scrollElement.scrollWidth > 0) { + restoreScroll(); + observer.disconnect(); + } + }); + observer.observe(scrollElement); + return () => observer.disconnect(); + } + + restoreInitialPlayheadIfNeeded(initialPlayheadTime?: MediaTime): void { + if (initialPlayheadTime === undefined) return; + if (this.hasRestoredPlayhead) return; + + this.hasRestoredPlayhead = true; + this.config.seek(initialPlayheadTime); + } + + bindPreventBrowserZoom(): () => void { + const preventZoom = (event: WheelEvent) => { + const isZoomKeyPressed = event.ctrlKey || event.metaKey; + const container = this.config.getContainerEl(); + const isInContainer = container?.contains(event.target as Node) ?? false; + if (isZoomKeyPressed && isInContainer) { + event.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { + passive: false, + capture: true, + }); + + return () => { + document.removeEventListener("wheel", preventZoom, { capture: true }); + }; + } + + private notify(): void { + for (const fn of this.subscribers) { + fn(); + } + } +} -- cgit v1.3.1