MDL-53249 atto: Make sure all editors see end move event
[moodle.git] / lib / editor / atto / yui / src / editor / js / selection.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  * @module moodle-editor_atto-editor
18  * @submodule selection
19  */
21 /**
22  * Selection functions for the Atto editor.
23  *
24  * See {{#crossLink "M.editor_atto.Editor"}}{{/crossLink}} for details.
25  *
26  * @namespace M.editor_atto
27  * @class EditorSelection
28  */
30 function EditorSelection() {}
32 EditorSelection.ATTRS= {
33 };
35 EditorSelection.prototype = {
37     /**
38      * List of saved selections per editor instance.
39      *
40      * @property _selections
41      * @private
42      */
43     _selections: null,
45     /**
46      * A unique identifier for the last selection recorded.
47      *
48      * @property _lastSelection
49      * @param lastselection
50      * @type string
51      * @private
52      */
53     _lastSelection: null,
55     /**
56      * Whether focus came from a click event.
57      *
58      * This is used to determine whether to restore the selection or not.
59      *
60      * @property _focusFromClick
61      * @type Boolean
62      * @default false
63      * @private
64      */
65     _focusFromClick: false,
67     /**
68      * Whether if the last gesturemovestart event target was contained in this editor or not.
69      *
70      * @property _gesturestartededitor
71      * @type Boolean
72      * @default false
73      * @private
74      */
75     _gesturestartededitor: false,
77     /**
78      * Set up the watchers for selection save and restoration.
79      *
80      * @method setupSelectionWatchers
81      * @chainable
82      */
83     setupSelectionWatchers: function() {
84         // Save the selection when a change was made.
85         this.on('atto:selectionchanged', this.saveSelection, this);
87         this.editor.on('focus', this.restoreSelection, this);
89         // Do not restore selection when focus is from a click event.
90         this.editor.on('mousedown', function() {
91             this._focusFromClick = true;
92         }, this);
94         // Copy the current value back to the textarea when focus leaves us and save the current selection.
95         this.editor.on('blur', function() {
96             // Clear the _focusFromClick value.
97             this._focusFromClick = false;
99             // Update the original text area.
100             this.updateOriginal();
101         }, this);
103         this.editor.on(['keyup', 'focus'], function(e) {
104                 Y.soon(Y.bind(this._hasSelectionChanged, this, e));
105             }, this);
107         Y.one(document.body).on('gesturemovestart', function(e) {
108             if (this._wrapper.contains(e.target._node)) {
109                 this._gesturestartededitor = true;
110             } else {
111                 this._gesturestartededitor = false;
112             }
113         }, null, this);
115         Y.one(document.body).on('gesturemoveend', function(e) {
116             if (!this._gesturestartededitor) {
117                 // Ignore the event if movestart target was not contained in the editor.
118                 return;
119             }
120             Y.soon(Y.bind(this._hasSelectionChanged, this, e));
121         }, {
122             // Standalone will make sure all editors receive the end event.
123             standAlone: true
124         }, this);
126         return this;
127     },
129     /**
130      * Work out if the cursor is in the editable area for this editor instance.
131      *
132      * @method isActive
133      * @return {boolean}
134      */
135     isActive: function() {
136         var range = rangy.createRange(),
137             selection = rangy.getSelection();
139         if (!selection.rangeCount) {
140             // If there was no range count, then there is no selection.
141             return false;
142         }
144         // We can't be active if the editor doesn't have focus at the moment.
145         if (!document.activeElement ||
146                 !(this.editor.compareTo(document.activeElement) || this.editor.contains(document.activeElement))) {
147             return false;
148         }
150         // Check whether the range intersects the editor selection.
151         range.selectNode(this.editor.getDOMNode());
152         return range.intersectsRange(selection.getRangeAt(0));
153     },
155     /**
156      * Create a cross browser selection object that represents a YUI node.
157      *
158      * @method getSelectionFromNode
159      * @param {Node} YUI Node to base the selection upon.
160      * @return {[rangy.Range]}
161      */
162     getSelectionFromNode: function(node) {
163         var range = rangy.createRange();
164         range.selectNode(node.getDOMNode());
165         return [range];
166     },
168     /**
169      * Save the current selection to an internal property.
170      *
171      * This allows more reliable return focus, helping improve keyboard navigation.
172      *
173      * Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/restoreSelection"}}{{/crossLink}}.
174      *
175      * @method saveSelection
176      */
177     saveSelection: function() {
178         if (this.isActive()) {
179             this._selections = this.getSelection();
180         }
181     },
183     /**
184      * Restore any stored selection when the editor gets focus again.
185      *
186      * Should be used in combination with {{#crossLink "M.editor_atto.EditorSelection/saveSelection"}}{{/crossLink}}.
187      *
188      * @method restoreSelection
189      */
190     restoreSelection: function() {
191         if (!this._focusFromClick) {
192             if (this._selections) {
193                 this.setSelection(this._selections);
194             }
195         }
196         this._focusFromClick = false;
197     },
199     /**
200      * Get the selection object that can be passed back to setSelection.
201      *
202      * @method getSelection
203      * @return {array} An array of rangy ranges.
204      */
205     getSelection: function() {
206         return rangy.getSelection().getAllRanges();
207     },
209     /**
210      * Check that a YUI node it at least partly contained by the current selection.
211      *
212      * @method selectionContainsNode
213      * @param {Node} The node to check.
214      * @return {boolean}
215      */
216     selectionContainsNode: function(node) {
217         return rangy.getSelection().containsNode(node.getDOMNode(), true);
218     },
220     /**
221      * Runs a filter on each node in the selection, and report whether the
222      * supplied selector(s) were found in the supplied Nodes.
223      *
224      * By default, all specified nodes must match the selection, but this
225      * can be controlled with the requireall property.
226      *
227      * @method selectionFilterMatches
228      * @param {String} selector
229      * @param {NodeList} [selectednodes] For performance this should be passed. If not passed, this will be looked up each time.
230      * @param {Boolean} [requireall=true] Used to specify that "any" match is good enough.
231      * @return {Boolean}
232      */
233     selectionFilterMatches: function(selector, selectednodes, requireall) {
234         if (typeof requireall === 'undefined') {
235             requireall = true;
236         }
237         if (!selectednodes) {
238             // Find this because it was not passed as a param.
239             selectednodes = this.getSelectedNodes();
240         }
241         var allmatch = selectednodes.size() > 0,
242             anymatch = false;
244         var editor = this.editor,
245             stopFn = function(node) {
246                 // The function getSelectedNodes only returns nodes within the editor, so this test is safe.
247                 return node === editor;
248             };
250         // If we do not find at least one match in the editor, no point trying to find them in the selection.
251         if (!editor.one(selector)) {
252             return false;
253         }
255         selectednodes.each(function(node){
256             // Check each node, if it doesn't match the tags AND is not within the specified tags then fail this thing.
257             if (requireall) {
258                 // Check for at least one failure.
259                 if (!allmatch || !node.ancestor(selector, true, stopFn)) {
260                     allmatch = false;
261                 }
262             } else {
263                 // Check for at least one match.
264                 if (!anymatch && node.ancestor(selector, true, stopFn)) {
265                     anymatch = true;
266                 }
267             }
268         }, this);
269         if (requireall) {
270             return allmatch;
271         } else {
272             return anymatch;
273         }
274     },
276     /**
277      * Get the deepest possible list of nodes in the current selection.
278      *
279      * @method getSelectedNodes
280      * @return {NodeList}
281      */
282     getSelectedNodes: function() {
283         var results = new Y.NodeList(),
284             nodes,
285             selection,
286             range,
287             node,
288             i;
290         selection = rangy.getSelection();
292         if (selection.rangeCount) {
293             range = selection.getRangeAt(0);
294         } else {
295             // Empty range.
296             range = rangy.createRange();
297         }
299         if (range.collapsed) {
300             // We do not want to select all the nodes in the editor if we managed to
301             // have a collapsed selection directly in the editor.
302             // It's also possible for the commonAncestorContainer to be the document, which selectNode does not handle
303             // so we must filter that out here too.
304             if (range.commonAncestorContainer !== this.editor.getDOMNode()
305                     && range.commonAncestorContainer !== Y.config.doc) {
306                 range = range.cloneRange();
307                 range.selectNode(range.commonAncestorContainer);
308             }
309         }
311         nodes = range.getNodes();
313         for (i = 0; i < nodes.length; i++) {
314             node = Y.one(nodes[i]);
315             if (this.editor.contains(node)) {
316                 results.push(node);
317             }
318         }
319         return results;
320     },
322     /**
323      * Check whether the current selection has changed since this method was last called.
324      *
325      * If the selection has changed, the atto:selectionchanged event is also fired.
326      *
327      * @method _hasSelectionChanged
328      * @private
329      * @param {EventFacade} e
330      * @return {Boolean}
331      */
332     _hasSelectionChanged: function(e) {
333         var selection = rangy.getSelection(),
334             range,
335             changed = false;
337         if (selection.rangeCount) {
338             range = selection.getRangeAt(0);
339         } else {
340             // Empty range.
341             range = rangy.createRange();
342         }
344         if (this._lastSelection) {
345             if (!this._lastSelection.equals(range)) {
346                 changed = true;
347                 return this._fireSelectionChanged(e);
348             }
349         }
350         this._lastSelection = range;
351         return changed;
352     },
354     /**
355      * Fires the atto:selectionchanged event.
356      *
357      * When the selectionchanged event is fired, the following arguments are provided:
358      *   - event : the original event that lead to this event being fired.
359      *   - selectednodes :  an array containing nodes that are entirely selected of contain partially selected content.
360      *
361      * @method _fireSelectionChanged
362      * @private
363      * @param {EventFacade} e
364      */
365     _fireSelectionChanged: function(e) {
366         this.fire('atto:selectionchanged', {
367             event: e,
368             selectedNodes: this.getSelectedNodes()
369         });
370     },
372     /**
373      * Get the DOM node representing the common anscestor of the selection nodes.
374      *
375      * @method getSelectionParentNode
376      * @return {Element|boolean} The DOM Node for this parent, or false if no seletion was made.
377      */
378     getSelectionParentNode: function() {
379         var selection = rangy.getSelection();
380         if (selection.rangeCount) {
381             return selection.getRangeAt(0).commonAncestorContainer;
382         }
383         return false;
384     },
386     /**
387      * Set the current selection. Used to restore a selection.
388      *
389      * @method selection
390      * @param {array} ranges A list of rangy.range objects in the selection.
391      */
392     setSelection: function(ranges) {
393         var selection = rangy.getSelection();
394         selection.setRanges(ranges);
395     },
397     /**
398      * Inserts the given HTML into the editable content at the currently focused point.
399      *
400      * @method insertContentAtFocusPoint
401      * @param {String} html
402      * @return {Node} The YUI Node object added to the DOM.
403      */
404     insertContentAtFocusPoint: function(html) {
405         var selection = rangy.getSelection(),
406             range,
407             node = Y.Node.create(html);
408         if (selection.rangeCount) {
409             range = selection.getRangeAt(0);
410         }
411         if (range) {
412             range.deleteContents();
413             range.insertNode(node.getDOMNode());
414         }
415         return node;
416     }
418 };
420 Y.Base.mix(Y.M.editor_atto.Editor, [EditorSelection]);