@madgex/design-system 13.4.0 → 13.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/icons.json +1 -1
- package/dist/css/index.css +1 -1
- package/dist/js/components/mds-image-cropper-standalone.js +4 -0
- package/dist/js/image-cropper-BlqRRHAU.js +58 -0
- package/dist/js/index-fractal.js +2 -2
- package/dist/js/index.js +1 -1
- package/package.json +6 -4
- package/src/components/image-cropper/README.md +80 -0
- package/src/components/image-cropper/helpers.js +229 -0
- package/src/components/image-cropper/helpers.spec.js +111 -0
- package/src/components/image-cropper/image-cropper-standalone.scss +2 -0
- package/src/components/image-cropper/image-cropper-touch-area.js +270 -0
- package/src/components/image-cropper/image-cropper.config.js +6 -0
- package/src/components/image-cropper/image-cropper.js +447 -0
- package/src/components/image-cropper/image-cropper.njk +99 -0
- package/src/components/image-cropper/image-cropper.scss +93 -0
- package/src/components/image-cropper/mds-image-cropper-standalone.js +39 -0
- package/src/js/index-fractal.js +1 -0
- package/src/js/index.js +4 -1
- package/src/scss/components/__index.scss +1 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute the dimension of the crop area based on image size and aspect ratio
|
|
3
|
+
* @param imgWidth width of the src image in pixels
|
|
4
|
+
* @param imgHeight height of the src image in pixels
|
|
5
|
+
* @param aspect aspect ratio of the crop
|
|
6
|
+
*/
|
|
7
|
+
export function getCropSize(imgWidth = 0, imgHeight = 0, aspect) {
|
|
8
|
+
if (imgWidth >= imgHeight * aspect) {
|
|
9
|
+
return {
|
|
10
|
+
width: imgHeight * aspect,
|
|
11
|
+
height: imgHeight,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
width: imgWidth,
|
|
16
|
+
height: imgWidth / aspect,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Ensure a new image position stays in the crop area.
|
|
22
|
+
* @param position new x/y position requested for the image
|
|
23
|
+
* @param imageSize width/height of the src image
|
|
24
|
+
* @param cropSize width/height of the crop area
|
|
25
|
+
* @param zoom zoom value
|
|
26
|
+
* @returns
|
|
27
|
+
*/
|
|
28
|
+
export function restrictPosition(position, imageSize, cropSize, zoom) {
|
|
29
|
+
return {
|
|
30
|
+
x: restrictPositionCoord(position.x, imageSize.width, cropSize.width, zoom),
|
|
31
|
+
y: restrictPositionCoord(position.y, imageSize.height, cropSize.height, zoom),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function restrictPositionCoord(position, imageSize, cropSize, zoom) {
|
|
36
|
+
// Default max position calculation
|
|
37
|
+
let maxPosition = (imageSize * zoom) / 2 - cropSize / 2;
|
|
38
|
+
// Allow free movement of the image inside the crop area if zoom is less than 1
|
|
39
|
+
// But limit the image's position to inside the cropBox
|
|
40
|
+
if (zoom < 1) {
|
|
41
|
+
maxPosition = cropSize / 2 - (imageSize * zoom) / 2;
|
|
42
|
+
}
|
|
43
|
+
return Math.min(maxPosition, Math.max(position, -maxPosition));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compute the output cropped area of the image in percentages and pixels.
|
|
48
|
+
* x/y are the top-left coordinates on the src image
|
|
49
|
+
* @param crop x/y position of the current center of the image
|
|
50
|
+
* @param imageSize width/height of the src image (default is size on the screen, natural is the original size)
|
|
51
|
+
* @param cropSize width/height of the crop area
|
|
52
|
+
* @param aspect aspect value
|
|
53
|
+
* @param zoom zoom value
|
|
54
|
+
* @param restrictPosition whether we should limit or not the cropped area
|
|
55
|
+
*/
|
|
56
|
+
export function computeCroppedArea({ crop, imageSize, cropSize, aspect, zoom, restrictPosition = true }) {
|
|
57
|
+
if (!cropSize?.width) return;
|
|
58
|
+
const limitAreaFn = restrictPosition ? limitArea : (_, value) => value;
|
|
59
|
+
const croppedAreaPercentages = {
|
|
60
|
+
x: limitAreaFn(100, (((imageSize.width - cropSize.width / zoom) / 2 - crop.x / zoom) / imageSize.width) * 100),
|
|
61
|
+
y: limitAreaFn(100, (((imageSize.height - cropSize.height / zoom) / 2 - crop.y / zoom) / imageSize.height) * 100),
|
|
62
|
+
width: limitAreaFn(100, ((cropSize.width / imageSize.width) * 100) / zoom),
|
|
63
|
+
height: limitAreaFn(100, ((cropSize.height / imageSize.height) * 100) / zoom),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// we compute the pixels size naively
|
|
67
|
+
const widthInPixels = limitAreaFn(
|
|
68
|
+
imageSize.naturalWidth,
|
|
69
|
+
(croppedAreaPercentages.width * imageSize.naturalWidth) / 100,
|
|
70
|
+
true,
|
|
71
|
+
);
|
|
72
|
+
const heightInPixels = limitAreaFn(
|
|
73
|
+
imageSize.naturalHeight,
|
|
74
|
+
(croppedAreaPercentages.height * imageSize.naturalHeight) / 100,
|
|
75
|
+
true,
|
|
76
|
+
);
|
|
77
|
+
const isImgWiderThanHigh = imageSize.naturalWidth >= imageSize.naturalHeight * aspect;
|
|
78
|
+
|
|
79
|
+
// then we ensure the width and height exactly match the aspect (to avoid rounding approximations)
|
|
80
|
+
// if the image is wider than high, when zoom is 0, the crop height will be equals to image height
|
|
81
|
+
// thus we want to compute the width from the height and aspect for accuracy.
|
|
82
|
+
// Otherwise, we compute the height from width and aspect.
|
|
83
|
+
const sizePixels = isImgWiderThanHigh
|
|
84
|
+
? {
|
|
85
|
+
width: Math.round(heightInPixels * aspect),
|
|
86
|
+
height: heightInPixels,
|
|
87
|
+
}
|
|
88
|
+
: {
|
|
89
|
+
width: widthInPixels,
|
|
90
|
+
height: Math.round(widthInPixels / aspect),
|
|
91
|
+
};
|
|
92
|
+
const croppedAreaPixels = {
|
|
93
|
+
...sizePixels,
|
|
94
|
+
x: limitAreaFn(
|
|
95
|
+
imageSize.naturalWidth - sizePixels.width,
|
|
96
|
+
(croppedAreaPercentages.x * imageSize.naturalWidth) / 100,
|
|
97
|
+
true,
|
|
98
|
+
),
|
|
99
|
+
y: limitAreaFn(
|
|
100
|
+
imageSize.naturalHeight - sizePixels.height,
|
|
101
|
+
(croppedAreaPercentages.y * imageSize.naturalHeight) / 100,
|
|
102
|
+
true,
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
return { percent: croppedAreaPercentages, pixels: croppedAreaPixels };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Ensure the returned value is between 0 and max
|
|
110
|
+
* @param max
|
|
111
|
+
* @param value
|
|
112
|
+
* @param shouldRound
|
|
113
|
+
*/
|
|
114
|
+
function limitArea(max, value, shouldRound = false) {
|
|
115
|
+
const v = shouldRound ? Math.round(value) : value;
|
|
116
|
+
return Math.min(max, Math.max(0, v));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const createImage = (url) =>
|
|
120
|
+
new Promise((resolve, reject) => {
|
|
121
|
+
const image = new Image();
|
|
122
|
+
image.addEventListener('load', () => resolve(image));
|
|
123
|
+
image.addEventListener('error', (error) => reject(error));
|
|
124
|
+
image.setAttribute('crossOrigin', 'anonymous'); // needed to avoid cross-origin issues
|
|
125
|
+
image.src = url;
|
|
126
|
+
});
|
|
127
|
+
const RAD_TO_DEG = Math.PI / 180;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
*
|
|
131
|
+
* @param {object} options
|
|
132
|
+
* @param {HTMLImageElement} options.imageEl
|
|
133
|
+
* @param {{x:number, y:number, width: number, height: number}} options.crop realsize in pixels vs original image dimensions, crop bounding box, origin 0 is center
|
|
134
|
+
* @param {number} options.rotateDeg degrees
|
|
135
|
+
* @param {string} options.format optional, default `png`
|
|
136
|
+
* @param {string} options.backgroundFill, optional, e.g. `#fff`
|
|
137
|
+
* @param {number} options.width, optional, fixed output width
|
|
138
|
+
* @param {number} options.height, optional, fixed output height
|
|
139
|
+
* @returns
|
|
140
|
+
*/
|
|
141
|
+
export async function getCroppedImage({
|
|
142
|
+
imageEl: origImageEl,
|
|
143
|
+
crop,
|
|
144
|
+
rotateDeg = 0,
|
|
145
|
+
format = 'png',
|
|
146
|
+
backgroundFill,
|
|
147
|
+
width,
|
|
148
|
+
height,
|
|
149
|
+
}) {
|
|
150
|
+
if (!origImageEl || !crop) return null;
|
|
151
|
+
|
|
152
|
+
const imageEl = await createImage(origImageEl.src);
|
|
153
|
+
const canvas = document.createElement('canvas');
|
|
154
|
+
const ctx = canvas.getContext('2d');
|
|
155
|
+
if (!ctx) return null;
|
|
156
|
+
|
|
157
|
+
const scaleX = width ? width / crop.width : 1;
|
|
158
|
+
const scaleY = height ? height / crop.height : 1;
|
|
159
|
+
|
|
160
|
+
// const scaleX = imageEl.naturalWidth / imageEl.width;
|
|
161
|
+
// const scaleY = imageEl.naturalHeight / imageEl.height;
|
|
162
|
+
|
|
163
|
+
canvas.width = Math.floor(crop.width * scaleX);
|
|
164
|
+
canvas.height = Math.floor(crop.height * scaleY);
|
|
165
|
+
|
|
166
|
+
ctx.imageSmoothingQuality = 'high';
|
|
167
|
+
|
|
168
|
+
const cropX = crop.x * scaleX;
|
|
169
|
+
const cropY = crop.y * scaleY;
|
|
170
|
+
|
|
171
|
+
const rotateRads = rotateDeg * RAD_TO_DEG;
|
|
172
|
+
const centerX = (imageEl.naturalWidth / 2) * scaleX;
|
|
173
|
+
const centerY = (imageEl.naturalHeight / 2) * scaleY;
|
|
174
|
+
|
|
175
|
+
ctx.save();
|
|
176
|
+
// background fill color
|
|
177
|
+
if (backgroundFill) {
|
|
178
|
+
ctx.fillStyle = backgroundFill;
|
|
179
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Move the crop origin to the canvas origin (0,0)
|
|
183
|
+
ctx.translate(-cropX, -cropY);
|
|
184
|
+
// Move the origin to the center of the original image
|
|
185
|
+
ctx.translate(centerX, centerY);
|
|
186
|
+
// Rotate around the origin
|
|
187
|
+
ctx.rotate(rotateRads);
|
|
188
|
+
// Move the center of the image to the origin (0,0)
|
|
189
|
+
ctx.translate(-centerX, -centerY);
|
|
190
|
+
|
|
191
|
+
ctx.drawImage(
|
|
192
|
+
imageEl,
|
|
193
|
+
0, // canvas is translated, origin is "0"
|
|
194
|
+
0, // canvas is translated, origin is "0"
|
|
195
|
+
imageEl.naturalWidth, // source image size
|
|
196
|
+
imageEl.naturalHeight, // source image size
|
|
197
|
+
0, // canvas is translated, origin is "0"
|
|
198
|
+
0, // canvas is translated, origin is "0"
|
|
199
|
+
imageEl.naturalWidth * scaleX, // destination image size
|
|
200
|
+
imageEl.naturalHeight * scaleY, // destination image size
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// As Base64 string
|
|
204
|
+
// return canvas.toDataURL('image/jpeg');
|
|
205
|
+
// As a blob
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
canvas.toBlob(
|
|
208
|
+
(blob) => {
|
|
209
|
+
try {
|
|
210
|
+
resolve(blob);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
reject(error);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
`image/${format}`,
|
|
216
|
+
0.9,
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Strip all HTML from a string. Useful as Safari does not support `setHTML`, a safe XSS function
|
|
222
|
+
* @param {string} htmlString
|
|
223
|
+
* @returns {string} clean text string
|
|
224
|
+
*/
|
|
225
|
+
export function stripHTML(htmlString) {
|
|
226
|
+
if (!htmlString) return htmlString;
|
|
227
|
+
let doc = new DOMParser().parseFromString(htmlString, 'text/html');
|
|
228
|
+
return doc.body.textContent || '';
|
|
229
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getCropSize, restrictPosition, computeCroppedArea, stripHTML } from './helpers.js';
|
|
3
|
+
|
|
4
|
+
// ------------------------------
|
|
5
|
+
// getCropSize
|
|
6
|
+
// ------------------------------
|
|
7
|
+
describe('getCropSize', () => {
|
|
8
|
+
it('returns correct size when image is wider than aspect', () => {
|
|
9
|
+
const result = getCropSize(1000, 500, 1);
|
|
10
|
+
expect(result).toEqual({ width: 500, height: 500 });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('returns correct size when image is taller than aspect', () => {
|
|
14
|
+
const result = getCropSize(400, 800, 1);
|
|
15
|
+
expect(result).toEqual({ width: 400, height: 400 });
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// ------------------------------
|
|
20
|
+
// restrictPosition
|
|
21
|
+
// ------------------------------
|
|
22
|
+
describe('restrictPosition', () => {
|
|
23
|
+
it('clamps values correctly for zoom >= 1', () => {
|
|
24
|
+
const position = { x: 500, y: -500 };
|
|
25
|
+
const imageSize = { width: 1000, height: 1000 };
|
|
26
|
+
const cropSize = { width: 500, height: 500 };
|
|
27
|
+
const zoom = 1;
|
|
28
|
+
|
|
29
|
+
const result = restrictPosition(position, imageSize, cropSize, zoom);
|
|
30
|
+
|
|
31
|
+
// max = 1000/2 - 500/2 = 250
|
|
32
|
+
expect(result).toEqual({ x: 250, y: -250 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('uses reversed clamp when zoom < 1', () => {
|
|
36
|
+
const position = { x: 300, y: -300 };
|
|
37
|
+
const imageSize = { width: 1000, height: 1000 };
|
|
38
|
+
const cropSize = { width: 500, height: 500 };
|
|
39
|
+
const zoom = 0.5;
|
|
40
|
+
|
|
41
|
+
const result = restrictPosition(position, imageSize, cropSize, zoom);
|
|
42
|
+
|
|
43
|
+
// max = cropSize/2 - (imageSize * zoom)/2 = 250 - 500/2 = 0
|
|
44
|
+
expect(result).toEqual({ x: 0, y: -0 });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ------------------------------
|
|
49
|
+
// computeCroppedArea
|
|
50
|
+
// ------------------------------
|
|
51
|
+
describe('computeCroppedArea', () => {
|
|
52
|
+
it('computes correct percentages and pixels', () => {
|
|
53
|
+
const crop = { x: 0, y: 0 };
|
|
54
|
+
const imageSize = {
|
|
55
|
+
width: 1000,
|
|
56
|
+
height: 500,
|
|
57
|
+
naturalWidth: 1000,
|
|
58
|
+
naturalHeight: 500,
|
|
59
|
+
};
|
|
60
|
+
const cropSize = { width: 500, height: 250 };
|
|
61
|
+
const zoom = 1;
|
|
62
|
+
const aspect = 2;
|
|
63
|
+
|
|
64
|
+
const result = computeCroppedArea({
|
|
65
|
+
crop,
|
|
66
|
+
imageSize,
|
|
67
|
+
cropSize,
|
|
68
|
+
aspect,
|
|
69
|
+
zoom,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result).toBeDefined();
|
|
73
|
+
expect(result.percent.width).toBe(50);
|
|
74
|
+
expect(result.pixels.width).toBe(Math.round(result.pixels.height * aspect));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns undefined if cropSize has no width', () => {
|
|
78
|
+
const result = computeCroppedArea({
|
|
79
|
+
crop: { x: 0, y: 0 },
|
|
80
|
+
imageSize: {
|
|
81
|
+
width: 1000,
|
|
82
|
+
height: 500,
|
|
83
|
+
naturalWidth: 1000,
|
|
84
|
+
naturalHeight: 500,
|
|
85
|
+
},
|
|
86
|
+
cropSize: { width: 0, height: 250 },
|
|
87
|
+
aspect: 2,
|
|
88
|
+
zoom: 1,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(result).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ------------------------------
|
|
96
|
+
// stripHTML
|
|
97
|
+
// ------------------------------
|
|
98
|
+
describe('stripHTML', () => {
|
|
99
|
+
it('removes all HTML tags', () => {
|
|
100
|
+
const result = stripHTML(
|
|
101
|
+
`<div>Hello <b>World</b></div><img src=x onerror=alert('bang')><script>alert('bang);</script>`,
|
|
102
|
+
);
|
|
103
|
+
expect(result).toBe(`Hello Worldalert('bang);`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns input when falsy', () => {
|
|
107
|
+
expect(stripHTML('')).toBe('');
|
|
108
|
+
expect(stripHTML(null)).toBe(null);
|
|
109
|
+
expect(stripHTML(undefined)).toBe(undefined);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MdsImageCropperTouchArea
|
|
3
|
+
*
|
|
4
|
+
* Handle mouse drag, touch drag, mouse wheel to zoom, pinch to zoom. A11y keypress to pan (drag)
|
|
5
|
+
*
|
|
6
|
+
* Emit position and zoom updates
|
|
7
|
+
*
|
|
8
|
+
*/
|
|
9
|
+
export class MdsImageCropperTouchArea extends HTMLElement {
|
|
10
|
+
/** on drag start, record initial position to calculate offset */
|
|
11
|
+
#dragStartPosition = { x: 0, y: 0 };
|
|
12
|
+
/** on drag start, record initial position, to calculate new offset position */
|
|
13
|
+
#dragStartRecordedPosition = { x: 0, y: 0 };
|
|
14
|
+
/** used to calculate zoom amount on pinch move */
|
|
15
|
+
#lastPinchDistance = 0;
|
|
16
|
+
/** reference to animationFrame, drag calculations are done per frame (not per drag event) */
|
|
17
|
+
#rafDragTimeout = null;
|
|
18
|
+
/** reference to animationFrame, zoom calculations are done per frame (not per drag event) */
|
|
19
|
+
#rafZoomTimeout = null;
|
|
20
|
+
|
|
21
|
+
#zoomSpeed = 1;
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
super();
|
|
25
|
+
}
|
|
26
|
+
get rootNode() {
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
get #document() {
|
|
30
|
+
return this.getRootNode({ composed: true });
|
|
31
|
+
}
|
|
32
|
+
connectedCallback() {
|
|
33
|
+
this.create();
|
|
34
|
+
// ensure this is focusable for keyboard arrow control
|
|
35
|
+
this.setAttribute('tabindex', '0');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
disconnectedCallback() {
|
|
39
|
+
this.destroy();
|
|
40
|
+
}
|
|
41
|
+
#dispatchEvent(type, detail) {
|
|
42
|
+
this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
|
|
43
|
+
}
|
|
44
|
+
create() {
|
|
45
|
+
this.addEventListener('gesturestart', this.#onGesturePreventZoomSafari);
|
|
46
|
+
this.addEventListener('gesturechange', this.#onGesturePreventZoomSafari);
|
|
47
|
+
|
|
48
|
+
this.addEventListener('touchstart', this.#onTouchStart);
|
|
49
|
+
this.addEventListener('mousedown', this.#onMouseDown);
|
|
50
|
+
this.addEventListener('wheel', this.#onWheel, { passive: false }); // ensure passive on ios
|
|
51
|
+
this.addEventListener('keydown', this.#onKeydown);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** destroy remove events */
|
|
55
|
+
destroy() {
|
|
56
|
+
this.removeEventListener('gesturestart', this.#onGesturePreventZoomSafari);
|
|
57
|
+
this.removeEventListener('gesturechange', this.#onGesturePreventZoomSafari);
|
|
58
|
+
|
|
59
|
+
this.removeEventListener('touchstart', this.#onTouchStart);
|
|
60
|
+
this.removeEventListener('mousedown', this.#onMouseDown);
|
|
61
|
+
this.removeEventListener('wheel', this.#onWheel);
|
|
62
|
+
this.removeEventListener('keydown', this.#onKeydown);
|
|
63
|
+
|
|
64
|
+
this.#removeDragEventListeners();
|
|
65
|
+
}
|
|
66
|
+
get #position() {
|
|
67
|
+
const x = this.getAttribute('position-x');
|
|
68
|
+
const y = this.getAttribute('position-y');
|
|
69
|
+
return { x: x ? parseFloat(x) : 0, y: y ? parseFloat(y) : 0 };
|
|
70
|
+
}
|
|
71
|
+
set #position({ x, y }) {
|
|
72
|
+
this.#dispatchEvent('position', { x, y });
|
|
73
|
+
}
|
|
74
|
+
get #zoom() {
|
|
75
|
+
return parseFloat(this.getAttribute('zoom') || '1');
|
|
76
|
+
}
|
|
77
|
+
set #zoom(newZoom) {
|
|
78
|
+
this.#dispatchEvent('zoom', newZoom);
|
|
79
|
+
}
|
|
80
|
+
get #minZoom() {
|
|
81
|
+
return parseFloat(this.getAttribute('min-zoom') || '1');
|
|
82
|
+
}
|
|
83
|
+
get #maxZoom() {
|
|
84
|
+
return parseFloat(this.getAttribute('max-zoom') || '3');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Return the point that is the center of point a and b */
|
|
88
|
+
#getCenter(a, b) {
|
|
89
|
+
return {
|
|
90
|
+
x: (b.x + a.x) / 2,
|
|
91
|
+
y: (b.y + a.y) / 2,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Distance between two points */
|
|
95
|
+
#getDistanceBetweenPoints(pointA, pointB) {
|
|
96
|
+
return Math.sqrt(Math.pow(pointA.y - pointB.y, 2) + Math.pow(pointA.x - pointB.x, 2));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Event Handlers
|
|
100
|
+
/** this is to prevent Safari on iOS >= 10 to zoom the page */
|
|
101
|
+
#onGesturePreventZoomSafari = (e) => {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
};
|
|
104
|
+
#onMouseDown = (e) => {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
// add mouse events only if mouse detected (no conflict with touch events)
|
|
107
|
+
this.#document.addEventListener('mousemove', this.#onMouseMove);
|
|
108
|
+
this.#document.addEventListener('mouseup', this.#onDragStopped);
|
|
109
|
+
this.#onDragStart({ x: e.clientX, y: e.clientY });
|
|
110
|
+
};
|
|
111
|
+
#onMouseMove = (e) => {
|
|
112
|
+
this.#onDrag({ x: e.clientX, y: e.clientY });
|
|
113
|
+
};
|
|
114
|
+
#onTouchStart = (e) => {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
// add touch events only if touch detected (no conflict with mouse events)
|
|
117
|
+
this.#document.addEventListener('touchmove', this.#onTouchMove, { passive: false }); // iOS 11 defaults to passive: true
|
|
118
|
+
this.#document.addEventListener('touchend', this.#onDragStopped);
|
|
119
|
+
|
|
120
|
+
if (e.touches.length === 2) {
|
|
121
|
+
this.#onPinchStart(e);
|
|
122
|
+
} else if (e.touches.length === 1) {
|
|
123
|
+
this.#onDragStart({ x: e.touches[0].clientX, y: e.touches[0].clientY });
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
#onTouchMove = (e) => {
|
|
127
|
+
// Prevent whole page from scrolling on iOS.
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
if (e.touches.length === 2) {
|
|
130
|
+
this.#onPinchMove(e);
|
|
131
|
+
} else if (e.touches.length === 1) {
|
|
132
|
+
this.#onDrag({ x: e.touches[0].clientX, y: e.touches[0].clientY });
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* on touch or mouse start, record the starting position
|
|
137
|
+
* @param {object} startingPosition
|
|
138
|
+
*/
|
|
139
|
+
#onDragStart({ x, y }) {
|
|
140
|
+
this.#dragStartPosition = { x, y };
|
|
141
|
+
this.#dragStartRecordedPosition = { x: this.#position.x, y: this.#position.y };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* used by both touchmove and mousemove, updates position on requestAnimationFrame
|
|
146
|
+
* @param {{x:number, y:number}} point current point, used to calculate drag offset
|
|
147
|
+
*/
|
|
148
|
+
#onDrag({ x, y }) {
|
|
149
|
+
if (this.#rafDragTimeout) window.cancelAnimationFrame(this.#rafDragTimeout);
|
|
150
|
+
|
|
151
|
+
this.#rafDragTimeout = window.requestAnimationFrame(() => {
|
|
152
|
+
if (x === undefined || y === undefined) return;
|
|
153
|
+
const offsetX = x - this.#dragStartPosition.x;
|
|
154
|
+
const offsetY = y - this.#dragStartPosition.y;
|
|
155
|
+
const requestedPosition = {
|
|
156
|
+
x: this.#dragStartRecordedPosition.x + offsetX,
|
|
157
|
+
y: this.#dragStartRecordedPosition.y + offsetY,
|
|
158
|
+
};
|
|
159
|
+
this.#position = requestedPosition;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* used for both mouseup and touchend
|
|
164
|
+
*/
|
|
165
|
+
#onDragStopped = () => {
|
|
166
|
+
this.#removeDragEventListeners();
|
|
167
|
+
this.#dispatchEvent('dragstopped');
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
#onPinchStart = (e) => {
|
|
171
|
+
const pointA = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
172
|
+
const pointB = { x: e.touches[1].clientX, y: e.touches[1].clientY };
|
|
173
|
+
this.#lastPinchDistance = this.#getDistanceBetweenPoints(pointA, pointB);
|
|
174
|
+
this.#onDragStart(this.#getCenter(pointA, pointB));
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
#onPinchMove = (e) => {
|
|
178
|
+
const pointA = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
179
|
+
const pointB = { x: e.touches[1].clientX, y: e.touches[1].clientY };
|
|
180
|
+
const center = this.#getCenter(pointA, pointB);
|
|
181
|
+
this.#onDrag(center);
|
|
182
|
+
|
|
183
|
+
if (this.#rafZoomTimeout) window.cancelAnimationFrame(this.#rafZoomTimeout);
|
|
184
|
+
this.#rafZoomTimeout = window.requestAnimationFrame(() => {
|
|
185
|
+
const distance = this.#getDistanceBetweenPoints(pointA, pointB);
|
|
186
|
+
const newZoom = this.#zoom * (distance / this.#lastPinchDistance);
|
|
187
|
+
this.#updateZoomAndPosition(newZoom, center);
|
|
188
|
+
this.#lastPinchDistance = distance;
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
#onWheel = (e) => {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
const point = { x: e.clientX, y: e.clientY };
|
|
195
|
+
const newZoom = this.#zoom - (e.deltaY * this.#zoomSpeed) / 200;
|
|
196
|
+
this.#updateZoomAndPosition(newZoom, point);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
#onKeydown = (e) => {
|
|
200
|
+
switch (e.key) {
|
|
201
|
+
case 'ArrowDown':
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
this.#position = { x: this.#position.x, y: this.#position.y + 10 };
|
|
204
|
+
break;
|
|
205
|
+
case 'ArrowUp':
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
this.#position = { x: this.#position.x, y: this.#position.y - 10 };
|
|
208
|
+
break;
|
|
209
|
+
case 'ArrowLeft':
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
this.#position = { x: this.#position.x - 10, y: this.#position.y };
|
|
212
|
+
break;
|
|
213
|
+
case 'ArrowRight':
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
this.#position = { x: this.#position.x + 10, y: this.#position.y };
|
|
216
|
+
break;
|
|
217
|
+
case 'PageUp':
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
this.#zoom = Math.min(this.#maxZoom, Math.max(this.#zoom + 0.1, this.#minZoom));
|
|
220
|
+
break;
|
|
221
|
+
case 'PageDown':
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
this.#zoom = Math.min(this.#maxZoom, Math.max(this.#zoom - 0.1, this.#minZoom));
|
|
224
|
+
break;
|
|
225
|
+
default:
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
#removeDragEventListeners() {
|
|
231
|
+
this.#document.removeEventListener('mousemove', this.#onMouseMove);
|
|
232
|
+
this.#document.removeEventListener('mouseup', this.#onDragStopped);
|
|
233
|
+
this.#document.removeEventListener('touchmove', this.#onTouchMove);
|
|
234
|
+
this.#document.removeEventListener('touchend', this.#onDragStopped);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
#getPointOnContainer({ x, y }) {
|
|
238
|
+
const containerRect = this.getBoundingClientRect();
|
|
239
|
+
if (!containerRect) {
|
|
240
|
+
throw new Error('The element is not mounted');
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
x: containerRect.width / 2 - (x - containerRect.left),
|
|
244
|
+
y: containerRect.height / 2 - (y - containerRect.top),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
#getPointRelativeToPosition({ x, y }) {
|
|
248
|
+
return {
|
|
249
|
+
x: (x + this.#position.x) / this.#zoom,
|
|
250
|
+
y: (y + this.#position.y) / this.#zoom,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* set a new zoom level, using the point location of the user's zoom on the screen to also update the position
|
|
255
|
+
* @param {number} newZoom float
|
|
256
|
+
* @param {{x:number, y: number}} point point based on area
|
|
257
|
+
*/
|
|
258
|
+
#updateZoomAndPosition(newZoom, point) {
|
|
259
|
+
const zoomPoint = this.#getPointOnContainer(point);
|
|
260
|
+
const zoomTarget = this.#getPointRelativeToPosition(zoomPoint);
|
|
261
|
+
|
|
262
|
+
this.#zoom = Math.min(this.#maxZoom, Math.max(newZoom, this.#minZoom));
|
|
263
|
+
|
|
264
|
+
const requestedPosition = {
|
|
265
|
+
x: zoomTarget.x * this.#zoom - zoomPoint.x,
|
|
266
|
+
y: zoomTarget.y * this.#zoom - zoomPoint.y,
|
|
267
|
+
};
|
|
268
|
+
this.#position = requestedPosition;
|
|
269
|
+
}
|
|
270
|
+
}
|