@madgex/design-system 13.3.2 → 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,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
+ };