Initial commit
This commit is contained in:
parent
f873697360
commit
740dec59e3
15 changed files with 5509 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
*.css.d.ts
|
75
README.md
Normal file
75
README.md
Normal file
|
@ -0,0 +1,75 @@
|
|||
# <pinch-zoom>
|
||||
|
||||
A web component for pinch zooming DOM elements.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
npm install --save-dev pinch-zoom-element
|
||||
```
|
||||
|
||||
```html
|
||||
<pinch-zoom>
|
||||
<h1>Hello!</h1>
|
||||
</pinch-zoom>
|
||||
```
|
||||
|
||||
Now the above can be pinch-zoomed!
|
||||
|
||||
## API
|
||||
|
||||
```html
|
||||
<pinch-zoom class="my-pinch-zoom">
|
||||
<h1>Hello!</h1>
|
||||
</pinch-zoom>
|
||||
<script>
|
||||
const pinchZoom = document.querySelector('.my-pinch-zoom');
|
||||
</script>
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
```js
|
||||
pinchZoom.x; // x offset
|
||||
pinchZoom.y; // y offset
|
||||
pinchZoom.scale; // scale
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
Set the transform. All values are optional.
|
||||
|
||||
```js
|
||||
pinchZoom.setTransform({
|
||||
scale: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
// Fire a 'change' event if values are different to current values
|
||||
allowChangeEvent: false,
|
||||
});
|
||||
```
|
||||
|
||||
Scale in/out of a particular point.
|
||||
|
||||
```js
|
||||
pinchZoom.scaleTo(scale, {
|
||||
// Transform origin. Can be a number, or string percent, eg "50%"
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
// Should the transform origin be relative to the container, or content?
|
||||
relativeTo: 'content',
|
||||
// Fire a 'change' event if values are different to current values
|
||||
allowChangeEvent: false,
|
||||
});
|
||||
```
|
||||
|
||||
## Demo
|
||||
|
||||
TODO.
|
||||
|
||||
## Files
|
||||
|
||||
* `lib/index.ts` - Original TypeScript.
|
||||
* `dist/pinch-zoom.mjs` - JS module. Default exports `PinchZoom`.
|
||||
* `dist/pinch-zoom.js` - Plain JS. Exposes `PinchZoom` on the global.
|
||||
* `dist/pinch-zoom-min.js` - Minified plain JS. 2.3k gzipped.
|
2
dist/index.d.ts
vendored
Normal file
2
dist/index.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './pinch-zoom';
|
||||
export { default } from './pinch-zoom';
|
1
dist/pinch-zoom-min.js
vendored
Normal file
1
dist/pinch-zoom-min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
54
dist/pinch-zoom.d.ts
vendored
Normal file
54
dist/pinch-zoom.d.ts
vendored
Normal file
|
@ -0,0 +1,54 @@
|
|||
import './styles.css';
|
||||
interface ChangeOptions {
|
||||
/**
|
||||
* Fire a 'change' event if values are different to current values
|
||||
*/
|
||||
allowChangeEvent?: boolean;
|
||||
}
|
||||
interface SetTransformOpts extends ChangeOptions {
|
||||
scale?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
declare type ScaleRelativeToValues = 'container' | 'content';
|
||||
export interface ScaleToOpts extends ChangeOptions {
|
||||
/** Transform origin. Can be a number, or string percent, eg "50%" */
|
||||
originX?: number | string;
|
||||
/** Transform origin. Can be a number, or string percent, eg "50%" */
|
||||
originY?: number | string;
|
||||
/** Should the transform origin be relative to the container, or content? */
|
||||
relativeTo?: ScaleRelativeToValues;
|
||||
}
|
||||
export default class PinchZoom extends HTMLElement {
|
||||
private _positioningEl?;
|
||||
private _transform;
|
||||
constructor();
|
||||
connectedCallback(): void;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly scale: number;
|
||||
/**
|
||||
* Change the scale, adjusting x/y by a given transform origin.
|
||||
*/
|
||||
scaleTo(scale: number, opts?: ScaleToOpts): void;
|
||||
/**
|
||||
* Update the stage with a given scale/x/y.
|
||||
*/
|
||||
setTransform(opts?: SetTransformOpts): void;
|
||||
/**
|
||||
* Update transform values without checking bounds. This is only called in setTransform.
|
||||
*/
|
||||
private _updateTransform;
|
||||
/**
|
||||
* Called when the direct children of this element change.
|
||||
* Until we have have shadow dom support across the board, we
|
||||
* require a single element to be the child of <pinch-zoom>, and
|
||||
* that's the element we pan/scale.
|
||||
*/
|
||||
private _stageElChange;
|
||||
private _onWheel;
|
||||
private _onPointerMove;
|
||||
/** Transform the view & fire a change event */
|
||||
private _applyChange;
|
||||
}
|
||||
export {};
|
484
dist/pinch-zoom.js
vendored
Normal file
484
dist/pinch-zoom.js
vendored
Normal file
|
@ -0,0 +1,484 @@
|
|||
var PinchZoom = (function () {
|
||||
'use strict';
|
||||
|
||||
class Pointer {
|
||||
constructor(nativePointer) {
|
||||
/** Unique ID for this pointer */
|
||||
this.id = -1;
|
||||
this.nativePointer = nativePointer;
|
||||
this.pageX = nativePointer.pageX;
|
||||
this.pageY = nativePointer.pageY;
|
||||
this.clientX = nativePointer.clientX;
|
||||
this.clientY = nativePointer.clientY;
|
||||
if (self.Touch && nativePointer instanceof Touch) {
|
||||
this.id = nativePointer.identifier;
|
||||
}
|
||||
else if (isPointerEvent(nativePointer)) { // is PointerEvent
|
||||
this.id = nativePointer.pointerId;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns an expanded set of Pointers for high-resolution inputs.
|
||||
*/
|
||||
getCoalesced() {
|
||||
if ('getCoalescedEvents' in this.nativePointer) {
|
||||
return this.nativePointer.getCoalescedEvents().map(p => new Pointer(p));
|
||||
}
|
||||
return [this];
|
||||
}
|
||||
}
|
||||
const isPointerEvent = (event) => self.PointerEvent && event instanceof PointerEvent;
|
||||
const noop = () => { };
|
||||
/**
|
||||
* Track pointers across a particular element
|
||||
*/
|
||||
class PointerTracker {
|
||||
/**
|
||||
* Track pointers across a particular element
|
||||
*
|
||||
* @param element Element to monitor.
|
||||
* @param callbacks
|
||||
*/
|
||||
constructor(_element, callbacks) {
|
||||
this._element = _element;
|
||||
/**
|
||||
* State of the tracked pointers when they were pressed/touched.
|
||||
*/
|
||||
this.startPointers = [];
|
||||
/**
|
||||
* Latest state of the tracked pointers. Contains the same number
|
||||
* of pointers, and in the same order as this.startPointers.
|
||||
*/
|
||||
this.currentPointers = [];
|
||||
const { start = () => true, move = noop, end = noop, } = callbacks;
|
||||
this._startCallback = start;
|
||||
this._moveCallback = move;
|
||||
this._endCallback = end;
|
||||
// Bind methods
|
||||
this._pointerStart = this._pointerStart.bind(this);
|
||||
this._touchStart = this._touchStart.bind(this);
|
||||
this._move = this._move.bind(this);
|
||||
this._triggerPointerEnd = this._triggerPointerEnd.bind(this);
|
||||
this._pointerEnd = this._pointerEnd.bind(this);
|
||||
this._touchEnd = this._touchEnd.bind(this);
|
||||
// Add listeners
|
||||
if (self.PointerEvent) {
|
||||
this._element.addEventListener('pointerdown', this._pointerStart);
|
||||
}
|
||||
else {
|
||||
this._element.addEventListener('mousedown', this._pointerStart);
|
||||
this._element.addEventListener('touchstart', this._touchStart);
|
||||
this._element.addEventListener('touchmove', this._move);
|
||||
this._element.addEventListener('touchend', this._touchEnd);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Call the start callback for this pointer, and track it if the user wants.
|
||||
*
|
||||
* @param pointer Pointer
|
||||
* @param event Related event
|
||||
* @returns Whether the pointer is being tracked.
|
||||
*/
|
||||
_triggerPointerStart(pointer, event) {
|
||||
if (!this._startCallback(pointer, event))
|
||||
return false;
|
||||
this.currentPointers.push(pointer);
|
||||
this.startPointers.push(pointer);
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Listener for mouse/pointer starts. Bound to the class in the constructor.
|
||||
*
|
||||
* @param event This will only be a MouseEvent if the browser doesn't support
|
||||
* pointer events.
|
||||
*/
|
||||
_pointerStart(event) {
|
||||
if (event.button !== 0 /* Left */)
|
||||
return;
|
||||
if (!this._triggerPointerStart(new Pointer(event), event))
|
||||
return;
|
||||
// Add listeners for additional events.
|
||||
// The listeners may already exist, but no harm in adding them again.
|
||||
if (isPointerEvent(event)) {
|
||||
this._element.setPointerCapture(event.pointerId);
|
||||
this._element.addEventListener('pointermove', this._move);
|
||||
this._element.addEventListener('pointerup', this._pointerEnd);
|
||||
}
|
||||
else { // MouseEvent
|
||||
window.addEventListener('mousemove', this._move);
|
||||
window.addEventListener('mouseup', this._pointerEnd);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Listener for touchstart. Bound to the class in the constructor.
|
||||
* Only used if the browser doesn't support pointer events.
|
||||
*/
|
||||
_touchStart(event) {
|
||||
for (const touch of Array.from(event.changedTouches)) {
|
||||
this._triggerPointerStart(new Pointer(touch), event);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Listener for pointer/mouse/touch move events.
|
||||
* Bound to the class in the constructor.
|
||||
*/
|
||||
_move(event) {
|
||||
const previousPointers = this.currentPointers.slice();
|
||||
const changedPointers = ('changedTouches' in event) ? // Shortcut for 'is touch event'.
|
||||
Array.from(event.changedTouches).map(t => new Pointer(t)) :
|
||||
[new Pointer(event)];
|
||||
const trackedChangedPointers = [];
|
||||
for (const pointer of changedPointers) {
|
||||
const index = this.currentPointers.findIndex(p => p.id === pointer.id);
|
||||
if (index === -1)
|
||||
continue; // Not a pointer we're tracking
|
||||
trackedChangedPointers.push(pointer);
|
||||
this.currentPointers[index] = pointer;
|
||||
}
|
||||
if (trackedChangedPointers.length === 0)
|
||||
return;
|
||||
this._moveCallback(previousPointers, trackedChangedPointers, event);
|
||||
}
|
||||
/**
|
||||
* Call the end callback for this pointer.
|
||||
*
|
||||
* @param pointer Pointer
|
||||
* @param event Related event
|
||||
*/
|
||||
_triggerPointerEnd(pointer, event) {
|
||||
const index = this.currentPointers.findIndex(p => p.id === pointer.id);
|
||||
// Not a pointer we're interested in?
|
||||
if (index === -1)
|
||||
return false;
|
||||
this.currentPointers.splice(index, 1);
|
||||
this.startPointers.splice(index, 1);
|
||||
this._endCallback(pointer, event);
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Listener for mouse/pointer ends. Bound to the class in the constructor.
|
||||
* @param event This will only be a MouseEvent if the browser doesn't support
|
||||
* pointer events.
|
||||
*/
|
||||
_pointerEnd(event) {
|
||||
if (!this._triggerPointerEnd(new Pointer(event), event))
|
||||
return;
|
||||
if (isPointerEvent(event)) {
|
||||
if (this.currentPointers.length)
|
||||
return;
|
||||
this._element.removeEventListener('pointermove', this._move);
|
||||
this._element.removeEventListener('pointerup', this._pointerEnd);
|
||||
}
|
||||
else { // MouseEvent
|
||||
window.removeEventListener('mousemove', this._move);
|
||||
window.removeEventListener('mouseup', this._pointerEnd);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Listener for touchend. Bound to the class in the constructor.
|
||||
* Only used if the browser doesn't support pointer events.
|
||||
*/
|
||||
_touchEnd(event) {
|
||||
for (const touch of Array.from(event.changedTouches)) {
|
||||
this._triggerPointerEnd(new Pointer(touch), event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function styleInject(css, ref) {
|
||||
if ( ref === void 0 ) ref = {};
|
||||
var insertAt = ref.insertAt;
|
||||
|
||||
if (!css || typeof document === 'undefined') { return; }
|
||||
|
||||
var head = document.head || document.getElementsByTagName('head')[0];
|
||||
var style = document.createElement('style');
|
||||
style.type = 'text/css';
|
||||
|
||||
if (insertAt === 'top') {
|
||||
if (head.firstChild) {
|
||||
head.insertBefore(style, head.firstChild);
|
||||
} else {
|
||||
head.appendChild(style);
|
||||
}
|
||||
} else {
|
||||
head.appendChild(style);
|
||||
}
|
||||
|
||||
if (style.styleSheet) {
|
||||
style.styleSheet.cssText = css;
|
||||
} else {
|
||||
style.appendChild(document.createTextNode(css));
|
||||
}
|
||||
}
|
||||
|
||||
var css = "pinch-zoom {\n display: block;\n overflow: hidden;\n touch-action: none;\n --scale: 1;\n --x: 0;\n --y: 0;\n}\n\npinch-zoom > * {\n transform: translate(var(--x), var(--y)) scale(var(--scale));\n transform-origin: 0 0;\n will-change: transform;\n}\n";
|
||||
styleInject(css);
|
||||
|
||||
function getDistance(a, b) {
|
||||
if (!b)
|
||||
return 0;
|
||||
return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2);
|
||||
}
|
||||
function getMidpoint(a, b) {
|
||||
if (!b)
|
||||
return a;
|
||||
return {
|
||||
clientX: (a.clientX + b.clientX) / 2,
|
||||
clientY: (a.clientY + b.clientY) / 2,
|
||||
};
|
||||
}
|
||||
function getAbsoluteValue(value, max) {
|
||||
if (typeof value === 'number')
|
||||
return value;
|
||||
if (value.trimRight().endsWith('%')) {
|
||||
return max * parseFloat(value) / 100;
|
||||
}
|
||||
return parseFloat(value);
|
||||
}
|
||||
// I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough.
|
||||
// Given that, better to use something everything supports.
|
||||
let cachedSvg;
|
||||
function getSVG() {
|
||||
return cachedSvg || (cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'));
|
||||
}
|
||||
function createMatrix() {
|
||||
return getSVG().createSVGMatrix();
|
||||
}
|
||||
function createPoint() {
|
||||
return getSVG().createSVGPoint();
|
||||
}
|
||||
const MIN_SCALE = 0.01;
|
||||
class PinchZoom extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
// Current transform.
|
||||
this._transform = createMatrix();
|
||||
// Watch for children changes.
|
||||
// Note this won't fire for initial contents,
|
||||
// so _stageElChange is also called in connectedCallback.
|
||||
new MutationObserver(() => this._stageElChange())
|
||||
.observe(this, { childList: true });
|
||||
// Watch for pointers
|
||||
const pointerTracker = new PointerTracker(this, {
|
||||
start: (pointer, event) => {
|
||||
// We only want to track 2 pointers at most
|
||||
if (pointerTracker.currentPointers.length === 2 || !this._positioningEl)
|
||||
return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
move: (previousPointers) => {
|
||||
this._onPointerMove(previousPointers, pointerTracker.currentPointers);
|
||||
},
|
||||
});
|
||||
this.addEventListener('wheel', event => this._onWheel(event));
|
||||
}
|
||||
connectedCallback() {
|
||||
this._stageElChange();
|
||||
}
|
||||
get x() {
|
||||
return this._transform.e;
|
||||
}
|
||||
get y() {
|
||||
return this._transform.f;
|
||||
}
|
||||
get scale() {
|
||||
return this._transform.a;
|
||||
}
|
||||
/**
|
||||
* Change the scale, adjusting x/y by a given transform origin.
|
||||
*/
|
||||
scaleTo(scale, opts = {}) {
|
||||
let { originX = 0, originY = 0, } = opts;
|
||||
const { relativeTo = 'content', allowChangeEvent = false, } = opts;
|
||||
const relativeToEl = (relativeTo === 'content' ? this._positioningEl : this);
|
||||
// No content element? Fall back to just setting scale
|
||||
if (!relativeToEl || !this._positioningEl) {
|
||||
this.setTransform({ scale, allowChangeEvent });
|
||||
return;
|
||||
}
|
||||
const rect = relativeToEl.getBoundingClientRect();
|
||||
originX = getAbsoluteValue(originX, rect.width);
|
||||
originY = getAbsoluteValue(originY, rect.height);
|
||||
if (relativeTo === 'content') {
|
||||
originX += this.x;
|
||||
originY += this.y;
|
||||
}
|
||||
else {
|
||||
const currentRect = this._positioningEl.getBoundingClientRect();
|
||||
originX -= currentRect.left;
|
||||
originY -= currentRect.top;
|
||||
}
|
||||
this._applyChange({
|
||||
allowChangeEvent,
|
||||
originX,
|
||||
originY,
|
||||
scaleDiff: scale / this.scale,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Update the stage with a given scale/x/y.
|
||||
*/
|
||||
setTransform(opts = {}) {
|
||||
const { scale = this.scale, allowChangeEvent = false, } = opts;
|
||||
let { x = this.x, y = this.y, } = opts;
|
||||
// If we don't have an element to position, just set the value as given.
|
||||
// We'll check bounds later.
|
||||
if (!this._positioningEl) {
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
return;
|
||||
}
|
||||
// Get current layout
|
||||
const thisBounds = this.getBoundingClientRect();
|
||||
const positioningElBounds = this._positioningEl.getBoundingClientRect();
|
||||
// Not displayed. May be disconnected or display:none.
|
||||
// Just take the values, and we'll check bounds later.
|
||||
if (!thisBounds.width || !thisBounds.height) {
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
return;
|
||||
}
|
||||
// Create points for _positioningEl.
|
||||
let topLeft = createPoint();
|
||||
topLeft.x = positioningElBounds.left - thisBounds.left;
|
||||
topLeft.y = positioningElBounds.top - thisBounds.top;
|
||||
let bottomRight = createPoint();
|
||||
bottomRight.x = positioningElBounds.width + topLeft.x;
|
||||
bottomRight.y = positioningElBounds.height + topLeft.y;
|
||||
// Calculate the intended position of _positioningEl.
|
||||
const matrix = createMatrix()
|
||||
.translate(x, y)
|
||||
.scale(scale)
|
||||
// Undo current transform
|
||||
.multiply(this._transform.inverse());
|
||||
topLeft = topLeft.matrixTransform(matrix);
|
||||
bottomRight = bottomRight.matrixTransform(matrix);
|
||||
// Ensure _positioningEl can't move beyond out-of-bounds.
|
||||
// Correct for x
|
||||
if (topLeft.x > thisBounds.width) {
|
||||
x += thisBounds.width - topLeft.x;
|
||||
}
|
||||
else if (bottomRight.x < 0) {
|
||||
x += -bottomRight.x;
|
||||
}
|
||||
// Correct for y
|
||||
if (topLeft.y > thisBounds.height) {
|
||||
y += thisBounds.height - topLeft.y;
|
||||
}
|
||||
else if (bottomRight.y < 0) {
|
||||
y += -bottomRight.y;
|
||||
}
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
}
|
||||
/**
|
||||
* Update transform values without checking bounds. This is only called in setTransform.
|
||||
*/
|
||||
_updateTransform(scale, x, y, allowChangeEvent) {
|
||||
// Avoid scaling to zero
|
||||
if (scale < MIN_SCALE)
|
||||
return;
|
||||
// Return if there's no change
|
||||
if (scale === this.scale &&
|
||||
x === this.x &&
|
||||
y === this.y)
|
||||
return;
|
||||
this._transform.e = x;
|
||||
this._transform.f = y;
|
||||
this._transform.d = this._transform.a = scale;
|
||||
this.style.setProperty('--x', this.x + 'px');
|
||||
this.style.setProperty('--y', this.y + 'px');
|
||||
this.style.setProperty('--scale', this.scale + '');
|
||||
if (allowChangeEvent) {
|
||||
const event = new Event('change', { bubbles: true });
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Called when the direct children of this element change.
|
||||
* Until we have have shadow dom support across the board, we
|
||||
* require a single element to be the child of <pinch-zoom>, and
|
||||
* that's the element we pan/scale.
|
||||
*/
|
||||
_stageElChange() {
|
||||
this._positioningEl = undefined;
|
||||
if (this.children.length === 0)
|
||||
return;
|
||||
this._positioningEl = this.children[0];
|
||||
if (this.children.length > 1) {
|
||||
console.warn('<pinch-zoom> must not have more than one child.');
|
||||
}
|
||||
// Do a bounds check
|
||||
this.setTransform({ allowChangeEvent: true });
|
||||
}
|
||||
_onWheel(event) {
|
||||
if (!this._positioningEl)
|
||||
return;
|
||||
event.preventDefault();
|
||||
const currentRect = this._positioningEl.getBoundingClientRect();
|
||||
let { deltaY } = event;
|
||||
const { ctrlKey, deltaMode } = event;
|
||||
if (deltaMode === 1) { // 1 is "lines", 0 is "pixels"
|
||||
// Firefox uses "lines" for some types of mouse
|
||||
deltaY *= 15;
|
||||
}
|
||||
// ctrlKey is true when pinch-zooming on a trackpad.
|
||||
const divisor = ctrlKey ? 100 : 300;
|
||||
const scaleDiff = 1 - deltaY / divisor;
|
||||
this._applyChange({
|
||||
scaleDiff,
|
||||
originX: event.clientX - currentRect.left,
|
||||
originY: event.clientY - currentRect.top,
|
||||
allowChangeEvent: true,
|
||||
});
|
||||
}
|
||||
_onPointerMove(previousPointers, currentPointers) {
|
||||
if (!this._positioningEl)
|
||||
return;
|
||||
// Combine next points with previous points
|
||||
const currentRect = this._positioningEl.getBoundingClientRect();
|
||||
// For calculating panning movement
|
||||
const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]);
|
||||
const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]);
|
||||
// Midpoint within the element
|
||||
const originX = prevMidpoint.clientX - currentRect.left;
|
||||
const originY = prevMidpoint.clientY - currentRect.top;
|
||||
// Calculate the desired change in scale
|
||||
const prevDistance = getDistance(previousPointers[0], previousPointers[1]);
|
||||
const newDistance = getDistance(currentPointers[0], currentPointers[1]);
|
||||
const scaleDiff = prevDistance ? newDistance / prevDistance : 1;
|
||||
this._applyChange({
|
||||
originX, originY, scaleDiff,
|
||||
panX: newMidpoint.clientX - prevMidpoint.clientX,
|
||||
panY: newMidpoint.clientY - prevMidpoint.clientY,
|
||||
allowChangeEvent: true,
|
||||
});
|
||||
}
|
||||
/** Transform the view & fire a change event */
|
||||
_applyChange(opts = {}) {
|
||||
const { panX = 0, panY = 0, originX = 0, originY = 0, scaleDiff = 1, allowChangeEvent = false, } = opts;
|
||||
const matrix = createMatrix()
|
||||
// Translate according to panning.
|
||||
.translate(panX, panY)
|
||||
// Scale about the origin.
|
||||
.translate(originX, originY)
|
||||
// Apply current translate
|
||||
.translate(this.x, this.y)
|
||||
.scale(scaleDiff)
|
||||
.translate(-originX, -originY)
|
||||
// Apply current scale.
|
||||
.scale(this.scale);
|
||||
// Convert the transform into basic translate & scale.
|
||||
this.setTransform({
|
||||
allowChangeEvent,
|
||||
scale: matrix.a,
|
||||
x: matrix.e,
|
||||
y: matrix.f,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('pinch-zoom', PinchZoom);
|
||||
|
||||
return PinchZoom;
|
||||
|
||||
}());
|
297
dist/pinch-zoom.mjs
vendored
Normal file
297
dist/pinch-zoom.mjs
vendored
Normal file
|
@ -0,0 +1,297 @@
|
|||
import PointerTracker from 'pointer-tracker';
|
||||
|
||||
function styleInject(css, ref) {
|
||||
if ( ref === void 0 ) ref = {};
|
||||
var insertAt = ref.insertAt;
|
||||
|
||||
if (!css || typeof document === 'undefined') { return; }
|
||||
|
||||
var head = document.head || document.getElementsByTagName('head')[0];
|
||||
var style = document.createElement('style');
|
||||
style.type = 'text/css';
|
||||
|
||||
if (insertAt === 'top') {
|
||||
if (head.firstChild) {
|
||||
head.insertBefore(style, head.firstChild);
|
||||
} else {
|
||||
head.appendChild(style);
|
||||
}
|
||||
} else {
|
||||
head.appendChild(style);
|
||||
}
|
||||
|
||||
if (style.styleSheet) {
|
||||
style.styleSheet.cssText = css;
|
||||
} else {
|
||||
style.appendChild(document.createTextNode(css));
|
||||
}
|
||||
}
|
||||
|
||||
var css = "pinch-zoom {\n display: block;\n overflow: hidden;\n touch-action: none;\n --scale: 1;\n --x: 0;\n --y: 0;\n}\n\npinch-zoom > * {\n transform: translate(var(--x), var(--y)) scale(var(--scale));\n transform-origin: 0 0;\n will-change: transform;\n}\n";
|
||||
styleInject(css);
|
||||
|
||||
function getDistance(a, b) {
|
||||
if (!b)
|
||||
return 0;
|
||||
return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2);
|
||||
}
|
||||
function getMidpoint(a, b) {
|
||||
if (!b)
|
||||
return a;
|
||||
return {
|
||||
clientX: (a.clientX + b.clientX) / 2,
|
||||
clientY: (a.clientY + b.clientY) / 2,
|
||||
};
|
||||
}
|
||||
function getAbsoluteValue(value, max) {
|
||||
if (typeof value === 'number')
|
||||
return value;
|
||||
if (value.trimRight().endsWith('%')) {
|
||||
return max * parseFloat(value) / 100;
|
||||
}
|
||||
return parseFloat(value);
|
||||
}
|
||||
// I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough.
|
||||
// Given that, better to use something everything supports.
|
||||
let cachedSvg;
|
||||
function getSVG() {
|
||||
return cachedSvg || (cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'));
|
||||
}
|
||||
function createMatrix() {
|
||||
return getSVG().createSVGMatrix();
|
||||
}
|
||||
function createPoint() {
|
||||
return getSVG().createSVGPoint();
|
||||
}
|
||||
const MIN_SCALE = 0.01;
|
||||
class PinchZoom extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
// Current transform.
|
||||
this._transform = createMatrix();
|
||||
// Watch for children changes.
|
||||
// Note this won't fire for initial contents,
|
||||
// so _stageElChange is also called in connectedCallback.
|
||||
new MutationObserver(() => this._stageElChange())
|
||||
.observe(this, { childList: true });
|
||||
// Watch for pointers
|
||||
const pointerTracker = new PointerTracker(this, {
|
||||
start: (pointer, event) => {
|
||||
// We only want to track 2 pointers at most
|
||||
if (pointerTracker.currentPointers.length === 2 || !this._positioningEl)
|
||||
return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
move: (previousPointers) => {
|
||||
this._onPointerMove(previousPointers, pointerTracker.currentPointers);
|
||||
},
|
||||
});
|
||||
this.addEventListener('wheel', event => this._onWheel(event));
|
||||
}
|
||||
connectedCallback() {
|
||||
this._stageElChange();
|
||||
}
|
||||
get x() {
|
||||
return this._transform.e;
|
||||
}
|
||||
get y() {
|
||||
return this._transform.f;
|
||||
}
|
||||
get scale() {
|
||||
return this._transform.a;
|
||||
}
|
||||
/**
|
||||
* Change the scale, adjusting x/y by a given transform origin.
|
||||
*/
|
||||
scaleTo(scale, opts = {}) {
|
||||
let { originX = 0, originY = 0, } = opts;
|
||||
const { relativeTo = 'content', allowChangeEvent = false, } = opts;
|
||||
const relativeToEl = (relativeTo === 'content' ? this._positioningEl : this);
|
||||
// No content element? Fall back to just setting scale
|
||||
if (!relativeToEl || !this._positioningEl) {
|
||||
this.setTransform({ scale, allowChangeEvent });
|
||||
return;
|
||||
}
|
||||
const rect = relativeToEl.getBoundingClientRect();
|
||||
originX = getAbsoluteValue(originX, rect.width);
|
||||
originY = getAbsoluteValue(originY, rect.height);
|
||||
if (relativeTo === 'content') {
|
||||
originX += this.x;
|
||||
originY += this.y;
|
||||
}
|
||||
else {
|
||||
const currentRect = this._positioningEl.getBoundingClientRect();
|
||||
originX -= currentRect.left;
|
||||
originY -= currentRect.top;
|
||||
}
|
||||
this._applyChange({
|
||||
allowChangeEvent,
|
||||
originX,
|
||||
originY,
|
||||
scaleDiff: scale / this.scale,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Update the stage with a given scale/x/y.
|
||||
*/
|
||||
setTransform(opts = {}) {
|
||||
const { scale = this.scale, allowChangeEvent = false, } = opts;
|
||||
let { x = this.x, y = this.y, } = opts;
|
||||
// If we don't have an element to position, just set the value as given.
|
||||
// We'll check bounds later.
|
||||
if (!this._positioningEl) {
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
return;
|
||||
}
|
||||
// Get current layout
|
||||
const thisBounds = this.getBoundingClientRect();
|
||||
const positioningElBounds = this._positioningEl.getBoundingClientRect();
|
||||
// Not displayed. May be disconnected or display:none.
|
||||
// Just take the values, and we'll check bounds later.
|
||||
if (!thisBounds.width || !thisBounds.height) {
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
return;
|
||||
}
|
||||
// Create points for _positioningEl.
|
||||
let topLeft = createPoint();
|
||||
topLeft.x = positioningElBounds.left - thisBounds.left;
|
||||
topLeft.y = positioningElBounds.top - thisBounds.top;
|
||||
let bottomRight = createPoint();
|
||||
bottomRight.x = positioningElBounds.width + topLeft.x;
|
||||
bottomRight.y = positioningElBounds.height + topLeft.y;
|
||||
// Calculate the intended position of _positioningEl.
|
||||
const matrix = createMatrix()
|
||||
.translate(x, y)
|
||||
.scale(scale)
|
||||
// Undo current transform
|
||||
.multiply(this._transform.inverse());
|
||||
topLeft = topLeft.matrixTransform(matrix);
|
||||
bottomRight = bottomRight.matrixTransform(matrix);
|
||||
// Ensure _positioningEl can't move beyond out-of-bounds.
|
||||
// Correct for x
|
||||
if (topLeft.x > thisBounds.width) {
|
||||
x += thisBounds.width - topLeft.x;
|
||||
}
|
||||
else if (bottomRight.x < 0) {
|
||||
x += -bottomRight.x;
|
||||
}
|
||||
// Correct for y
|
||||
if (topLeft.y > thisBounds.height) {
|
||||
y += thisBounds.height - topLeft.y;
|
||||
}
|
||||
else if (bottomRight.y < 0) {
|
||||
y += -bottomRight.y;
|
||||
}
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
}
|
||||
/**
|
||||
* Update transform values without checking bounds. This is only called in setTransform.
|
||||
*/
|
||||
_updateTransform(scale, x, y, allowChangeEvent) {
|
||||
// Avoid scaling to zero
|
||||
if (scale < MIN_SCALE)
|
||||
return;
|
||||
// Return if there's no change
|
||||
if (scale === this.scale &&
|
||||
x === this.x &&
|
||||
y === this.y)
|
||||
return;
|
||||
this._transform.e = x;
|
||||
this._transform.f = y;
|
||||
this._transform.d = this._transform.a = scale;
|
||||
this.style.setProperty('--x', this.x + 'px');
|
||||
this.style.setProperty('--y', this.y + 'px');
|
||||
this.style.setProperty('--scale', this.scale + '');
|
||||
if (allowChangeEvent) {
|
||||
const event = new Event('change', { bubbles: true });
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Called when the direct children of this element change.
|
||||
* Until we have have shadow dom support across the board, we
|
||||
* require a single element to be the child of <pinch-zoom>, and
|
||||
* that's the element we pan/scale.
|
||||
*/
|
||||
_stageElChange() {
|
||||
this._positioningEl = undefined;
|
||||
if (this.children.length === 0)
|
||||
return;
|
||||
this._positioningEl = this.children[0];
|
||||
if (this.children.length > 1) {
|
||||
console.warn('<pinch-zoom> must not have more than one child.');
|
||||
}
|
||||
// Do a bounds check
|
||||
this.setTransform({ allowChangeEvent: true });
|
||||
}
|
||||
_onWheel(event) {
|
||||
if (!this._positioningEl)
|
||||
return;
|
||||
event.preventDefault();
|
||||
const currentRect = this._positioningEl.getBoundingClientRect();
|
||||
let { deltaY } = event;
|
||||
const { ctrlKey, deltaMode } = event;
|
||||
if (deltaMode === 1) { // 1 is "lines", 0 is "pixels"
|
||||
// Firefox uses "lines" for some types of mouse
|
||||
deltaY *= 15;
|
||||
}
|
||||
// ctrlKey is true when pinch-zooming on a trackpad.
|
||||
const divisor = ctrlKey ? 100 : 300;
|
||||
const scaleDiff = 1 - deltaY / divisor;
|
||||
this._applyChange({
|
||||
scaleDiff,
|
||||
originX: event.clientX - currentRect.left,
|
||||
originY: event.clientY - currentRect.top,
|
||||
allowChangeEvent: true,
|
||||
});
|
||||
}
|
||||
_onPointerMove(previousPointers, currentPointers) {
|
||||
if (!this._positioningEl)
|
||||
return;
|
||||
// Combine next points with previous points
|
||||
const currentRect = this._positioningEl.getBoundingClientRect();
|
||||
// For calculating panning movement
|
||||
const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]);
|
||||
const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]);
|
||||
// Midpoint within the element
|
||||
const originX = prevMidpoint.clientX - currentRect.left;
|
||||
const originY = prevMidpoint.clientY - currentRect.top;
|
||||
// Calculate the desired change in scale
|
||||
const prevDistance = getDistance(previousPointers[0], previousPointers[1]);
|
||||
const newDistance = getDistance(currentPointers[0], currentPointers[1]);
|
||||
const scaleDiff = prevDistance ? newDistance / prevDistance : 1;
|
||||
this._applyChange({
|
||||
originX, originY, scaleDiff,
|
||||
panX: newMidpoint.clientX - prevMidpoint.clientX,
|
||||
panY: newMidpoint.clientY - prevMidpoint.clientY,
|
||||
allowChangeEvent: true,
|
||||
});
|
||||
}
|
||||
/** Transform the view & fire a change event */
|
||||
_applyChange(opts = {}) {
|
||||
const { panX = 0, panY = 0, originX = 0, originY = 0, scaleDiff = 1, allowChangeEvent = false, } = opts;
|
||||
const matrix = createMatrix()
|
||||
// Translate according to panning.
|
||||
.translate(panX, panY)
|
||||
// Scale about the origin.
|
||||
.translate(originX, originY)
|
||||
// Apply current translate
|
||||
.translate(this.x, this.y)
|
||||
.scale(scaleDiff)
|
||||
.translate(-originX, -originY)
|
||||
// Apply current scale.
|
||||
.scale(this.scale);
|
||||
// Convert the transform into basic translate & scale.
|
||||
this.setTransform({
|
||||
allowChangeEvent,
|
||||
scale: matrix.a,
|
||||
x: matrix.e,
|
||||
y: matrix.f,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('pinch-zoom', PinchZoom);
|
||||
|
||||
export default PinchZoom;
|
4
lib/index.ts
Normal file
4
lib/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import PinchZoom from './pinch-zoom';
|
||||
export * from './pinch-zoom';
|
||||
export { default } from './pinch-zoom';
|
||||
customElements.define('pinch-zoom', PinchZoom);
|
372
lib/pinch-zoom.ts
Normal file
372
lib/pinch-zoom.ts
Normal file
|
@ -0,0 +1,372 @@
|
|||
import PointerTracker, { Pointer } from 'pointer-tracker';
|
||||
import './styles.css';
|
||||
|
||||
interface Point {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
interface ChangeOptions {
|
||||
/**
|
||||
* Fire a 'change' event if values are different to current values
|
||||
*/
|
||||
allowChangeEvent?: boolean;
|
||||
}
|
||||
|
||||
interface ApplyChangeOpts extends ChangeOptions {
|
||||
panX?: number;
|
||||
panY?: number;
|
||||
scaleDiff?: number;
|
||||
originX?: number;
|
||||
originY?: number;
|
||||
}
|
||||
|
||||
interface SetTransformOpts extends ChangeOptions {
|
||||
scale?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
}
|
||||
|
||||
type ScaleRelativeToValues = 'container' | 'content';
|
||||
|
||||
export interface ScaleToOpts extends ChangeOptions {
|
||||
/** Transform origin. Can be a number, or string percent, eg "50%" */
|
||||
originX?: number | string;
|
||||
/** Transform origin. Can be a number, or string percent, eg "50%" */
|
||||
originY?: number | string;
|
||||
/** Should the transform origin be relative to the container, or content? */
|
||||
relativeTo?: ScaleRelativeToValues;
|
||||
}
|
||||
|
||||
function getDistance(a: Point, b?: Point): number {
|
||||
if (!b) return 0;
|
||||
return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2);
|
||||
}
|
||||
|
||||
function getMidpoint(a: Point, b?: Point): Point {
|
||||
if (!b) return a;
|
||||
|
||||
return {
|
||||
clientX: (a.clientX + b.clientX) / 2,
|
||||
clientY: (a.clientY + b.clientY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function getAbsoluteValue(value: string | number, max: number): number {
|
||||
if (typeof value === 'number') return value;
|
||||
|
||||
if (value.trimRight().endsWith('%')) {
|
||||
return max * parseFloat(value) / 100;
|
||||
}
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
// I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough.
|
||||
// Given that, better to use something everything supports.
|
||||
let cachedSvg: SVGSVGElement;
|
||||
|
||||
function getSVG(): SVGSVGElement {
|
||||
return cachedSvg || (cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'));
|
||||
}
|
||||
|
||||
function createMatrix(): SVGMatrix {
|
||||
return getSVG().createSVGMatrix();
|
||||
}
|
||||
|
||||
function createPoint(): SVGPoint {
|
||||
return getSVG().createSVGPoint();
|
||||
}
|
||||
|
||||
const MIN_SCALE = 0.01;
|
||||
|
||||
export default class PinchZoom extends HTMLElement {
|
||||
// The element that we'll transform.
|
||||
// Ideally this would be shadow DOM, but we don't have the browser
|
||||
// support yet.
|
||||
private _positioningEl?: Element;
|
||||
// Current transform.
|
||||
private _transform: SVGMatrix = createMatrix();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Watch for children changes.
|
||||
// Note this won't fire for initial contents,
|
||||
// so _stageElChange is also called in connectedCallback.
|
||||
new MutationObserver(() => this._stageElChange())
|
||||
.observe(this, { childList: true });
|
||||
|
||||
// Watch for pointers
|
||||
const pointerTracker: PointerTracker = new PointerTracker(this, {
|
||||
start: (pointer, event) => {
|
||||
// We only want to track 2 pointers at most
|
||||
if (pointerTracker.currentPointers.length === 2 || !this._positioningEl) return false;
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
move: (previousPointers) => {
|
||||
this._onPointerMove(previousPointers, pointerTracker.currentPointers);
|
||||
},
|
||||
});
|
||||
|
||||
this.addEventListener('wheel', event => this._onWheel(event));
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._stageElChange();
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this._transform.e;
|
||||
}
|
||||
|
||||
get y() {
|
||||
return this._transform.f;
|
||||
}
|
||||
|
||||
get scale() {
|
||||
return this._transform.a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the scale, adjusting x/y by a given transform origin.
|
||||
*/
|
||||
scaleTo(scale: number, opts: ScaleToOpts = {}) {
|
||||
let {
|
||||
originX = 0,
|
||||
originY = 0,
|
||||
} = opts;
|
||||
|
||||
const {
|
||||
relativeTo = 'content',
|
||||
allowChangeEvent = false,
|
||||
} = opts;
|
||||
|
||||
const relativeToEl = (relativeTo === 'content' ? this._positioningEl : this);
|
||||
|
||||
// No content element? Fall back to just setting scale
|
||||
if (!relativeToEl || !this._positioningEl) {
|
||||
this.setTransform({ scale, allowChangeEvent });
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = relativeToEl.getBoundingClientRect();
|
||||
originX = getAbsoluteValue(originX, rect.width);
|
||||
originY = getAbsoluteValue(originY, rect.height);
|
||||
|
||||
if (relativeTo === 'content') {
|
||||
originX += this.x;
|
||||
originY += this.y;
|
||||
} else {
|
||||
const currentRect = this._positioningEl.getBoundingClientRect();
|
||||
originX -= currentRect.left;
|
||||
originY -= currentRect.top;
|
||||
}
|
||||
|
||||
this._applyChange({
|
||||
allowChangeEvent,
|
||||
originX,
|
||||
originY,
|
||||
scaleDiff: scale / this.scale,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stage with a given scale/x/y.
|
||||
*/
|
||||
setTransform(opts: SetTransformOpts = {}) {
|
||||
const {
|
||||
scale = this.scale,
|
||||
allowChangeEvent = false,
|
||||
} = opts;
|
||||
|
||||
let {
|
||||
x = this.x,
|
||||
y = this.y,
|
||||
} = opts;
|
||||
|
||||
// If we don't have an element to position, just set the value as given.
|
||||
// We'll check bounds later.
|
||||
if (!this._positioningEl) {
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current layout
|
||||
const thisBounds = this.getBoundingClientRect();
|
||||
const positioningElBounds = this._positioningEl.getBoundingClientRect();
|
||||
|
||||
// Not displayed. May be disconnected or display:none.
|
||||
// Just take the values, and we'll check bounds later.
|
||||
if (!thisBounds.width || !thisBounds.height) {
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create points for _positioningEl.
|
||||
let topLeft = createPoint();
|
||||
topLeft.x = positioningElBounds.left - thisBounds.left;
|
||||
topLeft.y = positioningElBounds.top - thisBounds.top;
|
||||
let bottomRight = createPoint();
|
||||
bottomRight.x = positioningElBounds.width + topLeft.x;
|
||||
bottomRight.y = positioningElBounds.height + topLeft.y;
|
||||
|
||||
// Calculate the intended position of _positioningEl.
|
||||
const matrix = createMatrix()
|
||||
.translate(x, y)
|
||||
.scale(scale)
|
||||
// Undo current transform
|
||||
.multiply(this._transform.inverse());
|
||||
|
||||
topLeft = topLeft.matrixTransform(matrix);
|
||||
bottomRight = bottomRight.matrixTransform(matrix);
|
||||
|
||||
// Ensure _positioningEl can't move beyond out-of-bounds.
|
||||
// Correct for x
|
||||
if (topLeft.x > thisBounds.width) {
|
||||
x += thisBounds.width - topLeft.x;
|
||||
} else if (bottomRight.x < 0) {
|
||||
x += -bottomRight.x;
|
||||
}
|
||||
|
||||
// Correct for y
|
||||
if (topLeft.y > thisBounds.height) {
|
||||
y += thisBounds.height - topLeft.y;
|
||||
} else if (bottomRight.y < 0) {
|
||||
y += -bottomRight.y;
|
||||
}
|
||||
|
||||
this._updateTransform(scale, x, y, allowChangeEvent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update transform values without checking bounds. This is only called in setTransform.
|
||||
*/
|
||||
private _updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) {
|
||||
// Avoid scaling to zero
|
||||
if (scale < MIN_SCALE) return;
|
||||
|
||||
// Return if there's no change
|
||||
if (
|
||||
scale === this.scale &&
|
||||
x === this.x &&
|
||||
y === this.y
|
||||
) return;
|
||||
|
||||
this._transform.e = x;
|
||||
this._transform.f = y;
|
||||
this._transform.d = this._transform.a = scale;
|
||||
|
||||
this.style.setProperty('--x', this.x + 'px');
|
||||
this.style.setProperty('--y', this.y + 'px');
|
||||
this.style.setProperty('--scale', this.scale + '');
|
||||
|
||||
if (allowChangeEvent) {
|
||||
const event = new Event('change', { bubbles: true });
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the direct children of this element change.
|
||||
* Until we have have shadow dom support across the board, we
|
||||
* require a single element to be the child of <pinch-zoom>, and
|
||||
* that's the element we pan/scale.
|
||||
*/
|
||||
private _stageElChange() {
|
||||
this._positioningEl = undefined;
|
||||
|
||||
if (this.children.length === 0) return;
|
||||
|
||||
this._positioningEl = this.children[0];
|
||||
|
||||
if (this.children.length > 1) {
|
||||
console.warn('<pinch-zoom> must not have more than one child.');
|
||||
}
|
||||
|
||||
// Do a bounds check
|
||||
this.setTransform({ allowChangeEvent: true });
|
||||
}
|
||||
|
||||
private _onWheel(event: WheelEvent) {
|
||||
if (!this._positioningEl) return;
|
||||
event.preventDefault();
|
||||
|
||||
const currentRect = this._positioningEl.getBoundingClientRect();
|
||||
let { deltaY } = event;
|
||||
const { ctrlKey, deltaMode } = event;
|
||||
|
||||
if (deltaMode === 1) { // 1 is "lines", 0 is "pixels"
|
||||
// Firefox uses "lines" for some types of mouse
|
||||
deltaY *= 15;
|
||||
}
|
||||
|
||||
// ctrlKey is true when pinch-zooming on a trackpad.
|
||||
const divisor = ctrlKey ? 100 : 300;
|
||||
const scaleDiff = 1 - deltaY / divisor;
|
||||
|
||||
this._applyChange({
|
||||
scaleDiff,
|
||||
originX: event.clientX - currentRect.left,
|
||||
originY: event.clientY - currentRect.top,
|
||||
allowChangeEvent: true,
|
||||
});
|
||||
}
|
||||
|
||||
private _onPointerMove(previousPointers: Pointer[], currentPointers: Pointer[]) {
|
||||
if (!this._positioningEl) return;
|
||||
|
||||
// Combine next points with previous points
|
||||
const currentRect = this._positioningEl.getBoundingClientRect();
|
||||
|
||||
// For calculating panning movement
|
||||
const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]);
|
||||
const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]);
|
||||
|
||||
// Midpoint within the element
|
||||
const originX = prevMidpoint.clientX - currentRect.left;
|
||||
const originY = prevMidpoint.clientY - currentRect.top;
|
||||
|
||||
// Calculate the desired change in scale
|
||||
const prevDistance = getDistance(previousPointers[0], previousPointers[1]);
|
||||
const newDistance = getDistance(currentPointers[0], currentPointers[1]);
|
||||
const scaleDiff = prevDistance ? newDistance / prevDistance : 1;
|
||||
|
||||
this._applyChange({
|
||||
originX, originY, scaleDiff,
|
||||
panX: newMidpoint.clientX - prevMidpoint.clientX,
|
||||
panY: newMidpoint.clientY - prevMidpoint.clientY,
|
||||
allowChangeEvent: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Transform the view & fire a change event */
|
||||
private _applyChange(opts: ApplyChangeOpts = {}) {
|
||||
const {
|
||||
panX = 0, panY = 0,
|
||||
originX = 0, originY = 0,
|
||||
scaleDiff = 1,
|
||||
allowChangeEvent = false,
|
||||
} = opts;
|
||||
|
||||
const matrix = createMatrix()
|
||||
// Translate according to panning.
|
||||
.translate(panX, panY)
|
||||
// Scale about the origin.
|
||||
.translate(originX, originY)
|
||||
// Apply current translate
|
||||
.translate(this.x, this.y)
|
||||
.scale(scaleDiff)
|
||||
.translate(-originX, -originY)
|
||||
// Apply current scale.
|
||||
.scale(this.scale);
|
||||
|
||||
// Convert the transform into basic translate & scale.
|
||||
this.setTransform({
|
||||
allowChangeEvent,
|
||||
scale: matrix.a,
|
||||
x: matrix.e,
|
||||
y: matrix.f,
|
||||
});
|
||||
}
|
||||
}
|
14
lib/styles.css
Normal file
14
lib/styles.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
pinch-zoom {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
--scale: 1;
|
||||
--x: 0;
|
||||
--y: 0;
|
||||
}
|
||||
|
||||
pinch-zoom > * {
|
||||
transform: translate(var(--x), var(--y)) scale(var(--scale));
|
||||
transform-origin: 0 0;
|
||||
will-change: transform;
|
||||
}
|
4089
package-lock.json
generated
Normal file
4089
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
40
package.json
Normal file
40
package.json
Normal file
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "pinch-zoom-element",
|
||||
"version": "1.0.0",
|
||||
"description": "Put stuff in an element, now you can pinch-zoom it!",
|
||||
"main": "dist/pinch-zoom.js",
|
||||
"module": "dist/pinch-zoom.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "rm -r dist && rollup -c"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/GoogleChromeLabs/pinch-zoom.git"
|
||||
},
|
||||
"keywords": [
|
||||
"pointer",
|
||||
"touch",
|
||||
"mouse",
|
||||
"pinch zoom",
|
||||
"pan"
|
||||
],
|
||||
"author": "Jake Archibald",
|
||||
"license": "Apache-2.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/GoogleChromeLabs/pinch-zoom/issues"
|
||||
},
|
||||
"homepage": "https://github.com/GoogleChromeLabs/pinch-zoom#readme",
|
||||
"devDependencies": {
|
||||
"pointer-tracker": "^2.0.3",
|
||||
"rollup": "^0.66.6",
|
||||
"rollup-plugin-node-resolve": "^3.4.0",
|
||||
"rollup-plugin-terser": "^3.0.0",
|
||||
"rollup-plugin-typescript2": "^0.17.2",
|
||||
"tslint": "^5.11.0",
|
||||
"tslint-config-airbnb": "^5.11.0",
|
||||
"tslint-react": "^3.6.0",
|
||||
"typescript": "^3.1.4",
|
||||
"rollup-plugin-postcss": "^1.6.2"
|
||||
}
|
||||
}
|
44
rollup.config.js
Normal file
44
rollup.config.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
import resolve from 'rollup-plugin-node-resolve';
|
||||
import typescript from 'rollup-plugin-typescript2';
|
||||
import postcss from 'rollup-plugin-postcss';
|
||||
import { terser } from "rollup-plugin-terser";
|
||||
|
||||
const esm = {
|
||||
plugins: [
|
||||
typescript({ useTsconfigDeclarationDir: false }),
|
||||
postcss()
|
||||
],
|
||||
external: ['pointer-tracker'],
|
||||
input: 'lib/index.ts',
|
||||
output: {
|
||||
file: 'dist/pinch-zoom.mjs',
|
||||
format: 'esm'
|
||||
},
|
||||
};
|
||||
|
||||
const iffe = {
|
||||
plugins: [
|
||||
resolve()
|
||||
],
|
||||
input: 'dist/pinch-zoom.mjs',
|
||||
output: {
|
||||
file: 'dist/pinch-zoom.js',
|
||||
format: 'iife',
|
||||
name: 'PinchZoom'
|
||||
},
|
||||
};
|
||||
|
||||
const iffeMin = {
|
||||
input: 'dist/pinch-zoom.js',
|
||||
plugins: [
|
||||
terser({
|
||||
compress: { ecma: 6 },
|
||||
})
|
||||
],
|
||||
output: {
|
||||
file: 'dist/pinch-zoom-min.js',
|
||||
format: 'iife'
|
||||
},
|
||||
};
|
||||
|
||||
export default [esm, iffe, iffeMin];
|
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "es2017",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"noUnusedLocals": true,
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"allowJs": false,
|
||||
"baseUrl": "."
|
||||
},
|
||||
}
|
18
tslint.json
Normal file
18
tslint.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"extends": [
|
||||
"tslint-config-airbnb",
|
||||
"tslint-react"
|
||||
],
|
||||
"rules": {
|
||||
"function-name": false,
|
||||
"variable-name": [true, "check-format", "allow-leading-underscore"],
|
||||
"no-duplicate-imports": false,
|
||||
"prefer-template": [true, "allow-single-concat"],
|
||||
"import-name": false
|
||||
},
|
||||
"linterOptions": {
|
||||
"exclude": [
|
||||
"build"
|
||||
]
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue