This commit is contained in:
Ole
2026-05-31 20:25:41 +00:00
commit 0a07ab8593
275 changed files with 52660 additions and 0 deletions
@@ -0,0 +1,280 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import type React from 'react';
const ZERO_PAN = { x: 0, y: 0 };
export interface ViewportOptions {
zoom?: number;
onZoomChange?: (zoom: number) => void;
minZoom?: number;
maxZoom?: number;
panOffset?: { x: number; y: number };
onPanChange?: (offset: { x: number; y: number }) => void;
}
/** Get distance between two touch points. */
function getTouchDistance(t1: Touch, t2: Touch): number {
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
return Math.hypot(dx, dy);
}
/** Get midpoint between two touch points. */
function getTouchCenter(t1: Touch, t2: Touch): { x: number; y: number } {
return {
x: (t1.clientX + t2.clientX) / 2,
y: (t1.clientY + t2.clientY) / 2,
};
}
export function useViewport(options: ViewportOptions) {
const {
zoom: controlledZoom,
onZoomChange,
minZoom = 0.25,
maxZoom = 5,
panOffset: controlledPan,
onPanChange,
} = options;
// -- Zoom state (controlled + uncontrolled) --
const [internalZoom, setInternalZoom] = useState(1);
const currentZoom = controlledZoom ?? internalZoom;
const clampZoom = useCallback(
(z: number) => Math.min(maxZoom, Math.max(minZoom, z)),
[minZoom, maxZoom],
);
const handleZoomChange = useCallback(
(newZoom: number) => {
const clamped = clampZoom(newZoom);
setInternalZoom(clamped);
onZoomChange?.(clamped);
},
[onZoomChange, clampZoom],
);
// -- Pan state (controlled + uncontrolled) --
const [internalPan, setInternalPan] = useState(ZERO_PAN);
const currentPan = controlledPan ?? internalPan;
const handlePanChange = useCallback(
(offset: { x: number; y: number }) => {
setInternalPan(offset);
onPanChange?.(offset);
},
[onPanChange],
);
const effectivePan = currentZoom <= 1 ? ZERO_PAN : currentPan;
// -- Stable refs for event handlers --
const currentZoomRef = useRef(currentZoom);
currentZoomRef.current = currentZoom;
const currentPanRef = useRef(currentPan);
currentPanRef.current = currentPan;
const clampZoomRef = useRef(clampZoom);
clampZoomRef.current = clampZoom;
const handleZoomChangeRef = useRef(handleZoomChange);
handleZoomChangeRef.current = handleZoomChange;
const handlePanChangeRef = useRef(handlePanChange);
handlePanChangeRef.current = handlePanChange;
// -- Container dimensions --
const [containerDims, setContainerDims] = useState({ width: 0, height: 0 });
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const eventCleanupRef = useRef<(() => void) | null>(null);
// -- Touch gesture state (stored in ref to avoid re-renders during gesture) --
const touchStateRef = useRef<{
initialDistance: number;
initialZoom: number;
initialPan: { x: number; y: number };
initialCenter: { x: number; y: number };
initialRect: DOMRect;
} | null>(null);
const viewportRef = useCallback((node: HTMLDivElement | null) => {
eventCleanupRef.current?.();
eventCleanupRef.current = null;
viewportObserverRef.current?.disconnect();
viewportObserverRef.current = null;
if (node) {
const measure = () => {
const w = node.clientWidth;
const h = node.clientHeight;
setContainerDims((prev) =>
prev.width === w && prev.height === h ? prev : { width: w, height: h },
);
};
const observer = new ResizeObserver(measure);
observer.observe(node);
measure();
viewportObserverRef.current = observer;
// Ctrl/Cmd + wheel → zoom toward cursor
const wheelHandler = (e: WheelEvent) => {
// Ctrl/Cmd + wheel → zoom toward cursor
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const oldZoom = currentZoomRef.current;
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = clampZoomRef.current(oldZoom * factor);
if (newZoom === oldZoom) return;
const rect = node.getBoundingClientRect();
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
const pan = currentPanRef.current;
const ratio = newZoom / oldZoom;
handleZoomChangeRef.current(newZoom);
handlePanChangeRef.current({
x: cx - (cx - pan.x) * ratio,
y: cy - (cy - pan.y) * ratio,
});
return;
}
// Wheel/trackpad scroll → pan when zoomed in
if (currentZoomRef.current > 1) {
e.preventDefault();
const pan = currentPanRef.current;
handlePanChangeRef.current({
x: pan.x - e.deltaX,
y: pan.y - e.deltaY,
});
}
};
// Touch: pinch-to-zoom + two-finger pan
const touchStartHandler = (e: TouchEvent) => {
if (e.touches.length !== 2) return;
e.preventDefault();
const t1 = e.touches[0];
const t2 = e.touches[1];
touchStateRef.current = {
initialDistance: getTouchDistance(t1, t2),
initialZoom: currentZoomRef.current,
initialPan: { ...currentPanRef.current },
initialCenter: getTouchCenter(t1, t2),
initialRect: node.getBoundingClientRect(),
};
};
const touchMoveHandler = (e: TouchEvent) => {
if (e.touches.length !== 2 || !touchStateRef.current) return;
e.preventDefault();
const t1 = e.touches[0];
const t2 = e.touches[1];
const { initialDistance, initialZoom, initialPan, initialCenter, initialRect } = touchStateRef.current;
// Zoom
const currentDistance = getTouchDistance(t1, t2);
const scale = currentDistance / initialDistance;
const newZoom = clampZoomRef.current(initialZoom * scale);
handleZoomChangeRef.current(newZoom);
// Pan toward pinch center (use cached rect to avoid layout thrashing)
const center = getTouchCenter(t1, t2);
const cx = initialCenter.x - initialRect.left;
const cy = initialCenter.y - initialRect.top;
const ratio = newZoom / initialZoom;
handlePanChangeRef.current({
x: cx - (cx - initialPan.x) * ratio + (center.x - initialCenter.x),
y: cy - (cy - initialPan.y) * ratio + (center.y - initialCenter.y),
});
};
const touchEndHandler = () => {
touchStateRef.current = null;
};
node.addEventListener('wheel', wheelHandler, { passive: false });
node.addEventListener('touchstart', touchStartHandler, { passive: false });
node.addEventListener('touchmove', touchMoveHandler, { passive: false });
node.addEventListener('touchend', touchEndHandler);
node.addEventListener('touchcancel', touchEndHandler);
eventCleanupRef.current = () => {
node.removeEventListener('wheel', wheelHandler);
node.removeEventListener('touchstart', touchStartHandler);
node.removeEventListener('touchmove', touchMoveHandler);
node.removeEventListener('touchend', touchEndHandler);
node.removeEventListener('touchcancel', touchEndHandler);
};
}
}, []);
useEffect(() => {
return () => {
eventCleanupRef.current?.();
viewportObserverRef.current?.disconnect();
};
}, []);
// -- Drag to pan (when zoomed in) --
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef(ZERO_PAN);
const panStart = useRef(ZERO_PAN);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (currentZoomRef.current <= 1) return;
if (e.button !== 1) return; // middle-click only
e.preventDefault();
setIsDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY };
panStart.current = currentPanRef.current;
}, []);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
handlePanChangeRef.current({
x: panStart.current.x + (e.clientX - dragStart.current.x),
y: panStart.current.y + (e.clientY - dragStart.current.y),
});
};
const handleMouseUp = () => setIsDragging(false);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
const cursor = isDragging ? 'grabbing' : currentZoom > 1 ? 'grab' : 'default';
return {
currentZoom,
effectivePan,
containerDims,
viewportRef,
cursor,
handleMouseDown,
handleZoomChange,
handlePanChange,
};
}
export function computeBaseWidth(
fitMode: 'width' | 'page' | undefined,
explicitWidth: number | undefined,
containerDims: { width: number; height: number },
naturalSize: { width: number; height: number } | null,
): number | undefined {
if (!fitMode || containerDims.width <= 0) return explicitWidth;
if (fitMode === 'width') return containerDims.width;
if (fitMode === 'page' && naturalSize && naturalSize.height > 0 && containerDims.height > 0) {
const aspect = naturalSize.width / naturalSize.height;
const containerAspect = containerDims.width / containerDims.height;
return containerAspect > aspect
? containerDims.height * aspect
: containerDims.width;
}
return explicitWidth;
}