@zoom-image/core
Version:
A core implementation of zoom image
447 lines (442 loc) • 16.2 kB
JavaScript
;
var store = require('@namnode/store');
// src/createZoomImageWheel.ts
// src/utils.ts
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function preventDefault(event) {
event.preventDefault();
}
var keySet = /* @__PURE__ */ new Set(["ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft"]);
function preventDefaultForScrollKeys(event) {
if (keySet.has(event.key)) {
preventDefault(event);
return false;
}
}
var controller = new AbortController();
var signal = controller.signal;
function disableScroll() {
window.addEventListener("DOMMouseScroll", preventDefault, { signal });
window.addEventListener("wheel", preventDefault, { passive: false, signal });
window.addEventListener("touchmove", preventDefault, { passive: false, signal });
window.addEventListener("keydown", preventDefaultForScrollKeys, { signal });
}
function enableScroll() {
controller?.abort();
}
function getSourceImage(container) {
if (!container) {
throw new Error("Please specify a container for the zoom image");
}
const sourceImgElement = container.querySelector("img");
if (!sourceImgElement) {
throw new Error("Please place an image inside the container");
}
return sourceImgElement;
}
function getPointersCenter(first, second) {
return {
x: (first.x + second.x) / 2,
y: (first.y + second.y) / 2
};
}
function computeZoomGesture(prev, curr) {
const prevCenter = getPointersCenter(prev[0], prev[1]);
const currCenter = getPointersCenter(curr[0], curr[1]);
const centerDist = { x: currCenter.x - prevCenter.x, y: currCenter.y - prevCenter.y };
const prevDistance = Math.hypot(prev[0].x - prev[1].x, prev[0].y - prev[1].y);
const currDistance = Math.hypot(curr[0].x - curr[1].x, curr[0].y - curr[1].y);
let scale = currDistance / prevDistance;
const eps = 1e-5;
if (Math.abs(scale - 1) < eps) {
scale = 1 + eps;
}
return {
scale,
center: {
// We shift the zoom center away such that the translation part of the gesture
// is also captured by the zoom operation.
x: prevCenter.x + centerDist.x / (1 - scale),
y: prevCenter.y + centerDist.y / (1 - scale)
}
};
}
function makeMaybeCallFunction(predicateFn, fn) {
return (arg) => {
if (predicateFn()) {
fn(arg);
}
};
}
// src/createZoomImageWheel.ts
var ZOOM_DELTA = 0.5;
var defaultInitialState = {
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
currentRotation: 0
};
var defaultShouldZoomOnSingleTouch = () => true;
function createZoomImageWheel(container, options = {}) {
const sourceImgElement = getSourceImage(container);
const finalOptions = {
maxZoom: options.maxZoom || 4,
wheelZoomRatio: options.wheelZoomRatio || 0.1,
dblTapAnimationDuration: options.dblTapAnimationDuration || 300,
initialState: { ...defaultInitialState, ...options.initialState },
shouldZoomOnSingleTouch: options.shouldZoomOnSingleTouch || defaultShouldZoomOnSingleTouch
};
const store$1 = store.createStore(finalOptions.initialState);
const checkDimensionSwitched = () => {
return [90, 270].includes(store$1.getState().currentRotation % 360);
};
const calculatePositionX = (newPositionX, currentZoom) => {
if (newPositionX > 0)
return 0;
const width = container.clientWidth;
if (newPositionX + width * currentZoom < width)
return -width * (currentZoom - 1);
return newPositionX;
};
const calculatePositionY = (newPositionY, currentZoom) => {
if (newPositionY > 0)
return 0;
const height = container.clientHeight;
if (newPositionY + height * currentZoom < height)
return -height * (currentZoom - 1);
return newPositionY;
};
const updateStateOnNewZoom = (currentZoom) => {
const zoomPointX = container.clientWidth / 2;
const zoomPointY = container.clientHeight / 2;
const isDimensionSwitched = checkDimensionSwitched();
const currentState = store$1.getState();
const zoomX = isDimensionSwitched ? currentState.currentPositionY : currentState.currentPositionX;
const zoomY = isDimensionSwitched ? currentState.currentPositionX : currentState.currentPositionY;
const zoomTargetX = (zoomPointX - zoomX) / currentState.currentZoom;
const zoomTargetY = (zoomPointY - zoomY) / currentState.currentZoom;
store$1.setState({
currentZoom,
currentPositionX: calculatePositionX(-zoomTargetX * currentZoom + zoomPointX, currentZoom),
currentPositionY: calculatePositionY(-zoomTargetY * currentZoom + zoomPointY, currentZoom)
});
};
let prevTwoPositions = null;
let enabledScroll = true;
const pointerMap = /* @__PURE__ */ new Map();
let lastPositionX = 0;
let lastPositionY = 0;
let startX = 0;
let startY = 0;
container.style.overflow = "hidden";
sourceImgElement.style.transformOrigin = "0 0";
function updateZoom() {
const currentState = store$1.getState();
sourceImgElement.style.transform = `translate(${currentState.currentPositionX}px, ${currentState.currentPositionY}px) scale(${currentState.currentZoom})`;
container.style.rotate = `${currentState.currentRotation}deg`;
}
function setState(newState) {
store$1.batch(() => {
const currentState = store$1.getState();
if (typeof newState.enable === "boolean" && newState.enable !== currentState.enable) {
store$1.setState({
enable: newState.enable
});
if (!newState.enable) {
return;
}
}
if (typeof newState.currentRotation === "number") {
const newCurrentRotation = newState.currentRotation;
store$1.setState({
currentRotation: newCurrentRotation
});
}
if (typeof newState.currentZoom === "number" && newState.currentZoom !== currentState.currentZoom) {
const newCurrentZoom = clamp(newState.currentZoom, 1, finalOptions.maxZoom);
if (newCurrentZoom === currentState.currentZoom) {
return;
}
updateStateOnNewZoom(newCurrentZoom);
}
});
updateZoom();
}
function processZoomWheel({ delta, x, y }) {
const containerRect = container.getBoundingClientRect();
const currentState = store$1.getState();
const isDimensionSwitched = checkDimensionSwitched();
let zoomPointX = -1;
let zoomPointY = -1;
switch (currentState.currentRotation % 360) {
case 0:
zoomPointX = x - containerRect.left;
zoomPointY = y - containerRect.top;
break;
case 90:
zoomPointX = Math.abs(x - containerRect.right);
zoomPointY = Math.abs(y - containerRect.top);
break;
case 180:
zoomPointX = Math.abs(x - containerRect.right);
zoomPointY = Math.abs(y - containerRect.bottom);
break;
case 270:
zoomPointX = Math.abs(x - containerRect.left);
zoomPointY = Math.abs(y - containerRect.bottom);
break;
}
const zoomX = isDimensionSwitched ? currentState.currentPositionY : currentState.currentPositionX;
const zoomY = isDimensionSwitched ? currentState.currentPositionX : currentState.currentPositionY;
const zoomTargetX = (zoomPointX - zoomX) / currentState.currentZoom;
const zoomTargetY = (zoomPointY - zoomY) / currentState.currentZoom;
const newCurrentZoom = clamp(
currentState.currentZoom + delta * finalOptions.wheelZoomRatio * currentState.currentZoom,
1,
finalOptions.maxZoom
);
const newX = calculatePositionX(-zoomTargetX * newCurrentZoom + zoomPointX, newCurrentZoom);
const newY = calculatePositionY(-zoomTargetY * newCurrentZoom + zoomPointY, newCurrentZoom);
store$1.setState({
currentZoom: newCurrentZoom,
currentPositionX: isDimensionSwitched ? newY : newX,
currentPositionY: isDimensionSwitched ? newX : newY
});
}
function updatePositionsForSinglePointerFlow() {
if (pointerMap.size === 1) {
const { x, y } = pointerMap.values().next().value;
const isDimensionSwitched = checkDimensionSwitched();
startX = isDimensionSwitched ? y : x;
startY = isDimensionSwitched ? x : y;
}
const currentState = store$1.getState();
lastPositionX = currentState.currentPositionX;
lastPositionY = currentState.currentPositionY;
}
function _handleWheel(event) {
event.preventDefault();
if (store$1.getState().currentZoom === finalOptions.maxZoom && event.deltaY < 0) {
return;
}
const delta = -clamp(event.deltaY, -ZOOM_DELTA, ZOOM_DELTA);
processZoomWheel({ delta, x: event.clientX, y: event.clientY });
updateZoom();
updatePositionsForSinglePointerFlow();
}
function _handlePointerMove(event) {
event.preventDefault();
const { clientX, clientY, pointerId } = event;
for (const [cachedPointerId] of pointerMap.entries()) {
if (cachedPointerId === pointerId) {
pointerMap.set(cachedPointerId, { x: clientX, y: clientY });
}
}
const { currentZoom, currentRotation } = store$1.getState();
if (pointerMap.size === 1 && currentZoom !== 1) {
const isDimensionSwitched = checkDimensionSwitched();
const normalizedClientX = isDimensionSwitched ? clientY : clientX;
const normalizedClientY = isDimensionSwitched ? clientX : clientY;
let offsetX = -1;
let offsetY = -1;
switch (currentRotation % 360) {
case 0:
offsetX = normalizedClientX - startX;
offsetY = normalizedClientY - startY;
break;
case 90:
offsetX = normalizedClientX - startX;
offsetY = startY - normalizedClientY;
break;
case 180:
offsetX = startX - normalizedClientX;
offsetY = startY - normalizedClientY;
break;
case 270:
offsetX = startX - normalizedClientX;
offsetY = normalizedClientY - startY;
break;
}
store$1.setState({
currentPositionX: calculatePositionX(lastPositionX + offsetX, currentZoom),
currentPositionY: calculatePositionY(lastPositionY + offsetY, currentZoom)
});
updateZoom();
}
}
const animationState = {
startTimestamp: null,
// the state at the start of the zoom animation
start: { x: 0, y: 0, zoom: 0 },
// the target state at the end of the zoom animation
target: { x: 0, y: 0, zoom: 0 }
};
function animateZoom(touchCoordinate) {
const currentState = store$1.getState();
animationState.startTimestamp = null;
animationState.start = {
x: currentState.currentPositionX,
y: currentState.currentPositionY,
zoom: currentState.currentZoom
};
if (currentState.currentZoom > 1) {
animationState.target = {
x: 0,
y: 0,
zoom: 1
};
} else {
animationState.target = {
zoom: finalOptions.maxZoom,
x: touchCoordinate.x * (1 - finalOptions.maxZoom),
y: touchCoordinate.y * (1 - finalOptions.maxZoom)
};
}
function lerp(a, b, t) {
return a * (1 - t) + b * t;
}
function frame(timestamp) {
if (animationState.startTimestamp === null) {
animationState.startTimestamp = timestamp;
}
let t = (timestamp - animationState.startTimestamp) / finalOptions.dblTapAnimationDuration;
if (t > 1) {
t = 1;
}
store$1.setState({
currentPositionX: lerp(animationState.start.x, animationState.target.x, t),
currentPositionY: lerp(animationState.start.y, animationState.target.y, t),
currentZoom: lerp(animationState.start.zoom, animationState.target.zoom, t)
});
updateZoom();
if (t < 1) {
requestAnimationFrame(frame);
}
}
requestAnimationFrame(frame);
}
let touchTimer = null;
const durationBetweenTap = 300;
function _handleTouchStart(event) {
if (event.touches.length > 1) {
return;
}
if (touchTimer === null) {
touchTimer = setTimeout(() => {
touchTimer = null;
}, durationBetweenTap);
} else {
clearTimeout(touchTimer);
touchTimer = null;
const rect = container.getBoundingClientRect();
const touch = event.touches[0];
animateZoom({
x: touch.clientX - rect.left,
y: touch.clientY - rect.top
});
return;
}
}
function _handleTouchMove(event) {
if (finalOptions.shouldZoomOnSingleTouch())
event.preventDefault();
if (event.touches.length > 1) {
event.preventDefault();
const currentTwoPositions = [...event.touches].map((t) => ({ x: t.clientX, y: t.clientY }));
if (prevTwoPositions !== null) {
const { scale, center } = computeZoomGesture(prevTwoPositions, currentTwoPositions);
processZoomWheel({ delta: Math.log(scale) / finalOptions.wheelZoomRatio, ...center });
}
prevTwoPositions = currentTwoPositions;
updateZoom();
return;
}
}
function _handlePointerDown(event) {
if (event.pointerType === "touch" && !finalOptions.shouldZoomOnSingleTouch())
return;
event.preventDefault();
if (pointerMap.size === 2) {
return;
}
if (enabledScroll) {
disableScroll();
enabledScroll = false;
}
const { clientX, clientY, pointerId } = event;
const currentState = store$1.getState();
lastPositionX = currentState.currentPositionX;
lastPositionY = currentState.currentPositionY;
const isDimensionSwitched = checkDimensionSwitched();
startX = isDimensionSwitched ? clientY : clientX;
startY = isDimensionSwitched ? clientX : clientY;
pointerMap.set(pointerId, { x: clientX, y: clientY });
}
function _handlePointerUp(event) {
event.preventDefault();
pointerMap.delete(event.pointerId);
if (pointerMap.size < 2) {
prevTwoPositions = null;
}
if (pointerMap.size === 0 && !enabledScroll) {
enableScroll();
enabledScroll = true;
}
updatePositionsForSinglePointerFlow();
}
function _handlePointerLeave(event) {
event.preventDefault();
pointerMap.delete(event.pointerId);
prevTwoPositions = null;
if (!enabledScroll) {
enableScroll();
enabledScroll = true;
}
}
function checkZoomEnabled() {
return store$1.getState().enable;
}
const handleWheel = makeMaybeCallFunction(checkZoomEnabled, _handleWheel);
const handlePointerDown = makeMaybeCallFunction(checkZoomEnabled, _handlePointerDown);
const handlePointerLeave = makeMaybeCallFunction(checkZoomEnabled, _handlePointerLeave);
const handlePointerMove = makeMaybeCallFunction(checkZoomEnabled, _handlePointerMove);
const handlePointerUp = makeMaybeCallFunction(checkZoomEnabled, _handlePointerUp);
const handleTouchStart = makeMaybeCallFunction(checkZoomEnabled, _handleTouchStart);
const handleTouchMove = makeMaybeCallFunction(checkZoomEnabled, _handleTouchMove);
const controller2 = new AbortController();
const { signal: signal2 } = controller2;
container.addEventListener("wheel", handleWheel, { signal: signal2 });
container.addEventListener("touchstart", handleTouchStart, { signal: signal2 });
container.addEventListener("touchmove", handleTouchMove, { signal: signal2 });
container.addEventListener("pointerdown", handlePointerDown, { signal: signal2 });
container.addEventListener("pointerleave", handlePointerLeave, { signal: signal2 });
container.addEventListener("pointermove", handlePointerMove, { signal: signal2 });
container.addEventListener("pointerup", handlePointerUp, { signal: signal2 });
container.addEventListener(
"touchend",
() => {
enabledScroll = true;
enableScroll();
},
{ signal: signal2 }
);
if (store$1.getState().currentZoom !== defaultInitialState.currentZoom) {
updateStateOnNewZoom(store$1.getState().currentZoom);
updateZoom();
}
return {
cleanup() {
controller2.abort();
store$1.cleanup();
},
subscribe: store$1.subscribe,
setState,
getState: store$1.getState
};
}
exports.createZoomImageWheel = createZoomImageWheel;
//# sourceMappingURL=out.js.map
//# sourceMappingURL=createZoomImageWheel.js.map