summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/controllers/zoom-controller.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-26 17:31:28 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-26 17:31:28 +0200
commit56ca09692a308b22455420d11376ff8d41244c47 (patch)
tree4eca5ae032451c5db954801008aa9f164b2e2b1d /apps/web/src/timeline/controllers/zoom-controller.ts
parente6c6193638e4247ff1e6f2df40de2a057112c773 (diff)
downloadopencut-56ca09692a308b22455420d11376ff8d41244c47.tar.gz
refactor: thinner hooks
Diffstat (limited to 'apps/web/src/timeline/controllers/zoom-controller.ts')
-rw-r--r--apps/web/src/timeline/controllers/zoom-controller.ts286
1 files changed, 286 insertions, 0 deletions
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<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?: 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();
+ }
+ }
+}