@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,447 @@
1
+ import * as helpers from './helpers.js';
2
+ import { MdsImageCropperTouchArea } from './image-cropper-touch-area.js';
3
+
4
+ // TODO: how to have a separate copy for standalone version?
5
+ if (!window.customElements.get('mds-image-cropper-touch-area')) {
6
+ window.customElements.define('mds-image-cropper-touch-area', MdsImageCropperTouchArea);
7
+ }
8
+ /**
9
+ * MdsImageCropper
10
+ */
11
+ export class MdsImageCropper extends HTMLElement {
12
+ static get observedAttributes() {
13
+ return [
14
+ 'src',
15
+ 'aspect-ratio',
16
+ 'output-width',
17
+ 'output-height',
18
+ 'zoom',
19
+ 'min-zoom',
20
+ 'max-zoom',
21
+ 'crop-x',
22
+ 'crop-y',
23
+ 'no-grid',
24
+ 'restrict-position',
25
+ ];
26
+ }
27
+ /** watch this web component container size, to update all calculations */
28
+ #resizeObserver = null;
29
+ /** automatically generated id */
30
+ #_id = window.crypto.randomUUID();
31
+
32
+ constructor() {
33
+ super();
34
+ }
35
+ get rootNode() {
36
+ return this;
37
+ }
38
+ get #document() {
39
+ return this.getRootNode({ composed: true });
40
+ }
41
+ /**
42
+ * if attributes change, re-render template, refresh state
43
+ * @param {string} name
44
+ * @param {string} oldValue
45
+ * @param {string} newValue
46
+ */
47
+ attributeChangedCallback(name, oldValue = null, newValue = null) {
48
+ if (!this.isConnected) return; // attributes are null before connect, causing callback to fire
49
+ if (oldValue === newValue) return; // no need to re-render or update if attribute value stays the same
50
+
51
+ if (name === 'zoom') {
52
+ this.#emitCropData();
53
+ }
54
+
55
+ const syncTouchAreaAttrs = ['zoom', 'min-zoom', 'max-zoom', 'crop-x', 'crop-y'];
56
+ if (syncTouchAreaAttrs.includes(name)) {
57
+ this.#syncTouchAreaAttributes();
58
+ }
59
+
60
+ if (name === 'src') {
61
+ this.#loadImage(newValue);
62
+ }
63
+ const updateTemplateAttrs = [
64
+ 'src',
65
+ 'aspect-ratio',
66
+ 'output-width',
67
+ 'output-height',
68
+ 'zoom',
69
+ 'min-zoom',
70
+ 'max-zoom',
71
+ 'crop-x',
72
+ 'crop-y',
73
+ 'no-grid',
74
+ 'restrict-position',
75
+ ];
76
+ if (updateTemplateAttrs.includes(name)) {
77
+ this.#updateTemplate();
78
+ }
79
+ }
80
+ connectedCallback() {
81
+ this.create();
82
+ }
83
+ disconnectedCallback() {
84
+ this.destroy();
85
+ }
86
+ create() {
87
+ if (this.rootNode.setHTML) {
88
+ // setHTML always removes XSS risks, we provide an empty sanitizer config to allow everything (but still XSS will be removed)
89
+ this.rootNode.setHTML(this.#template.innerHTML, { sanitizer: {} });
90
+ } else {
91
+ this.rootNode.replaceChildren(this.#template.content.cloneNode(true));
92
+ }
93
+
94
+ this.#syncTouchAreaAttributes();
95
+ this.#elContainer.addEventListener('zoom', this.#onTouchAreaZoom);
96
+ this.#elContainer.addEventListener('position', this.#onTouchAreaPosition);
97
+ this.#elContainer.addEventListener('dragstopped', this.#onTouchAreaDragStopped);
98
+
99
+ this.#resizeObserver = new ResizeObserver(() => this.#onContainerResize());
100
+ this.#resizeObserver.observe(this.#elContainer);
101
+
102
+ this.#elImage.addEventListener('load', this.#onImgLoad);
103
+ this.#loadImage(this.#imageSrc);
104
+
105
+ this.#elZoomInput.addEventListener('input', this.#onChangeZoomInput);
106
+ this.#elButtonZoomOut.addEventListener('click', this.#onClickButtonZoomOut);
107
+ this.#elButtonZoomIn.addEventListener('click', this.#onClickButtonZoomIn);
108
+ this.#elButtonReset.addEventListener('click', this.#onClickButtonReset);
109
+ this.#elButtonCommit.addEventListener('click', this.#onClickButtonCommit);
110
+ }
111
+
112
+ /** destroy cropper, remove events, remove added DOM */
113
+ destroy() {
114
+ this.#resizeObserver?.disconnect();
115
+ this.#resizeObserver = null;
116
+ this.#elImage?.removeEventListener('load', this.#onImgLoad);
117
+
118
+ this.#elContainer?.removeEventListener('zoom', this.#onTouchAreaZoom);
119
+ this.#elContainer?.removeEventListener('position', this.#onTouchAreaPosition);
120
+ this.#elContainer?.removeEventListener('dragstopped', this.#onTouchAreaDragStopped);
121
+
122
+ this.#elZoomInput?.removeEventListener('input', this.#onChangeZoomInput);
123
+ this.#elButtonZoomOut?.removeEventListener('click', this.#onClickButtonZoomOut);
124
+ this.#elButtonZoomIn?.removeEventListener('click', this.#onClickButtonZoomIn);
125
+ this.#elButtonReset?.removeEventListener('click', this.#onClickButtonReset);
126
+ this.#elButtonCommit?.removeEventListener('click', this.#onClickButtonCommit);
127
+
128
+ this.rootNode?.replaceChildren();
129
+ }
130
+
131
+ get #template() {
132
+ const template = this.#document.createElement('template');
133
+ template.innerHTML = /* HTML */ `
134
+ <div class="mds-image-cropper__wrapper">
135
+ <mds-image-cropper-touch-area
136
+ class="mds-image-cropper__container"
137
+ aria-describedby="${this.#_id}-touch-area-controls"
138
+ >
139
+ <div class="mds-image-cropper__background"></div>
140
+ <img class="mds-image-cropper__image" alt="" />
141
+ <div class="mds-image-cropper__area"></div>
142
+ </mds-image-cropper-touch-area>
143
+ <div class="mds-visually-hidden" id="${this.#_id}-touch-area-controls">
144
+ ${this.#translations['touch-area-controls']}
145
+ </div>
146
+ <div class="mds-image-cropper__aria-live mds-visually-hidden" aria-live="polite"></div>
147
+ </div>
148
+
149
+ <label class="mds-form-label mds-image-cropper__control-label" for="${this.#_id}-zoom">
150
+ ${this.#translations['zoom-label']}
151
+ </label>
152
+ <div class="mds-image-cropper__control mds-margin-bottom-b8">
153
+ <button
154
+ type="button"
155
+ class="mds-image-cropper__control-button mds-image-cropper__control-button-out"
156
+ aria-label="${this.#translations['btn-zoom-out-aria']}"
157
+ >
158
+ <svg aria-hidden viewBox="0 0 32 32" fill="currentColor">
159
+ <path
160
+ d="M4 16c0-1.14.846-2.08 1.945-2.23l.305-.02h19.5a2.25 2.25 0 0 1 .305 4.48l-.305.02H6.25A2.25 2.25 0 0 1 4 16Z"
161
+ ></path>
162
+ </svg>
163
+ </button>
164
+ <input id="${this.#_id}-zoom" type="range" class="mds-image-cropper__control-zoom" step="0.1" />
165
+ <button
166
+ type="button"
167
+ class="mds-image-cropper__control-button mds-image-cropper__control-button-in"
168
+ aria-label="${this.#translations['btn-zoom-in-aria']}"
169
+ >
170
+ <svg aria-hidden viewBox="0 0 32 32" fill="currentColor">
171
+ <path
172
+ d="M16 4c1.14 0 2.08.846 2.23 1.945l.02.305v7.5h7.5a2.25 2.25 0 0 1 .305 4.48l-.305.02h-7.5v7.5a2.25 2.25 0 0 1-4.48.305l-.02-.305v-7.5h-7.5a2.25 2.25 0 0 1-.305-4.48l.305-.02h7.5v-7.5A2.25 2.25 0 0 1 16 4Z"
173
+ ></path>
174
+ </svg>
175
+ </button>
176
+ </div>
177
+
178
+ <div class="mds-grid-row mds-grid-end">
179
+ <div class="mds-grid-col mds-grid-fit-content">
180
+ <button type="button" class="mds-button mds-button--neutral mds-image-cropper__button-reset">
181
+ ${this.#translations['btn-reset']}
182
+ </button>
183
+ </div>
184
+ <div class="mds-grid-col mds-grid-fit-content">
185
+ <button type="button" class="mds-button mds-image-cropper__button-commit">
186
+ ${this.#translations['btn-commit']}
187
+ </button>
188
+ </div>
189
+ </div>
190
+ `;
191
+ return template;
192
+ }
193
+ // Elements
194
+ get #elZoomInput() {
195
+ return this.rootNode.querySelector('.mds-image-cropper__control-zoom');
196
+ }
197
+ get #elWrapper() {
198
+ return this.rootNode.querySelector('.mds-image-cropper__wrapper');
199
+ }
200
+ get #elContainer() {
201
+ return this.rootNode.querySelector('.mds-image-cropper__container');
202
+ }
203
+ get #elImage() {
204
+ return this.rootNode.querySelector('.mds-image-cropper__image');
205
+ }
206
+ get #elCropArea() {
207
+ return this.rootNode.querySelector('.mds-image-cropper__area');
208
+ }
209
+ get #elBackground() {
210
+ return this.rootNode.querySelector('.mds-image-cropper__background');
211
+ }
212
+ get #elAriaLive() {
213
+ return this.rootNode.querySelector('.mds-image-cropper__aria-live');
214
+ }
215
+ get #elButtonReset() {
216
+ return this.rootNode.querySelector('.mds-image-cropper__button-reset');
217
+ }
218
+ get #elButtonCommit() {
219
+ return this.rootNode.querySelector('.mds-image-cropper__button-commit');
220
+ }
221
+ get #elButtonZoomOut() {
222
+ return this.rootNode.querySelector('.mds-image-cropper__control-button-out');
223
+ }
224
+ get #elButtonZoomIn() {
225
+ return this.rootNode.querySelector('.mds-image-cropper__control-button-in');
226
+ }
227
+
228
+ // Attributes
229
+ get #translations() {
230
+ return {
231
+ 'zoom-label': helpers.stripHTML(this.getAttribute('t-zoom-label')) || 'Zoom',
232
+ 'btn-reset': helpers.stripHTML(this.getAttribute('t-btn-reset')) || 'Reset',
233
+ 'btn-commit': helpers.stripHTML(this.getAttribute('t-btn-commit')) || 'Save and crop',
234
+ 'btn-zoom-out-aria': helpers.stripHTML(this.getAttribute('t-btn-zoom-out-aria')) || 'Minus button - Zoom out',
235
+ 'btn-zoom-in-aria': helpers.stripHTML(this.getAttribute('t-btn-zoom-in-aria')) || 'Plus button - Zoom in',
236
+ 'touch-area-controls':
237
+ helpers.stripHTML(this.getAttribute('t-touch-area-controls')) ||
238
+ 'On this element you can use arrow keys to pan the image around the crop area, and Page Up / Page Down to zoom in and out.',
239
+ 'aria-live':
240
+ helpers.stripHTML(this.getAttribute('t-aria-live')) ||
241
+ `Crop Top Position: {y} pixels, Crop Left Position: {x} pixels, Crop Width {width} pixels, Crop Height: {height} pixels. Image Width: {imageWidth} pixels, Image Height: {imageHeight} pixels.`,
242
+ };
243
+ }
244
+ get #zoom() {
245
+ return parseFloat(this.getAttribute('zoom') || '1');
246
+ }
247
+ set #zoom(newZoom) {
248
+ this.setAttribute('zoom', newZoom);
249
+ }
250
+ /** x/y coordinates are 0 at the center of the image */
251
+ get #crop() {
252
+ const x = this.getAttribute('crop-x');
253
+ const y = this.getAttribute('crop-y');
254
+ const crop = { x: x ? parseFloat(x) : 0, y: y ? parseFloat(y) : 0 };
255
+ if (this.#restrictPosition) return helpers.restrictPosition(crop, this.#imageSize, this.#cropperSize, this.#zoom);
256
+ return crop;
257
+ }
258
+ set #crop(newCrop) {
259
+ const { x, y } = this.#restrictPosition
260
+ ? helpers.restrictPosition(newCrop, this.#imageSize, this.#cropperSize, this.#zoom)
261
+ : newCrop;
262
+ this.setAttribute('crop-x', x);
263
+ this.setAttribute('crop-y', y);
264
+ }
265
+ get #minZoom() {
266
+ return parseFloat(this.getAttribute('min-zoom') || '1');
267
+ }
268
+ get #maxZoom() {
269
+ return parseFloat(this.getAttribute('max-zoom') || '3');
270
+ }
271
+ get #noGrid() {
272
+ const attr = this.getAttribute('no-grid');
273
+ return attr !== null && attr !== 'false';
274
+ }
275
+ get #outputWidth() {
276
+ const val = parseInt(this.getAttribute('output-width'), 10);
277
+ return Number.isNaN(val) ? null : val;
278
+ }
279
+ get #outputHeight() {
280
+ const val = parseInt(this.getAttribute('output-height'), 10);
281
+ return Number.isNaN(val) ? null : val;
282
+ }
283
+ // if output width/height attributes were supplied, use those to create aspect ratio, otherwise use aspect-ratio attribute
284
+ get #aspectRatio() {
285
+ if (this.#outputHeight && this.#outputWidth) return this.#outputWidth / this.#outputHeight;
286
+ return parseFloat(this.getAttribute('aspect-ratio') || '1.77777'); // 16/9
287
+ }
288
+ /** automatically create a crop area in pixels, derived from image size and aspect ratio */
289
+ get #cropperSize() {
290
+ return helpers.getCropSize(this.#elImage?.width, this.#elImage?.height, this.#aspectRatio);
291
+ }
292
+ /** prevent image from moving beyond crop area - not restricting can be useful to allow whitespace beyond the image e.g for logos */
293
+ get #restrictPosition() {
294
+ return this.getAttribute('restrict-position') !== 'false';
295
+ }
296
+ get #imageSrc() {
297
+ return this.getAttribute('src');
298
+ }
299
+ get #imageSize() {
300
+ return {
301
+ width: this.#elImage?.width || 0,
302
+ height: this.#elImage?.height || 0,
303
+ naturalWidth: this.#elImage?.naturalWidth || 0,
304
+ naturalHeight: this.#elImage?.naturalHeight || 0,
305
+ };
306
+ }
307
+
308
+ // Event Handlers
309
+ #onChangeZoomInput = (e) => {
310
+ this.#zoom = Math.min(this.#maxZoom, Math.max(e.target.value, this.#minZoom));
311
+ };
312
+ #onClickButtonZoomOut = () => {
313
+ this.#zoom = Math.min(this.#maxZoom, Math.max(this.#zoom - 0.1, this.#minZoom));
314
+ };
315
+ #onClickButtonZoomIn = () => {
316
+ this.#zoom = Math.min(this.#maxZoom, Math.max(this.#zoom + 0.1, this.#minZoom));
317
+ };
318
+ #onClickButtonReset = () => {
319
+ this.#resetZoomAndCrop();
320
+ this.#dispatchEvent('reset');
321
+ };
322
+ #onClickButtonCommit = async () => {
323
+ const { pixels } = this.#computeCroppedArea || {};
324
+ if (!pixels) {
325
+ const error = new Error("Can't perform image crop, pixel crop data missing");
326
+ this.#dispatchEvent('error', error);
327
+ return;
328
+ }
329
+ try {
330
+ const blob = await helpers.getCroppedImage({
331
+ imageEl: this.#elImage,
332
+ crop: pixels,
333
+ backgroundFill: '#fff',
334
+ width: this.#outputWidth,
335
+ height: this.#outputHeight,
336
+ });
337
+ this.#dispatchEvent('commit', blob);
338
+ } catch (error) {
339
+ this.#dispatchEvent('error', error);
340
+ }
341
+ };
342
+ #onTouchAreaZoom = (e) => {
343
+ if (e.detail) this.#zoom = e.detail;
344
+ this.#syncTouchAreaAttributes();
345
+ };
346
+ #onTouchAreaPosition = (e) => {
347
+ if (e.detail) this.#crop = e.detail;
348
+ this.#syncTouchAreaAttributes();
349
+ };
350
+ #onTouchAreaDragStopped = () => {
351
+ this.#emitCropData();
352
+ };
353
+ /** emit crop data/ update template render if image loads/changes */
354
+ #onImgLoad = () => {
355
+ this.#emitCropData();
356
+ this.#updateTemplate();
357
+ };
358
+ /** update template render if component resizes */
359
+ #onContainerResize() {
360
+ this.#updateTemplate();
361
+ }
362
+
363
+ // Methods
364
+ #dispatchEvent(type, detail) {
365
+ this.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
366
+ }
367
+ #loadImage(imageSrc) {
368
+ if (!this.#elImage) return;
369
+
370
+ if (!imageSrc) {
371
+ this.#elImage.removeAttribute('src');
372
+ return;
373
+ }
374
+
375
+ this.#elImage.src = imageSrc;
376
+ this.#resetZoomAndCrop();
377
+ }
378
+ #resetZoomAndCrop() {
379
+ this.#zoom = Math.min(this.#maxZoom, Math.max(1, this.#minZoom));
380
+ this.#crop = { x: 0, y: 0 };
381
+ }
382
+ /** ensure touch area component has up to date attributes */
383
+ #syncTouchAreaAttributes() {
384
+ this.#elContainer?.setAttribute('zoom', this.#zoom);
385
+ this.#elContainer?.setAttribute('position-x', this.#crop.x);
386
+ this.#elContainer?.setAttribute('position-y', this.#crop.y);
387
+ this.#elContainer?.setAttribute('min-zoom', this.#minZoom);
388
+ this.#elContainer?.setAttribute('max-zoom', this.#maxZoom);
389
+ }
390
+ get #computeCroppedArea() {
391
+ return (
392
+ helpers.computeCroppedArea({
393
+ crop: this.#crop,
394
+ imageSize: this.#imageSize,
395
+ cropSize: this.#cropperSize,
396
+ aspect: this.#aspectRatio,
397
+ zoom: this.#zoom,
398
+ restrictPosition: this.#restrictPosition,
399
+ }) || {}
400
+ );
401
+ }
402
+ /** emit crop data in pixels and percentage, on data change */
403
+ async #emitCropData() {
404
+ const { percent, pixels } = this.#computeCroppedArea;
405
+ try {
406
+ this.#dispatchEvent('cropchange', { percent, pixels });
407
+ } catch (error) {
408
+ console.error(error);
409
+ }
410
+ }
411
+ /** called when any dynamic values change, ensuring visual render is sync'd with values */
412
+ #updateTemplate() {
413
+ if (this.#elImage) {
414
+ this.#elImage.style.transform = `translate(${this.#crop.x}px, ${this.#crop.y}px) scale(${this.#zoom})`;
415
+ }
416
+ if (this.#elCropArea) {
417
+ this.#elCropArea.style.width = `${this.#cropperSize?.width || 0}px`;
418
+ this.#elCropArea.style.height = `${this.#cropperSize?.height || 0}px`;
419
+ this.#elCropArea.classList[this.#noGrid ? 'remove' : 'add']('mds-image-cropper-grid');
420
+ }
421
+ if (this.#elBackground) {
422
+ this.#elBackground.style.width = `${this.#cropperSize?.width || 0}px`;
423
+ this.#elBackground.style.height = `${this.#cropperSize?.height || 0}px`;
424
+ }
425
+ if (this.#elWrapper) {
426
+ this.#elWrapper.style.aspectRatio = this.#aspectRatio;
427
+ }
428
+ if (this.#elZoomInput) {
429
+ this.#elZoomInput.setAttribute('min', this.#minZoom);
430
+ this.#elZoomInput.setAttribute('max', this.#maxZoom);
431
+ this.#elZoomInput.value = this.#zoom;
432
+ }
433
+ this.#updateAriaLive();
434
+ }
435
+ /** update aria-live area with latest image crop information */
436
+ #updateAriaLive() {
437
+ const { pixels } = this.#computeCroppedArea || {};
438
+ if (!this.#elAriaLive || !this.#imageSize?.naturalWidth || !pixels?.width) return;
439
+ this.#elAriaLive.textContent = this.#translations['aria-live']
440
+ .replace('{imageWidth}', this.#imageSize.naturalWidth)
441
+ .replace('{imageHeight}', this.#imageSize.naturalHeight)
442
+ .replace('{x}', pixels.x)
443
+ .replace('{y}', pixels.y)
444
+ .replace('{width}', pixels.width)
445
+ .replace('{height}', pixels.height);
446
+ }
447
+ }
@@ -0,0 +1,99 @@
1
+ {% from "./modal/_macro.njk" import MdsModal %}
2
+
3
+ <h2>Aspect Ratio 1:1</h2>
4
+ <div class="mds-grid-row mds-margin-bottom-b4">
5
+ <div class="mds-grid-col-12 mds-grid-col-md-9 mds-grid-col-lg-6 position-relative">
6
+ <mds-image-cropper aspect-ratio="1" src="/assets/images/image-cropper-example.jpg"></mds-image-cropper>
7
+ </div>
8
+ </div>
9
+ <h2>Fixed output in pixels 380x160</h2>
10
+ <div class="mds-grid-row mds-margin-bottom-b4">
11
+ <div class="mds-grid-col-12 mds-grid-col-md-9 mds-grid-col-lg-6 position-relative">
12
+ <mds-image-cropper output-width="380" output-height="160" src="/assets/images/image-cropper-example.jpg"></mds-image-cropper>
13
+ </div>
14
+ </div>
15
+ <h2>Standalone Shadow DOM</h2>
16
+ <div class="mds-grid-row mds-margin-bottom-b4">
17
+ <div class="mds-grid-col-12 mds-grid-col-md-9 mds-grid-col-lg-6 position-relative" id="shadow-container">
18
+ <template shadowrootmode="open">
19
+ <mds-image-cropper-standalone aspect-ratio="1" src="/assets/images/image-cropper-example.jpg"></mds-image-cropper-standalone>
20
+ </template>
21
+ </div>
22
+ </div>
23
+
24
+ <h2>Inside Dialog</h2>
25
+ <p id="site-container">
26
+ <button class="mds-button" data-modal-id="modal">Open Modal</button>
27
+ </p>
28
+
29
+ {% call MdsModal({
30
+ id: 'modal',
31
+ siteContainerId: 'site-container'
32
+ }) -%}
33
+ <h2>Image Cropper</h2>
34
+ <mds-image-cropper class="inside-dialog" aspect-ratio="1" src="/assets/images/image-cropper-example.jpg"></mds-image-cropper>
35
+ {%- endcall %}
36
+
37
+ <script type="module">
38
+ const el = document.querySelector('.inside-dialog');
39
+ el.addEventListener('commit', ({detail}) => {
40
+ if (detail) {
41
+ try {
42
+ // cleanup any previous ObjectURLs to clear memory
43
+ URL.revokeObjectURL(el.getAttribute('src'));
44
+ } catch (e) {}
45
+ // feed cropped image right back in to Image Cropper
46
+ el.setAttribute('src', URL.createObjectURL(detail));
47
+ if (window.confirm("How do I close this dialog! View your image? Also updating image in cropper")) {
48
+ window.open(el.getAttribute('src'));
49
+ }
50
+ }
51
+ });
52
+ </script>
53
+
54
+ <h2>Small logo - zoom below 1 to create whitespace around original image</h2>
55
+ <div class="mds-grid-row mds-margin-bottom-b4">
56
+ <div class="mds-grid-col-12 mds-grid-col-md-9 mds-grid-col-lg-6 position-relative">
57
+ <mds-image-cropper
58
+ min-zoom="0.5"
59
+ restrict-position="false"
60
+ output-width="380" output-height="160"
61
+ src="/assets/images/image-cropper-example-logo2.png"></mds-image-cropper>
62
+ </div>
63
+ </div>
64
+ <h2>Wide image</h2>
65
+ <div class="mds-grid-row mds-margin-bottom-b4">
66
+ <div class="mds-grid-col-12 mds-grid-col-md-9 mds-grid-col-lg-6 position-relative">
67
+ <mds-image-cropper aspect-ratio="1.777" src="/assets/images/image-cropper-example-wide.jpg"></mds-image-cropper>
68
+ </div>
69
+ </div>
70
+ <h2>Tall image</h2>
71
+ <div class="mds-grid-row mds-margin-bottom-b4">
72
+ <div class="mds-grid-col-12 mds-grid-col-md-9 mds-grid-col-lg-6 position-relative">
73
+ <mds-image-cropper aspect-ratio="1" src="/assets/images/image-cropper-example-tall.png"></mds-image-cropper>
74
+ </div>
75
+ </div>
76
+ <h2>No grid</h2>
77
+ <div class="mds-grid-row mds-margin-bottom-b4">
78
+ <div class="mds-grid-col-12 mds-grid-col-md-9 mds-grid-col-lg-6 position-relative">
79
+ <mds-image-cropper aspect-ratio="1.777" no-grid src="/assets/images/image-cropper-example-wide.jpg"></mds-image-cropper>
80
+ </div>
81
+ </div>
82
+ <script type="module">
83
+ const shadowEl = document.querySelector('#shadow-container').shadowRoot.querySelector('mds-image-cropper-standalone');
84
+ for (const el of [...Array.from(document.querySelectorAll('mds-image-cropper:not(.inside-dialog)')), shadowEl]) {
85
+ el.addEventListener('commit', ({detail}) => {
86
+ if (detail) {
87
+ try {
88
+ // cleanup any previous ObjectURLs to clear memory
89
+ URL.revokeObjectURL(el.getAttribute('src'));
90
+ } catch (e) {}
91
+ // feed cropped image right back in to Image Cropper
92
+ el.setAttribute('src', URL.createObjectURL(detail));
93
+ if (window.confirm("View your image? Also updating image in cropper")) {
94
+ window.open(el.getAttribute('src'));
95
+ }
96
+ }
97
+ });
98
+ }
99
+ </script>
@@ -0,0 +1,93 @@
1
+ .mds-image-cropper__container {
2
+ position: absolute;
3
+ top: 0;
4
+ left: 0;
5
+ right: 0;
6
+ bottom: 0;
7
+ overflow: hidden;
8
+ user-select: none;
9
+ touch-action: none;
10
+ cursor: move;
11
+ background: repeating-conic-gradient(#808080 0 25%, #0000 0 50%) 50% / 20px 20px;
12
+ // background-color: #fff;
13
+ }
14
+
15
+ .mds-image-cropper__image {
16
+ max-width: 100%;
17
+ max-height: 100%;
18
+ margin: auto;
19
+ position: absolute;
20
+ top: 0;
21
+ bottom: 0;
22
+ left: 0;
23
+ right: 0;
24
+ will-change: transform;
25
+ }
26
+
27
+ .mds-image-cropper__area {
28
+ position: absolute;
29
+ left: 50%;
30
+ top: 50%;
31
+ transform: translate(-50%, -50%);
32
+ box-shadow: 0 0 0 9999em;
33
+ box-sizing: border-box;
34
+ color: rgba(0, 0, 0, 0.7);
35
+ border: 1px solid rgba(255, 255, 255, 0.5);
36
+ overflow: hidden;
37
+ }
38
+
39
+ .mds-image-cropper__background {
40
+ position: absolute;
41
+ left: 50%;
42
+ top: 50%;
43
+ transform: translate(-50%, -50%);
44
+ background-color: #fff;
45
+ }
46
+
47
+ .mds-image-cropper-grid:before {
48
+ content: ' ';
49
+ box-sizing: border-box;
50
+ border: 1px solid rgba(255, 255, 255, 0.5);
51
+ position: absolute;
52
+ top: 0;
53
+ bottom: 0;
54
+ left: 33.33%;
55
+ right: 33.33%;
56
+ border-top: 0;
57
+ border-bottom: 0;
58
+ }
59
+ .mds-image-cropper-grid:after {
60
+ content: ' ';
61
+ box-sizing: border-box;
62
+ border: 1px solid rgba(255, 255, 255, 0.5);
63
+ position: absolute;
64
+ top: 33.33%;
65
+ bottom: 33.33%;
66
+ left: 0;
67
+ right: 0;
68
+ border-left: 0;
69
+ border-right: 0;
70
+ }
71
+
72
+ .mds-image-cropper__wrapper {
73
+ position: relative;
74
+ aspect-ratio: 1; // default
75
+ }
76
+
77
+ .mds-image-cropper__control {
78
+ display: flex;
79
+ }
80
+ .mds-image-cropper__control-label {
81
+ margin-top: 20px;
82
+ }
83
+ .mds-image-cropper__control-zoom {
84
+ flex: 1;
85
+ }
86
+ .mds-image-cropper__control-button {
87
+ cursor: pointer;
88
+ appearance: none;
89
+ background: transparent;
90
+ border: 0;
91
+ width: 2.2rem;
92
+ padding: 0 6px;
93
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * "entry" file for standalone Web Component Vite output - allows hashed chunk file names, and at the same time predictable named entry files .
3
+ *
4
+ * MdsImageCropperStandalone wraps the regular MdsImageCropper, and adds ShadowDOM and standalone CSS.
5
+ *
6
+ * TODO: apply true-cropper image-cropper.scss, but cropper is not in shadowDOM so CSS does not work as adoptedStyleSheet....
7
+ */
8
+
9
+ import { MdsImageCropper } from './image-cropper.js';
10
+
11
+ // css generated using regular design system CSS, but stripped down using image-cropper.js usage
12
+ import css from './image-cropper-standalone.scss?purgecss=./image-cropper.js&inline';
13
+ // include 100% of image-cropper.scss, all is required even though true-cropper isnt initialized yet
14
+ import cssImageCropper from './image-cropper.scss?inline';
15
+
16
+ /**
17
+ * MdsImageCropper in shadowDOM with standalone CSS
18
+ */
19
+ class MdsImageCropperStandalone extends MdsImageCropper {
20
+ constructor(...args) {
21
+ super(...args);
22
+
23
+ this.attachShadow({ mode: 'open' });
24
+ // shadow CSS, with a small reset
25
+ const sheet = new CSSStyleSheet();
26
+ sheet.replaceSync(`
27
+ * {border: 0;outline:0;padding: 0;margin:0;box-sizing:border-box;}
28
+ ${css}
29
+ ${cssImageCropper}`);
30
+ this.rootNode.adoptedStyleSheets = [sheet];
31
+ }
32
+ get rootNode() {
33
+ return this.shadowRoot;
34
+ }
35
+ }
36
+ // alternative name vs the main MDS bundle version, to avoid collision of defined names
37
+ if (!window.customElements.get('mds-image-cropper-standalone')) {
38
+ window.customElements.define('mds-image-cropper-standalone', MdsImageCropperStandalone);
39
+ }
@@ -5,4 +5,5 @@ document.addEventListener('DOMContentLoaded', async () => {
5
5
  switchStateScript.init();
6
6
  notificationScript.init();
7
7
  await import('../components/timeout-dialog/mds-timeout-dialog-standalone.js');
8
+ await import('../components/image-cropper/mds-image-cropper-standalone.js');
8
9
  });