summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/controllers/zoom-controller.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
commit6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 (patch)
tree976d60dbe1ac8e44d6a9259f9d6e20fe6530c698 /apps/web/src/timeline/controllers/zoom-controller.ts
parent0dffefdd59d8c70fece63436e8556f8ec1e43a49 (diff)
downloadopencut-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/zoom-controller.ts')
-rw-r--r--apps/web/src/timeline/controllers/zoom-controller.ts285
1 files changed, 285 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..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();
+ }
+ }
+}