export interface PreviewZoomState { zoomPercent: number; panX: number; panY: number; } export const MIN_PREVIEW_ZOOM_PERCENT = 25; export const MAX_PREVIEW_ZOOM_PERCENT = 400; export const PREVIEW_PAN_SURFACE_SELECTOR = '[data-preview-pan-surface="true"]'; export const PREVIEW_PAN_OVERSCROLL_PX = 48; export const DEFAULT_PREVIEW_ZOOM: PreviewZoomState = { zoomPercent: 100, panX: 0, panY: 0, }; const ZOOM_SENSITIVITY = 0.007; const MAX_DELTA = 10; export function toDomPrecision(value: number): number { return Math.round(value * 10000) / 10000; } export function clampPreviewZoomPercent(percent: number): number { if (!Number.isFinite(percent)) return 100; return Math.min(MAX_PREVIEW_ZOOM_PERCENT, Math.max(MIN_PREVIEW_ZOOM_PERCENT, percent)); } export function canStartPreviewPan(button: number): boolean { return button === 1; } export function ownsPreviewPanTarget( target: EventTarget | null, stage: HTMLElement | null, ): boolean { if (!(target instanceof Element)) return false; if (stage?.contains(target)) return true; return !!target.closest(PREVIEW_PAN_SURFACE_SELECTOR); } export function getPreviewWheelZoomPercent(deltaY: number, currentZoomPercent: number): number { if (!Number.isFinite(deltaY)) return clampPreviewZoomPercent(currentZoomPercent); const clamped = Math.abs(deltaY) > MAX_DELTA ? MAX_DELTA * Math.sign(deltaY) : deltaY; const step = -clamped * ZOOM_SENSITIVITY; const current = clampPreviewZoomPercent(currentZoomPercent); return clampPreviewZoomPercent(current * Math.exp(step)); } export function getNextPreviewZoomPercent( direction: "in" | "out", currentZoomPercent: number, ): number { const current = clampPreviewZoomPercent(currentZoomPercent); const multiplier = direction === "in" ? 1.25 : 0.8; return clampPreviewZoomPercent(current * multiplier); } export function clampPreviewPan(input: { panX: number; panY: number; zoomPercent: number; viewportWidth: number; viewportHeight: number; contentWidth?: number; contentHeight?: number; }): Pick { const scale = clampPreviewZoomPercent(input.zoomPercent) / 100; const contentWidth = input.contentWidth ?? input.viewportWidth; const contentHeight = input.contentHeight ?? input.viewportHeight; const maxPanX = Math.max(0, (contentWidth * scale - input.viewportWidth) / 2) + PREVIEW_PAN_OVERSCROLL_PX; const maxPanY = Math.max(0, (contentHeight * scale - input.viewportHeight) / 2) + PREVIEW_PAN_OVERSCROLL_PX; return { panX: Math.min(maxPanX, Math.max(-maxPanX, input.panX)), panY: Math.min(maxPanY, Math.max(-maxPanY, input.panY)), }; } function clampPreviewPanForZoom( panX: number, panY: number, zoomPercent: number, viewportWidth: number, viewportHeight: number, contentWidth: number, contentHeight: number, ): Pick { const scale = clampPreviewZoomPercent(zoomPercent) / 100; const maxPanX = Math.abs(contentWidth * scale - viewportWidth) / 2 + PREVIEW_PAN_OVERSCROLL_PX; const maxPanY = Math.abs(contentHeight * scale - viewportHeight) / 2 + PREVIEW_PAN_OVERSCROLL_PX; return { panX: Math.min(maxPanX, Math.max(-maxPanX, panX)), panY: Math.min(maxPanY, Math.max(-maxPanY, panY)), }; } export function resolvePreviewWheelZoom(input: { state: PreviewZoomState; deltaY: number; viewportWidth: number; viewportHeight: number; contentWidth?: number; contentHeight?: number; cursorX?: number; cursorY?: number; }): PreviewZoomState { const oldZoom = clampPreviewZoomPercent(input.state.zoomPercent); const nextZoomPercent = getPreviewWheelZoomPercent(input.deltaY, oldZoom); const oldScale = oldZoom / 100; const newScale = nextZoomPercent / 100; let panX = input.state.panX; let panY = input.state.panY; if (input.cursorX !== undefined && input.cursorY !== undefined) { const ratio = newScale / oldScale; panX = input.cursorX * (1 - ratio) + panX * ratio; panY = input.cursorY * (1 - ratio) + panY * ratio; } const cw = input.contentWidth ?? input.viewportWidth; const ch = input.contentHeight ?? input.viewportHeight; const pan = clampPreviewPanForZoom( panX, panY, nextZoomPercent, input.viewportWidth, input.viewportHeight, cw, ch, ); return { zoomPercent: nextZoomPercent, ...pan, }; } export function resolvePreviewWheelPan(input: { state: PreviewZoomState; deltaX: number; deltaY: number; viewportWidth: number; viewportHeight: number; contentWidth?: number; contentHeight?: number; }): PreviewZoomState { const pan = clampPreviewPan({ panX: input.state.panX - input.deltaX, panY: input.state.panY - input.deltaY, zoomPercent: input.state.zoomPercent, viewportWidth: input.viewportWidth, viewportHeight: input.viewportHeight, contentWidth: input.contentWidth, contentHeight: input.contentHeight, }); return { zoomPercent: clampPreviewZoomPercent(input.state.zoomPercent), ...pan, }; }