Files
OpenCut/apps/web/src/timeline/controllers/zoom-controller.ts
T
2026-04-26 17:31:28 +02:00

287 lines
8.0 KiB
TypeScript

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();
}
}
}