Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | 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';
1441 ariadna 32
import {MediaBase} from './mediabase';
1 efrain 33
import {
1441 ariadna 34
    body,
35
    footer,
36
    hideElements,
1 efrain 37
    showElements,
38
    isPercentageValue,
1441 ariadna 39
} from './helpers';
1 efrain 40
 
1441 ariadna 41
export class ImageDetails extends MediaBase {
1 efrain 42
    DEFAULTS = {
43
        WIDTH: 160,
44
        HEIGHT: 160,
45
    };
46
 
1441 ariadna 47
    selectorType = Selectors.IMAGE.type;
1 efrain 48
 
1441 ariadna 49
    mediaDimensions = null;
50
 
1 efrain 51
    constructor(
52
        root,
53
        editor,
54
        currentModal,
55
        canShowFilePicker,
56
        canShowDropZone,
57
        currentUrl,
58
        image,
59
    ) {
1441 ariadna 60
        super();
1 efrain 61
        this.root = root;
62
        this.editor = editor;
63
        this.currentModal = currentModal;
64
        this.canShowFilePicker = canShowFilePicker;
65
        this.canShowDropZone = canShowDropZone;
66
        this.currentUrl = currentUrl;
67
        this.image = image;
1441 ariadna 68
        this.toggleMaxlengthFeedbackSuffix = false;
1 efrain 69
    }
70
 
71
    init = function() {
72
        this.currentModal.setTitle(getString('imagedetails', 'tiny_media'));
73
        this.imageTypeChecked();
74
        this.presentationChanged();
75
        this.storeImageDimensions(this.image);
76
        this.setImageDimensions();
77
        this.registerEventListeners();
78
    };
79
 
80
    /**
81
     * Loads and displays a preview image based on the provided URL, and handles image loading events.
82
     */
83
    loadInsertImage = async function() {
84
        const templateContext = {
85
            elementid: this.editor.id,
86
            showfilepicker: this.canShowFilePicker,
87
            showdropzone: this.canShowDropZone,
1441 ariadna 88
            bodyTemplate: Selectors.IMAGE.template.body.insertImageBody,
89
            footerTemplate: Selectors.IMAGE.template.footer.insertImageFooter,
90
            selector: Selectors.IMAGE.type,
1 efrain 91
        };
92
 
1441 ariadna 93
        Promise.all([body(templateContext, this.root), footer(templateContext, this.root)])
1 efrain 94
            .then(() => {
95
                const imageinsert = new ImageInsert(
96
                    this.root,
97
                    this.editor,
98
                    this.currentModal,
99
                    this.canShowFilePicker,
100
                    this.canShowDropZone,
101
                );
102
                imageinsert.init();
103
                return;
104
            })
105
            .catch(error => {
106
                window.console.log(error);
107
            });
108
    };
109
 
110
    storeImageDimensions(image) {
111
        // Store dimensions of the raw image, falling back to defaults for images without dimensions (e.g. SVG).
1441 ariadna 112
        this.mediaDimensions = {
1 efrain 113
            width: image.width || this.DEFAULTS.WIDTH,
114
            height: image.height || this.DEFAULTS.HEIGHT,
115
        };
116
 
117
        const getCurrentWidth = (element) => {
118
            if (element.value === '') {
1441 ariadna 119
                element.value = this.mediaDimensions.width;
1 efrain 120
            }
121
            return element.value;
122
        };
123
 
124
        const getCurrentHeight = (element) => {
125
            if (element.value === '') {
1441 ariadna 126
                element.value = this.mediaDimensions.height;
1 efrain 127
            }
128
            return element.value;
129
        };
130
 
131
        const widthInput = this.root.querySelector(Selectors.IMAGE.elements.width);
132
        const currentWidth = getCurrentWidth(widthInput);
133
 
134
        const heightInput = this.root.querySelector(Selectors.IMAGE.elements.height);
135
        const currentHeight = getCurrentHeight(heightInput);
136
 
137
        const preview = this.root.querySelector(Selectors.IMAGE.elements.preview);
138
        preview.setAttribute('src', image.src);
139
        preview.style.display = '';
140
 
141
        // Ensure the checkbox always in unchecked status when an image loads at first.
142
        const constrain = this.root.querySelector(Selectors.IMAGE.elements.constrain);
143
        if (isPercentageValue(currentWidth) && isPercentageValue(currentHeight)) {
144
            constrain.checked = currentWidth === currentHeight;
145
        } else if (image.width === 0 || image.height === 0) {
146
            // If we don't have both dimensions of the image, we can't auto-size it, so disable control.
147
            constrain.disabled = 'disabled';
148
        } else {
149
            // This is the same as comparing to 3 decimal places.
150
            const widthRatio = Math.round(100 * parseInt(currentWidth, 10) / image.width);
151
            const heightRatio = Math.round(100 * parseInt(currentHeight, 10) / image.height);
152
            constrain.checked = widthRatio === heightRatio;
153
        }
154
 
155
        /**
156
         * Sets the selected size option based on current width and height values.
157
         *
158
         * @param {number} currentWidth - The current width value.
159
         * @param {number} currentHeight - The current height value.
160
         */
161
        const setSelectedSize = (currentWidth, currentHeight) => {
1441 ariadna 162
            if (this.mediaDimensions.width === currentWidth &&
163
                this.mediaDimensions.height === currentHeight
1 efrain 164
            ) {
1441 ariadna 165
                this.currentWidth = this.mediaDimensions.width;
166
                this.currentHeight = this.mediaDimensions.height;
1 efrain 167
                this.sizeChecked('original');
168
            } else {
169
                this.currentWidth = currentWidth;
170
                this.currentHeight = currentHeight;
171
                this.sizeChecked('custom');
172
            }
173
        };
174
 
175
        setSelectedSize(Number(currentWidth), Number(currentHeight));
176
    }
177
 
178
    /**
179
     * Sets the dimensions of the image preview element based on user input and constraints.
180
     */
181
    setImageDimensions = () => {
182
        const imagePreviewBox = this.root.querySelector(Selectors.IMAGE.elements.previewBox);
183
        const image = this.root.querySelector(Selectors.IMAGE.elements.preview);
184
        const widthField = this.root.querySelector(Selectors.IMAGE.elements.width);
185
        const heightField = this.root.querySelector(Selectors.IMAGE.elements.height);
186
 
187
        const updateImageDimensions = () => {
188
            // Get the latest dimensions of the preview box for responsiveness.
189
            const boxWidth = imagePreviewBox.clientWidth;
190
            const boxHeight = imagePreviewBox.clientHeight;
191
            // Get the new width and height for the image.
192
            const dimensions = this.fitSquareIntoBox(widthField.value, heightField.value, boxWidth, boxHeight);
193
            image.style.width = `${dimensions.width}px`;
194
            image.style.height = `${dimensions.height}px`;
195
        };
196
        // If the client size is zero, then get the new dimensions once the modal is shown.
197
        if (imagePreviewBox.clientWidth === 0) {
198
            // Call the shown event.
199
            this.currentModal.getRoot().on(ModalEvents.shown, () => {
200
                updateImageDimensions();
201
            });
202
        } else {
203
            updateImageDimensions();
204
        }
205
    };
206
 
207
    /**
208
     * Handles changes in the image presentation checkbox and enables/disables the image alt text input accordingly.
209
     */
1441 ariadna 210
    async presentationChanged() {
1 efrain 211
        const presentation = this.root.querySelector(Selectors.IMAGE.elements.presentation);
212
        const alt = this.root.querySelector(Selectors.IMAGE.elements.alt);
213
        alt.disabled = presentation.checked;
214
 
215
        // Counting the image description characters.
1441 ariadna 216
        await this.handleKeyupCharacterCount();
1 efrain 217
    }
218
 
219
    /**
220
     * This function checks whether an image URL is local (within the same website's domain) or external (from an external source).
221
     * Depending on the result, it dynamically updates the visibility and content of HTML elements in a user interface.
222
     * If the image is local then we only show it's filename.
223
     * If the image is external then it will show full URL and it can be updated.
224
     */
225
    imageTypeChecked() {
226
        const regex = new RegExp(`${Config.wwwroot}`);
227
 
228
        // True if the URL is from external, otherwise false.
229
        const isExternalUrl = regex.test(this.currentUrl) === false;
230
 
231
        // Hide the URL input.
232
        hideElements(Selectors.IMAGE.elements.url, this.root);
233
 
234
        if (!isExternalUrl) {
235
            // Split the URL by '/' to get an array of segments.
236
            const segments = this.currentUrl.split('/');
237
            // Get the last segment, which should be the filename.
238
            const filename = segments.pop().split('?')[0];
239
            // Show the file name.
240
            this.setFilenameLabel(decodeURI(filename));
241
        } else {
242
 
243
            this.setFilenameLabel(decodeURI(this.currentUrl));
244
        }
245
    }
246
 
247
    /**
248
     * Set the string for the URL label element.
249
     *
250
     * @param {string} label - The label text to set.
251
     */
252
    setFilenameLabel(label) {
253
        const urlLabelEle = this.root.querySelector(Selectors.IMAGE.elements.fileNameLabel);
254
        if (urlLabelEle) {
255
            urlLabelEle.innerHTML = label;
256
            urlLabelEle.setAttribute("title", label);
257
        }
258
    }
259
 
260
    toggleAriaInvalid(selectors, predicate) {
261
        selectors.forEach((selector) => {
262
            const elements = this.root.querySelectorAll(selector);
263
            elements.forEach((element) => element.setAttribute('aria-invalid', predicate));
264
        });
265
    }
266
 
267
    hasErrorUrlField() {
268
        const urlError = this.currentUrl === '';
269
        if (urlError) {
270
            showElements(Selectors.IMAGE.elements.urlWarning, this.root);
271
        } else {
272
            hideElements(Selectors.IMAGE.elements.urlWarning, this.root);
273
        }
274
        this.toggleAriaInvalid([Selectors.IMAGE.elements.url], urlError);
275
 
276
        return urlError;
277
    }
278
 
279
    hasErrorAltField() {
280
        const alt = this.root.querySelector(Selectors.IMAGE.elements.alt).value;
281
        const presentation = this.root.querySelector(Selectors.IMAGE.elements.presentation).checked;
282
        const imageAltError = alt === '' && !presentation;
283
        if (imageAltError) {
284
            showElements(Selectors.IMAGE.elements.altWarning, this.root);
285
        } else {
1441 ariadna 286
            hideElements(Selectors.IMAGE.elements.altWarning, this.root);
1 efrain 287
        }
288
        this.toggleAriaInvalid([Selectors.IMAGE.elements.alt, Selectors.IMAGE.elements.presentation], imageAltError);
289
 
290
        return imageAltError;
291
    }
292
 
293
    updateWarning() {
294
        const urlError = this.hasErrorUrlField();
295
        const imageAltError = this.hasErrorAltField();
296
 
297
        return urlError || imageAltError;
298
    }
299
 
300
    getImageContext() {
301
        // Check if there are any accessibility issues.
302
        if (this.updateWarning()) {
303
            return null;
304
        }
305
 
306
        const classList = [];
307
        const constrain = this.root.querySelector(Selectors.IMAGE.elements.constrain).checked;
308
        const sizeOriginal = this.root.querySelector(Selectors.IMAGE.elements.sizeOriginal).checked;
309
        if (constrain || sizeOriginal) {
310
            // If the Auto size checkbox is checked or the Original size is checked, then apply the responsive class.
311
            classList.push(Selectors.IMAGE.styles.responsive);
312
        } else {
313
            // Otherwise, remove it.
314
            classList.pop(Selectors.IMAGE.styles.responsive);
315
        }
316
 
317
        return {
318
            url: this.currentUrl,
319
            alt: this.root.querySelector(Selectors.IMAGE.elements.alt).value,
320
            width: this.root.querySelector(Selectors.IMAGE.elements.width).value,
321
            height: this.root.querySelector(Selectors.IMAGE.elements.height).value,
322
            presentation: this.root.querySelector(Selectors.IMAGE.elements.presentation).checked,
323
            customStyle: this.root.querySelector(Selectors.IMAGE.elements.customStyle).value,
324
            classlist: classList.join(' '),
325
        };
326
    }
327
 
328
    setImage() {
329
        const pendingPromise = new Pending('tiny_media:setImage');
330
        const url = this.currentUrl;
331
        if (url === '') {
332
            return;
333
        }
334
 
335
        // Check if there are any accessibility issues.
336
        if (this.updateWarning()) {
337
            pendingPromise.resolve();
338
            return;
339
        }
340
 
341
        // Check for invalid width or height.
342
        const width = this.root.querySelector(Selectors.IMAGE.elements.width).value;
343
        if (!isPercentageValue(width) && isNaN(parseInt(width, 10))) {
344
            this.root.querySelector(Selectors.IMAGE.elements.width).focus();
345
            pendingPromise.resolve();
346
            return;
347
        }
348
 
349
        const height = this.root.querySelector(Selectors.IMAGE.elements.height).value;
350
        if (!isPercentageValue(height) && isNaN(parseInt(height, 10))) {
351
            this.root.querySelector(Selectors.IMAGE.elements.height).focus();
352
            pendingPromise.resolve();
353
            return;
354
        }
355
 
356
        Templates.render('tiny_media/image', this.getImageContext())
357
        .then((html) => {
358
            this.editor.insertContent(html);
359
            this.currentModal.destroy();
360
            pendingPromise.resolve();
361
 
362
            return html;
363
        })
364
        .catch(error => {
365
            window.console.log(error);
366
        });
367
    }
368
 
369
    /**
370
     * Deletes the image after confirming with the user and loads the insert image page.
371
     */
372
    deleteImage() {
373
        Notification.deleteCancelPromise(
374
            getString('deleteimage', 'tiny_media'),
375
            getString('deleteimagewarning', 'tiny_media'),
376
        ).then(() => {
377
            hideElements(Selectors.IMAGE.elements.altWarning, this.root);
378
            // Removing the image in the preview will bring the user to the insert page.
379
            this.loadInsertImage();
380
            return;
381
        }).catch(error => {
382
            window.console.log(error);
383
        });
384
    }
385
 
386
    registerEventListeners() {
387
        const submitAction = this.root.querySelector(Selectors.IMAGE.actions.submit);
388
        submitAction.addEventListener('click', (e) => {
389
            e.preventDefault();
390
            this.setImage();
391
        });
392
 
393
        const deleteImageEle = this.root.querySelector(Selectors.IMAGE.actions.deleteImage);
394
        deleteImageEle.addEventListener('click', () => {
395
            this.deleteImage();
396
        });
397
        deleteImageEle.addEventListener("keydown", (e) => {
398
            if (e.key === "Enter") {
399
                this.deleteImage();
400
            }
401
        });
402
 
1441 ariadna 403
        this.root.addEventListener('change', async(e) => {
1 efrain 404
            const presentationEle = e.target.closest(Selectors.IMAGE.elements.presentation);
405
            if (presentationEle) {
1441 ariadna 406
                await this.presentationChanged();
1 efrain 407
            }
408
 
409
            const constrainEle = e.target.closest(Selectors.IMAGE.elements.constrain);
410
            if (constrainEle) {
411
                this.autoAdjustSize();
412
            }
413
 
414
            const sizeOriginalEle = e.target.closest(Selectors.IMAGE.elements.sizeOriginal);
415
            if (sizeOriginalEle) {
416
                this.sizeChecked('original');
417
            }
418
 
419
            const sizeCustomEle = e.target.closest(Selectors.IMAGE.elements.sizeCustom);
420
            if (sizeCustomEle) {
421
                this.sizeChecked('custom');
422
            }
423
        });
424
 
1441 ariadna 425
        this.root.addEventListener('blur', async(e) => {
1 efrain 426
            if (e.target.nodeType === Node.ELEMENT_NODE) {
427
 
428
                const presentationEle = e.target.closest(Selectors.IMAGE.elements.presentation);
429
                if (presentationEle) {
1441 ariadna 430
                    await this.presentationChanged();
1 efrain 431
                }
432
            }
433
        }, true);
434
 
435
        // Character count.
1441 ariadna 436
        this.root.addEventListener('keyup', async(e) => {
1 efrain 437
            const altEle = e.target.closest(Selectors.IMAGE.elements.alt);
438
            if (altEle) {
1441 ariadna 439
                await this.handleKeyupCharacterCount();
1 efrain 440
            }
441
        });
442
 
443
        this.root.addEventListener('input', (e) => {
444
            const widthEle = e.target.closest(Selectors.IMAGE.elements.width);
445
            if (widthEle) {
446
                // Avoid empty value.
447
                widthEle.value = widthEle.value === "" ? 0 : Number(widthEle.value);
448
                this.autoAdjustSize();
449
            }
450
 
451
            const heightEle = e.target.closest(Selectors.IMAGE.elements.height);
452
            if (heightEle) {
453
                // Avoid empty value.
454
                heightEle.value = heightEle.value === "" ? 0 : Number(heightEle.value);
455
                this.autoAdjustSize(true);
456
            }
457
        });
458
    }
459
 
1441 ariadna 460
    async handleKeyupCharacterCount() {
461
        const altField = this.root.querySelector(Selectors.IMAGE.elements.alt);
462
        const alt = altField.value;
1 efrain 463
        const current = this.root.querySelector('#currentcount');
464
        current.innerHTML = alt.length;
1441 ariadna 465
        const maxLength = altField.getAttribute('maxlength');
466
        const maxLengthFeedback = document.getElementById('maxlength_feedback');
467
        if (alt.length >= maxLength) {
468
            maxLengthFeedback.textContent = await getString('maxlengthreached', 'core', maxLength);
469
 
470
            // Clever (or hacky?;p) way to ensure that the feedback message is announced to screen readers.
471
            const suffix = this.toggleMaxlengthFeedbackSuffix ? '' : '.';
472
            maxLengthFeedback.textContent += suffix;
473
            this.toggleMaxlengthFeedbackSuffix = !this.toggleMaxlengthFeedbackSuffix;
474
 
475
            // Clear the feedback message after 4 seconds. This is similar to the default timeout of toast messages
476
            // before disappearing from view. It is important to clear the message to prevent screen reader users from navigating
477
            // into this region and avoiding confusion.
478
            setTimeout(() => {
479
                maxLengthFeedback.textContent = '';
480
            }, 4000);
481
        }
1 efrain 482
    }
483
 
484
    /**
485
     * Calculates the dimensions to fit a square into a specified box while maintaining aspect ratio.
486
     *
487
     * @param {number} squareWidth - The width of the square.
488
     * @param {number} squareHeight - The height of the square.
489
     * @param {number} boxWidth - The width of the box.
490
     * @param {number} boxHeight - The height of the box.
491
     * @returns {Object} An object with the new width and height of the square to fit in the box.
492
     */
493
    fitSquareIntoBox = (squareWidth, squareHeight, boxWidth, boxHeight) => {
494
        if (squareWidth < boxWidth && squareHeight < boxHeight) {
495
          // If the square is smaller than the box, keep its dimensions.
496
          return {
497
            width: squareWidth,
498
            height: squareHeight,
499
          };
500
        }
501
        // Calculate the scaling factor based on the minimum scaling required to fit in the box.
502
        const widthScaleFactor = boxWidth / squareWidth;
503
        const heightScaleFactor = boxHeight / squareHeight;
504
        const minScaleFactor = Math.min(widthScaleFactor, heightScaleFactor);
505
        // Scale the square's dimensions based on the aspect ratio and the minimum scaling factor.
506
        const newWidth = squareWidth * minScaleFactor;
507
        const newHeight = squareHeight * minScaleFactor;
508
        return {
509
          width: newWidth,
510
          height: newHeight,
511
        };
512
    };
513
}