Compare commits

..

10 Commits

20 changed files with 4242 additions and 164 deletions

136
.gitignore vendored
View File

@ -1,4 +1,138 @@
# from https://github.com/github/gitignore/blob/main/Node.gitignore
# Logs
logs
*.log *.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz *.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist dist
node_modules
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

106
README.md
View File

@ -1,15 +1,113 @@
# 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 ## Usage
### Requirements
All the content to be panned/zoomed must be in one root element.
`image.svg`
``` xml
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="..." version="1.1" xmlns="http://www.w3.org/2000/svg">
<g id="main">
<!-- SVG elements to be panned/zoomed... -->
</g>
</svg>
```
`index.html`
``` html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
</head>
<body>
<object type="image/svg+xml" data="image.svg"></object>
<script src="index.js"></script>
</body>
</html>
```
### Standard
`index.js`
``` js
import { pan, zoom } from 'pan-zoom';
const optionalZoomFactor = 0.1,
object = document.querySelector('object');
window.addEventListener('load', function () {
const svg = object.contentDocument.querySelector('svg'),
targetEl = svg.querySelector('#main');
svg.addEventListener('wheel', zoom(targetEl, optionalZoomFactor), { passive: false });
svg.addEventListener('pointerdown', pan(targetEl), { passive: false });
});
```
### With restore
The same as standard except the pan/zoom returns to what it was after a page
refresh.
`index.js`
``` js
import { withRestore as panzoom } from 'pan-zoom';
const object = document.querySelector('object');
window.addEventListener('load', function () {
const svg = object.contentDocument.querySelector('svg');
panzoom.start(svg, '#main');
});
```
## Start the demo
### Docker
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 npm run start
3. Visit `localhost:8080` to view. 3. Visit `localhost:8080` to view.
### Debian
1. Install the development server packages.
$ npm install
2. Start the server.
$ npm run start
3. Visit `localhost:8080` to view.
## Tests
Requires Chrome/ChromeDriver or Chromium/ChromiumDriver.
### Docker
$ docker run --rm --init -it -w /app -v $PWD:/app node bash -c "apt-get \
update && apt-get install -y chromium-driver chromium && npm run test"
### Debian
$ apt-get update && apt-get install chromium-driver chromium
$ npm run test

View File

@ -0,0 +1,21 @@
const { Builder } = require('selenium-webdriver');
const chrome = require('selenium-webdriver/chrome');
const chromeOptions = new chrome.Options();
let driver;
chromeOptions.addArguments('--headless', '--disable-gpu', '--no-sandbox');
beforeEach(async () => {
const builder = new Builder().forBrowser('chrome').setChromeOptions(chromeOptions);
driver = builder.build();
});
it('loads the page', async () => {
await driver.get('http://localhost:8080');
expect(await driver.getTitle()).toEqual('SVG Element Pan & Zoom Demo');
});
afterEach(async () => {
await driver.quit();
});

View File

@ -0,0 +1,21 @@
const { spawn } = require('child_process');
module.exports = async function (globalConfig, projectConfig) {
console.info('\nSpawning server process...');
const child = spawn('node', ['server.js']);
child.stdout.on('data', data => {
console.log(`${data}`);
});
globalThis.__INTEG_TEST_SERVER_PID__ = child.pid;
child.stderr.on('data', data => {
const str = data.toString();
console.log('[server]', str);
if (str.includes(projectConfig.globals.testServerUrl)) {
setTimeout(resolve, 200);
}
});
};

View File

@ -0,0 +1,4 @@
module.exports = async function (globalConfig, projectConfig) {
console.info('Stopping server.');
process.kill(globalThis.__INTEG_TEST_SERVER_PID__);
};

View File

@ -1,11 +0,0 @@
require('esbuild-server')
.createServer(
{
bundle: true,
entryPoints: ['src/app.js'],
},
{
static: 'public',
}
)
.start();

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';

219
jest.config.js Normal file
View File

@ -0,0 +1,219 @@
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
/** @type {import('jest').Config} */
const config = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/tmp/jest_rs",
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
globalSetup: './__tests__/integration/setup.js',
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
globalTeardown: './__tests__/integration/teardown.js',
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as
// % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as
// the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "mjs",
// "cjs",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module
// names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths
// before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
//testEnvironment: "jsdom",
testEnvironment: "node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
testPathIgnorePatterns: [
"/node_modules/",
"setup.js",
"teardown.js",
],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source
// file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules
// before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
verbose: true,
// An array of regexp patterns that are matched against all source file paths
// before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
// Shuffle the order of the tests within a file
randomize: true,
};
console.info('Jest config file read.');
module.exports = config;

3537
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,29 @@
{ {
"name": "pan-zoom", "name": "pan-zoom",
"version": "0.1.0", "version": "0.3.1",
"description": "Pan/zoom SVG images in the browser",
"browser": "index.js", "browser": "index.js",
"devDependencies": {
"esbuild": "^0.20.2",
"esbuild-server": "^0.3.0"
},
"files": [ "files": [
"./src/modules" "./src/modules"
] ],
"scripts": {
"test": "jest"
},
"repository": {
"type": "git",
"url": "git://git.webdevcat.me/pan-zoom.git"
},
"keywords": [
"SVG",
"pan",
"zoom"
],
"author": "Catalin Mititiuc <webdevcat@proton.me> (https://webdevcat.me)",
"license": "ISC",
"devDependencies": {
"esbuild": "^0.20.2",
"esbuild-server": "^0.3.0",
"jest": "^29.7.0",
"selenium-webdriver": "^4.29.0"
}
} }

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" encoding="UTF-8" standalone="no"?>
<!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>

