Compare commits

4 Commits

Author SHA1 Message Date
79964ead91 Add module to save and restore pan/zoom values on page reload 2025-03-05 14:18:31 -08:00
ffdf173cda Add function for panning programmatically 2025-03-05 12:17:28 -08:00
ad69b0011a Fix formatting in README.md 2024-11-08 12:48:07 -08:00
9c34e15c47 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
2024-06-11 15:09:41 -07:00
12 changed files with 167 additions and 477 deletions

View File

@@ -1,15 +1,16 @@
# Pan-Zoom # 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 ## View demo
1. Install the development server packages. 1. Install the development server packages.
docker run --rm -w /app -v $PWD:/app -u $(id -u):$(id -u) node npm install docker run --rm -w /app -v $PWD:/app -u $(id -u):$(id -u) node npm install
2. Start the server. 2. Start the server.
docker run --rm --init -it -w /app -v $PWD:/app -p 8080:8080 node node dev-server.js docker run --rm --init -it -w /app -v $PWD:/app -p 8080:8080 node node dev-server.js
3. Visit `localhost:8080` to view. 3. Visit `localhost:8080` to view.

View File

@@ -1,2 +1,3 @@
export { default as pan } from './src/modules/pan.js'; export { default as pan, programmaticPan } from './src/modules/pan';
export { default as zoom } from './src/modules/zoom.js'; export { default as zoom } from './src/modules/zoom';
export * as withRestore from './src/modules/with-restore';

357
package-lock.json generated
View File

