���ѧۧݧ�ӧ�� �ާ֧ߧ֧էا֧� - ���֧էѧܧ�ڧ��ӧѧ�� - /home3/cpr76684/public_html/image.tar
���ѧ٧ѧ�
styles.css 0000644 00000001776 15152177407 0006625 0 ustar 00 .atto_image_preview { width: 100%; height: 100%; margin-left: auto; margin-right: auto; } .atto_image_preview_box { max-height: 150px; margin-bottom: 1em; overflow: auto; } .editor_atto_content img { cursor: pointer; } .atto_image_button_text-top { vertical-align: text-top; margin: 0 0.5em; } .atto_image_button_middle { vertical-align: middle; margin: 0 0.5em; } .atto_image_button_text-bottom { vertical-align: text-bottom; margin: 0 0.5em; } .atto_image_button_text-top.img-fluid, .atto_image_button_middle.img-fluid, .atto_image_button_text-bottom.img-fluid { /* If the image is display: block then linking the image to URLs won't work. */ display: inline-block; max-width: calc(100% - 1em); } /*rtl:begin:ignore*/ .atto_image_button_left { float: left; margin: 0 0.5em 0 0; max-width: calc(100% - 1em); } .atto_image_button_right { float: right; margin: 0 0 0 0.5em; max-width: calc(100% - 1em); } /*rtl:end:ignore*/ tests/behat/image.feature 0000644 00000011175 15152177407 0011446 0 ustar 00 @editor @editor_atto @atto @atto_image @_file_upload Feature: Add images to Atto To write rich text - I need to add images. @javascript Scenario: Insert an image Given the following "blocks" exist: | blockname | contextlevel | reference | pagetypepattern | defaultregion | | private_files | System | 1 | my-index | side-post | And I log in as "admin" And I follow "Manage private files..." And I upload "lib/editor/atto/tests/fixtures/moodle-logo.png" file to "Files" filemanager And I click on "Save changes" "button" And I open my profile in edit mode When I set the field "Description" to "<p>Image test</p>" And I select the text in the "Description" Atto editor And I click on "Insert or edit image" "button" And I click on "Browse repositories..." "button" And I click on "Private files" "link" in the ".fp-repo-area" "css_element" And I click on "moodle-logo.png" "link" And I click on "Select this file" "button" And I set the field "Describe this image for someone who cannot see it" to "It's the Moodle" # Wait for the page to "settle". And I wait until the page is ready And the field "Width" matches value "204" And the field "Height" matches value "61" And I set the field "Auto size" to "1" And I wait until the page is ready And I set the field "Width" to "2040" # Trigger blur on the width field. And I take focus off "Width" "field" And the field "Height" matches value "610" And I set the field "Height" to "61" # Trigger blur on the height field. And I take focus off "Height" "field" And the field "Width" matches value "204" And I set the field "Auto size" to "0" And I wait until the page is ready And I set the field "Width" to "123" And I set the field "Height" to "456" # Trigger blur on the height field. And I take focus off "Height" "field" And the field "Width" matches value "123" And the field "Height" matches value "456" And I change window size to "large" And I press "Save image" And I press "Update profile" And I click on "Edit profile" "link" in the "region-main" "region" And I select the text in the "Description" Atto editor And I click on "Insert or edit image" "button" Then the field "Describe this image for someone who cannot see it" matches value "It's the Moodle" And the field "Width" matches value "123" And the field "Height" matches value "456" @javascript Scenario: Manually inserting an image Given I log in as "admin" And I open my profile in edit mode And I set the field "Description" to "<p>Image: <img src='/nothing/here'>.</p>" And I select the text in the "Description" Atto editor When I click on "Insert or edit image" "button" Then the field "Enter URL" matches value "/nothing/here" And I set the field "Describe this image for someone who cannot see it" to "Something" And I set the field "Width" to "1" And I set the field "Height" to "1" And I press "Save image" And I set the field "Description" to "<p>Image: <img src='/nothing/again' width='123' height='456' alt='Awesome!'>.</p>" And I press "Update profile" And I click on "Edit profile" "link" in the "region-main" "region" And I select the text in the "Description" Atto editor And I click on "Insert or edit image" "button" And the field "Enter URL" matches value "/nothing/again" And the field "Width" matches value "123" And the field "Height" matches value "456" And the field "Describe this image" matches value "Awesome!" @javascript Scenario: Error handling when inserting an image manually Given I log in as "admin" And I open my profile in edit mode And I set the field "Description" to "<p>Image: <img src='/nothing/here'>.</p>" And I select the text in the "Description" Atto editor When I click on "Insert or edit image" "button" Then the field "Enter URL" matches value "/nothing/here" And I set the field "Describe this image for someone who cannot see it" to "" And I take focus off "Describe this image for someone who cannot see it" "field" And I should see "An image must have a description, unless it is marked as decorative only." And I set the field "Describe this image for someone who cannot see it" to "Something" And I set the field "Enter URL" to "" And I press "Save image" And I should see "An image must have a URL." And I set the field "Enter URL" to "/nothing/here" And I set the field "Width" to "1" And I set the field "Height" to "1" And I press "Save image" And I press "Update profile" lib.php 0000644 00000003122 15152177407 0006032 0 ustar 00 <?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Atto text editor integration version file. * * @package atto_image * @copyright 2013 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); /** * Initialise the strings required for js */ function atto_image_strings_for_js() { global $PAGE; $strings = array( 'alignment', 'alignment_bottom', 'alignment_left', 'alignment_middle', 'alignment_right', 'alignment_top', 'browserepositories', 'constrain', 'saveimage', 'imageproperties', 'customstyle', 'enterurl', 'enteralt', 'height', 'presentation', 'presentationoraltrequired', 'imageurlrequired', 'size', 'width', 'uploading', ); $PAGE->requires->strings_for_js($strings, 'atto_image'); } version.php 0000644 00000002244 15152177407 0006755 0 ustar 00 <?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Atto text editor integration version file. * * @package atto_image * @copyright 2013 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->version = 2022112800; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2022111800; // Requires this Moodle version. $plugin->component = 'atto_image'; // Full name of the plugin (used for diagnostics). classes/privacy/provider.php 0000644 00000002773 15152177407 0012243 0 ustar 00 <?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Privacy Subsystem implementation for block_activity_modules. * * @package atto_image * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace atto_image\privacy; defined('MOODLE_INTERNAL') || die(); /** * Privacy Subsystem for atto_image implementing null_provider. * * @copyright 2018 Andrew Nicols <andrew@nicols.co.uk> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider implements \core_privacy\local\metadata\null_provider { /** * Get the language string identifier with the component's language * file to explain why this plugin stores no data. * * @return string */ public static function get_reason() : string { return 'privacy:metadata'; } } yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js 0000644 00000117471 15152177407 0021715 0 ustar 00 YUI.add('moodle-atto_image-button', function (Y, NAME) { // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /* * @package atto_image * @copyright 2013 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * @module moodle-atto_image_alignment-button */ /** * Atto image selection tool. * * @namespace M.atto_image * @class Button * @extends M.editor_atto.EditorPlugin */ var CSS = { RESPONSIVE: 'img-fluid', INPUTALIGNMENT: 'atto_image_alignment', INPUTALT: 'atto_image_altentry', INPUTHEIGHT: 'atto_image_heightentry', INPUTSUBMIT: 'atto_image_urlentrysubmit', INPUTURL: 'atto_image_urlentry', INPUTSIZE: 'atto_image_size', INPUTWIDTH: 'atto_image_widthentry', IMAGEALTWARNING: 'atto_image_altwarning', IMAGEURLWARNING: 'atto_image_urlwarning', IMAGEBROWSER: 'openimagebrowser', IMAGEPRESENTATION: 'atto_image_presentation', INPUTCONSTRAIN: 'atto_image_constrain', INPUTCUSTOMSTYLE: 'atto_image_customstyle', IMAGEPREVIEW: 'atto_image_preview', IMAGEPREVIEWBOX: 'atto_image_preview_box', ALIGNSETTINGS: 'atto_image_button' }, FORMNAMES = { URL: 'urlentry', ALT: 'altentry' }, SELECTORS = { INPUTURL: '.' + CSS.INPUTURL }, ALIGNMENTS = [ // Vertical alignment. { name: 'verticalAlign', str: 'alignment_top', value: 'text-top', margin: '0 0.5em' }, { name: 'verticalAlign', str: 'alignment_middle', value: 'middle', margin: '0 0.5em' }, { name: 'verticalAlign', str: 'alignment_bottom', value: 'text-bottom', margin: '0 0.5em', isDefault: true }, // Floats. { name: 'float', str: 'alignment_left', value: 'left', margin: '0 0.5em 0 0' }, { name: 'float', str: 'alignment_right', value: 'right', margin: '0 0 0 0.5em' } ], DEFAULTS = { WIDTH: 160, HEIGHT: 160, }, REGEX = { ISPERCENT: /\d+%/ }, COMPONENTNAME = 'atto_image', TEMPLATE = '' + '<form class="atto_form">' + // Add the repository browser button. '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.IMAGEURLWARNING}}">' + '<label for="{{elementid}}_{{CSS.INPUTURL}}">' + '{{get_string "imageurlrequired" component}}' + '</label>' + '</div>' + '{{#if showFilepicker}}' + '<div class="mb-1">' + '<label for="{{elementid}}_{{CSS.INPUTURL}}">{{get_string "enterurl" component}}</label>' + '<div class="input-group input-append w-100">' + '<input name="{{FORMNAMES.URL}}" class="form-control {{CSS.INPUTURL}}" type="url" ' + 'id="{{elementid}}_{{CSS.INPUTURL}}" size="32"/>' + '<span class="input-group-append">' + '<button class="btn btn-secondary {{CSS.IMAGEBROWSER}}" type="button">' + '{{get_string "browserepositories" component}}</button>' + '</span>' + '</div>' + '</div>' + '{{else}}' + '<div class="mb-1">' + '<label for="{{elementid}}_{{CSS.INPUTURL}}">{{get_string "enterurl" component}}</label>' + '<input name="{{FORMNAMES.URL}}" class="form-control fullwidth {{CSS.INPUTURL}}" type="url" ' + 'id="{{elementid}}_{{CSS.INPUTURL}}" size="32"/>' + '</div>' + '{{/if}}' + '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.IMAGEALTWARNING}}">' + '<label for="{{elementid}}_{{CSS.INPUTALT}}">' + '{{get_string "presentationoraltrequired" component}}' + '</label>' + '</div>' + // Add the Alt box. '<div class="mb-1">' + '<label for="{{elementid}}_{{CSS.INPUTALT}}">{{get_string "enteralt" component}}</label>' + '<textarea class="form-control fullwidth {{CSS.INPUTALT}}" ' + 'id="{{elementid}}_{{CSS.INPUTALT}}" name="{{FORMNAMES.ALT}}" maxlength="125"></textarea>' + // Add the character count. '<div id="the-count" class="d-flex justify-content-end small">' + '<span id="currentcount">0</span>' + '<span id="maximumcount"> / 125</span>' + '</div>' + // Add the presentation select box. '<div class="form-check">' + '<input type="checkbox" class="form-check-input {{CSS.IMAGEPRESENTATION}}" ' + 'id="{{elementid}}_{{CSS.IMAGEPRESENTATION}}"/>' + '<label class="form-check-label" for="{{elementid}}_{{CSS.IMAGEPRESENTATION}}">' + '{{get_string "presentation" component}}' + '</label>' + '</div>' + '</div>' + // Add the size entry boxes. '<div class="mb-1">' + '<label class="" for="{{elementid}}_{{CSS.INPUTSIZE}}">{{get_string "size" component}}</label>' + '<div id="{{elementid}}_{{CSS.INPUTSIZE}}" class="form-inline {{CSS.INPUTSIZE}}">' + '<label class="accesshide" for="{{elementid}}_{{CSS.INPUTWIDTH}}">{{get_string "width" component}}</label>' + '<input type="text" class="form-control mr-1 input-mini {{CSS.INPUTWIDTH}}" ' + 'id="{{elementid}}_{{CSS.INPUTWIDTH}}" size="4"/> x' + // Add the height entry box. '<label class="accesshide" for="{{elementid}}_{{CSS.INPUTHEIGHT}}">{{get_string "height" component}}</label>' + '<input type="text" class="form-control ml-1 input-mini {{CSS.INPUTHEIGHT}}" ' + 'id="{{elementid}}_{{CSS.INPUTHEIGHT}}" size="4"/>' + // Add the constrain checkbox. '<div class="form-check ml-2">' + '<input type="checkbox" class="form-check-input {{CSS.INPUTCONSTRAIN}}" ' + 'id="{{elementid}}_{{CSS.INPUTCONSTRAIN}}"/>' + '<label class="form-check-label" for="{{elementid}}_{{CSS.INPUTCONSTRAIN}}">' + '{{get_string "constrain" component}}</label>' + '</div>' + '</div>' + '</div>' + // Add the alignment selector. '<div class="form-inline mb-1">' + '<label class="for="{{elementid}}_{{CSS.INPUTALIGNMENT}}">{{get_string "alignment" component}}</label>' + '<select class="custom-select {{CSS.INPUTALIGNMENT}}" id="{{elementid}}_{{CSS.INPUTALIGNMENT}}">' + '{{#each alignments}}' + '<option value="{{value}}">{{get_string str ../component}}</option>' + '{{/each}}' + '</select>' + '</div>' + // Hidden input to store custom styles. '<input type="hidden" class="{{CSS.INPUTCUSTOMSTYLE}}"/>' + '<br/>' + // Add the image preview. '<div class="mdl-align">' + '<div class="{{CSS.IMAGEPREVIEWBOX}}">' + '<img class="{{CSS.IMAGEPREVIEW}}" alt="" style="display: none;"/>' + '</div>' + // Add the submit button and close the form. '<button class="btn btn-secondary {{CSS.INPUTSUBMIT}}" type="submit">' + '' + '{{get_string "saveimage" component}}</button>' + '</div>' + '</form>', IMAGETEMPLATE = '' + '<img src="{{url}}" alt="{{alt}}" ' + '{{#if width}}width="{{width}}" {{/if}}' + '{{#if height}}height="{{height}}" {{/if}}' + '{{#if presentation}}role="presentation" {{/if}}' + '{{#if customstyle}}style="{{customstyle}}" {{/if}}' + '{{#if classlist}}class="{{classlist}}" {{/if}}' + '{{#if id}}id="{{id}}" {{/if}}' + '/>'; Y.namespace('M.atto_image').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], { /** * A reference to the current selection at the time that the dialogue * was opened. * * @property _currentSelection * @type Range * @private */ _currentSelection: null, /** * The most recently selected image. * * @param _selectedImage * @type Node * @private */ _selectedImage: null, /** * A reference to the currently open form. * * @param _form * @type Node * @private */ _form: null, /** * The dimensions of the raw image before we manipulate it. * * @param _rawImageDimensions * @type Object * @private */ _rawImageDimensions: null, initializer: function() { this.addButton({ icon: 'e/insert_edit_image', callback: this._displayDialogue, tags: 'img', tagMatchRequiresAll: false }); this.editor.delegate('dblclick', this._displayDialogue, 'img', this); this.editor.delegate('click', this._handleClick, 'img', this); this.editor.on('paste', this._handlePaste, this); this.editor.on('drop', this._handleDragDrop, this); // ...e.preventDefault needed to stop the default event from clobbering the desired behaviour in some browsers. this.editor.on('dragover', function(e) { e.preventDefault(); }, this); this.editor.on('dragenter', function(e) { e.preventDefault(); }, this); }, /** * Handle a drag and drop event with an image. * * @method _handleDragDrop * @param {EventFacade} e * @private */ _handleDragDrop: function(e) { if (!e._event || !e._event.dataTransfer) { // Drop not fully supported in this browser. return; } this._handlePasteOrDropHelper(e, e._event.dataTransfer); }, /** * Handles paste events where - if the thing being pasted is an image. * * @method _handlePaste * @param {EventFacade} e * @return {boolean} false if we handled the event, else true. * @private */ _handlePaste: function(e) { if (!e._event || !e._event.clipboardData) { // Paste not fully supported in this browser. return true; } return this._handlePasteOrDropHelper(e, e._event.clipboardData); }, /** * Handle a drag and drop event with an image. * * @method _handleDragDrop * @param {EventFacade} e * @param {DataTransfer} dataTransfer * @return {boolean} false if we handled the event, else true. * @private */ _handlePasteOrDropHelper: function(e, dataTransfer) { var items = dataTransfer.items, didUpload = false; for (var i = 0; i < items.length; i++) { var item = items[i]; if (item.kind !== 'file') { continue; } if (!this._isImage(item.type)) { continue; } this._uploadImage(item.getAsFile()); didUpload = true; } if (didUpload) { e.preventDefault(); } }, /** * Is this file an image? * * @method _isImage * @param {string} mimeType the file's mime type. * @return {boolean} true if the file has an image mimeType. * @private */ _isImage: function(mimeType) { return mimeType.indexOf('image/') === 0; }, /** * Used by _handleDragDrop and _handlePaste to upload an image and insert it. * * @method _uploadImage * @param {File} fileToSave * @private */ _uploadImage: function(fileToSave) { var self = this, host = this.get('host'), template = Y.Handlebars.compile(IMAGETEMPLATE); host.saveSelection(); // Trigger form upload start events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadStarted(self.editor.get('id')); }); var options = host.get('filepickeroptions').image, savepath = (options.savepath === undefined) ? '/' : options.savepath, formData = new FormData(), timestamp = 0, uploadid = "", xhr = new XMLHttpRequest(), imagehtml = "", keys = Object.keys(options.repositories); formData.append('repo_upload_file', fileToSave); formData.append('itemid', options.itemid); // List of repositories is an object rather than an array. This makes iteration more awkward. for (var i = 0; i < keys.length; i++) { if (options.repositories[keys[i]].type === 'upload') { formData.append('repo_id', options.repositories[keys[i]].id); break; } } formData.append('env', options.env); formData.append('sesskey', M.cfg.sesskey); formData.append('client_id', options.client_id); formData.append('savepath', savepath); formData.append('ctx_id', options.context.id); // Insert spinner as a placeholder. timestamp = new Date().getTime(); uploadid = 'moodleimage_' + Math.round(Math.random() * 100000) + '-' + timestamp; host.focus(); host.restoreSelection(); imagehtml = template({ url: M.util.image_url("i/loading_small", 'moodle'), alt: M.util.get_string('uploading', COMPONENTNAME), id: uploadid }); host.insertContentAtFocusPoint(imagehtml); self.markUpdated(); // Kick off a XMLHttpRequest. xhr.onreadystatechange = function() { var placeholder = self.editor.one('#' + uploadid), result, file, newhtml, newimage; if (xhr.readyState === 4) { if (xhr.status === 200) { result = JSON.parse(xhr.responseText); if (result) { if (result.error) { if (placeholder) { placeholder.remove(true); } // Trigger form upload complete events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadCompleted(self.editor.get('id')); }); throw new M.core.ajaxException(result); } file = result; if (result.event && result.event === 'fileexists') { // A file with this name is already in use here - rename to avoid conflict. // Chances are, it's a different image (stored in a different folder on the user's computer). // If the user wants to reuse an existing image, they can copy/paste it within the editor. file = result.newfile; } // Replace placeholder with actual image. newhtml = template({ url: file.url, presentation: true, classlist: CSS.RESPONSIVE }); newimage = Y.Node.create(newhtml); if (placeholder) { placeholder.replace(newimage); } else { self.editor.appendChild(newimage); } self.markUpdated(); } } else { Y.use('moodle-core-notification-alert', function() { // Trigger form upload complete events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadCompleted(self.editor.get('id')); }); new M.core.alert({message: M.util.get_string('servererror', 'moodle')}); }); if (placeholder) { placeholder.remove(true); } } // Trigger form upload complete events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadCompleted(self.editor.get('id')); }); } }; xhr.open("POST", M.cfg.wwwroot + '/repository/repository_ajax.php?action=upload', true); xhr.send(formData); }, /** * Handle a click on an image. * * @method _handleClick * @param {EventFacade} e * @private */ _handleClick: function(e) { var image = e.target; var selection = this.get('host').getSelectionFromNode(image); if (this.get('host').getSelection() !== selection) { this.get('host').setSelection(selection); } }, /** * Display the image editing tool. * * @method _displayDialogue * @private */ _displayDialogue: function() { // Store the current selection. this._currentSelection = this.get('host').getSelection(); if (this._currentSelection === false) { return; } // Reset the image dimensions. this._rawImageDimensions = null; var dialogue = this.getDialogue({ headerContent: M.util.get_string('imageproperties', COMPONENTNAME), width: 'auto', focusAfterHide: true, focusOnShowSelector: SELECTORS.INPUTURL }); // Set a maximum width for the dialog. This will prevent the dialog width to extend beyond the screen width // in cases when the uploaded image has larger width. dialogue.get('boundingBox').setStyle('maxWidth', '90%'); // Set the dialogue content, and then show the dialogue. dialogue.set('bodyContent', this._getDialogueContent()) .show(); }, /** * Set the inputs for width and height if they are not set, and calculate * if the constrain checkbox should be checked or not. * * @method _loadPreviewImage * @param {String} url * @private */ _loadPreviewImage: function(url) { var image = new Image(); var self = this; image.onerror = function() { var preview = self._form.one('.' + CSS.IMAGEPREVIEW); preview.setStyles({ 'display': 'none' }); // Centre the dialogue when clearing the image preview. self.getDialogue().centerDialogue(); }; image.onload = function() { var input, currentwidth, currentheight, widthRatio, heightRatio; // Store dimensions of the raw image, falling back to defaults for images without dimensions (e.g. SVG). self._rawImageDimensions = { width: this.width || DEFAULTS.WIDTH, height: this.height || DEFAULTS.HEIGHT, }; input = self._form.one('.' + CSS.INPUTWIDTH); currentwidth = input.get('value'); if (currentwidth === '') { input.set('value', self._rawImageDimensions.width); currentwidth = "" + self._rawImageDimensions.width; } input = self._form.one('.' + CSS.INPUTHEIGHT); currentheight = input.get('value'); if (currentheight === '') { input.set('value', self._rawImageDimensions.height); currentheight = "" + self._rawImageDimensions.height; } input = self._form.one('.' + CSS.IMAGEPREVIEW); input.setAttribute('src', this.src); input.setStyles({ 'display': 'inline' }); input = self._form.one('.' + CSS.INPUTCONSTRAIN); if (currentwidth.match(REGEX.ISPERCENT) && currentheight.match(REGEX.ISPERCENT)) { input.set('checked', currentwidth === currentheight); } else if (this.width === 0 || this.height === 0) { // If we don't have both dimensions of the image, we can't auto-size it, so disable control. input.set('disabled', 'disabled'); } else { // This is the same as comparing to 3 decimal places. widthRatio = Math.round(1000 * parseInt(currentwidth, 10) / this.width); heightRatio = Math.round(1000 * parseInt(currentheight, 10) / this.height); input.set('checked', widthRatio === heightRatio); } // Apply the image sizing. self._autoAdjustSize(self); // Centre the dialogue once the preview image has loaded. self.getDialogue().centerDialogue(); }; image.src = url; }, /** * Return the dialogue content for the tool, attaching any required * events. * * @method _getDialogueContent * @return {Node} The content to place in the dialogue. * @private */ _getDialogueContent: function() { var template = Y.Handlebars.compile(TEMPLATE), canShowFilepicker = this.get('host').canShowFilepicker('image'), content = Y.Node.create(template({ elementid: this.get('host').get('elementid'), CSS: CSS, FORMNAMES: FORMNAMES, component: COMPONENTNAME, showFilepicker: canShowFilepicker, alignments: ALIGNMENTS })); this._form = content; // Configure the view of the current image. this._applyImageProperties(this._form); this._form.one('.' + CSS.INPUTURL).on('blur', this._urlChanged, this); this._form.one('.' + CSS.INPUTURL).on('change', this._hasErrorUrlField, this); this._form.one('.' + CSS.IMAGEPRESENTATION).on('change', this._hasErrorAltField, this); this._form.one('.' + CSS.INPUTALT).on('blur', this._hasErrorAltField, this); this._form.one('.' + CSS.INPUTWIDTH).on('blur', this._autoAdjustSize, this); this._form.one('.' + CSS.INPUTHEIGHT).on('blur', this._autoAdjustSize, this, true); this._form.one('.' + CSS.INPUTCONSTRAIN).on('change', function(event) { if (event.target.get('checked')) { this._autoAdjustSize(event); } }, this); this._form.one('.' + CSS.INPUTSUBMIT).on('click', this._setImage, this); if (canShowFilepicker) { this._form.one('.' + CSS.IMAGEBROWSER).on('click', function() { this.get('host').showFilepicker('image', this._filepickerCallback, this); }, this); } // Character count. this._form.one('.' + CSS.INPUTALT).on('keyup', this._handleKeyup, this); return content; }, _autoAdjustSize: function(e, forceHeight) { forceHeight = forceHeight || false; var keyField = this._form.one('.' + CSS.INPUTWIDTH), keyFieldType = 'width', subField = this._form.one('.' + CSS.INPUTHEIGHT), subFieldType = 'height', constrainField = this._form.one('.' + CSS.INPUTCONSTRAIN), keyFieldValue = keyField.get('value'), subFieldValue = subField.get('value'), imagePreview = this._form.one('.' + CSS.IMAGEPREVIEW), rawPercentage, rawSize; // If we do not know the image size, do not do anything. if (!this._rawImageDimensions) { return; } // Set the width back to default if it is empty. if (keyFieldValue === '') { keyFieldValue = this._rawImageDimensions[keyFieldType]; keyField.set('value', keyFieldValue); keyFieldValue = keyField.get('value'); } // Clear the existing preview sizes. imagePreview.setStyles({ width: null, height: null }); // Now update with the new values. if (!constrainField.get('checked')) { // We are not keeping the image proportion - update the preview accordingly. // Width. if (keyFieldValue.match(REGEX.ISPERCENT)) { rawPercentage = parseInt(keyFieldValue, 10); rawSize = this._rawImageDimensions.width / 100 * rawPercentage; imagePreview.setStyle('width', rawSize + 'px'); } else { imagePreview.setStyle('width', keyFieldValue + 'px'); } // Height. if (subFieldValue.match(REGEX.ISPERCENT)) { rawPercentage = parseInt(subFieldValue, 10); rawSize = this._rawImageDimensions.height / 100 * rawPercentage; imagePreview.setStyle('height', rawSize + 'px'); } else { imagePreview.setStyle('height', subFieldValue + 'px'); } } else { // We are keeping the image in proportion. if (forceHeight) { // By default we update based on width. Swap the key and sub fields around to achieve a height-based scale. var _temporaryValue; _temporaryValue = keyField; keyField = subField; subField = _temporaryValue; _temporaryValue = keyFieldType; keyFieldType = subFieldType; subFieldType = _temporaryValue; _temporaryValue = keyFieldValue; keyFieldValue = subFieldValue; subFieldValue = _temporaryValue; } if (keyFieldValue.match(REGEX.ISPERCENT)) { // This is a percentage based change. Copy it verbatim. subFieldValue = keyFieldValue; // Set the width to the calculated pixel width. rawPercentage = parseInt(keyFieldValue, 10); rawSize = this._rawImageDimensions.width / 100 * rawPercentage; // And apply the width/height to the container. imagePreview.setStyle('width', rawSize); rawSize = this._rawImageDimensions.height / 100 * rawPercentage; imagePreview.setStyle('height', rawSize); } else { // Calculate the scaled subFieldValue from the keyFieldValue. subFieldValue = Math.round((keyFieldValue / this._rawImageDimensions[keyFieldType]) * this._rawImageDimensions[subFieldType]); if (forceHeight) { imagePreview.setStyles({ 'width': subFieldValue, 'height': keyFieldValue }); } else { imagePreview.setStyles({ 'width': keyFieldValue, 'height': subFieldValue }); } } // Update the subField's value within the form to reflect the changes. subField.set('value', subFieldValue); } }, /** * Update the dialogue after an image was selected in the File Picker. * * @method _filepickerCallback * @param {object} params The parameters provided by the filepicker * containing information about the image. * @private */ _filepickerCallback: function(params) { if (params.url !== '') { var input = this._form.one('.' + CSS.INPUTURL); input.set('value', params.url); // Auto set the width and height. this._form.one('.' + CSS.INPUTWIDTH).set('value', ''); this._form.one('.' + CSS.INPUTHEIGHT).set('value', ''); // Load the preview image. this._loadPreviewImage(params.url); } }, /** * Applies properties of an existing image to the image dialogue for editing. * * @method _applyImageProperties * @param {Node} form * @private */ _applyImageProperties: function(form) { var properties = this._getSelectedImageProperties(), img = form.one('.' + CSS.IMAGEPREVIEW); if (properties === false) { img.setStyle('display', 'none'); // Set the default alignment. ALIGNMENTS.some(function(alignment) { if (alignment.isDefault) { form.one('.' + CSS.INPUTALIGNMENT).set('value', alignment.value); return true; } return false; }, this); return; } if (properties.align) { form.one('.' + CSS.INPUTALIGNMENT).set('value', properties.align); } if (properties.customstyle) { form.one('.' + CSS.INPUTCUSTOMSTYLE).set('value', properties.customstyle); } if (properties.width) { form.one('.' + CSS.INPUTWIDTH).set('value', properties.width); } if (properties.height) { form.one('.' + CSS.INPUTHEIGHT).set('value', properties.height); } if (properties.alt) { form.one('.' + CSS.INPUTALT).set('value', properties.alt); } if (properties.src) { form.one('.' + CSS.INPUTURL).set('value', properties.src); this._loadPreviewImage(properties.src); } if (properties.presentation) { form.one('.' + CSS.IMAGEPRESENTATION).set('checked', 'checked'); } // Update the image preview based on the form properties. this._autoAdjustSize(); }, /** * Gets the properties of the currently selected image. * * The first image only if multiple images are selected. * * @method _getSelectedImageProperties * @return {object} * @private */ _getSelectedImageProperties: function() { var properties = { src: null, alt: null, width: null, height: null, align: '', presentation: false }, // Get the current selection. images = this.get('host').getSelectedNodes(), width, height, style, image; if (images) { images = images.filter('img'); } if (images && images.size()) { image = this._removeLegacyAlignment(images.item(0)); this._selectedImage = image; style = image.getAttribute('style'); properties.customstyle = style; width = image.getAttribute('width'); if (!width.match(REGEX.ISPERCENT)) { width = parseInt(width, 10); } height = image.getAttribute('height'); if (!height.match(REGEX.ISPERCENT)) { height = parseInt(height, 10); } if (width !== 0) { properties.width = width; } if (height !== 0) { properties.height = height; } this._getAlignmentPropeties(image, properties); properties.src = image.getAttribute('src'); properties.alt = image.getAttribute('alt') || ''; properties.presentation = (image.get('role') === 'presentation'); return properties; } // No image selected - clean up. this._selectedImage = null; return false; }, /** * Sets the alignment of a properties object. * * @method _getAlignmentPropeties * @param {Node} image The image that the alignment properties should be found for * @param {Object} properties The properties object that is created in _getSelectedImageProperties() * @private */ _getAlignmentPropeties: function(image, properties) { var complete = false, defaultAlignment; // Check for an alignment value. complete = ALIGNMENTS.some(function(alignment) { var classname = this._getAlignmentClass(alignment.value); if (image.hasClass(classname)) { properties.align = alignment.value; Y.log('Found alignment ' + alignment.value, 'debug', 'atto_image-button'); return true; } if (alignment.isDefault) { defaultAlignment = alignment.value; } return false; }, this); if (!complete && defaultAlignment) { properties.align = defaultAlignment; } }, /** * Update the form when the URL was changed. This includes updating the * height, width, and image preview. * * @method _urlChanged * @private */ _urlChanged: function() { var input = this._form.one('.' + CSS.INPUTURL); if (input.get('value') !== '') { // Load the preview image. this._loadPreviewImage(input.get('value')); } }, /** * Update the image in the contenteditable. * * @method _setImage * @param {EventFacade} e * @private */ _setImage: function(e) { var form = this._form, url = form.one('.' + CSS.INPUTURL).get('value'), alt = form.one('.' + CSS.INPUTALT).get('value'), width = form.one('.' + CSS.INPUTWIDTH).get('value'), height = form.one('.' + CSS.INPUTHEIGHT).get('value'), alignment = this._getAlignmentClass(form.one('.' + CSS.INPUTALIGNMENT).get('value')), presentation = form.one('.' + CSS.IMAGEPRESENTATION).get('checked'), constrain = form.one('.' + CSS.INPUTCONSTRAIN).get('checked'), imagehtml, customstyle = form.one('.' + CSS.INPUTCUSTOMSTYLE).get('value'), classlist = [], host = this.get('host'); e.preventDefault(); // Check if there are any accessibility issues. if (this._updateWarning()) { return; } // Focus on the editor in preparation for inserting the image. host.focus(); if (url !== '') { if (this._selectedImage) { host.setSelection(host.getSelectionFromNode(this._selectedImage)); } else { host.setSelection(this._currentSelection); } if (constrain) { classlist.push(CSS.RESPONSIVE); } // Add the alignment class for the image. classlist.push(alignment); if (!width.match(REGEX.ISPERCENT) && isNaN(parseInt(width, 10))) { form.one('.' + CSS.INPUTWIDTH).focus(); return; } if (!height.match(REGEX.ISPERCENT) && isNaN(parseInt(height, 10))) { form.one('.' + CSS.INPUTHEIGHT).focus(); return; } var template = Y.Handlebars.compile(IMAGETEMPLATE); imagehtml = template({ url: url, alt: alt, width: width, height: height, presentation: presentation, customstyle: customstyle, classlist: classlist.join(' ') }); this.get('host').insertContentAtFocusPoint(imagehtml); this.markUpdated(); } this.getDialogue({ focusAfterHide: null }).hide(); }, /** * Removes any legacy styles added by previous versions of the atto image button. * * @method _removeLegacyAlignment * @param {Y.Node} imageNode * @return {Y.Node} * @private */ _removeLegacyAlignment: function(imageNode) { if (!imageNode.getStyle('margin')) { // There is no margin therefore this cannot match any known alignments. return imageNode; } ALIGNMENTS.some(function(alignment) { if (imageNode.getStyle(alignment.name) !== alignment.value) { // The name/value do not match. Skip. return false; } var normalisedNode = Y.Node.create('<div>'); normalisedNode.setStyle('margin', alignment.margin); if (imageNode.getStyle('margin') !== normalisedNode.getStyle('margin')) { // The margin does not match. return false; } Y.log('Legacy alignment found and removed.', 'info', 'atto_image-button'); imageNode.addClass(this._getAlignmentClass(alignment.value)); imageNode.setStyle(alignment.name, null); imageNode.setStyle('margin', null); return true; }, this); return imageNode; }, _getAlignmentClass: function(alignment) { return CSS.ALIGNSETTINGS + '_' + alignment; }, _toggleVisibility: function(selector, predicate) { var form = this._form; var element = form.all(selector); element.setStyle('display', predicate ? 'block' : 'none'); }, _toggleAriaInvalid: function(selectors, predicate) { var form = this._form; selectors.forEach(function(selector) { var element = form.all(selector); element.setAttribute('aria-invalid', predicate); }); }, _hasErrorUrlField: function() { var form = this._form; var url = form.one('.' + CSS.INPUTURL).get('value'); var urlerror = url === ''; this._toggleVisibility('.' + CSS.IMAGEURLWARNING, urlerror); this._toggleAriaInvalid(['.' + CSS.INPUTURL], urlerror); return urlerror; }, _hasErrorAltField: function() { var form = this._form; var alt = form.one('.' + CSS.INPUTALT).get('value'); var presentation = form.one('.' + CSS.IMAGEPRESENTATION).get('checked'); var imagealterror = alt === '' && !presentation; this._toggleVisibility('.' + CSS.IMAGEALTWARNING, imagealterror); this._toggleAriaInvalid(['.' + CSS.INPUTALT, '.' + CSS.IMAGEPRESENTATION], imagealterror); return imagealterror; }, /** * Update the alt text warning live. * * @method _updateWarning * @return {boolean} whether a warning should be displayed. * @private */ _updateWarning: function() { var urlerror = this._hasErrorUrlField(); var imagealterror = this._hasErrorAltField(); var haserrors = urlerror || imagealterror; this.getDialogue().centerDialogue(); return haserrors; }, /** * Handle the keyup to update the character count. */ _handleKeyup: function() { var form = this._form, alt = form.one('.' + CSS.INPUTALT).get('value'), characterCount = alt.length, current = form.one('#currentcount'); current.setHTML(characterCount); } }); }, '@VERSION@', {"requires": ["moodle-editor_atto-plugin"]}); yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js 0000644 00000036617 15152177407 0021414 0 ustar 00 YUI.add("moodle-atto_image-button",function(m,e){var c={RESPONSIVE:"img-fluid",INPUTALIGNMENT:"atto_image_alignment",INPUTALT:"atto_image_altentry",INPUTHEIGHT:"atto_image_heightentry",INPUTSUBMIT:"atto_image_urlentrysubmit",INPUTURL:"atto_image_urlentry",INPUTSIZE:"atto_image_size",INPUTWIDTH:"atto_image_widthentry",IMAGEALTWARNING:"atto_image_altwarning",IMAGEURLWARNING:"atto_image_urlwarning",IMAGEBROWSER:"openimagebrowser",IMAGEPRESENTATION:"atto_image_presentation",INPUTCONSTRAIN:"atto_image_constrain",INPUTCUSTOMSTYLE:"atto_image_customstyle",IMAGEPREVIEW:"atto_image_preview",IMAGEPREVIEWBOX:"atto_image_preview_box",ALIGNSETTINGS:"atto_image_button"},i={URL:"urlentry",ALT:"altentry"},t={INPUTURL:"."+c.INPUTURL},s=[{name:"verticalAlign",str:"alignment_top",value:"text-top",margin:"0 0.5em"},{name:"verticalAlign",str:"alignment_middle",value:"middle",margin:"0 0.5em"},{name:"verticalAlign",str:"alignment_bottom",value:"text-bottom",margin:"0 0.5em",isDefault:!0},{name:"float",str:"alignment_left",value:"left",margin:"0 0.5em 0 0"},{name:"float",str:"alignment_right",value:"right",margin:"0 0 0 0.5em"}],a=160,o=160,u=/\d+%/,d="atto_image",I='<img src="{{url}}" alt="{{alt}}" {{#if width}}width="{{width}}" {{/if}}{{#if height}}height="{{height}}" {{/if}}{{#if presentation}}role="presentation" {{/if}}{{#if customstyle}}style="{{customstyle}}" {{/if}}{{#if classlist}}class="{{classlist}}" {{/if}}{{#if id}}id="{{id}}" {{/if}}/>';m.namespace("M.atto_image").Button=m.Base.create("button",m.M.editor_atto.EditorPlugin,[],{_currentSelection:null,_selectedImage:null,_form:null,_rawImageDimensions:null,initializer:function(){this.addButton({icon:"e/insert_edit_image",callback:this._displayDialogue,tags:"img",tagMatchRequiresAll:!1}),this.editor.delegate("dblclick",this._displayDialogue,"img",this),this.editor.delegate("click",this._handleClick,"img",this),this.editor.on("paste",this._handlePaste,this),this.editor.on("drop",this._handleDragDrop,this),this.editor.on("dragover",function(e){e.preventDefault()},this),this.editor.on("dragenter",function(e){e.preventDefault()},this)},_handleDragDrop:function(e){e._event&&e._event.dataTransfer&&this._handlePasteOrDropHelper(e,e._event.dataTransfer)},_handlePaste:function(e){return!e._event||!e._event.clipboardData||this._handlePasteOrDropHelper(e,e._event.clipboardData)},_handlePasteOrDropHelper:function(e,t){for(var i,n=t.items,a=!1,s=0;s<n.length;s++)"file"===(i=n[s]).kind&&this._isImage(i.type)&&(this._uploadImage(i.getAsFile()),a=!0);a&&e.preventDefault()},_isImage:function(e){return 0===e.indexOf("image/")},_uploadImage:function(e){var t,i,n,a,s,o,l,r=this,h=this.get("host"),g=m.Handlebars.compile(I);for(h.saveSelection(),require(["core_form/events"],function(e){e.notifyUploadStarted(r.editor.get("id"))}),s=(t=h.get("filepickeroptions").image).savepath===undefined?"/":t.savepath,i=new FormData,a=new XMLHttpRequest,o=Object.keys(t.repositories),i.append("repo_upload_file",e),i.append("itemid",t.itemid),l=0;l<o.length;l++)if("upload"===t.repositories[o[l]].type){i.append("repo_id",t.repositories[o[l]].id);break}i.append("env",t.env),i.append("sesskey",M.cfg.sesskey),i.append("client_id",t.client_id),i.append("savepath",s),i.append("ctx_id",t.context.id),e=(new Date).getTime(),n="moodleimage_"+Math.round(1e5*Math.random())+"-"+e,h.focus(),h.restoreSelection(),s=g({url:M.util.image_url("i/loading_small","moodle"),alt:M.util.get_string("uploading",d),id:n}),h.insertContentAtFocusPoint(s),r.markUpdated(),a.onreadystatechange=function(){var e,t,i=r.editor.one("#"+n);if(4===a.readyState){if(200===a.status){if(e=JSON.parse(a.responseText)){if(e.error)throw i&&i.remove(!0),require(["core_form/events"],function(e){e.notifyUploadCompleted(r.editor.get("id"))}),new M.core.ajaxException(e);(t=e).event&&"fileexists"===e.event&&(t=e.newfile),e=g({url:t.url,presentation:!0,classlist:c.RESPONSIVE}),t=m.Node.create(e),i?i.replace(t):r.editor.appendChild(t),r.markUpdated()}}else m.use("moodle-core-notification-alert",function(){require(["core_form/events"],function(e){e.notifyUploadCompleted(r.editor.get("id"))}),new M.core.alert({message:M.util.get_string("servererror","moodle")})}),i&&i.remove(!0);require(["core_form/events"],function(e){e.notifyUploadCompleted(r.editor.get("id"))})}},a.open("POST",M.cfg.wwwroot+"/repository/repository_ajax.php?action=upload",!0),a.send(i)},_handleClick:function(e){e=e.target,e=this.get("host").getSelectionFromNode(e);this.get("host").getSelection()!==e&&this.get("host").setSelection(e)},_displayDialogue:function(){var e;this._currentSelection=this.get("host").getSelection(),!1!==this._currentSelection&&(this._rawImageDimensions=null,(e=this.getDialogue({headerContent:M.util.get_string("imageproperties",d),width:"auto",focusAfterHide:!0,focusOnShowSelector:t.INPUTURL})).get("boundingBox").setStyle("maxWidth","90%"),e.set("bodyContent",this._getDialogueContent()).show())},_loadPreviewImage:function(e){var t=new Image,n=this;t.onerror=function(){n._form.one("."+c.IMAGEPREVIEW).setStyles({display:"none"}),n.getDialogue().centerDialogue()},t.onload=function(){var e,t,i;n._rawImageDimensions={width:this.width||a,height:this.height||o},""===(t=(e=n._form.one("."+c.INPUTWIDTH)).get("value"))&&(e.set("value",n._rawImageDimensions.width),t=""+n._rawImageDimensions.width),""===(i=(e=n._form.one("."+c.INPUTHEIGHT)).get("value"))&&(e.set("value",n._rawImageDimensions.height),i=""+n._rawImageDimensions.height),(e=n._form.one("."+c.IMAGEPREVIEW)).setAttribute("src",this.src),e.setStyles({display:"inline"}),e=n._form.one("."+c.INPUTCONSTRAIN),t.match(u)&&i.match(u)?e.set("checked",t===i):0===this.width||0===this.height?e.set("disabled","disabled"):(t=Math.round(1e3*parseInt(t,10)/this.width),i=Math.round(1e3*parseInt(i,10)/this.height),e.set("checked",t===i)),n._autoAdjustSize(n),n.getDialogue().centerDialogue()},t.src=e},_getDialogueContent:function(){var e=m.Handlebars.compile( '<form class="atto_form"><div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.IMAGEURLWARNING}}"><label for="{{elementid}}_{{CSS.INPUTURL}}">{{get_string "imageurlrequired" component}}</label></div>{{#if showFilepicker}}<div class="mb-1"><label for="{{elementid}}_{{CSS.INPUTURL}}">{{get_string "enterurl" component}}</label><div class="input-group input-append w-100"><input name="{{FORMNAMES.URL}}" class="form-control {{CSS.INPUTURL}}" type="url" id="{{elementid}}_{{CSS.INPUTURL}}" size="32"/><span class="input-group-append"><button class="btn btn-secondary {{CSS.IMAGEBROWSER}}" type="button">{{get_string "browserepositories" component}}</button></span></div></div>{{else}}<div class="mb-1"><label for="{{elementid}}_{{CSS.INPUTURL}}">{{get_string "enterurl" component}}</label><input name="{{FORMNAMES.URL}}" class="form-control fullwidth {{CSS.INPUTURL}}" type="url" id="{{elementid}}_{{CSS.INPUTURL}}" size="32"/></div>{{/if}}<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.IMAGEALTWARNING}}"><label for="{{elementid}}_{{CSS.INPUTALT}}">{{get_string "presentationoraltrequired" component}}</label></div><div class="mb-1"><label for="{{elementid}}_{{CSS.INPUTALT}}">{{get_string "enteralt" component}}</label><textarea class="form-control fullwidth {{CSS.INPUTALT}}" id="{{elementid}}_{{CSS.INPUTALT}}" name="{{FORMNAMES.ALT}}" maxlength="125"></textarea><div id="the-count" class="d-flex justify-content-end small"><span id="currentcount">0</span><span id="maximumcount"> / 125</span></div><div class="form-check"><input type="checkbox" class="form-check-input {{CSS.IMAGEPRESENTATION}}" id="{{elementid}}_{{CSS.IMAGEPRESENTATION}}"/><label class="form-check-label" for="{{elementid}}_{{CSS.IMAGEPRESENTATION}}">{{get_string "presentation" component}}</label></div></div><div class="mb-1"><label class="" for="{{elementid}}_{{CSS.INPUTSIZE}}">{{get_string "size" component}}</label><div id="{{elementid}}_{{CSS.INPUTSIZE}}" class="form-inline {{CSS.INPUTSIZE}}"><label class="accesshide" for="{{elementid}}_{{CSS.INPUTWIDTH}}">{{get_string "width" component}}</label><input type="text" class="form-control mr-1 input-mini {{CSS.INPUTWIDTH}}" id="{{elementid}}_{{CSS.INPUTWIDTH}}" size="4"/> x<label class="accesshide" for="{{elementid}}_{{CSS.INPUTHEIGHT}}">{{get_string "height" component}}</label><input type="text" class="form-control ml-1 input-mini {{CSS.INPUTHEIGHT}}" id="{{elementid}}_{{CSS.INPUTHEIGHT}}" size="4"/><div class="form-check ml-2"><input type="checkbox" class="form-check-input {{CSS.INPUTCONSTRAIN}}" id="{{elementid}}_{{CSS.INPUTCONSTRAIN}}"/><label class="form-check-label" for="{{elementid}}_{{CSS.INPUTCONSTRAIN}}">{{get_string "constrain" component}}</label></div></div></div><div class="form-inline mb-1"><label class="for="{{elementid}}_{{CSS.INPUTALIGNMENT}}">{{get_string "alignment" component}}</label><select class="custom-select {{CSS.INPUTALIGNMENT}}" id="{{elementid}}_{{CSS.INPUTALIGNMENT}}">{{#each alignments}}<option value="{{value}}">{{get_string str ../component}}</option>{{/each}}</select></div><input type="hidden" class="{{CSS.INPUTCUSTOMSTYLE}}"/><br/><div class="mdl-align"><div class="{{CSS.IMAGEPREVIEWBOX}}"><img class="{{CSS.IMAGEPREVIEW}}" alt="" style="display: none;"/></div><button class="btn btn-secondary {{CSS.INPUTSUBMIT}}" type="submit">{{get_string "saveimage" component}}</button></div></form>'),t=this.get("host").canShowFilepicker("image"),e=m.Node.create(e({elementid:this.get("host").get("elementid"),CSS:c,FORMNAMES:i,component:d,showFilepicker:t,alignments:s}));return this._form=e,this._applyImageProperties(this._form),this._form.one("."+c.INPUTURL).on("blur",this._urlChanged,this),this._form.one("."+c.INPUTURL).on("change",this._hasErrorUrlField,this),this._form.one("."+c.IMAGEPRESENTATION).on("change",this._hasErrorAltField,this),this._form.one("."+c.INPUTALT).on("blur",this._hasErrorAltField,this),this._form.one("."+c.INPUTWIDTH).on("blur",this._autoAdjustSize,this),this._form.one("."+c.INPUTHEIGHT).on("blur",this._autoAdjustSize,this,!0),this._form.one("."+c.INPUTCONSTRAIN).on("change",function(e){e.target.get("checked")&&this._autoAdjustSize(e)},this),this._form.one("."+c.INPUTSUBMIT).on("click",this._setImage,this),t&&this._form.one("."+c.IMAGEBROWSER).on("click",function(){this.get("host").showFilepicker("image",this._filepickerCallback,this)},this),this._form.one("."+c.INPUTALT).on("keyup",this._handleKeyup,this),e},_autoAdjustSize:function(g,e){var t,i,n,d,a,s,o,l,r,h;e=e||!1,t=this._form.one("."+c.INPUTWIDTH),i="width",n=this._form.one("."+c.INPUTHEIGHT),d="height",h=this._form.one("."+c.INPUTCONSTRAIN),a=t.get("value"),s=n.get("value"),o=this._form.one("."+c.IMAGEPREVIEW),this._rawImageDimensions&&(""===a&&(a=this._rawImageDimensions[i],t.set("value",a),a=t.get("value")),o.setStyles({width:null,height:null}),h.get("checked")?(e&&(h=t,t=n,n=h,h=i,i=d,d=h,h=a,a=s,s=h),a.match(u)?(s=a,l=parseInt(a,10),r=this._rawImageDimensions.width/100*l,o.setStyle("width",r),r=this._rawImageDimensions.height/100*l,o.setStyle("height",r)):(s=Math.round(a/this._rawImageDimensions[i]*this._rawImageDimensions[d]),e?o.setStyles({width:s,height:a}):o.setStyles({width:a,height:s})),n.set("value",s)):(a.match(u)?(l=parseInt(a,10),r=this._rawImageDimensions.width/100*l,o.setStyle("width",r+"px")):o.setStyle("width",a+"px"),s.match(u)?(l=parseInt(s,10),r=this._rawImageDimensions.height/100*l,o.setStyle("height",r+"px")):o.setStyle("height",s+"px")))},_filepickerCallback:function(e){""!==e.url&&(this._form.one("."+c.INPUTURL).set("value",e.url),this._form.one("."+c.INPUTWIDTH).set("value",""),this._form.one("."+c.INPUTHEIGHT).set("value",""),this._loadPreviewImage(e.url))},_applyImageProperties:function(t){var e=this._getSelectedImageProperties(),i=t.one("."+c.IMAGEPREVIEW);if(!1===e)return i.setStyle("display","none"),void s.some(function(e){return!!e.isDefault&&(t.one("."+c.INPUTALIGNMENT).set("value",e.value),!0)},this); e.align&&t.one("."+c.INPUTALIGNMENT).set("value",e.align),e.customstyle&&t.one("."+c.INPUTCUSTOMSTYLE).set("value",e.customstyle),e.width&&t.one("."+c.INPUTWIDTH).set("value",e.width),e.height&&t.one("."+c.INPUTHEIGHT).set("value",e.height),e.alt&&t.one("."+c.INPUTALT).set("value",e.alt),e.src&&(t.one("."+c.INPUTURL).set("value",e.src),this._loadPreviewImage(e.src)),e.presentation&&t.one("."+c.IMAGEPRESENTATION).set("checked","checked"),this._autoAdjustSize()},_getSelectedImageProperties:function(){var e,t,i={src:null,alt:null,width:null,height:null,align:"",presentation:!1},n=this.get("host").getSelectedNodes();return(n=n&&n.filter("img"))&&n.size()?(n=this._removeLegacyAlignment(n.item(0)),t=(this._selectedImage=n).getAttribute("style"),i.customstyle=t,(t=n.getAttribute("width")).match(u)||(t=parseInt(t,10)),(e=n.getAttribute("height")).match(u)||(e=parseInt(e,10)),0!==t&&(i.width=t),0!==e&&(i.height=e),this._getAlignmentPropeties(n,i),i.src=n.getAttribute("src"),i.alt=n.getAttribute("alt")||"",i.presentation="presentation"===n.get("role"),i):(this._selectedImage=null,!1)},_getAlignmentPropeties:function(i,n){var a;!s.some(function(e){var t=this._getAlignmentClass(e.value);return i.hasClass(t)?(n.align=e.value,!0):(e.isDefault&&(a=e.value),!1)},this)&&a&&(n.align=a)},_urlChanged:function(){var e=this._form.one("."+c.INPUTURL);""!==e.get("value")&&this._loadPreviewImage(e.get("value"))},_setImage:function(e){var t=this._form,i=t.one("."+c.INPUTURL).get("value"),n=t.one("."+c.INPUTALT).get("value"),a=t.one("."+c.INPUTWIDTH).get("value"),s=t.one("."+c.INPUTHEIGHT).get("value"),o=this._getAlignmentClass(t.one("."+c.INPUTALIGNMENT).get("value")),l=t.one("."+c.IMAGEPRESENTATION).get("checked"),g=t.one("."+c.INPUTCONSTRAIN).get("checked"),d=t.one("."+c.INPUTCUSTOMSTYLE).get("value"),r=[],h=this.get("host");if(e.preventDefault(),!this._updateWarning()){if(h.focus(),""!==i){if(this._selectedImage?h.setSelection(h.getSelectionFromNode(this._selectedImage)):h.setSelection(this._currentSelection),g&&r.push(c.RESPONSIVE),r.push(o),!a.match(u)&&isNaN(parseInt(a,10)))return void t.one("."+c.INPUTWIDTH).focus();if(!s.match(u)&&isNaN(parseInt(s,10)))return void t.one("."+c.INPUTHEIGHT).focus();e=m.Handlebars.compile(I)({url:i,alt:n,width:a,height:s,presentation:l,customstyle:d,classlist:r.join(" ")}),this.get("host").insertContentAtFocusPoint(e),this.markUpdated()}this.getDialogue({focusAfterHide:null}).hide()}},_removeLegacyAlignment:function(i){return i.getStyle("margin")&&s.some(function(e){if(i.getStyle(e.name)!==e.value)return!1;var t=m.Node.create("<div>");return t.setStyle("margin",e.margin),i.getStyle("margin")===t.getStyle("margin")&&(i.addClass(this._getAlignmentClass(e.value)),i.setStyle(e.name,null),i.setStyle("margin",null),!0)},this),i},_getAlignmentClass:function(e){return c.ALIGNSETTINGS+"_"+e},_toggleVisibility:function(e,t){var i=this._form,e=i.all(e);e.setStyle("display",t?"block":"none")},_toggleAriaInvalid:function(e,t){var i=this._form;e.forEach(function(e){i.all(e).setAttribute("aria-invalid",t)})},_hasErrorUrlField:function(){var e=this._form,e=e.one("."+c.INPUTURL).get("value"),e=""===e;return this._toggleVisibility("."+c.IMAGEURLWARNING,e),this._toggleAriaInvalid(["."+c.INPUTURL],e),e},_hasErrorAltField:function(){var e=this._form,t=e.one("."+c.INPUTALT).get("value"),e=e.one("."+c.IMAGEPRESENTATION).get("checked"),t=""===t&&!e;return this._toggleVisibility("."+c.IMAGEALTWARNING,t),this._toggleAriaInvalid(["."+c.INPUTALT,"."+c.IMAGEPRESENTATION],t),t},_updateWarning:function(){var e=this._hasErrorUrlField(),t=this._hasErrorAltField(),e=e||t;return this.getDialogue().centerDialogue(),e},_handleKeyup:function(){var e=this._form,t=e.one("."+c.INPUTALT).get("value").length;e.one("#currentcount").setHTML(t)}})},"@VERSION@",{requires:["moodle-editor_atto-plugin"]}); yui/build/moodle-atto_image-button/moodle-atto_image-button.js 0000644 00000117207 15152177407 0020626 0 ustar 00 YUI.add('moodle-atto_image-button', function (Y, NAME) { // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /* * @package atto_image * @copyright 2013 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * @module moodle-atto_image_alignment-button */ /** * Atto image selection tool. * * @namespace M.atto_image * @class Button * @extends M.editor_atto.EditorPlugin */ var CSS = { RESPONSIVE: 'img-fluid', INPUTALIGNMENT: 'atto_image_alignment', INPUTALT: 'atto_image_altentry', INPUTHEIGHT: 'atto_image_heightentry', INPUTSUBMIT: 'atto_image_urlentrysubmit', INPUTURL: 'atto_image_urlentry', INPUTSIZE: 'atto_image_size', INPUTWIDTH: 'atto_image_widthentry', IMAGEALTWARNING: 'atto_image_altwarning', IMAGEURLWARNING: 'atto_image_urlwarning', IMAGEBROWSER: 'openimagebrowser', IMAGEPRESENTATION: 'atto_image_presentation', INPUTCONSTRAIN: 'atto_image_constrain', INPUTCUSTOMSTYLE: 'atto_image_customstyle', IMAGEPREVIEW: 'atto_image_preview', IMAGEPREVIEWBOX: 'atto_image_preview_box', ALIGNSETTINGS: 'atto_image_button' }, FORMNAMES = { URL: 'urlentry', ALT: 'altentry' }, SELECTORS = { INPUTURL: '.' + CSS.INPUTURL }, ALIGNMENTS = [ // Vertical alignment. { name: 'verticalAlign', str: 'alignment_top', value: 'text-top', margin: '0 0.5em' }, { name: 'verticalAlign', str: 'alignment_middle', value: 'middle', margin: '0 0.5em' }, { name: 'verticalAlign', str: 'alignment_bottom', value: 'text-bottom', margin: '0 0.5em', isDefault: true }, // Floats. { name: 'float', str: 'alignment_left', value: 'left', margin: '0 0.5em 0 0' }, { name: 'float', str: 'alignment_right', value: 'right', margin: '0 0 0 0.5em' } ], DEFAULTS = { WIDTH: 160, HEIGHT: 160, }, REGEX = { ISPERCENT: /\d+%/ }, COMPONENTNAME = 'atto_image', TEMPLATE = '' + '<form class="atto_form">' + // Add the repository browser button. '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.IMAGEURLWARNING}}">' + '<label for="{{elementid}}_{{CSS.INPUTURL}}">' + '{{get_string "imageurlrequired" component}}' + '</label>' + '</div>' + '{{#if showFilepicker}}' + '<div class="mb-1">' + '<label for="{{elementid}}_{{CSS.INPUTURL}}">{{get_string "enterurl" component}}</label>' + '<div class="input-group input-append w-100">' + '<input name="{{FORMNAMES.URL}}" class="form-control {{CSS.INPUTURL}}" type="url" ' + 'id="{{elementid}}_{{CSS.INPUTURL}}" size="32"/>' + '<span class="input-group-append">' + '<button class="btn btn-secondary {{CSS.IMAGEBROWSER}}" type="button">' + '{{get_string "browserepositories" component}}</button>' + '</span>' + '</div>' + '</div>' + '{{else}}' + '<div class="mb-1">' + '<label for="{{elementid}}_{{CSS.INPUTURL}}">{{get_string "enterurl" component}}</label>' + '<input name="{{FORMNAMES.URL}}" class="form-control fullwidth {{CSS.INPUTURL}}" type="url" ' + 'id="{{elementid}}_{{CSS.INPUTURL}}" size="32"/>' + '</div>' + '{{/if}}' + '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.IMAGEALTWARNING}}">' + '<label for="{{elementid}}_{{CSS.INPUTALT}}">' + '{{get_string "presentationoraltrequired" component}}' + '</label>' + '</div>' + // Add the Alt box. '<div class="mb-1">' + '<label for="{{elementid}}_{{CSS.INPUTALT}}">{{get_string "enteralt" component}}</label>' + '<textarea class="form-control fullwidth {{CSS.INPUTALT}}" ' + 'id="{{elementid}}_{{CSS.INPUTALT}}" name="{{FORMNAMES.ALT}}" maxlength="125"></textarea>' + // Add the character count. '<div id="the-count" class="d-flex justify-content-end small">' + '<span id="currentcount">0</span>' + '<span id="maximumcount"> / 125</span>' + '</div>' + // Add the presentation select box. '<div class="form-check">' + '<input type="checkbox" class="form-check-input {{CSS.IMAGEPRESENTATION}}" ' + 'id="{{elementid}}_{{CSS.IMAGEPRESENTATION}}"/>' + '<label class="form-check-label" for="{{elementid}}_{{CSS.IMAGEPRESENTATION}}">' + '{{get_string "presentation" component}}' + '</label>' + '</div>' + '</div>' + // Add the size entry boxes. '<div class="mb-1">' + '<label class="" for="{{elementid}}_{{CSS.INPUTSIZE}}">{{get_string "size" component}}</label>' + '<div id="{{elementid}}_{{CSS.INPUTSIZE}}" class="form-inline {{CSS.INPUTSIZE}}">' + '<label class="accesshide" for="{{elementid}}_{{CSS.INPUTWIDTH}}">{{get_string "width" component}}</label>' + '<input type="text" class="form-control mr-1 input-mini {{CSS.INPUTWIDTH}}" ' + 'id="{{elementid}}_{{CSS.INPUTWIDTH}}" size="4"/> x' + // Add the height entry box. '<label class="accesshide" for="{{elementid}}_{{CSS.INPUTHEIGHT}}">{{get_string "height" component}}</label>' + '<input type="text" class="form-control ml-1 input-mini {{CSS.INPUTHEIGHT}}" ' + 'id="{{elementid}}_{{CSS.INPUTHEIGHT}}" size="4"/>' + // Add the constrain checkbox. '<div class="form-check ml-2">' + '<input type="checkbox" class="form-check-input {{CSS.INPUTCONSTRAIN}}" ' + 'id="{{elementid}}_{{CSS.INPUTCONSTRAIN}}"/>' + '<label class="form-check-label" for="{{elementid}}_{{CSS.INPUTCONSTRAIN}}">' + '{{get_string "constrain" component}}</label>' + '</div>' + '</div>' + '</div>' + // Add the alignment selector. '<div class="form-inline mb-1">' + '<label class="for="{{elementid}}_{{CSS.INPUTALIGNMENT}}">{{get_string "alignment" component}}</label>' + '<select class="custom-select {{CSS.INPUTALIGNMENT}}" id="{{elementid}}_{{CSS.INPUTALIGNMENT}}">' + '{{#each alignments}}' + '<option value="{{value}}">{{get_string str ../component}}</option>' + '{{/each}}' + '</select>' + '</div>' + // Hidden input to store custom styles. '<input type="hidden" class="{{CSS.INPUTCUSTOMSTYLE}}"/>' + '<br/>' + // Add the image preview. '<div class="mdl-align">' + '<div class="{{CSS.IMAGEPREVIEWBOX}}">' + '<img class="{{CSS.IMAGEPREVIEW}}" alt="" style="display: none;"/>' + '</div>' + // Add the submit button and close the form. '<button class="btn btn-secondary {{CSS.INPUTSUBMIT}}" type="submit">' + '' + '{{get_string "saveimage" component}}</button>' + '</div>' + '</form>', IMAGETEMPLATE = '' + '<img src="{{url}}" alt="{{alt}}" ' + '{{#if width}}width="{{width}}" {{/if}}' + '{{#if height}}height="{{height}}" {{/if}}' + '{{#if presentation}}role="presentation" {{/if}}' + '{{#if customstyle}}style="{{customstyle}}" {{/if}}' + '{{#if classlist}}class="{{classlist}}" {{/if}}' + '{{#if id}}id="{{id}}" {{/if}}' + '/>'; Y.namespace('M.atto_image').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], { /** * A reference to the current selection at the time that the dialogue * was opened. * * @property _currentSelection * @type Range * @private */ _currentSelection: null, /** * The most recently selected image. * * @param _selectedImage * @type Node * @private */ _selectedImage: null, /** * A reference to the currently open form. * * @param _form * @type Node * @private */ _form: null, /** * The dimensions of the raw image before we manipulate it. * * @param _rawImageDimensions * @type Object * @private */ _rawImageDimensions: null, initializer: function() { this.addButton({ icon: 'e/insert_edit_image', callback: this._displayDialogue, tags: 'img', tagMatchRequiresAll: false }); this.editor.delegate('dblclick', this._displayDialogue, 'img', this); this.editor.delegate('click', this._handleClick, 'img', this); this.editor.on('paste', this._handlePaste, this); this.editor.on('drop', this._handleDragDrop, this); // ...e.preventDefault needed to stop the default event from clobbering the desired behaviour in some browsers. this.editor.on('dragover', function(e) { e.preventDefault(); }, this); this.editor.on('dragenter', function(e) { e.preventDefault(); }, this); }, /** * Handle a drag and drop event with an image. * * @method _handleDragDrop * @param {EventFacade} e * @private */ _handleDragDrop: function(e) { if (!e._event || !e._event.dataTransfer) { // Drop not fully supported in this browser. return; } this._handlePasteOrDropHelper(e, e._event.dataTransfer); }, /** * Handles paste events where - if the thing being pasted is an image. * * @method _handlePaste * @param {EventFacade} e * @return {boolean} false if we handled the event, else true. * @private */ _handlePaste: function(e) { if (!e._event || !e._event.clipboardData) { // Paste not fully supported in this browser. return true; } return this._handlePasteOrDropHelper(e, e._event.clipboardData); }, /** * Handle a drag and drop event with an image. * * @method _handleDragDrop * @param {EventFacade} e * @param {DataTransfer} dataTransfer * @return {boolean} false if we handled the event, else true. * @private */ _handlePasteOrDropHelper: function(e, dataTransfer) { var items = dataTransfer.items, didUpload = false; for (var i = 0; i < items.length; i++) { var item = items[i]; if (item.kind !== 'file') { continue; } if (!this._isImage(item.type)) { continue; } this._uploadImage(item.getAsFile()); didUpload = true; } if (didUpload) { e.preventDefault(); } }, /** * Is this file an image? * * @method _isImage * @param {string} mimeType the file's mime type. * @return {boolean} true if the file has an image mimeType. * @private */ _isImage: function(mimeType) { return mimeType.indexOf('image/') === 0; }, /** * Used by _handleDragDrop and _handlePaste to upload an image and insert it. * * @method _uploadImage * @param {File} fileToSave * @private */ _uploadImage: function(fileToSave) { var self = this, host = this.get('host'), template = Y.Handlebars.compile(IMAGETEMPLATE); host.saveSelection(); // Trigger form upload start events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadStarted(self.editor.get('id')); }); var options = host.get('filepickeroptions').image, savepath = (options.savepath === undefined) ? '/' : options.savepath, formData = new FormData(), timestamp = 0, uploadid = "", xhr = new XMLHttpRequest(), imagehtml = "", keys = Object.keys(options.repositories); formData.append('repo_upload_file', fileToSave); formData.append('itemid', options.itemid); // List of repositories is an object rather than an array. This makes iteration more awkward. for (var i = 0; i < keys.length; i++) { if (options.repositories[keys[i]].type === 'upload') { formData.append('repo_id', options.repositories[keys[i]].id); break; } } formData.append('env', options.env); formData.append('sesskey', M.cfg.sesskey); formData.append('client_id', options.client_id); formData.append('savepath', savepath); formData.append('ctx_id', options.context.id); // Insert spinner as a placeholder. timestamp = new Date().getTime(); uploadid = 'moodleimage_' + Math.round(Math.random() * 100000) + '-' + timestamp; host.focus(); host.restoreSelection(); imagehtml = template({ url: M.util.image_url("i/loading_small", 'moodle'), alt: M.util.get_string('uploading', COMPONENTNAME), id: uploadid }); host.insertContentAtFocusPoint(imagehtml); self.markUpdated(); // Kick off a XMLHttpRequest. xhr.onreadystatechange = function() { var placeholder = self.editor.one('#' + uploadid), result, file, newhtml, newimage; if (xhr.readyState === 4) { if (xhr.status === 200) { result = JSON.parse(xhr.responseText); if (result) { if (result.error) { if (placeholder) { placeholder.remove(true); } // Trigger form upload complete events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadCompleted(self.editor.get('id')); }); throw new M.core.ajaxException(result); } file = result; if (result.event && result.event === 'fileexists') { // A file with this name is already in use here - rename to avoid conflict. // Chances are, it's a different image (stored in a different folder on the user's computer). // If the user wants to reuse an existing image, they can copy/paste it within the editor. file = result.newfile; } // Replace placeholder with actual image. newhtml = template({ url: file.url, presentation: true, classlist: CSS.RESPONSIVE }); newimage = Y.Node.create(newhtml); if (placeholder) { placeholder.replace(newimage); } else { self.editor.appendChild(newimage); } self.markUpdated(); } } else { Y.use('moodle-core-notification-alert', function() { // Trigger form upload complete events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadCompleted(self.editor.get('id')); }); new M.core.alert({message: M.util.get_string('servererror', 'moodle')}); }); if (placeholder) { placeholder.remove(true); } } // Trigger form upload complete events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadCompleted(self.editor.get('id')); }); } }; xhr.open("POST", M.cfg.wwwroot + '/repository/repository_ajax.php?action=upload', true); xhr.send(formData); }, /** * Handle a click on an image. * * @method _handleClick * @param {EventFacade} e * @private */ _handleClick: function(e) { var image = e.target; var selection = this.get('host').getSelectionFromNode(image); if (this.get('host').getSelection() !== selection) { this.get('host').setSelection(selection); } }, /** * Display the image editing tool. * * @method _displayDialogue * @private */ _displayDialogue: function() { // Store the current selection. this._currentSelection = this.get('host').getSelection(); if (this._currentSelection === false) { return; } // Reset the image dimensions. this._rawImageDimensions = null; var dialogue = this.getDialogue({ headerContent: M.util.get_string('imageproperties', COMPONENTNAME), width: 'auto', focusAfterHide: true, focusOnShowSelector: SELECTORS.INPUTURL }); // Set a maximum width for the dialog. This will prevent the dialog width to extend beyond the screen width // in cases when the uploaded image has larger width. dialogue.get('boundingBox').setStyle('maxWidth', '90%'); // Set the dialogue content, and then show the dialogue. dialogue.set('bodyContent', this._getDialogueContent()) .show(); }, /** * Set the inputs for width and height if they are not set, and calculate * if the constrain checkbox should be checked or not. * * @method _loadPreviewImage * @param {String} url * @private */ _loadPreviewImage: function(url) { var image = new Image(); var self = this; image.onerror = function() { var preview = self._form.one('.' + CSS.IMAGEPREVIEW); preview.setStyles({ 'display': 'none' }); // Centre the dialogue when clearing the image preview. self.getDialogue().centerDialogue(); }; image.onload = function() { var input, currentwidth, currentheight, widthRatio, heightRatio; // Store dimensions of the raw image, falling back to defaults for images without dimensions (e.g. SVG). self._rawImageDimensions = { width: this.width || DEFAULTS.WIDTH, height: this.height || DEFAULTS.HEIGHT, }; input = self._form.one('.' + CSS.INPUTWIDTH); currentwidth = input.get('value'); if (currentwidth === '') { input.set('value', self._rawImageDimensions.width); currentwidth = "" + self._rawImageDimensions.width; } input = self._form.one('.' + CSS.INPUTHEIGHT); currentheight = input.get('value'); if (currentheight === '') { input.set('value', self._rawImageDimensions.height); currentheight = "" + self._rawImageDimensions.height; } input = self._form.one('.' + CSS.IMAGEPREVIEW); input.setAttribute('src', this.src); input.setStyles({ 'display': 'inline' }); input = self._form.one('.' + CSS.INPUTCONSTRAIN); if (currentwidth.match(REGEX.ISPERCENT) && currentheight.match(REGEX.ISPERCENT)) { input.set('checked', currentwidth === currentheight); } else if (this.width === 0 || this.height === 0) { // If we don't have both dimensions of the image, we can't auto-size it, so disable control. input.set('disabled', 'disabled'); } else { // This is the same as comparing to 3 decimal places. widthRatio = Math.round(1000 * parseInt(currentwidth, 10) / this.width); heightRatio = Math.round(1000 * parseInt(currentheight, 10) / this.height); input.set('checked', widthRatio === heightRatio); } // Apply the image sizing. self._autoAdjustSize(self); // Centre the dialogue once the preview image has loaded. self.getDialogue().centerDialogue(); }; image.src = url; }, /** * Return the dialogue content for the tool, attaching any required * events. * * @method _getDialogueContent * @return {Node} The content to place in the dialogue. * @private */ _getDialogueContent: function() { var template = Y.Handlebars.compile(TEMPLATE), canShowFilepicker = this.get('host').canShowFilepicker('image'), content = Y.Node.create(template({ elementid: this.get('host').get('elementid'), CSS: CSS, FORMNAMES: FORMNAMES, component: COMPONENTNAME, showFilepicker: canShowFilepicker, alignments: ALIGNMENTS })); this._form = content; // Configure the view of the current image. this._applyImageProperties(this._form); this._form.one('.' + CSS.INPUTURL).on('blur', this._urlChanged, this); this._form.one('.' + CSS.INPUTURL).on('change', this._hasErrorUrlField, this); this._form.one('.' + CSS.IMAGEPRESENTATION).on('change', this._hasErrorAltField, this); this._form.one('.' + CSS.INPUTALT).on('blur', this._hasErrorAltField, this); this._form.one('.' + CSS.INPUTWIDTH).on('blur', this._autoAdjustSize, this); this._form.one('.' + CSS.INPUTHEIGHT).on('blur', this._autoAdjustSize, this, true); this._form.one('.' + CSS.INPUTCONSTRAIN).on('change', function(event) { if (event.target.get('checked')) { this._autoAdjustSize(event); } }, this); this._form.one('.' + CSS.INPUTSUBMIT).on('click', this._setImage, this); if (canShowFilepicker) { this._form.one('.' + CSS.IMAGEBROWSER).on('click', function() { this.get('host').showFilepicker('image', this._filepickerCallback, this); }, this); } // Character count. this._form.one('.' + CSS.INPUTALT).on('keyup', this._handleKeyup, this); return content; }, _autoAdjustSize: function(e, forceHeight) { forceHeight = forceHeight || false; var keyField = this._form.one('.' + CSS.INPUTWIDTH), keyFieldType = 'width', subField = this._form.one('.' + CSS.INPUTHEIGHT), subFieldType = 'height', constrainField = this._form.one('.' + CSS.INPUTCONSTRAIN), keyFieldValue = keyField.get('value'), subFieldValue = subField.get('value'), imagePreview = this._form.one('.' + CSS.IMAGEPREVIEW), rawPercentage, rawSize; // If we do not know the image size, do not do anything. if (!this._rawImageDimensions) { return; } // Set the width back to default if it is empty. if (keyFieldValue === '') { keyFieldValue = this._rawImageDimensions[keyFieldType]; keyField.set('value', keyFieldValue); keyFieldValue = keyField.get('value'); } // Clear the existing preview sizes. imagePreview.setStyles({ width: null, height: null }); // Now update with the new values. if (!constrainField.get('checked')) { // We are not keeping the image proportion - update the preview accordingly. // Width. if (keyFieldValue.match(REGEX.ISPERCENT)) { rawPercentage = parseInt(keyFieldValue, 10); rawSize = this._rawImageDimensions.width / 100 * rawPercentage; imagePreview.setStyle('width', rawSize + 'px'); } else { imagePreview.setStyle('width', keyFieldValue + 'px'); } // Height. if (subFieldValue.match(REGEX.ISPERCENT)) { rawPercentage = parseInt(subFieldValue, 10); rawSize = this._rawImageDimensions.height / 100 * rawPercentage; imagePreview.setStyle('height', rawSize + 'px'); } else { imagePreview.setStyle('height', subFieldValue + 'px'); } } else { // We are keeping the image in proportion. if (forceHeight) { // By default we update based on width. Swap the key and sub fields around to achieve a height-based scale. var _temporaryValue; _temporaryValue = keyField; keyField = subField; subField = _temporaryValue; _temporaryValue = keyFieldType; keyFieldType = subFieldType; subFieldType = _temporaryValue; _temporaryValue = keyFieldValue; keyFieldValue = subFieldValue; subFieldValue = _temporaryValue; } if (keyFieldValue.match(REGEX.ISPERCENT)) { // This is a percentage based change. Copy it verbatim. subFieldValue = keyFieldValue; // Set the width to the calculated pixel width. rawPercentage = parseInt(keyFieldValue, 10); rawSize = this._rawImageDimensions.width / 100 * rawPercentage; // And apply the width/height to the container. imagePreview.setStyle('width', rawSize); rawSize = this._rawImageDimensions.height / 100 * rawPercentage; imagePreview.setStyle('height', rawSize); } else { // Calculate the scaled subFieldValue from the keyFieldValue. subFieldValue = Math.round((keyFieldValue / this._rawImageDimensions[keyFieldType]) * this._rawImageDimensions[subFieldType]); if (forceHeight) { imagePreview.setStyles({ 'width': subFieldValue, 'height': keyFieldValue }); } else { imagePreview.setStyles({ 'width': keyFieldValue, 'height': subFieldValue }); } } // Update the subField's value within the form to reflect the changes. subField.set('value', subFieldValue); } }, /** * Update the dialogue after an image was selected in the File Picker. * * @method _filepickerCallback * @param {object} params The parameters provided by the filepicker * containing information about the image. * @private */ _filepickerCallback: function(params) { if (params.url !== '') { var input = this._form.one('.' + CSS.INPUTURL); input.set('value', params.url); // Auto set the width and height. this._form.one('.' + CSS.INPUTWIDTH).set('value', ''); this._form.one('.' + CSS.INPUTHEIGHT).set('value', ''); // Load the preview image. this._loadPreviewImage(params.url); } }, /** * Applies properties of an existing image to the image dialogue for editing. * * @method _applyImageProperties * @param {Node} form * @private */ _applyImageProperties: function(form) { var properties = this._getSelectedImageProperties(), img = form.one('.' + CSS.IMAGEPREVIEW); if (properties === false) { img.setStyle('display', 'none'); // Set the default alignment. ALIGNMENTS.some(function(alignment) { if (alignment.isDefault) { form.one('.' + CSS.INPUTALIGNMENT).set('value', alignment.value); return true; } return false; }, this); return; } if (properties.align) { form.one('.' + CSS.INPUTALIGNMENT).set('value', properties.align); } if (properties.customstyle) { form.one('.' + CSS.INPUTCUSTOMSTYLE).set('value', properties.customstyle); } if (properties.width) { form.one('.' + CSS.INPUTWIDTH).set('value', properties.width); } if (properties.height) { form.one('.' + CSS.INPUTHEIGHT).set('value', properties.height); } if (properties.alt) { form.one('.' + CSS.INPUTALT).set('value', properties.alt); } if (properties.src) { form.one('.' + CSS.INPUTURL).set('value', properties.src); this._loadPreviewImage(properties.src); } if (properties.presentation) { form.one('.' + CSS.IMAGEPRESENTATION).set('checked', 'checked'); } // Update the image preview based on the form properties. this._autoAdjustSize(); }, /** * Gets the properties of the currently selected image. * * The first image only if multiple images are selected. * * @method _getSelectedImageProperties * @return {object} * @private */ _getSelectedImageProperties: function() { var properties = { src: null, alt: null, width: null, height: null, align: '', presentation: false }, // Get the current selection. images = this.get('host').getSelectedNodes(), width, height, style, image; if (images) { images = images.filter('img'); } if (images && images.size()) { image = this._removeLegacyAlignment(images.item(0)); this._selectedImage = image; style = image.getAttribute('style'); properties.customstyle = style; width = image.getAttribute('width'); if (!width.match(REGEX.ISPERCENT)) { width = parseInt(width, 10); } height = image.getAttribute('height'); if (!height.match(REGEX.ISPERCENT)) { height = parseInt(height, 10); } if (width !== 0) { properties.width = width; } if (height !== 0) { properties.height = height; } this._getAlignmentPropeties(image, properties); properties.src = image.getAttribute('src'); properties.alt = image.getAttribute('alt') || ''; properties.presentation = (image.get('role') === 'presentation'); return properties; } // No image selected - clean up. this._selectedImage = null; return false; }, /** * Sets the alignment of a properties object. * * @method _getAlignmentPropeties * @param {Node} image The image that the alignment properties should be found for * @param {Object} properties The properties object that is created in _getSelectedImageProperties() * @private */ _getAlignmentPropeties: function(image, properties) { var complete = false, defaultAlignment; // Check for an alignment value. complete = ALIGNMENTS.some(function(alignment) { var classname = this._getAlignmentClass(alignment.value); if (image.hasClass(classname)) { properties.align = alignment.value; return true; } if (alignment.isDefault) { defaultAlignment = alignment.value; } return false; }, this); if (!complete && defaultAlignment) { properties.align = defaultAlignment; } }, /** * Update the form when the URL was changed. This includes updating the * height, width, and image preview. * * @method _urlChanged * @private */ _urlChanged: function() { var input = this._form.one('.' + CSS.INPUTURL); if (input.get('value') !== '') { // Load the preview image. this._loadPreviewImage(input.get('value')); } }, /** * Update the image in the contenteditable. * * @method _setImage * @param {EventFacade} e * @private */ _setImage: function(e) { var form = this._form, url = form.one('.' + CSS.INPUTURL).get('value'), alt = form.one('.' + CSS.INPUTALT).get('value'), width = form.one('.' + CSS.INPUTWIDTH).get('value'), height = form.one('.' + CSS.INPUTHEIGHT).get('value'), alignment = this._getAlignmentClass(form.one('.' + CSS.INPUTALIGNMENT).get('value')), presentation = form.one('.' + CSS.IMAGEPRESENTATION).get('checked'), constrain = form.one('.' + CSS.INPUTCONSTRAIN).get('checked'), imagehtml, customstyle = form.one('.' + CSS.INPUTCUSTOMSTYLE).get('value'), classlist = [], host = this.get('host'); e.preventDefault(); // Check if there are any accessibility issues. if (this._updateWarning()) { return; } // Focus on the editor in preparation for inserting the image. host.focus(); if (url !== '') { if (this._selectedImage) { host.setSelection(host.getSelectionFromNode(this._selectedImage)); } else { host.setSelection(this._currentSelection); } if (constrain) { classlist.push(CSS.RESPONSIVE); } // Add the alignment class for the image. classlist.push(alignment); if (!width.match(REGEX.ISPERCENT) && isNaN(parseInt(width, 10))) { form.one('.' + CSS.INPUTWIDTH).focus(); return; } if (!height.match(REGEX.ISPERCENT) && isNaN(parseInt(height, 10))) { form.one('.' + CSS.INPUTHEIGHT).focus(); return; } var template = Y.Handlebars.compile(IMAGETEMPLATE); imagehtml = template({ url: url, alt: alt, width: width, height: height, presentation: presentation, customstyle: customstyle, classlist: classlist.join(' ') }); this.get('host').insertContentAtFocusPoint(imagehtml); this.markUpdated(); } this.getDialogue({ focusAfterHide: null }).hide(); }, /** * Removes any legacy styles added by previous versions of the atto image button. * * @method _removeLegacyAlignment * @param {Y.Node} imageNode * @return {Y.Node} * @private */ _removeLegacyAlignment: function(imageNode) { if (!imageNode.getStyle('margin')) { // There is no margin therefore this cannot match any known alignments. return imageNode; } ALIGNMENTS.some(function(alignment) { if (imageNode.getStyle(alignment.name) !== alignment.value) { // The name/value do not match. Skip. return false; } var normalisedNode = Y.Node.create('<div>'); normalisedNode.setStyle('margin', alignment.margin); if (imageNode.getStyle('margin') !== normalisedNode.getStyle('margin')) { // The margin does not match. return false; } imageNode.addClass(this._getAlignmentClass(alignment.value)); imageNode.setStyle(alignment.name, null); imageNode.setStyle('margin', null); return true; }, this); return imageNode; }, _getAlignmentClass: function(alignment) { return CSS.ALIGNSETTINGS + '_' + alignment; }, _toggleVisibility: function(selector, predicate) { var form = this._form; var element = form.all(selector); element.setStyle('display', predicate ? 'block' : 'none'); }, _toggleAriaInvalid: function(selectors, predicate) { var form = this._form; selectors.forEach(function(selector) { var element = form.all(selector); element.setAttribute('aria-invalid', predicate); }); }, _hasErrorUrlField: function() { var form = this._form; var url = form.one('.' + CSS.INPUTURL).get('value'); var urlerror = url === ''; this._toggleVisibility('.' + CSS.IMAGEURLWARNING, urlerror); this._toggleAriaInvalid(['.' + CSS.INPUTURL], urlerror); return urlerror; }, _hasErrorAltField: function() { var form = this._form; var alt = form.one('.' + CSS.INPUTALT).get('value'); var presentation = form.one('.' + CSS.IMAGEPRESENTATION).get('checked'); var imagealterror = alt === '' && !presentation; this._toggleVisibility('.' + CSS.IMAGEALTWARNING, imagealterror); this._toggleAriaInvalid(['.' + CSS.INPUTALT, '.' + CSS.IMAGEPRESENTATION], imagealterror); return imagealterror; }, /** * Update the alt text warning live. * * @method _updateWarning * @return {boolean} whether a warning should be displayed. * @private */ _updateWarning: function() { var urlerror = this._hasErrorUrlField(); var imagealterror = this._hasErrorAltField(); var haserrors = urlerror || imagealterror; this.getDialogue().centerDialogue(); return haserrors; }, /** * Handle the keyup to update the character count. */ _handleKeyup: function() { var form = this._form, alt = form.one('.' + CSS.INPUTALT).get('value'), characterCount = alt.length, current = form.one('#currentcount'); current.setHTML(characterCount); } }); }, '@VERSION@', {"requires": ["moodle-editor_atto-plugin"]}); yui/src/button/meta/button.json 0000644 00000000164 15152177407 0012622 0 ustar 00 { "moodle-atto_image-button": { "requires": [ "moodle-editor_atto-plugin" ] } } yui/src/button/build.json 0000644 00000000223 15152177407 0011454 0 ustar 00 { "name": "moodle-atto_image-button", "builds": { "moodle-atto_image-button": { "jsfiles": [ "button.js" ] } } } yui/src/button/js/button.js 0000644 00000117277 15152177407 0011771 0 ustar 00 // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /* * @package atto_image * @copyright 2013 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ /** * @module moodle-atto_image_alignment-button */ /** * Atto image selection tool. * * @namespace M.atto_image * @class Button * @extends M.editor_atto.EditorPlugin */ var CSS = { RESPONSIVE: 'img-fluid', INPUTALIGNMENT: 'atto_image_alignment', INPUTALT: 'atto_image_altentry', INPUTHEIGHT: 'atto_image_heightentry', INPUTSUBMIT: 'atto_image_urlentrysubmit', INPUTURL: 'atto_image_urlentry', INPUTSIZE: 'atto_image_size', INPUTWIDTH: 'atto_image_widthentry', IMAGEALTWARNING: 'atto_image_altwarning', IMAGEURLWARNING: 'atto_image_urlwarning', IMAGEBROWSER: 'openimagebrowser', IMAGEPRESENTATION: 'atto_image_presentation', INPUTCONSTRAIN: 'atto_image_constrain', INPUTCUSTOMSTYLE: 'atto_image_customstyle', IMAGEPREVIEW: 'atto_image_preview', IMAGEPREVIEWBOX: 'atto_image_preview_box', ALIGNSETTINGS: 'atto_image_button' }, FORMNAMES = { URL: 'urlentry', ALT: 'altentry' }, SELECTORS = { INPUTURL: '.' + CSS.INPUTURL }, ALIGNMENTS = [ // Vertical alignment. { name: 'verticalAlign', str: 'alignment_top', value: 'text-top', margin: '0 0.5em' }, { name: 'verticalAlign', str: 'alignment_middle', value: 'middle', margin: '0 0.5em' }, { name: 'verticalAlign', str: 'alignment_bottom', value: 'text-bottom', margin: '0 0.5em', isDefault: true }, // Floats. { name: 'float', str: 'alignment_left', value: 'left', margin: '0 0.5em 0 0' }, { name: 'float', str: 'alignment_right', value: 'right', margin: '0 0 0 0.5em' } ], DEFAULTS = { WIDTH: 160, HEIGHT: 160, }, REGEX = { ISPERCENT: /\d+%/ }, COMPONENTNAME = 'atto_image', TEMPLATE = '' + '<form class="atto_form">' + // Add the repository browser button. '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.IMAGEURLWARNING}}">' + '<label for="{{elementid}}_{{CSS.INPUTURL}}">' + '{{get_string "imageurlrequired" component}}' + '</label>' + '</div>' + '{{#if showFilepicker}}' + '<div class="mb-1">' + '<label for="{{elementid}}_{{CSS.INPUTURL}}">{{get_string "enterurl" component}}</label>' + '<div class="input-group input-append w-100">' + '<input name="{{FORMNAMES.URL}}" class="form-control {{CSS.INPUTURL}}" type="url" ' + 'id="{{elementid}}_{{CSS.INPUTURL}}" size="32"/>' + '<span class="input-group-append">' + '<button class="btn btn-secondary {{CSS.IMAGEBROWSER}}" type="button">' + '{{get_string "browserepositories" component}}</button>' + '</span>' + '</div>' + '</div>' + '{{else}}' + '<div class="mb-1">' + '<label for="{{elementid}}_{{CSS.INPUTURL}}">{{get_string "enterurl" component}}</label>' + '<input name="{{FORMNAMES.URL}}" class="form-control fullwidth {{CSS.INPUTURL}}" type="url" ' + 'id="{{elementid}}_{{CSS.INPUTURL}}" size="32"/>' + '</div>' + '{{/if}}' + '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.IMAGEALTWARNING}}">' + '<label for="{{elementid}}_{{CSS.INPUTALT}}">' + '{{get_string "presentationoraltrequired" component}}' + '</label>' + '</div>' + // Add the Alt box. '<div class="mb-1">' + '<label for="{{elementid}}_{{CSS.INPUTALT}}">{{get_string "enteralt" component}}</label>' + '<textarea class="form-control fullwidth {{CSS.INPUTALT}}" ' + 'id="{{elementid}}_{{CSS.INPUTALT}}" name="{{FORMNAMES.ALT}}" maxlength="125"></textarea>' + // Add the character count. '<div id="the-count" class="d-flex justify-content-end small">' + '<span id="currentcount">0</span>' + '<span id="maximumcount"> / 125</span>' + '</div>' + // Add the presentation select box. '<div class="form-check">' + '<input type="checkbox" class="form-check-input {{CSS.IMAGEPRESENTATION}}" ' + 'id="{{elementid}}_{{CSS.IMAGEPRESENTATION}}"/>' + '<label class="form-check-label" for="{{elementid}}_{{CSS.IMAGEPRESENTATION}}">' + '{{get_string "presentation" component}}' + '</label>' + '</div>' + '</div>' + // Add the size entry boxes. '<div class="mb-1">' + '<label class="" for="{{elementid}}_{{CSS.INPUTSIZE}}">{{get_string "size" component}}</label>' + '<div id="{{elementid}}_{{CSS.INPUTSIZE}}" class="form-inline {{CSS.INPUTSIZE}}">' + '<label class="accesshide" for="{{elementid}}_{{CSS.INPUTWIDTH}}">{{get_string "width" component}}</label>' + '<input type="text" class="form-control mr-1 input-mini {{CSS.INPUTWIDTH}}" ' + 'id="{{elementid}}_{{CSS.INPUTWIDTH}}" size="4"/> x' + // Add the height entry box. '<label class="accesshide" for="{{elementid}}_{{CSS.INPUTHEIGHT}}">{{get_string "height" component}}</label>' + '<input type="text" class="form-control ml-1 input-mini {{CSS.INPUTHEIGHT}}" ' + 'id="{{elementid}}_{{CSS.INPUTHEIGHT}}" size="4"/>' + // Add the constrain checkbox. '<div class="form-check ml-2">' + '<input type="checkbox" class="form-check-input {{CSS.INPUTCONSTRAIN}}" ' + 'id="{{elementid}}_{{CSS.INPUTCONSTRAIN}}"/>' + '<label class="form-check-label" for="{{elementid}}_{{CSS.INPUTCONSTRAIN}}">' + '{{get_string "constrain" component}}</label>' + '</div>' + '</div>' + '</div>' + // Add the alignment selector. '<div class="form-inline mb-1">' + '<label class="for="{{elementid}}_{{CSS.INPUTALIGNMENT}}">{{get_string "alignment" component}}</label>' + '<select class="custom-select {{CSS.INPUTALIGNMENT}}" id="{{elementid}}_{{CSS.INPUTALIGNMENT}}">' + '{{#each alignments}}' + '<option value="{{value}}">{{get_string str ../component}}</option>' + '{{/each}}' + '</select>' + '</div>' + // Hidden input to store custom styles. '<input type="hidden" class="{{CSS.INPUTCUSTOMSTYLE}}"/>' + '<br/>' + // Add the image preview. '<div class="mdl-align">' + '<div class="{{CSS.IMAGEPREVIEWBOX}}">' + '<img class="{{CSS.IMAGEPREVIEW}}" alt="" style="display: none;"/>' + '</div>' + // Add the submit button and close the form. '<button class="btn btn-secondary {{CSS.INPUTSUBMIT}}" type="submit">' + '' + '{{get_string "saveimage" component}}</button>' + '</div>' + '</form>', IMAGETEMPLATE = '' + '<img src="{{url}}" alt="{{alt}}" ' + '{{#if width}}width="{{width}}" {{/if}}' + '{{#if height}}height="{{height}}" {{/if}}' + '{{#if presentation}}role="presentation" {{/if}}' + '{{#if customstyle}}style="{{customstyle}}" {{/if}}' + '{{#if classlist}}class="{{classlist}}" {{/if}}' + '{{#if id}}id="{{id}}" {{/if}}' + '/>'; Y.namespace('M.atto_image').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], { /** * A reference to the current selection at the time that the dialogue * was opened. * * @property _currentSelection * @type Range * @private */ _currentSelection: null, /** * The most recently selected image. * * @param _selectedImage * @type Node * @private */ _selectedImage: null, /** * A reference to the currently open form. * * @param _form * @type Node * @private */ _form: null, /** * The dimensions of the raw image before we manipulate it. * * @param _rawImageDimensions * @type Object * @private */ _rawImageDimensions: null, initializer: function() { this.addButton({ icon: 'e/insert_edit_image', callback: this._displayDialogue, tags: 'img', tagMatchRequiresAll: false }); this.editor.delegate('dblclick', this._displayDialogue, 'img', this); this.editor.delegate('click', this._handleClick, 'img', this); this.editor.on('paste', this._handlePaste, this); this.editor.on('drop', this._handleDragDrop, this); // ...e.preventDefault needed to stop the default event from clobbering the desired behaviour in some browsers. this.editor.on('dragover', function(e) { e.preventDefault(); }, this); this.editor.on('dragenter', function(e) { e.preventDefault(); }, this); }, /** * Handle a drag and drop event with an image. * * @method _handleDragDrop * @param {EventFacade} e * @private */ _handleDragDrop: function(e) { if (!e._event || !e._event.dataTransfer) { // Drop not fully supported in this browser. return; } this._handlePasteOrDropHelper(e, e._event.dataTransfer); }, /** * Handles paste events where - if the thing being pasted is an image. * * @method _handlePaste * @param {EventFacade} e * @return {boolean} false if we handled the event, else true. * @private */ _handlePaste: function(e) { if (!e._event || !e._event.clipboardData) { // Paste not fully supported in this browser. return true; } return this._handlePasteOrDropHelper(e, e._event.clipboardData); }, /** * Handle a drag and drop event with an image. * * @method _handleDragDrop * @param {EventFacade} e * @param {DataTransfer} dataTransfer * @return {boolean} false if we handled the event, else true. * @private */ _handlePasteOrDropHelper: function(e, dataTransfer) { var items = dataTransfer.items, didUpload = false; for (var i = 0; i < items.length; i++) { var item = items[i]; if (item.kind !== 'file') { continue; } if (!this._isImage(item.type)) { continue; } this._uploadImage(item.getAsFile()); didUpload = true; } if (didUpload) { e.preventDefault(); } }, /** * Is this file an image? * * @method _isImage * @param {string} mimeType the file's mime type. * @return {boolean} true if the file has an image mimeType. * @private */ _isImage: function(mimeType) { return mimeType.indexOf('image/') === 0; }, /** * Used by _handleDragDrop and _handlePaste to upload an image and insert it. * * @method _uploadImage * @param {File} fileToSave * @private */ _uploadImage: function(fileToSave) { var self = this, host = this.get('host'), template = Y.Handlebars.compile(IMAGETEMPLATE); host.saveSelection(); // Trigger form upload start events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadStarted(self.editor.get('id')); }); var options = host.get('filepickeroptions').image, savepath = (options.savepath === undefined) ? '/' : options.savepath, formData = new FormData(), timestamp = 0, uploadid = "", xhr = new XMLHttpRequest(), imagehtml = "", keys = Object.keys(options.repositories); formData.append('repo_upload_file', fileToSave); formData.append('itemid', options.itemid); // List of repositories is an object rather than an array. This makes iteration more awkward. for (var i = 0; i < keys.length; i++) { if (options.repositories[keys[i]].type === 'upload') { formData.append('repo_id', options.repositories[keys[i]].id); break; } } formData.append('env', options.env); formData.append('sesskey', M.cfg.sesskey); formData.append('client_id', options.client_id); formData.append('savepath', savepath); formData.append('ctx_id', options.context.id); // Insert spinner as a placeholder. timestamp = new Date().getTime(); uploadid = 'moodleimage_' + Math.round(Math.random() * 100000) + '-' + timestamp; host.focus(); host.restoreSelection(); imagehtml = template({ url: M.util.image_url("i/loading_small", 'moodle'), alt: M.util.get_string('uploading', COMPONENTNAME), id: uploadid }); host.insertContentAtFocusPoint(imagehtml); self.markUpdated(); // Kick off a XMLHttpRequest. xhr.onreadystatechange = function() { var placeholder = self.editor.one('#' + uploadid), result, file, newhtml, newimage; if (xhr.readyState === 4) { if (xhr.status === 200) { result = JSON.parse(xhr.responseText); if (result) { if (result.error) { if (placeholder) { placeholder.remove(true); } // Trigger form upload complete events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadCompleted(self.editor.get('id')); }); throw new M.core.ajaxException(result); } file = result; if (result.event && result.event === 'fileexists') { // A file with this name is already in use here - rename to avoid conflict. // Chances are, it's a different image (stored in a different folder on the user's computer). // If the user wants to reuse an existing image, they can copy/paste it within the editor. file = result.newfile; } // Replace placeholder with actual image. newhtml = template({ url: file.url, presentation: true, classlist: CSS.RESPONSIVE }); newimage = Y.Node.create(newhtml); if (placeholder) { placeholder.replace(newimage); } else { self.editor.appendChild(newimage); } self.markUpdated(); } } else { Y.use('moodle-core-notification-alert', function() { // Trigger form upload complete events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadCompleted(self.editor.get('id')); }); new M.core.alert({message: M.util.get_string('servererror', 'moodle')}); }); if (placeholder) { placeholder.remove(true); } } // Trigger form upload complete events. require(['core_form/events'], function(FormEvent) { FormEvent.notifyUploadCompleted(self.editor.get('id')); }); } }; xhr.open("POST", M.cfg.wwwroot + '/repository/repository_ajax.php?action=upload', true); xhr.send(formData); }, /** * Handle a click on an image. * * @method _handleClick * @param {EventFacade} e * @private */ _handleClick: function(e) { var image = e.target; var selection = this.get('host').getSelectionFromNode(image); if (this.get('host').getSelection() !== selection) { this.get('host').setSelection(selection); } }, /** * Display the image editing tool. * * @method _displayDialogue * @private */ _displayDialogue: function() { // Store the current selection. this._currentSelection = this.get('host').getSelection(); if (this._currentSelection === false) { return; } // Reset the image dimensions. this._rawImageDimensions = null; var dialogue = this.getDialogue({ headerContent: M.util.get_string('imageproperties', COMPONENTNAME), width: 'auto', focusAfterHide: true, focusOnShowSelector: SELECTORS.INPUTURL }); // Set a maximum width for the dialog. This will prevent the dialog width to extend beyond the screen width // in cases when the uploaded image has larger width. dialogue.get('boundingBox').setStyle('maxWidth', '90%'); // Set the dialogue content, and then show the dialogue. dialogue.set('bodyContent', this._getDialogueContent()) .show(); }, /** * Set the inputs for width and height if they are not set, and calculate * if the constrain checkbox should be checked or not. * * @method _loadPreviewImage * @param {String} url * @private */ _loadPreviewImage: function(url) { var image = new Image(); var self = this; image.onerror = function() { var preview = self._form.one('.' + CSS.IMAGEPREVIEW); preview.setStyles({ 'display': 'none' }); // Centre the dialogue when clearing the image preview. self.getDialogue().centerDialogue(); }; image.onload = function() { var input, currentwidth, currentheight, widthRatio, heightRatio; // Store dimensions of the raw image, falling back to defaults for images without dimensions (e.g. SVG). self._rawImageDimensions = { width: this.width || DEFAULTS.WIDTH, height: this.height || DEFAULTS.HEIGHT, }; input = self._form.one('.' + CSS.INPUTWIDTH); currentwidth = input.get('value'); if (currentwidth === '') { input.set('value', self._rawImageDimensions.width); currentwidth = "" + self._rawImageDimensions.width; } input = self._form.one('.' + CSS.INPUTHEIGHT); currentheight = input.get('value'); if (currentheight === '') { input.set('value', self._rawImageDimensions.height); currentheight = "" + self._rawImageDimensions.height; } input = self._form.one('.' + CSS.IMAGEPREVIEW); input.setAttribute('src', this.src); input.setStyles({ 'display': 'inline' }); input = self._form.one('.' + CSS.INPUTCONSTRAIN); if (currentwidth.match(REGEX.ISPERCENT) && currentheight.match(REGEX.ISPERCENT)) { input.set('checked', currentwidth === currentheight); } else if (this.width === 0 || this.height === 0) { // If we don't have both dimensions of the image, we can't auto-size it, so disable control. input.set('disabled', 'disabled'); } else { // This is the same as comparing to 3 decimal places. widthRatio = Math.round(1000 * parseInt(currentwidth, 10) / this.width); heightRatio = Math.round(1000 * parseInt(currentheight, 10) / this.height); input.set('checked', widthRatio === heightRatio); } // Apply the image sizing. self._autoAdjustSize(self); // Centre the dialogue once the preview image has loaded. self.getDialogue().centerDialogue(); }; image.src = url; }, /** * Return the dialogue content for the tool, attaching any required * events. * * @method _getDialogueContent * @return {Node} The content to place in the dialogue. * @private */ _getDialogueContent: function() { var template = Y.Handlebars.compile(TEMPLATE), canShowFilepicker = this.get('host').canShowFilepicker('image'), content = Y.Node.create(template({ elementid: this.get('host').get('elementid'), CSS: CSS, FORMNAMES: FORMNAMES, component: COMPONENTNAME, showFilepicker: canShowFilepicker, alignments: ALIGNMENTS })); this._form = content; // Configure the view of the current image. this._applyImageProperties(this._form); this._form.one('.' + CSS.INPUTURL).on('blur', this._urlChanged, this); this._form.one('.' + CSS.INPUTURL).on('change', this._hasErrorUrlField, this); this._form.one('.' + CSS.IMAGEPRESENTATION).on('change', this._hasErrorAltField, this); this._form.one('.' + CSS.INPUTALT).on('blur', this._hasErrorAltField, this); this._form.one('.' + CSS.INPUTWIDTH).on('blur', this._autoAdjustSize, this); this._form.one('.' + CSS.INPUTHEIGHT).on('blur', this._autoAdjustSize, this, true); this._form.one('.' + CSS.INPUTCONSTRAIN).on('change', function(event) { if (event.target.get('checked')) { this._autoAdjustSize(event); } }, this); this._form.one('.' + CSS.INPUTSUBMIT).on('click', this._setImage, this); if (canShowFilepicker) { this._form.one('.' + CSS.IMAGEBROWSER).on('click', function() { this.get('host').showFilepicker('image', this._filepickerCallback, this); }, this); } // Character count. this._form.one('.' + CSS.INPUTALT).on('keyup', this._handleKeyup, this); return content; }, _autoAdjustSize: function(e, forceHeight) { forceHeight = forceHeight || false; var keyField = this._form.one('.' + CSS.INPUTWIDTH), keyFieldType = 'width', subField = this._form.one('.' + CSS.INPUTHEIGHT), subFieldType = 'height', constrainField = this._form.one('.' + CSS.INPUTCONSTRAIN), keyFieldValue = keyField.get('value'), subFieldValue = subField.get('value'), imagePreview = this._form.one('.' + CSS.IMAGEPREVIEW), rawPercentage, rawSize; // If we do not know the image size, do not do anything. if (!this._rawImageDimensions) { return; } // Set the width back to default if it is empty. if (keyFieldValue === '') { keyFieldValue = this._rawImageDimensions[keyFieldType]; keyField.set('value', keyFieldValue); keyFieldValue = keyField.get('value'); } // Clear the existing preview sizes. imagePreview.setStyles({ width: null, height: null }); // Now update with the new values. if (!constrainField.get('checked')) { // We are not keeping the image proportion - update the preview accordingly. // Width. if (keyFieldValue.match(REGEX.ISPERCENT)) { rawPercentage = parseInt(keyFieldValue, 10); rawSize = this._rawImageDimensions.width / 100 * rawPercentage; imagePreview.setStyle('width', rawSize + 'px'); } else { imagePreview.setStyle('width', keyFieldValue + 'px'); } // Height. if (subFieldValue.match(REGEX.ISPERCENT)) { rawPercentage = parseInt(subFieldValue, 10); rawSize = this._rawImageDimensions.height / 100 * rawPercentage; imagePreview.setStyle('height', rawSize + 'px'); } else { imagePreview.setStyle('height', subFieldValue + 'px'); } } else { // We are keeping the image in proportion. if (forceHeight) { // By default we update based on width. Swap the key and sub fields around to achieve a height-based scale. var _temporaryValue; _temporaryValue = keyField; keyField = subField; subField = _temporaryValue; _temporaryValue = keyFieldType; keyFieldType = subFieldType; subFieldType = _temporaryValue; _temporaryValue = keyFieldValue; keyFieldValue = subFieldValue; subFieldValue = _temporaryValue; } if (keyFieldValue.match(REGEX.ISPERCENT)) { // This is a percentage based change. Copy it verbatim. subFieldValue = keyFieldValue; // Set the width to the calculated pixel width. rawPercentage = parseInt(keyFieldValue, 10); rawSize = this._rawImageDimensions.width / 100 * rawPercentage; // And apply the width/height to the container. imagePreview.setStyle('width', rawSize); rawSize = this._rawImageDimensions.height / 100 * rawPercentage; imagePreview.setStyle('height', rawSize); } else { // Calculate the scaled subFieldValue from the keyFieldValue. subFieldValue = Math.round((keyFieldValue / this._rawImageDimensions[keyFieldType]) * this._rawImageDimensions[subFieldType]); if (forceHeight) { imagePreview.setStyles({ 'width': subFieldValue, 'height': keyFieldValue }); } else { imagePreview.setStyles({ 'width': keyFieldValue, 'height': subFieldValue }); } } // Update the subField's value within the form to reflect the changes. subField.set('value', subFieldValue); } }, /** * Update the dialogue after an image was selected in the File Picker. * * @method _filepickerCallback * @param {object} params The parameters provided by the filepicker * containing information about the image. * @private */ _filepickerCallback: function(params) { if (params.url !== '') { var input = this._form.one('.' + CSS.INPUTURL); input.set('value', params.url); // Auto set the width and height. this._form.one('.' + CSS.INPUTWIDTH).set('value', ''); this._form.one('.' + CSS.INPUTHEIGHT).set('value', ''); // Load the preview image. this._loadPreviewImage(params.url); } }, /** * Applies properties of an existing image to the image dialogue for editing. * * @method _applyImageProperties * @param {Node} form * @private */ _applyImageProperties: function(form) { var properties = this._getSelectedImageProperties(), img = form.one('.' + CSS.IMAGEPREVIEW); if (properties === false) { img.setStyle('display', 'none'); // Set the default alignment. ALIGNMENTS.some(function(alignment) { if (alignment.isDefault) { form.one('.' + CSS.INPUTALIGNMENT).set('value', alignment.value); return true; } return false; }, this); return; } if (properties.align) { form.one('.' + CSS.INPUTALIGNMENT).set('value', properties.align); } if (properties.customstyle) { form.one('.' + CSS.INPUTCUSTOMSTYLE).set('value', properties.customstyle); } if (properties.width) { form.one('.' + CSS.INPUTWIDTH).set('value', properties.width); } if (properties.height) { form.one('.' + CSS.INPUTHEIGHT).set('value', properties.height); } if (properties.alt) { form.one('.' + CSS.INPUTALT).set('value', properties.alt); } if (properties.src) { form.one('.' + CSS.INPUTURL).set('value', properties.src); this._loadPreviewImage(properties.src); } if (properties.presentation) { form.one('.' + CSS.IMAGEPRESENTATION).set('checked', 'checked'); } // Update the image preview based on the form properties. this._autoAdjustSize(); }, /** * Gets the properties of the currently selected image. * * The first image only if multiple images are selected. * * @method _getSelectedImageProperties * @return {object} * @private */ _getSelectedImageProperties: function() { var properties = { src: null, alt: null, width: null, height: null, align: '', presentation: false }, // Get the current selection. images = this.get('host').getSelectedNodes(), width, height, style, image; if (images) { images = images.filter('img'); } if (images && images.size()) { image = this._removeLegacyAlignment(images.item(0)); this._selectedImage = image; style = image.getAttribute('style'); properties.customstyle = style; width = image.getAttribute('width'); if (!width.match(REGEX.ISPERCENT)) { width = parseInt(width, 10); } height = image.getAttribute('height'); if (!height.match(REGEX.ISPERCENT)) { height = parseInt(height, 10); } if (width !== 0) { properties.width = width; } if (height !== 0) { properties.height = height; } this._getAlignmentPropeties(image, properties); properties.src = image.getAttribute('src'); properties.alt = image.getAttribute('alt') || ''; properties.presentation = (image.get('role') === 'presentation'); return properties; } // No image selected - clean up. this._selectedImage = null; return false; }, /** * Sets the alignment of a properties object. * * @method _getAlignmentPropeties * @param {Node} image The image that the alignment properties should be found for * @param {Object} properties The properties object that is created in _getSelectedImageProperties() * @private */ _getAlignmentPropeties: function(image, properties) { var complete = false, defaultAlignment; // Check for an alignment value. complete = ALIGNMENTS.some(function(alignment) { var classname = this._getAlignmentClass(alignment.value); if (image.hasClass(classname)) { properties.align = alignment.value; Y.log('Found alignment ' + alignment.value, 'debug', 'atto_image-button'); return true; } if (alignment.isDefault) { defaultAlignment = alignment.value; } return false; }, this); if (!complete && defaultAlignment) { properties.align = defaultAlignment; } }, /** * Update the form when the URL was changed. This includes updating the * height, width, and image preview. * * @method _urlChanged * @private */ _urlChanged: function() { var input = this._form.one('.' + CSS.INPUTURL); if (input.get('value') !== '') { // Load the preview image. this._loadPreviewImage(input.get('value')); } }, /** * Update the image in the contenteditable. * * @method _setImage * @param {EventFacade} e * @private */ _setImage: function(e) { var form = this._form, url = form.one('.' + CSS.INPUTURL).get('value'), alt = form.one('.' + CSS.INPUTALT).get('value'), width = form.one('.' + CSS.INPUTWIDTH).get('value'), height = form.one('.' + CSS.INPUTHEIGHT).get('value'), alignment = this._getAlignmentClass(form.one('.' + CSS.INPUTALIGNMENT).get('value')), presentation = form.one('.' + CSS.IMAGEPRESENTATION).get('checked'), constrain = form.one('.' + CSS.INPUTCONSTRAIN).get('checked'), imagehtml, customstyle = form.one('.' + CSS.INPUTCUSTOMSTYLE).get('value'), classlist = [], host = this.get('host'); e.preventDefault(); // Check if there are any accessibility issues. if (this._updateWarning()) { return; } // Focus on the editor in preparation for inserting the image. host.focus(); if (url !== '') { if (this._selectedImage) { host.setSelection(host.getSelectionFromNode(this._selectedImage)); } else { host.setSelection(this._currentSelection); } if (constrain) { classlist.push(CSS.RESPONSIVE); } // Add the alignment class for the image. classlist.push(alignment); if (!width.match(REGEX.ISPERCENT) && isNaN(parseInt(width, 10))) { form.one('.' + CSS.INPUTWIDTH).focus(); return; } if (!height.match(REGEX.ISPERCENT) && isNaN(parseInt(height, 10))) { form.one('.' + CSS.INPUTHEIGHT).focus(); return; } var template = Y.Handlebars.compile(IMAGETEMPLATE); imagehtml = template({ url: url, alt: alt, width: width, height: height, presentation: presentation, customstyle: customstyle, classlist: classlist.join(' ') }); this.get('host').insertContentAtFocusPoint(imagehtml); this.markUpdated(); } this.getDialogue({ focusAfterHide: null }).hide(); }, /** * Removes any legacy styles added by previous versions of the atto image button. * * @method _removeLegacyAlignment * @param {Y.Node} imageNode * @return {Y.Node} * @private */ _removeLegacyAlignment: function(imageNode) { if (!imageNode.getStyle('margin')) { // There is no margin therefore this cannot match any known alignments. return imageNode; } ALIGNMENTS.some(function(alignment) { if (imageNode.getStyle(alignment.name) !== alignment.value) { // The name/value do not match. Skip. return false; } var normalisedNode = Y.Node.create('<div>'); normalisedNode.setStyle('margin', alignment.margin); if (imageNode.getStyle('margin') !== normalisedNode.getStyle('margin')) { // The margin does not match. return false; } Y.log('Legacy alignment found and removed.', 'info', 'atto_image-button'); imageNode.addClass(this._getAlignmentClass(alignment.value)); imageNode.setStyle(alignment.name, null); imageNode.setStyle('margin', null); return true; }, this); return imageNode; }, _getAlignmentClass: function(alignment) { return CSS.ALIGNSETTINGS + '_' + alignment; }, _toggleVisibility: function(selector, predicate) { var form = this._form; var element = form.all(selector); element.setStyle('display', predicate ? 'block' : 'none'); }, _toggleAriaInvalid: function(selectors, predicate) { var form = this._form; selectors.forEach(function(selector) { var element = form.all(selector); element.setAttribute('aria-invalid', predicate); }); }, _hasErrorUrlField: function() { var form = this._form; var url = form.one('.' + CSS.INPUTURL).get('value'); var urlerror = url === ''; this._toggleVisibility('.' + CSS.IMAGEURLWARNING, urlerror); this._toggleAriaInvalid(['.' + CSS.INPUTURL], urlerror); return urlerror; }, _hasErrorAltField: function() { var form = this._form; var alt = form.one('.' + CSS.INPUTALT).get('value'); var presentation = form.one('.' + CSS.IMAGEPRESENTATION).get('checked'); var imagealterror = alt === '' && !presentation; this._toggleVisibility('.' + CSS.IMAGEALTWARNING, imagealterror); this._toggleAriaInvalid(['.' + CSS.INPUTALT, '.' + CSS.IMAGEPRESENTATION], imagealterror); return imagealterror; }, /** * Update the alt text warning live. * * @method _updateWarning * @return {boolean} whether a warning should be displayed. * @private */ _updateWarning: function() { var urlerror = this._hasErrorUrlField(); var imagealterror = this._hasErrorAltField(); var haserrors = urlerror || imagealterror; this.getDialogue().centerDialogue(); return haserrors; }, /** * Handle the keyup to update the character count. */ _handleKeyup: function() { var form = this._form, alt = form.one('.' + CSS.INPUTALT).get('value'), characterCount = alt.length, current = form.one('#currentcount'); current.setHTML(characterCount); } }); lang/en/atto_image.php 0000644 00000003773 15152177407 0010734 0 ustar 00 <?php // This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see <http://www.gnu.org/licenses/>. /** * Strings for component 'atto_image', language 'en'. * * @package atto_image * @copyright 2013 Damyon Wiese <damyon@moodle.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['alignment'] = 'Alignment'; $string['alignment_bottom'] = 'Bottom'; $string['alignment_left'] = 'Left'; $string['alignment_middle'] = 'Middle'; $string['alignment_right'] = 'Right'; $string['alignment_top'] = 'Top'; $string['browserepositories'] = 'Browse repositories...'; $string['constrain'] = 'Auto size'; $string['createimage'] = 'Insert image'; $string['customstyle'] = 'Custom style'; $string['enteralt'] = 'Describe this image for someone who cannot see it'; $string['enterurl'] = 'Enter URL'; $string['height'] = 'Height'; $string['imageproperties'] = 'Image properties'; $string['presentation'] = 'This image is decorative only'; $string['pluginname'] = 'Insert or edit image'; $string['presentationoraltrequired'] = 'An image must have a description, unless it is marked as decorative only.'; $string['imageurlrequired'] = 'An image must have a URL.'; $string['preview'] = 'Preview'; $string['saveimage'] = 'Save image'; $string['size'] = 'Size'; $string['uploading'] = 'Uploading, please wait...'; $string['width'] = 'Width'; $string['privacy:metadata'] = 'The atto_image plugin does not store any personal data.';
| ver. 1.4 |
Github
|
.
| PHP 7.4.33 | ���֧ߧ֧�ѧ�ڧ� ����ѧߧڧ��: 0.01 |
proxy
|
phpinfo
|
���ѧ����ۧܧ