Compare commits

..

No commits in common. "27e43c34e220f63d1664dc57641d793850528236" and "2d3fc1cd22ffcc61ec178eeaf97f3a4d7cba98bf" have entirely different histories.

20 changed files with 166 additions and 4244 deletions

136
.gitignore vendored
View File

@ -1,138 +1,4 @@
# from https://github.com/github/gitignore/blob/main/Node.gitignore
# Logs
logs
*.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
# 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
# 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.*
node_modules

106
README.md
View File

@ -1,113 +1,15 @@
# Pan-Zoom
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.
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.
## 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
## To view the demo using Docker
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.
$ docker run --rm --init -it -w /app -v $PWD:/app -p 8080:8080 node npm run start
docker run --rm --init -it -w /app -v $PWD:/app -p 8080:8080 node node dev-server.js
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

@ -1,21 +0,0 @@
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

@ -1,21 +0,0 @@
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

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

11
dev-server.js Normal file
View File

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

View File

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

View File

@ -1,219 +0,0 @@
/**
* 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,29 +1,12 @@
{
"name": "pan-zoom",
"version": "0.3.1",
"description": "Pan/zoom SVG images in the browser",
"version": "0.1.0",
"browser": "index.js",
"files": [
"./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"
}
"esbuild-server": "^0.3.0"
},
"files": [
"./src/modules"
]
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

View File

@ -1,20 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<?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">
<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,
@ -101,6 +103,6 @@
[...Array(shapeCount)]
.map(() => getRandomFillAndStrokeVals())
.forEach(fillAndStrokeVal => pointerEl.before(getRandomShape(fillAndStrokeVal)));
.forEach(fillAndStrokeVal => svg.appendChild(getRandomShape(fillAndStrokeVal)));
//]]></script>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

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

View File

@ -1,27 +0,0 @@
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,8 +1,15 @@
import zoom from './modules/zoom';
import pan from './modules/pan';
import zoom from './modules/zoom.js';
import pan from './modules/pan.js';
const optionalZoomFactor = 0.1,
object = document.querySelector('object');
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'));
}
// 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
@ -10,18 +17,15 @@ const optionalZoomFactor = 0.1,
window.addEventListener('load', function () {
const svg = object.contentDocument.querySelector('svg'),
targetEl = svg.querySelector('g'),
pointer = svg.querySelector('#pointer'),
options = { passive: false };
pannableAndZoomableElements = [img, svg];
svg.addEventListener('wheel', zoom(targetEl, optionalZoomFactor), options);
svg.addEventListener('pointerdown', pan(targetEl), options);
button.addEventListener('click', () => {
[button, container].forEach(el => el.classList.toggle('switch'));
reset(pannableAndZoomableElements);
});
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);
pannableAndZoomableElements.forEach(el => {
el.addEventListener('wheel', e => zoom(el, e, optionalZoomFactor), { passive: false });
el.addEventListener('pointerdown', e => pan(el, e), { passive: false });
});
});

View File

@ -1,16 +1,19 @@
import { default as getComputedTransformMatrix } from './utils';
import getComputedTransformMatrix from './utils.js';
const minDistanceThreshold = 5;
function mainButtonPressed(e) {
return e.button === 0;
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);
}
function exceedsMinDistanceThreshhold(startPt, endPt) {
function minDistanceThresholdIsMet(startPt, endPt) {
return distanceBetween(startPt, endPt) >= minDistanceThreshold;
}
@ -24,71 +27,35 @@ function getTranslateMatrix(startPt, movePt) {
return translateMatrix.translate(movePt.x - startPt.x, movePt.y - startPt.y);
}
function getTransformMatrices(el) {
return {
computed: getComputedTransformMatrix(el),
inverseScreen: el.getScreenCTM().inverse()
};
}
export default function (el, e) {
e.preventDefault();
function clientToSvgPt(e, inverseScreenMtx, pt = new DOMPoint()) {
pt.x = e.clientX;
pt.y = e.clientY;
return pt.matrixTransform(inverseScreenMtx);
}
const mtx = getComputedTransformMatrix(el),
startPt = new DOMPoint(e.clientX, e.clientY),
movePt = new DOMPoint();
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;
let isPanning = false;
function pointerMove(e) {
movePt.x = e.clientX;
movePt.y = e.clientY;
setToCurrentPointerCoords(movePt, e);
if (!isPanning && exceedsMinDistanceThreshhold(startPt, movePt)) {
if (!isPanning && minDistanceThresholdIsMet(startPt, movePt)) {
isPanning = true;
startPt = clientToSvgPt(e, matrices.inverseScreen, startPt);
e.target.setPointerCapture(e.pointerId);
setToCurrentPointerCoords(startPt, e);
stopEventPropagationToChildren(el, 'click');
}
if (isPanning) {
movePt = clientToSvgPt(e, matrices.inverseScreen, movePt);
setTransform(el, matrices.computed, startPt, movePt);
el.style.transform = getTranslateMatrix(startPt, movePt).multiply(mtx);
}
}
return function(e) {
if (!mainButtonPressed(e)) return;
e.preventDefault();
e.target.setPointerCapture(e.pointerId);
el.addEventListener('pointermove', pointerMove);
isPanning = false;
matrices = getTransformMatrices(el);
startPt = new DOMPoint(e.clientX, e.clientY);
movePt = new DOMPoint();
this.addEventListener('pointermove', pointerMove);
this.addEventListener(
'pointerup',
() => this.removeEventListener('pointermove', pointerMove),
{ once: true }
);
}
el.addEventListener(
'pointerup',
() => el.removeEventListener('pointermove', pointerMove),
{ once: true }
);
}

View File

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

View File

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