Web Dev Solutions

Catalin Mititiuc

From 9c34e15c47cf3578adeff41693a62061a25fdcde Mon Sep 17 00:00:00 2001 From: Catalin Mititiuc Date: Tue, 11 Jun 2024 14:58:29 -0700 Subject: 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 element, we can only transform a child element --- README.md | 3 +- package-lock.json | 357 +-------------------- package.json | 2 +- public/assets/css/style.css | 37 +-- public/assets/images/41156165560-4438592e93-o.webp | Bin 585068 -> 0 bytes public/assets/images/image.svg | 20 +- public/index.html | 19 +- src/app.js | 28 +- src/modules/pan.js | 37 ++- src/modules/zoom.js | 30 +- 10 files changed, 77 insertions(+), 456 deletions(-) delete mode 100644 public/assets/images/41156165560-4438592e93-o.webp diff --git a/README.md b/README.md index 4d96e8f..8ed4dc8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index d47231c..44f82aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ccfb11c..b99e239 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pan-zoom", - "version": "0.1.0", + "version": "0.2.0", "browser": "index.js", "devDependencies": { "esbuild": "^0.20.2", diff --git a/public/assets/css/style.css b/public/assets/css/style.css index db47790..0596f4e 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -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; } diff --git a/public/assets/images/41156165560-4438592e93-o.webp b/public/assets/images/41156165560-4438592e93-o.webp deleted file mode 100644 index 2ad3fa4..0000000 Binary files a/public/assets/images/41156165560-4438592e93-o.webp and /dev/null differ diff --git a/public/assets/images/image.svg b/public/assets/images/image.svg index 29f9306..a823339 100644 --- a/public/assets/images/image.svg +++ b/public/assets/images/image.svg @@ -1,22 +1,20 @@ - - + + + + + + diff --git a/public/index.html b/public/index.html index 35a3030..d97baee 100644 --- a/public/index.html +++ b/public/index.html @@ -3,28 +3,17 @@ - JavaScript/CSS Pan & Zoom Demo + SVG Element Pan & Zoom Demo -

Pan & Zoom an Element with CSS/JavaScript

+

Pan & Zoom SVG Element with CSS/JavaScript

- 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.

-

- -

- -
- - -
+ diff --git a/src/app.js b/src/app.js index 3afc329..30b4467 100644 --- a/src/app.js +++ b/src/app.js @@ -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 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); + + svg.addEventListener('pointermove', e => { + const pt = new DOMPoint(e.clientX, e.clientY), + svgP = pt.matrixTransform(targetEl.getScreenCTM().inverse()); - pannableAndZoomableElements.forEach(el => { - el.addEventListener('wheel', e => zoom(el, e, optionalZoomFactor), { passive: false }); - el.addEventListener('pointerdown', e => pan(el, e), { passive: false }); + pointer.setAttributeNS(null, 'cx', svgP.x); + pointer.setAttributeNS(null, 'cy', svgP.y); }); }); diff --git a/src/modules/pan.js b/src/modules/pan.js index 201c2f1..6f2cacf 100644 --- a/src/modules/pan.js +++ b/src/modules/pan.js @@ -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 } ); } diff --git a/src/modules/zoom.js b/src/modules/zoom.js index 97a23e1..fc0540b 100644 --- a/src/modules/zoom.js +++ b/src/modules/zoom.js @@ -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); } -- cgit v1.2.3