23884531169f5ef04276f7db224daa505726a26c
[moodle.git] / lib / editor / atto / plugins / link / yui / src / button / js / button.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /*
17  * @package    atto_link
18  * @copyright  2013 Damyon Wiese  <damyon@moodle.com>
19  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
20  */
22 /**
23  * @module moodle-atto_link-button
24  */
26 /**
27  * Atto text editor link plugin.
28  *
29  * @namespace M.atto_link
30  * @class button
31  * @extends M.editor_atto.EditorPlugin
32  */
34 var COMPONENTNAME = 'atto_link',
35     CSS = {
36         NEWWINDOW: 'atto_link_openinnewwindow',
37         URLINPUT: 'atto_link_urlentry'
38     },
39     SELECTORS = {
40         URLINPUT: '.atto_link_urlentry'
41     },
42     TEMPLATE = '' +
43             '<form class="atto_form">' +
44                 '{{#if showFilepicker}}' +
45                     '<label for="{{elementid}}_atto_link_urlentry">{{get_string "enterurl" component}}</label>' +
46                     '<div class="input-group input-append w-100 m-b-1">' +
47                         '<input class="form-control url {{CSS.URLINPUT}}" type="url" ' +
48                         'id="{{elementid}}_atto_link_urlentry"/>' +
49                         '<span class="input-group-append">' +
50                             '<button class="btn btn-default openlinkbrowser" type="button">' +
51                             '{{get_string "browserepositories" component}}</button>' +
52                         '</span>' +
53                     '</div>' +
54                 '{{else}}' +
55                     '<div class="m-b-1">' +
56                         '<label for="{{elementid}}_atto_link_urlentry">{{get_string "enterurl" component}}</label>' +
57                         '<input class="form-control fullwidth url {{CSS.URLINPUT}}" type="url" ' +
58                         'id="{{elementid}}_atto_link_urlentry" size="32"/>' +
59                     '</div>' +
60                 '{{/if}}' +
61                 '<div class="form-check">' +
62                     '<input type="checkbox" class="form-check-input newwindow" id="{{elementid}}_{{CSS.NEWWINDOW}}"/>' +
63                     '<label class="form-check-label" for="{{elementid}}_{{CSS.NEWWINDOW}}">' +
64                     '{{get_string "openinnewwindow" component}}' +
65                     '</label>' +
66                 '</div>' +
67                 '<div class="mdl-align">' +
68                     '<br/>' +
69                     '<button type="submit" class="btn btn-default submit">{{get_string "createlink" component}}</button>' +
70                 '</div>' +
71             '</form>';
72 Y.namespace('M.atto_link').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
74     /**
75      * A reference to the current selection at the time that the dialogue
76      * was opened.
77      *
78      * @property _currentSelection
79      * @type Range
80      * @private
81      */
82     _currentSelection: null,
84     /**
85      * A reference to the dialogue content.
86      *
87      * @property _content
88      * @type Node
89      * @private
90      */
91     _content: null,
93     initializer: function() {
94         // Add the link button first.
95         this.addButton({
96             icon: 'e/insert_edit_link',
97             keys: '75',
98             callback: this._displayDialogue,
99             tags: 'a',
100             tagMatchRequiresAll: false
101         });
103         // And then the unlink button.
104         this.addButton({
105             buttonName: 'unlink',
106             callback: this._unlink,
107             icon: 'e/remove_link',
108             title: 'unlink',
110             // Watch the following tags and add/remove highlighting as appropriate:
111             tags: 'a',
112             tagMatchRequiresAll: false
113         });
114     },
116     /**
117      * Display the link editor.
118      *
119      * @method _displayDialogue
120      * @private
121      */
122     _displayDialogue: function() {
123         // Store the current selection.
124         this._currentSelection = this.get('host').getSelection();
125         if (this._currentSelection === false) {
126             return;
127         }
129         var dialogue = this.getDialogue({
130             headerContent: M.util.get_string('createlink', COMPONENTNAME),
131             width: 'auto',
132             focusAfterHide: true,
133             focusOnShowSelector: SELECTORS.URLINPUT
134         });
136         // Set the dialogue content, and then show the dialogue.
137         dialogue.set('bodyContent', this._getDialogueContent());
139         // Resolve anchors in the selected text.
140         this._resolveAnchors();
141         dialogue.show();
142     },
144     /**
145      * If there is selected text and it is part of an anchor link,
146      * extract the url (and target) from the link (and set them in the form).
147      *
148      * @method _resolveAnchors
149      * @private
150      */
151     _resolveAnchors: function() {
152         // Find the first anchor tag in the selection.
153         var selectednode = this.get('host').getSelectionParentNode(),
154             anchornodes,
155             anchornode,
156             url,
157             target;
159         // Note this is a document fragment and YUI doesn't like them.
160         if (!selectednode) {
161             return;
162         }
164         anchornodes = this._findSelectedAnchors(Y.one(selectednode));
165         if (anchornodes.length > 0) {
166             anchornode = anchornodes[0];
167             this._currentSelection = this.get('host').getSelectionFromNode(anchornode);
168             url = anchornode.getAttribute('href');
169             target = anchornode.getAttribute('target');
170             if (url !== '') {
171                 this._content.one('.url').setAttribute('value', url);
172             }
173             if (target === '_blank') {
174                 this._content.one('.newwindow').setAttribute('checked', 'checked');
175             } else {
176                 this._content.one('.newwindow').removeAttribute('checked');
177             }
178         }
179     },
181     /**
182      * Update the dialogue after a link was selected in the File Picker.
183      *
184      * @method _filepickerCallback
185      * @param {object} params The parameters provided by the filepicker
186      * containing information about the link.
187      * @private
188      */
189     _filepickerCallback: function(params) {
190         this.getDialogue()
191                 .set('focusAfterHide', null)
192                 .hide();
194         if (params.url !== '') {
195             // Add the link.
196             this._setLinkOnSelection(params.url);
198             // And mark the text area as updated.
199             this.markUpdated();
200         }
201     },
203     /**
204      * The link was inserted, so make changes to the editor source.
205      *
206      * @method _setLink
207      * @param {EventFacade} e
208      * @private
209      */
210     _setLink: function(e) {
211         var input,
212             value;
214         e.preventDefault();
215         this.getDialogue({
216             focusAfterHide: null
217         }).hide();
219         input = this._content.one('.url');
221         value = input.get('value');
222         if (value !== '') {
224             // We add a prefix if it is not already prefixed.
225             value = value.trim();
226             var expr = new RegExp(/^[a-zA-Z]*\.*\/|^#|^[a-zA-Z]*:/);
227             if (!expr.test(value)) {
228                 value = 'http://' + value;
229             }
231             // Add the link.
232             this._setLinkOnSelection(value);
234             this.markUpdated();
235         }
236     },
238     /**
239      * Final step setting the anchor on the selection.
240      *
241      * @private
242      * @method _setLinkOnSelection
243      * @param  {String} url URL the link will point to.
244      * @return {Node} The added Node.
245      */
246     _setLinkOnSelection: function(url) {
247         var host = this.get('host'),
248             link,
249             selectednode,
250             target,
251             anchornodes;
253         this.editor.focus();
254         host.setSelection(this._currentSelection);
256         if (this._currentSelection[0].collapsed) {
257             // Firefox cannot add links when the selection is empty so we will add it manually.
258             link = Y.Node.create('<a>' + url + '</a>');
259             link.setAttribute('href', url);
261             // Add the node and select it to replicate the behaviour of execCommand.
262             selectednode = host.insertContentAtFocusPoint(link.get('outerHTML'));
263             host.setSelection(host.getSelectionFromNode(selectednode));
264         } else {
265             document.execCommand('unlink', false, null);
266             document.execCommand('createLink', false, url);
268             // Now set the target.
269             selectednode = host.getSelectionParentNode();
270         }
272         // Note this is a document fragment and YUI doesn't like them.
273         if (!selectednode) {
274             return;
275         }
277         anchornodes = this._findSelectedAnchors(Y.one(selectednode));
278         // Add new window attributes if requested.
279         Y.Array.each(anchornodes, function(anchornode) {
280             target = this._content.one('.newwindow');
281             if (target.get('checked')) {
282                 anchornode.setAttribute('target', '_blank');
283             } else {
284                 anchornode.removeAttribute('target');
285             }
286         }, this);
288         return selectednode;
289     },
291     /**
292      * Look up and down for the nearest anchor tags that are least partly contained in the selection.
293      *
294      * @method _findSelectedAnchors
295      * @param {Node} node The node to search under for the selected anchor.
296      * @return {Node|Boolean} The Node, or false if not found.
297      * @private
298      */
299     _findSelectedAnchors: function(node) {
300         var tagname = node.get('tagName'),
301             hit, hits;
303         // Direct hit.
304         if (tagname && tagname.toLowerCase() === 'a') {
305             return [node];
306         }
308         // Search down but check that each node is part of the selection.
309         hits = [];
310         node.all('a').each(function(n) {
311             if (!hit && this.get('host').selectionContainsNode(n)) {
312                 hits.push(n);
313             }
314         }, this);
315         if (hits.length > 0) {
316             return hits;
317         }
318         // Search up.
319         hit = node.ancestor('a');
320         if (hit) {
321             return [hit];
322         }
323         return [];
324     },
326     /**
327      * Generates the content of the dialogue.
328      *
329      * @method _getDialogueContent
330      * @return {Node} Node containing the dialogue content
331      * @private
332      */
333     _getDialogueContent: function() {
334         var canShowFilepicker = this.get('host').canShowFilepicker('link'),
335             template = Y.Handlebars.compile(TEMPLATE);
337         this._content = Y.Node.create(template({
338             showFilepicker: canShowFilepicker,
339             component: COMPONENTNAME,
340             CSS: CSS
341         }));
343         this._content.one('.submit').on('click', this._setLink, this);
344         if (canShowFilepicker) {
345             this._content.one('.openlinkbrowser').on('click', function(e) {
346                 e.preventDefault();
347                 this.get('host').showFilepicker('link', this._filepickerCallback, this);
348             }, this);
349         }
351         return this._content;
352     },
354     /**
355      * Unlinks the current selection.
356      * If the selection is empty (e.g. the cursor is placed within a link),
357      * then the whole link is unlinked.
358      *
359      * @method _unlink
360      * @private
361      */
362     _unlink: function() {
363         var host = this.get('host'),
364             range = host.getSelection();
366         if (range && range.length) {
367             if (range[0].startOffset === range[0].endOffset) {
368                 // The cursor was placed in the editor but there was no selection - select the whole parent.
369                 var nodes = host.getSelectedNodes();
370                 if (nodes) {
371                     // We need to unlink each anchor individually - we cannot select a range because it may only consist of a
372                     // fragment of an anchor. Selecting the parent would be dangerous because it may contain other links which
373                     // would then be unlinked too.
374                     nodes.each(function(node) {
375                         // We need to select the whole anchor node for this to work in some browsers.
376                         // We only need to search up because getSeletedNodes returns all Nodes in the selection.
377                         var anchor = node.ancestor('a', true);
378                         if (anchor) {
379                             // Set the selection to the whole of the first anchro.
380                             host.setSelection(host.getSelectionFromNode(anchor));
382                             // Call the browser unlink.
383                             document.execCommand('unlink', false, null);
384                         }
385                     }, this);
387                     // And mark the text area as updated.
388                     this.markUpdated();
389                 }
390             } else {
391                 // Call the browser unlink.
392                 document.execCommand('unlink', false, null);
394                 // And mark the text area as updated.
395                 this.markUpdated();
396             }
397         }
398     }
399 });