init
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user