@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.
- 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/components/inputs/_checkbox-elem/_template.njk +1 -0
- package/src/components/inputs/checkbox-list/_template.njk +11 -2
- package/src/components/inputs/checkbox-list/checkbox-list.config.js +8 -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,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
|
+
}
|