@@ -1,270 +1,17 @@
{ {
"name": "app", "name": "pan-zoom",
"version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pan-zoom",
"version": "0.3.0",
"devDependencies": { "devDependencies": {
"esbuild": "^0.20.2", "esbuild": "^0.20.2",
"esbuild-server": "^0.3.0" "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": { "node_modules/@esbuild/linux-x64": {
"version": "0.20.2", "version": "0.20.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
@@ -281,102 +28,6 @@
"node": ">=12" "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": { "node_modules/esbuild": {
"version": "0.20.2", "version": "0.20.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz",

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

View File

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

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -3,28 +3,17 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <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"> <link rel="stylesheet" href="assets/css/style.css">
</head> </head>
<body> <body>
<h1>Pan & Zoom an Element with CSS/JavaScript</h1> <h1>Pan & Zoom SVG Element with CSS/JavaScript</h1>
<p> <p>
Click and drag on the image to pan. Use the mouse wheel Click and drag the image to pan. Use the mouse wheel to zoom in and out.
to zoom in and out.
</p> </p>
<p> <object type="image/svg+xml" data="assets/images/image.svg"></object>
<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>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

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

View File

@@ -2,18 +2,15 @@ import getComputedTransformMatrix from './utils.js';
const minDistanceThreshold = 5; const minDistanceThreshold = 5;
function setToCurrentPointerCoords(point, e) { function mainButtonPressed(e) {
point.x = e.clientX; return e.button === 0;
point.y = e.clientY;
return point;
} }
function distanceBetween({ x: x1, y: y1 }, { x: x2, y: y2 }) { function distanceBetween({ x: x1, y: y1 }, { x: x2, y: y2 }) {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
} }
function minDistanceThresholdIsMet(startPt, endPt) { function exceedsMinDistanceThreshhold(startPt, endPt) {
return distanceBetween(startPt, endPt) >= minDistanceThreshold; return distanceBetween(startPt, endPt) >= minDistanceThreshold;
} }
@@ -27,35 +24,68 @@ function getTranslateMatrix(startPt, movePt) {
return translateMatrix.translate(movePt.x - startPt.x, movePt.y - startPt.y); return translateMatrix.translate(movePt.x - startPt.x, movePt.y - startPt.y);
} }
export default function (el, e) { function getTransformMatrices(el) {
e.preventDefault(); return {
computed: getComputedTransformMatrix(el),
inverseScreen: el.getScreenCTM().inverse()
};
}
const mtx = getComputedTransformMatrix(el), function clientToSvgPt({ clientX, clientY }, { inverseScreen }, pt = new DOMPoint()) {
startPt = new DOMPoint(e.clientX, e.clientY), pt.x = clientX;
movePt = new DOMPoint(); pt.y = clientY;
return pt.matrixTransform(inverseScreen);
}
let isPanning = false; function setPanTransform(el, { computed }, startPt, endPt) {
el.style.transform = computed.multiply(getTranslateMatrix(startPt, endPt));
}
export function programmaticPan(el, from, to) {
const matrices = getTransformMatrices(el);
const startPt = clientToSvgPt(from, matrices);
const endPt = clientToSvgPt(to, matrices);
el.style.transition = 'transform 0.5s';
setPanTransform(el, matrices, startPt, endPt);
el.addEventListener('transitionend', () => el.style.transition = '', { once: true });
}
export default function (el) {
let matrices, startPt, movePt, isPanning;
function pointerMove(e) { function pointerMove(e) {
setToCurrentPointerCoords(movePt, e); movePt.x = e.clientX;
movePt.y = e.clientY;
if (!isPanning && minDistanceThresholdIsMet(startPt, movePt)) { if (!isPanning && exceedsMinDistanceThreshhold(startPt, movePt)) {
isPanning = true; isPanning = true;
e.target.setPointerCapture(e.pointerId); startPt = clientToSvgPt(e, matrices, startPt);
setToCurrentPointerCoords(startPt, e);
stopEventPropagationToChildren(el, 'click'); stopEventPropagationToChildren(el, 'click');
} }
if (isPanning) { if (isPanning) {
el.style.transform = getTranslateMatrix(startPt, movePt).multiply(mtx); movePt = clientToSvgPt(e, matrices, movePt);
setPanTransform(el, matrices, startPt, movePt);
} }
} }
el.addEventListener('pointermove', pointerMove); return function(e) {
if (!mainButtonPressed(e)) return;
e.preventDefault();
e.target.setPointerCapture(e.pointerId);
el.addEventListener( isPanning = false;
'pointerup', matrices = getTransformMatrices(el);
() => el.removeEventListener('pointermove', pointerMove), startPt = new DOMPoint(e.clientX, e.clientY);
{ once: true } movePt = new DOMPoint();
);
this.addEventListener('pointermove', pointerMove);
this.addEventListener(
'pointerup',
() => this.removeEventListener('pointermove', pointerMove),
{ once: true }
);
}
} }

View File

@@ -0,0 +1,36 @@
import zoom from './zoom';
import pan from './pan';
const storageKey = "pan-zoom";
const zoomFactor = 0.25;
function restorePanZoomVal(el) {
const storedPanZoomVal = localStorage.getItem(storageKey);
if (storedPanZoomVal) el.style.transform = storedPanZoomVal;
}
function addEventListeners(svg, el) {
svg.addEventListener("wheel", zoom(el, zoomFactor), { passive: false });
svg.addEventListener("pointerdown", pan(el), { passive: false });
}
function storePanZoomVal(transformMatrix) {
localStorage.setItem(storageKey, transformMatrix);
}
function observePanZoomChanges(el) {
const observer = new MutationObserver(() =>
storePanZoomVal(el.style.transform),
);
observer.observe(el, { attributeFilter: ["style"] });
}
export function start(svg, selector) {
const targetEl = svg.querySelector(selector);
restorePanZoomVal(targetEl);
addEventListeners(svg, targetEl);
observePanZoomChanges(targetEl);
}

View File

@@ -5,39 +5,48 @@ function zoomIn(deltaY) {
} }
function getScale(e, factor) { function getScale(e, factor) {
return zoomIn(e.deltaY) ? 1 + factor : 1 - factor; const outMult = 1 - factor;
const inMult = 1 + factor / outMult
return zoomIn(e.deltaY) ? inMult : outMult;
} }
function getFocalPointBeforeTransform(el, e) { function getFocalPointBeforeTransform(el, e, inverseScreenCTM) {
const { x, y, width, height } = el.getBoundingClientRect(); const { x, y, width, height } = el.getBoundingClientRect();
const pointer = (new DOMPoint(e.clientX, e.clientY)).matrixTransform(inverseScreenCTM);
const origin = (new DOMPoint(x, y)).matrixTransform(inverseScreenCTM);
const terminus = (new DOMPoint(x + width, y + height)).matrixTransform(inverseScreenCTM);
return { return {
x: e.clientX, x: pointer.x,
y: e.clientY, y: pointer.y,
relativeToImageSize: { relativeToImageSize: {
x: (e.clientX - x) / width, x: (pointer.x - origin.x) / (terminus.x - origin.x),
y: (e.clientY - y) / height 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(), const { x, y, width, height } = el.getBoundingClientRect();
relativeFocalPoint = fpBeforeTrans.relativeToImageSize; const origin = (new DOMPoint(x, y)).matrixTransform(inverseScreenCTM);
const terminus = (new DOMPoint(x + width, y + height)).matrixTransform(inverseScreenCTM);
const relativeFocalPoint = fpBeforeTrans.relativeToImageSize;
return { return {
x: x + width * relativeFocalPoint.x, x: origin.x + (terminus.x - origin.x) * relativeFocalPoint.x,
y: y + height * relativeFocalPoint.y y: origin.y + (terminus.y - origin.y) * relativeFocalPoint.y
}; };
} }
function getTranslateMatrix(el, e, scaleMatrix) { function getTranslateMatrix(el, e, scaleMatrix) {
const fpBeforeTrans = getFocalPointBeforeTransform(el, e); const inverseScreenCTM = el.getScreenCTM().inverse();
const fpBeforeTrans = getFocalPointBeforeTransform(el, e, inverseScreenCTM);
el.style.transform = scaleMatrix; el.style.transform = scaleMatrix;
const fpAfterTrans = getFocalPointAfterTransform(el, fpBeforeTrans), const fpAfterTrans = getFocalPointAfterTransform(el, fpBeforeTrans, inverseScreenCTM);
translateMatrix = new DOMMatrix(); const translateMatrix = new DOMMatrix();
return translateMatrix.translate( return translateMatrix.translate(
fpBeforeTrans.x - fpAfterTrans.x, fpBeforeTrans.x - fpAfterTrans.x,
@@ -45,12 +54,14 @@ function getTranslateMatrix(el, e, scaleMatrix) {
); );
} }
export default function (el, e, factor = 0.1) { export default function (el, factor = 0.1) {
e.preventDefault(); return e => {
e.preventDefault();
const mtx = getComputedTransformMatrix(el), const mtx = getComputedTransformMatrix(el);
scale = getScale(e, factor), const scale = getScale(e, factor);
transMtx = getTranslateMatrix(el, e, mtx.scale(scale)); const transMtx = getTranslateMatrix(el, e, mtx.scale(scale));
el.style.transform = transMtx.multiply(mtx).scale(scale); el.style.transform = mtx.multiply(transMtx).scale(scale);
}
} }