Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
/**
17
 * Tiny media plugin image details class for Moodle.
18
 *
19
 * @module      tiny_media/imagedetails
20
 * @copyright   2024 Meirza <meirza.arson@moodle.com>
21
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 */
23
 
24
import Config from 'core/config';
25
import ModalEvents from 'core/modal_events';
26
import Notification from 'core/notification';
27
import Pending from 'core/pending';
28
import Selectors from './selectors';
29
import Templates from 'core/templates';
30
import {getString} from 'core/str';
31
import {ImageInsert} from 'tiny_media/imageinsert';
32
import {
33
    bodyImageInsert,
34
    footerImageInsert,
35
    showElements,
36
    hideElements,
37
    isPercentageValue,
38
} from 'tiny_media/imagehelpers';
39
 
40
export class ImageDetails {
41
    DEFAULTS = {
42
        WIDTH: 160,
43
        HEIGHT: 160,
44
    };
45
 
46
    rawImageDimensions = null;
47
 
48
    constructor(
49
        root,
50
        editor,
51
        currentModal,
52
        canShowFilePicker,
53
        canShowDropZone,
54
        currentUrl,
55
        image,
56
    ) {
57
        this.root = root;
58
        this.editor = editor;
59
        this.currentModal = currentModal;
60
        this.canShowFilePicker = canShowFilePicker;
61
        this.canShowDropZone = canShowDropZone;
62
        this.currentUrl = currentUrl;
63
        this.image = image;
64
    }
65
 
66
    init = function() {
67
        this.currentModal.setTitle(getString('imagedetails', 'tiny_media'));
68
        this.imageTypeChecked();
69
        this.presentationChanged();
70
        this.storeImageDimensions(this.image);
71
        this.setImageDimensions();
72
        this.registerEventListeners();
73
    };
74
 
75
    /**
76
     * Loads and displays a preview image based on the provided URL, and handles image loading events.
77
     */
78
    loadInsertImage = async function() {
79
        const templateContext = {
80
            elementid: this.editor.id,
81
            showfilepicker: this.canShowFilePicker,
82
            showdropzone: this.canShowDropZone,
83
        };
84
 
85
        Promise.all([bodyImageInsert(templateContext, this.root), footerImageInsert(templateContext, this.root)])
86
            .then(() => {
87
                const imageinsert = new ImageInsert(
88
                    this.root,
89
                    this.editor,
90
                    this.currentModal,
91
                    this.canShowFilePicker,
92
                    this.canShowDropZone,
93
                );
94
                imageinsert.init();
95
                return;
96
            })
97
            .catch(error => {
98
                window.console.log(error);
99
            });
100
    };
101
 
102
    storeImageDimensions(image) {
103
        // Store dimensions of the raw image, falling back to defaults for images without dimensions (e.g. SVG).
104
        this.rawImageDimensions = {
105
            width: image.width || this.DEFAULTS.WIDTH,
106
            height: image.height || this.DEFAULTS.HEIGHT,
107
        };
108
 
109
        const getCurrentWidth = (element) => {
110
            if (element.value === '') {
111
                element.value = this.rawImageDimensions.width;
112
            }
113
            return element.value;
114
        };
115
 
116
        const getCurrentHeight = (element) => {
117
            if (element.value === '') {
118
                element.value = this.rawImageDimensions.height;
119
            }
120
            return element.value;
121
        };
122
 
123
        const widthInput = this.root.querySelector(Selectors.IMAGE.elements.width);
124
        const currentWidth = getCurrentWidth(widthInput);
125
 
126
        const heightInput = this.root.querySelector(Selectors.IMAGE.elements.height);
127
        const currentHeight = getCurrentHeight(heightInput);
128
 
129
        const preview = this.root.querySelector(Selectors.IMAGE.elements.preview);
130
        preview.setAttribute('src', image.src);
131
        preview.style.display = '';
132
 
133
        // Ensure the checkbox always in unchecked status when an image loads at first.
134
        const constrain = this.root.querySelector(Selectors.IMAGE.elements.constrain);
135
        if (isPercentageValue(currentWidth) && isPercentageValue(currentHeight)) {
136
            constrain.checked = currentWidth === currentHeight;
137
        } else if (image.width === 0 || image.height === 0) {
138
            // If we don't have both dimensions of the image, we can't auto-size it, so disable control.
139
            constrain.disabled = 'disabled';
140
        } else {
141
            // This is the same as comparing to 3 decimal places.
142
            const widthRatio = Math.round(100 * parseInt(currentWidth, 10) / image.width);
143
            const heightRatio = Math.round(100 * parseInt(currentHeight, 10) / image.height);
144
            constrain.checked = widthRatio === heightRatio;
145
        }
146
 
147
        /**
148
         * Sets the selected size option based on current width and height values.
149
         *
150
         * @param {number} currentWidth - The current width value.
151
         * @param {number} currentHeight - The current height value.
152
         */
153
        const setSelectedSize = (currentWidth, currentHeight) => {
154
            if (this.rawImageDimensions.width === currentWidth &&
155
                this.rawImageDimensions.height === currentHeight
156
            ) {
157
                this.currentWidth = this.rawImageDimensions.width;
158
                this.currentHeight = this.rawImageDimensions.height;
159
                this.sizeChecked('original');
160
            } else {
161
                this.currentWidth = currentWidth;
162
                this.currentHeight = currentHeight;
163
                this.sizeChecked('custom');
164
            }
165
        };
166
 
167
        setSelectedSize(Number(currentWidth), Number(currentHeight));
168
    }
169
 
170
    /**
171
     * Handles the selection of image size options and updates the form inputs accordingly.
172
     *
173
     * @param {string} option - The selected image size option ("original" or "custom").
174
     */
175
    sizeChecked(option) {
176
        const widthInput = this.root.querySelector(Selectors.IMAGE.elements.width);
177
        const heightInput = this.root.querySelector(Selectors.IMAGE.elements.height);
178
        if (option === "original") {
179
            this.sizeOriginalChecked();
180
            widthInput.value = this.rawImageDimensions.width;
181
            heightInput.value = this.rawImageDimensions.height;
182
        } else if (option === "custom") {
183
            this.sizeCustomChecked();
184
            widthInput.value = this.currentWidth;
185
            heightInput.value = this.currentHeight;
186
 
187
            // If the current size is equal to the original size, then check the Keep proportion checkbox.
188
            if (this.currentWidth === this.rawImageDimensions.width && this.currentHeight === this.rawImageDimensions.height) {
189
                const constrainField = this.root.querySelector(Selectors.IMAGE.elements.constrain);
190
                constrainField.checked = true;
191
            }
192
        }
193
        this.autoAdjustSize();
194
    }
195
 
196
    autoAdjustSize(forceHeight = false) {
197
        // If we do not know the image size, do not do anything.
198
        if (!this.rawImageDimensions) {
199
            return;
200
        }
201
 
202
        const widthField = this.root.querySelector(Selectors.IMAGE.elements.width);
203
        const heightField = this.root.querySelector(Selectors.IMAGE.elements.height);
204
 
205
        const normalizeFieldData = (fieldData) => {
206
            fieldData.isPercentageValue = !!isPercentageValue(fieldData.field.value);
207
            if (fieldData.isPercentageValue) {
208
                fieldData.percentValue = parseInt(fieldData.field.value, 10);
209
                fieldData.pixelSize = this.rawImageDimensions[fieldData.type] / 100 * fieldData.percentValue;
210
            } else {
211
                fieldData.pixelSize = parseInt(fieldData.field.value, 10);
212
                fieldData.percentValue = fieldData.pixelSize / this.rawImageDimensions[fieldData.type] * 100;
213
            }
214
 
215
            return fieldData;
216
        };
217
 
218
        const getKeyField = () => {
219
            const getValue = () => {
220
                if (forceHeight) {
221
                    return {
222
                        field: heightField,
223
                        type: 'height',
224
                    };
225
                } else {
226
                    return {
227
                        field: widthField,
228
                        type: 'width',
229
                    };
230
                }
231
            };
232
 
233
            const currentValue = getValue();
234
            if (currentValue.field.value === '') {
235
                currentValue.field.value = this.rawImageDimensions[currentValue.type];
236
            }
237
 
238
            return normalizeFieldData(currentValue);
239
        };
240
 
241
        const getRelativeField = () => {
242
            if (forceHeight) {
243
                return normalizeFieldData({
244
                    field: widthField,
245
                    type: 'width',
246
                });
247
            } else {
248
                return normalizeFieldData({
249
                    field: heightField,
250
                    type: 'height',
251
                });
252
            }
253
        };
254
 
255
        // Now update with the new values.
256
        const constrainField = this.root.querySelector(Selectors.IMAGE.elements.constrain);
257
        if (constrainField.checked) {
258
            const keyField = getKeyField();
259
            const relativeField = getRelativeField();
260
            // We are keeping the image in proportion.
261
            // Calculate the size for the relative field.
262
            if (keyField.isPercentageValue) {
263
                // In proportion, so the percentages are the same.
264
                relativeField.field.value = keyField.field.value;
265
                relativeField.percentValue = keyField.percentValue;
266
            } else {
267
                relativeField.pixelSize = Math.round(
268
                    keyField.pixelSize / this.rawImageDimensions[keyField.type] * this.rawImageDimensions[relativeField.type]
269
                );
270
                relativeField.field.value = relativeField.pixelSize;
271
            }
272
        }
273
 
274
        // Store the custom width and height to reuse.
275
        this.currentWidth = Number(widthField.value) !== this.rawImageDimensions.width ? widthField.value : this.currentWidth;
276
        this.currentHeight = Number(heightField.value) !== this.rawImageDimensions.height ? heightField.value : this.currentHeight;
277
    }
278
 
279
    /**
280
     * Sets the dimensions of the image preview element based on user input and constraints.
281
     */
282
    setImageDimensions = () => {
283
        const imagePreviewBox = this.root.querySelector(Selectors.IMAGE.elements.previewBox);
284
        const image = this.root.querySelector(Selectors.IMAGE.elements.preview);
285
        const widthField = this.root.querySelector(Selectors.IMAGE.elements.width);
286
        const heightField = this.root.querySelector(Selectors.IMAGE.elements.height);
287
 
288
        const updateImageDimensions = () => {
289
            // Get the latest dimensions of the preview box for responsiveness.
290
            const boxWidth = imagePreviewBox.clientWidth;
291
            const boxHeight = imagePreviewBox.clientHeight;
292
            // Get the new width and height for the image.
293
            const dimensions = this.fitSquareIntoBox(widthField.value, heightField.value, boxWidth, boxHeight);
294
            image.style.width = `${dimensions.width}px`;
295
            image.style.height = `${dimensions.height}px`;
296
        };
297
        // If the client size is zero, then get the new dimensions once the modal is shown.
298
        if (imagePreviewBox.clientWidth === 0) {
299
            // Call the shown event.
300
            this.currentModal.getRoot().on(ModalEvents.shown, () => {
301
                updateImageDimensions();
302
            });
303
        } else {
304
            updateImageDimensions();
305
        }
306
    };
307
 
308
    /**
309
     * Handles the selection of the "Original Size" option and updates the form elements accordingly.
310
     */
311
    sizeOriginalChecked() {
312
        this.root.querySelector(Selectors.IMAGE.elements.sizeOriginal).checked = true;
313
        this.root.querySelector(Selectors.IMAGE.elements.sizeCustom).checked = false;
314
        hideElements(Selectors.IMAGE.elements.properties, this.root);
315
    }
316
 
317
    /**
318
     * Handles the selection of the "Custom Size" option and updates the form elements accordingly.
319
     */
320
    sizeCustomChecked() {
321
        this.root.querySelector(Selectors.IMAGE.elements.sizeOriginal).checked = false;
322
        this.root.querySelector(Selectors.IMAGE.elements.sizeCustom).checked = true;
323
        showElements(Selectors.IMAGE.elements.properties, this.root);
324
    }
325
 
326
    /**
327
     * Handles changes in the image presentation checkbox and enables/disables the image alt text input accordingly.
328
     */
329
    presentationChanged() {
330
        const presentation = this.root.querySelector(Selectors.IMAGE.elements.presentation);
331
        const alt = this.root.querySelector(Selectors.IMAGE.elements.alt);
332
        alt.disabled = presentation.checked;
333
 
334
        // Counting the image description characters.
335
        this.handleKeyupCharacterCount();
336
    }
337
 
338
    /**
339
     * This function checks whether an image URL is local (within the same website's domain) or external (from an external source).
340
     * Depending on the result, it dynamically updates the visibility and content of HTML elements in a user interface.
341
     * If the image is local then we only show it's filename.
342
     * If the image is external then it will show full URL and it can be updated.
343
     */
344
    imageTypeChecked() {
345
        const regex = new RegExp(`${Config.wwwroot}`);
346
 
347
        // True if the URL is from external, otherwise false.
348
        const isExternalUrl = regex.test(this.currentUrl) === false;
349
 
350
        // Hide the URL input.
351
        hideElements(Selectors.IMAGE.elements.url, this.root);
352
 
353
        if (!isExternalUrl) {
354
            // Split the URL by '/' to get an array of segments.
355
            const segments = this.currentUrl.split('/');
356
            // Get the last segment, which should be the filename.
357
            const filename = segments.pop().split('?')[0];
358
            // Show the file name.
359
            this.setFilenameLabel(decodeURI(filename));
360
        } else {
361
 
362
            this.setFilenameLabel(decodeURI(this.currentUrl));
363
        }
364
    }
365
 
366
    /**
367
     * Set the string for the URL label element.
368
     *
369
     * @param {string} label - The label text to set.
370
     */
371
    setFilenameLabel(label) {
372
        const urlLabelEle = this.root.querySelector(Selectors.IMAGE.elements.fileNameLabel);
373
        if (urlLabelEle) {
374
            urlLabelEle.innerHTML = label;
375
            urlLabelEle.setAttribute("title", label);
376
        }
377
    }
378
 
379
    toggleAriaInvalid(selectors, predicate) {
380
        selectors.forEach((selector) => {
381
            const elements = this.root.querySelectorAll(selector);
382
            elements.forEach((element) => element.setAttribute('aria-invalid', predicate));
383
        });
384
    }
385
 
386
    hasErrorUrlField() {
387
        const urlError = this.currentUrl === '';
388
        if (urlError) {
389
            showElements(Selectors.IMAGE.elements.urlWarning, this.root);
390
        } else {
391
            hideElements(Selectors.IMAGE.elements.urlWarning, this.root);
392
        }
393
        this.toggleAriaInvalid([Selectors.IMAGE.elements.url], urlError);
394
 
395
        return urlError;
396
    }
397
 
398
    hasErrorAltField() {
399
        const alt = this.root.querySelector(Selectors.IMAGE.elements.alt).value;
400
        const presentation = this.root.querySelector(Selectors.IMAGE.elements.presentation).checked;
401
        const imageAltError = alt === '' && !presentation;
402
        if (imageAltError) {
403
            showElements(Selectors.IMAGE.elements.altWarning, this.root);
404
        } else {
405
            hideElements(Selectors.IMAGE.elements.urlWaaltWarningrning, this.root);
406
        }
407
        this.toggleAriaInvalid([Selectors.IMAGE.elements.alt, Selectors.IMAGE.elements.presentation], imageAltError);
408
 
409
        return imageAltError;
410
    }
411
 
412
    updateWarning() {
413
        const urlError = this.hasErrorUrlField();
414
        const imageAltError = this.hasErrorAltField();
415
 
416
        return urlError || imageAltError;
417
    }
418
 
419
    getImageContext() {
420
        // Check if there are any accessibility issues.
421
        if (this.updateWarning()) {
422
            return null;
423
        }
424
 
425
        const classList = [];
426
        const constrain = this.root.querySelector(Selectors.IMAGE.elements.constrain).checked;
427
        const sizeOriginal = this.root.querySelector(Selectors.IMAGE.elements.sizeOriginal).checked;
428
        if (constrain || sizeOriginal) {
429
            // If the Auto size checkbox is checked or the Original size is checked, then apply the responsive class.
430
            classList.push(Selectors.IMAGE.styles.responsive);
431
        } else {
432
            // Otherwise, remove it.
433
            classList.pop(Selectors.IMAGE.styles.responsive);
434
        }
435
 
436
        return {
437
            url: this.currentUrl,
438
            alt: this.root.querySelector(Selectors.IMAGE.elements.alt).value,
439
            width: this.root.querySelector(Selectors.IMAGE.elements.width).value,
440
            height: this.root.querySelector(Selectors.IMAGE.elements.height).value,
441
            presentation: this.root.querySelector(Selectors.IMAGE.elements.presentation).checked,
442
            customStyle: this.root.querySelector(Selectors.IMAGE.elements.customStyle).value,
443
            classlist: classList.join(' '),
444
        };
445
    }
446
 
447
    setImage() {
448
        const pendingPromise = new Pending('tiny_media:setImage');
449
        const url = this.currentUrl;
450
        if (url === '') {
451
            return;
452
        }
453
 
454
        // Check if there are any accessibility issues.
455
        if (this.updateWarning()) {
456
            pendingPromise.resolve();
457
            return;
458
        }
459
 
460
        // Check for invalid width or height.
461
        const width = this.root.querySelector(Selectors.IMAGE.elements.width).value;
462
        if (!isPercentageValue(width) && isNaN(parseInt(width, 10))) {
463
            this.root.querySelector(Selectors.IMAGE.elements.width).focus();
464
            pendingPromise.resolve();
465
            return;
466
        }
467
 
468
        const height = this.root.querySelector(Selectors.IMAGE.elements.height).value;
469
        if (!isPercentageValue(height) && isNaN(parseInt(height, 10))) {
470
            this.root.querySelector(Selectors.IMAGE.elements.height).focus();
471
            pendingPromise.resolve();
472
            return;
473
        }
474
 
475
        Templates.render('tiny_media/image', this.getImageContext())
476
        .then((html) => {
477
            this.editor.insertContent(html);
478
            this.currentModal.destroy();
479
            pendingPromise.resolve();
480
 
481
            return html;
482
        })
483
        .catch(error => {
484
            window.console.log(error);
485
        });
486
    }
487
 
488
    /**
489
     * Deletes the image after confirming with the user and loads the insert image page.
490
     */
491
    deleteImage() {
492
        Notification.deleteCancelPromise(
493
            getString('deleteimage', 'tiny_media'),
494
            getString('deleteimagewarning', 'tiny_media'),
495
        ).then(() => {
496
            hideElements(Selectors.IMAGE.elements.altWarning, this.root);
497
            // Removing the image in the preview will bring the user to the insert page.
498
            this.loadInsertImage();
499
            return;
500
        }).catch(error => {
501
            window.console.log(error);
502
        });
503
    }
504
 
505
    registerEventListeners() {
506
        const submitAction = this.root.querySelector(Selectors.IMAGE.actions.submit);
507
        submitAction.addEventListener('click', (e) => {
508
            e.preventDefault();
509
            this.setImage();
510
        });
511
 
512
        const deleteImageEle = this.root.querySelector(Selectors.IMAGE.actions.deleteImage);
513
        deleteImageEle.addEventListener('click', () => {
514
            this.deleteImage();
515
        });
516
        deleteImageEle.addEventListener("keydown", (e) => {
517
            if (e.key === "Enter") {
518
                this.deleteImage();
519
            }
520
        });
521
 
522
        this.root.addEventListener('change', (e) => {
523
            const presentationEle = e.target.closest(Selectors.IMAGE.elements.presentation);
524
            if (presentationEle) {
525
                this.presentationChanged();
526
            }
527
 
528
            const constrainEle = e.target.closest(Selectors.IMAGE.elements.constrain);
529
            if (constrainEle) {
530
                this.autoAdjustSize();
531
            }
532
 
533
            const sizeOriginalEle = e.target.closest(Selectors.IMAGE.elements.sizeOriginal);
534
            if (sizeOriginalEle) {
535
                this.sizeChecked('original');
536
            }
537
 
538
            const sizeCustomEle = e.target.closest(Selectors.IMAGE.elements.sizeCustom);
539
            if (sizeCustomEle) {
540
                this.sizeChecked('custom');
541
            }
542
        });
543
 
544
        this.root.addEventListener('blur', (e) => {
545
            if (e.target.nodeType === Node.ELEMENT_NODE) {
546
 
547
                const presentationEle = e.target.closest(Selectors.IMAGE.elements.presentation);
548
                if (presentationEle) {
549
                    this.presentationChanged();
550
                }
551
            }
552
        }, true);
553
 
554
        // Character count.
555
        this.root.addEventListener('keyup', (e) => {
556
            const altEle = e.target.closest(Selectors.IMAGE.elements.alt);
557
            if (altEle) {
558
                this.handleKeyupCharacterCount();
559
            }
560
        });
561
 
562
        this.root.addEventListener('input', (e) => {
563
            const widthEle = e.target.closest(Selectors.IMAGE.elements.width);
564
            if (widthEle) {
565
                // Avoid empty value.
566
                widthEle.value = widthEle.value === "" ? 0 : Number(widthEle.value);
567
                this.autoAdjustSize();
568
            }
569
 
570
            const heightEle = e.target.closest(Selectors.IMAGE.elements.height);
571
            if (heightEle) {
572
                // Avoid empty value.
573
                heightEle.value = heightEle.value === "" ? 0 : Number(heightEle.value);
574
                this.autoAdjustSize(true);
575
            }
576
        });
577
    }
578
 
579
    handleKeyupCharacterCount() {
580
        const alt = this.root.querySelector(Selectors.IMAGE.elements.alt).value;
581
        const current = this.root.querySelector('#currentcount');
582
        current.innerHTML = alt.length;
583
    }
584
 
585
    /**
586
     * Calculates the dimensions to fit a square into a specified box while maintaining aspect ratio.
587
     *
588
     * @param {number} squareWidth - The width of the square.
589
     * @param {number} squareHeight - The height of the square.
590
     * @param {number} boxWidth - The width of the box.
591
     * @param {number} boxHeight - The height of the box.
592
     * @returns {Object} An object with the new width and height of the square to fit in the box.
593
     */
594
    fitSquareIntoBox = (squareWidth, squareHeight, boxWidth, boxHeight) => {
595
        if (squareWidth < boxWidth && squareHeight < boxHeight) {
596
          // If the square is smaller than the box, keep its dimensions.
597
          return {
598
            width: squareWidth,
599
            height: squareHeight,
600
          };
601
        }
602
        // Calculate the scaling factor based on the minimum scaling required to fit in the box.
603
        const widthScaleFactor = boxWidth / squareWidth;
604
        const heightScaleFactor = boxHeight / squareHeight;
605
        const minScaleFactor = Math.min(widthScaleFactor, heightScaleFactor);
606
        // Scale the square's dimensions based on the aspect ratio and the minimum scaling factor.
607
        const newWidth = squareWidth * minScaleFactor;
608
        const newHeight = squareHeight * minScaleFactor;
609
        return {
610
          width: newWidth,
611
          height: newHeight,
612
        };
613
    };
614
}