mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: thinner hooks
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user