@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.
@@ -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,2 @@
1
+ /* relying on purgecss to remove unused css */
2
+ @import '../../scss/index.scss';
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ module.exports = {
2
+ title: 'Image Cropper',
3
+ label: 'Image Cropper',
4
+ status: 'wip',
5
+ context: {},
6
+ };