mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
287 lines
8.0 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|