Update implementation to account for WebKit bug

getScreenCTM() on WebKit does not reflect transformations applied to an ancestor (see bug https://bugs.webkit.org/show_bug.cgi?id=209220), so instead of transforming the root <svg> element, we can only transform a child element
This commit is contained in:
Catalin Constantin Mititiuc 2025-06-16 23:25:25 -07:00
parent e0fbba8fee
commit b449601a6b
10 changed files with 77 additions and 456 deletions

View File

@ -1,6 +1,7 @@
# Pan-Zoom
Pan/zoom library for web browsers. Hold and drag an element to pan. Use the mouse wheel to zoom. See `src/app.js` for a usage example.
Pan/zoom library for SVG elements. Hold and drag to pan. Use the mouse wheel to
zoom. See `src/app.js` for a usage example.
## To view the demo using Docker

357
package-lock.json generated
View File

@ -1,270 +1,17 @@
{
"name": "app",
"name": "pan-zoom",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pan-zoom",
"version": "0.2.0",
"devDependencies": {
"esbuild": "^0.20.2",
"esbuild-server": "^0.3.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
"integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
@ -281,102 +28,6 @@
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/esbuild": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "pan-zoom",
"version": "0.1.0",
"version": "0.2.0",
"browser": "index.js",
"devDependencies": {
"esbuild": "^0.20.2",

View File

@ -1,40 +1,17 @@
body {
text-align: center;
max-width: 100vw;
max-height: 100vh;
display: flex;
flex-direction: column;
margin: 0;
}
.container {
object {
padding: 0;
max-width: 586.033px;
max-height: 586.033px;
margin: 0 auto;
overflow: hidden;
margin: 5px;
border: 1px solid steelblue;
background-color: gray;
}
img, object {
touch-action: none;
}
img {
max-width: 100%;
border: 1px solid silver;
transform: scale(0.9);
}
.container object, .container.switch img {
display: block;
}
.container img, .container.switch object {
display: none;
}
button .button-text.raster, button.switch .button-text.svg {
display: none;
}
button.switch .button-text.raster {
display: inline;
min-height: 0;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

View File

@ -1,22 +1,20 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<?xml version="1.0" standalone="yes"?>
<svg viewBox="-200 -150 400 300" version="1.1" xmlns="http://www.w3.org/2000/svg">
<style>
svg {
overflow: hidden;
border: 1px solid silver;
transform: scale(0.9);
}
circle, rect {
fill-opacity: 0.9;
filter: drop-shadow(5px 5px 2px rgba(0, 0, 0, .5));
}
</style>
<g>
<circle id="pointer" cx="0" cy="0" r="5" fill="red" stroke="maroon"/>
</g>
<script type="text/javascript">//<![CDATA[
const svgns = 'http://www.w3.org/2000/svg',
svg = document.querySelector('svg'),
group = svg.querySelector('g'),
pointerEl = svg.querySelector('#pointer'),
{ x: vbX, y: vbY, width: vbWidth, height: vbHeight } = svg.viewBox.baseVal,
shapeCount = 100,
@ -103,6 +101,6 @@
[...Array(shapeCount)]
.map(() => getRandomFillAndStrokeVals())
.forEach(fillAndStrokeVal => svg.appendChild(getRandomShape(fillAndStrokeVal)));
.forEach(fillAndStrokeVal => pointerEl.before(getRandomShape(fillAndStrokeVal)));
//]]></script>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -3,28 +3,17 @@
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>JavaScript/CSS Pan & Zoom Demo</title>
<title>SVG Element Pan & Zoom Demo</title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<h1>Pan & Zoom an Element with CSS/JavaScript</h1>
<h1>Pan & Zoom SVG Element with CSS/JavaScript</h1>
<p>
Click and drag on the image to pan. Use the mouse wheel
to zoom in and out.
Click and drag the image to pan. Use the mouse wheel to zoom in and out.
</p>
<p>
<button>
<span class="button-text svg">Raster image</span>
<span class="button-text raster">SVG image</span>
</button>
</p>
<div class="container">
<object type="image/svg+xml" data="assets/images/image.svg"></object>
<img src="assets/images/41156165560-4438592e93-o.webp"/>
</div>
<object type="image/svg+xml" data="assets/images/image.svg"></object>
<script src="app.js"></script>
</body>
</html>

View File

@ -2,14 +2,7 @@ import zoom from './modules/zoom.js';
import pan from './modules/pan.js';
const optionalZoomFactor = 0.1,
container = document.querySelector('.container'),
object = document.querySelector('object'),
img = document.querySelector('img'),
button = document.querySelector('button');
function reset(elements) {
elements.forEach(el => el.removeAttribute('style'));
}
object = document.querySelector('object');
// If embedding an SVG using an <object> tag, it's necessary to wait until the
// page has loaded before querying its `contentDocument`, otherwise it will be
@ -17,15 +10,18 @@ function reset(elements) {
window.addEventListener('load', function () {
const svg = object.contentDocument.querySelector('svg'),
pannableAndZoomableElements = [img, svg];
targetEl = svg.querySelector('g'),
pointer = svg.querySelector('#pointer'),
options = { passive: false };
button.addEventListener('click', () => {
[button, container].forEach(el => el.classList.toggle('switch'));
reset(pannableAndZoomableElements);
});
svg.addEventListener('wheel', e => zoom(targetEl, e, optionalZoomFactor), options);
svg.addEventListener('pointerdown', e => pan(svg, targetEl, e), options);
pannableAndZoomableElements.forEach(el => {
el.addEventListener('wheel', e => zoom(el, e, optionalZoomFactor), { passive: false });
el.addEventListener('pointerdown', e => pan(el, e), { passive: false });
svg.addEventListener('pointermove', e => {
const pt = new DOMPoint(e.clientX, e.clientY),
svgP = pt.matrixTransform(targetEl.getScreenCTM().inverse());
pointer.setAttributeNS(null, 'cx', svgP.x);
pointer.setAttributeNS(null, 'cy', svgP.y);
});
});

View File

@ -2,13 +2,6 @@ import getComputedTransformMatrix from './utils.js';
const minDistanceThreshold = 5;
function setToCurrentPointerCoords(point, e) {
point.x = e.clientX;
point.y = e.clientY;
return point;
}
function distanceBetween({ x: x1, y: y1 }, { x: x2, y: y2 }) {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
@ -27,35 +20,45 @@ function getTranslateMatrix(startPt, movePt) {
return translateMatrix.translate(movePt.x - startPt.x, movePt.y - startPt.y);
}
export default function (el, e) {
export default function (svg, el, e) {
e.preventDefault();
const mtx = getComputedTransformMatrix(el),
startPt = new DOMPoint(e.clientX, e.clientY),
movePt = new DOMPoint();
inverseScreenCTM = el.getScreenCTM().inverse();
let isPanning = false;
let startPt = new DOMPoint(e.clientX, e.clientY),
movePt = new DOMPoint(),
isPanning = false;
function pointerMove(e) {
setToCurrentPointerCoords(movePt, e);
movePt.x = e.clientX;
movePt.y = e.clientY;
if (!isPanning && minDistanceThresholdIsMet(startPt, movePt)) {
isPanning = true;
e.target.setPointerCapture(e.pointerId);
setToCurrentPointerCoords(startPt, e);
startPt.x = e.clientX;
startPt.y = e.clientY;
startPt = startPt.matrixTransform(inverseScreenCTM);
stopEventPropagationToChildren(el, 'click');
}
if (isPanning) {
el.style.transform = getTranslateMatrix(startPt, movePt).multiply(mtx);
movePt.x = e.clientX;
movePt.y = e.clientY;
movePt = movePt.matrixTransform(inverseScreenCTM);
el.style.transform = mtx.multiply(getTranslateMatrix(startPt, movePt));
}
}
el.addEventListener('pointermove', pointerMove);
svg.addEventListener('pointermove', pointerMove);
el.addEventListener(
svg.addEventListener(
'pointerup',
() => el.removeEventListener('pointermove', pointerMove),
() => svg.removeEventListener('pointermove', pointerMove),
{ once: true }
);
}

View File

@ -8,35 +8,41 @@ function getScale(e, factor) {
return zoomIn(e.deltaY) ? 1 + factor : 1 - factor;
}
function getFocalPointBeforeTransform(el, e) {
const { x, y, width, height } = el.getBoundingClientRect();
function getFocalPointBeforeTransform(el, e, inverseScreenCTM) {
const { x, y, width, height } = el.getBoundingClientRect(),
pointer = (new DOMPoint(e.clientX, e.clientY)).matrixTransform(inverseScreenCTM),
origin = (new DOMPoint(x, y)).matrixTransform(inverseScreenCTM),
terminus = (new DOMPoint(x + width, y + height)).matrixTransform(inverseScreenCTM);
return {
x: e.clientX,
y: e.clientY,
x: pointer.x,
y: pointer.y,
relativeToImageSize: {
x: (e.clientX - x) / width,
y: (e.clientY - y) / height
x: (pointer.x - origin.x) / (terminus.x - origin.x),
y: (pointer.y - origin.y) / (terminus.y - origin.y)
}
};
}
function getFocalPointAfterTransform(el, fpBeforeTrans) {
function getFocalPointAfterTransform(el, fpBeforeTrans, inverseScreenCTM) {
const { x, y, width, height } = el.getBoundingClientRect(),
origin = (new DOMPoint(x, y)).matrixTransform(inverseScreenCTM),
terminus = (new DOMPoint(x + width, y + height)).matrixTransform(inverseScreenCTM),
relativeFocalPoint = fpBeforeTrans.relativeToImageSize;
return {
x: x + width * relativeFocalPoint.x,
y: y + height * relativeFocalPoint.y
x: origin.x + (terminus.x - origin.x) * relativeFocalPoint.x,
y: origin.y + (terminus.y - origin.y) * relativeFocalPoint.y
};
}
function getTranslateMatrix(el, e, scaleMatrix) {
const fpBeforeTrans = getFocalPointBeforeTransform(el, e);
const inverseScreenCTM = el.getScreenCTM().inverse(),
fpBeforeTrans = getFocalPointBeforeTransform(el, e, inverseScreenCTM);
el.style.transform = scaleMatrix;
const fpAfterTrans = getFocalPointAfterTransform(el, fpBeforeTrans),
const fpAfterTrans = getFocalPointAfterTransform(el, fpBeforeTrans, inverseScreenCTM),
translateMatrix = new DOMMatrix();
return translateMatrix.translate(
@ -52,5 +58,5 @@ export default function (el, e, factor = 0.1) {
scale = getScale(e, factor),
transMtx = getTranslateMatrix(el, e, mtx.scale(scale));
el.style.transform = transMtx.multiply(mtx).scale(scale);
el.style.transform = mtx.multiply(transMtx).scale(scale);
}