27
server.js Normal file
View File

@ -0,0 +1,27 @@
const { createServer } = require('esbuild-server');
//https://esbuild.github.io/api/#build
const buildOptions = {
bundle: true,
entryPoints: ['src/app.js'],
};
//https://github.com/oblador/esbuild-server?tab=readme-ov-file#serveroptions
const serverOptions = {
static: 'public',
};
const env = process.env.NODE_ENV === 'test' ? 'Test' : 'Development';
const server = createServer(buildOptions, serverOptions);
const buildStart = Date.now();
server
.start()
.then(() => {
console.log(`Build completed in ${Date.now() - buildStart}ms`);
})
.catch(() => {
console.error('Build failed');
});
console.log(`${env} server running at ${server.url}`);

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

@ -1,19 +1,16 @@
import getComputedTransformMatrix from './utils.js'; import { default as getComputedTransformMatrix } from './utils';
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,71 @@ 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(e, inverseScreenMtx, pt = new DOMPoint()) {
startPt = new DOMPoint(e.clientX, e.clientY), pt.x = e.clientX;
movePt = new DOMPoint(); pt.y = e.clientY;
return pt.matrixTransform(inverseScreenMtx);
}
let isPanning = false; function setTransform(el, computedMtx, startPt, endPt) {
const translateMtx = getTranslateMatrix(startPt, endPt);
const transformMtx = computedMtx.multiply(translateMtx);
el.style.transform = transformMtx;
}
export function programmaticPan(el, from, to) {
const matrices = getTransformMatrices(el);
const startPt = clientToSvgPt(from, matrices.inverseScreen);
const endPt = clientToSvgPt(to, matrices.inverseScreen);
el.style.transition = 'transform 0.5s';
setTransform(el, matrices.computed, 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.inverseScreen, 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.inverseScreen, movePt);
setTransform(el, matrices.computed, 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

@ -1,56 +1,40 @@
import getComputedTransformMatrix from './utils.js'; import { default as getComputedTransformMatrix } from './utils';
function zoomIn(deltaY) { function zoomIn(deltaY) {
return deltaY < 0; return deltaY < 0;
} }
function getScale(e, factor) { function getScale(deltaY, factor) {
return zoomIn(e.deltaY) ? 1 + factor : 1 - factor; const outMult = 1 - factor;
const inMult = 1 + factor / outMult
return zoomIn(deltaY) ? inMult : outMult;
} }
function getFocalPointBeforeTransform(el, e) { function getTranslateMatrix(el, clientX, clientY) {
const { x, y, width, height } = el.getBoundingClientRect(); const inverseScreenCTM = el.getScreenCTM().inverse();
const translateMtx = new DOMMatrix();
const pointer = new DOMPoint(clientX, clientY)
const { x, y } = pointer.matrixTransform(inverseScreenCTM);
return { return translateMtx.translate(x, y);
x: e.clientX,
y: e.clientY,
relativeToImageSize: {
x: (e.clientX - x) / width,
y: (e.clientY - y) / height
}
};
} }
function getFocalPointAfterTransform(el, fpBeforeTrans) { function setTransform(el, computedMtx, translateMtx, scale) {
const { x, y, width, height } = el.getBoundingClientRect(), const transformMtx =
relativeFocalPoint = fpBeforeTrans.relativeToImageSize; computedMtx.multiply(translateMtx).scale(scale).multiply(translateMtx.inverse());
return { el.style.transform = transformMtx;
x: x + width * relativeFocalPoint.x,
y: y + height * relativeFocalPoint.y
};
} }
function getTranslateMatrix(el, e, scaleMatrix) { export default function (el, factor = 0.1) {
const fpBeforeTrans = getFocalPointBeforeTransform(el, e); return e => {
e.preventDefault();
el.style.transform = scaleMatrix; const computedMtx = getComputedTransformMatrix(el);
const scale = getScale(e.deltaY, factor);
const translateMtx = getTranslateMatrix(el, e.clientX, e.clientY);
const fpAfterTrans = getFocalPointAfterTransform(el, fpBeforeTrans), setTransform(el, computedMtx, translateMtx, scale);
translateMatrix = new DOMMatrix(); }
return translateMatrix.translate(
fpBeforeTrans.x - fpAfterTrans.x,
fpBeforeTrans.y - fpAfterTrans.y
);
}
export default function (el, e, factor = 0.1) {
e.preventDefault();
const mtx = getComputedTransformMatrix(el),
scale = getScale(e, factor),
transMtx = getTranslateMatrix(el, e, mtx.scale(scale));
el.style.transform = transMtx.multiply(mtx).scale(scale);
} }

1
test.md Normal file
View File

@ -0,0 +1 @@
## hello