8
0

selectionobserver.js 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. /**
  2. * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
  4. */
  5. /**
  6. * @module engine/view/observer/selectionobserver
  7. */
  8. /* global setInterval, clearInterval */
  9. import Observer from './observer';
  10. import MutationObserver from './mutationobserver';
  11. import { debounce } from 'lodash-es';
  12. /**
  13. * Selection observer class observes selection changes in the document. If selection changes on the document this
  14. * observer checks if there are any mutations and if DOM selection is different than the
  15. * {@link module:engine/view/document~Document#selection view selection}. Selection observer fires
  16. * {@link module:engine/view/document~Document#event:selectionChange} event only if selection change was the only change in the document
  17. * and DOM selection is different then the view selection.
  18. *
  19. * Note that this observer is attached by the {@link module:engine/view/view~View} and is available by default.
  20. *
  21. * @see module:engine/view/observer/mutationobserver~MutationObserver
  22. * @extends module:engine/view/observer/observer~Observer
  23. */
  24. export default class SelectionObserver extends Observer {
  25. constructor( view ) {
  26. super( view );
  27. /**
  28. * Instance of the mutation observer. Selection observer calls
  29. * {@link module:engine/view/observer/mutationobserver~MutationObserver#flush} to ensure that the mutations will be handled
  30. * before the {@link module:engine/view/document~Document#event:selectionChange} event is fired.
  31. *
  32. * @readonly
  33. * @member {module:engine/view/observer/mutationobserver~MutationObserver}
  34. * module:engine/view/observer/selectionobserver~SelectionObserver#mutationObserver
  35. */
  36. this.mutationObserver = view.getObserver( MutationObserver );
  37. /**
  38. * Reference to the view {@link module:engine/view/documentselection~DocumentSelection} object used to compare
  39. * new selection with it.
  40. *
  41. * @readonly
  42. * @member {module:engine/view/documentselection~DocumentSelection}
  43. * module:engine/view/observer/selectionobserver~SelectionObserver#selection
  44. */
  45. this.selection = this.document.selection;
  46. /* eslint-disable max-len */
  47. /**
  48. * Reference to the {@link module:engine/view/view~View#domConverter}.
  49. *
  50. * @readonly
  51. * @member {module:engine/view/domconverter~DomConverter} module:engine/view/observer/selectionobserver~SelectionObserver#domConverter
  52. */
  53. /* eslint-enable max-len */
  54. this.domConverter = view.domConverter;
  55. /**
  56. * Set of documents which have added "selectionchange" listener to avoid adding listener twice to the same
  57. * document.
  58. *
  59. * @private
  60. * @member {WeakSet.<Document>} module:engine/view/observer/selectionobserver~SelectionObserver#_documents
  61. */
  62. this._documents = new WeakSet();
  63. /**
  64. * Fires debounced event `selectionChangeDone`. It uses `lodash#debounce` method to delay function call.
  65. *
  66. * @private
  67. * @param {Object} data Selection change data.
  68. * @method #_fireSelectionChangeDoneDebounced
  69. */
  70. this._fireSelectionChangeDoneDebounced = debounce( data => this.document.fire( 'selectionChangeDone', data ), 200 );
  71. this._clearInfiniteLoopInterval = setInterval( () => this._clearInfiniteLoop(), 1000 );
  72. /**
  73. * Private property to check if the code does not enter infinite loop.
  74. *
  75. * @private
  76. * @member {Number} module:engine/view/observer/selectionobserver~SelectionObserver#_loopbackCounter
  77. */
  78. this._loopbackCounter = 0;
  79. }
  80. /**
  81. * @inheritDoc
  82. */
  83. observe( domElement ) {
  84. const domDocument = domElement.ownerDocument;
  85. // Add listener once per each document.
  86. if ( this._documents.has( domDocument ) ) {
  87. return;
  88. }
  89. this.listenTo( domDocument, 'selectionchange', () => {
  90. this._handleSelectionChange( domDocument );
  91. } );
  92. this._documents.add( domDocument );
  93. }
  94. /**
  95. * @inheritDoc
  96. */
  97. destroy() {
  98. super.destroy();
  99. clearInterval( this._clearInfiniteLoopInterval );
  100. this._fireSelectionChangeDoneDebounced.cancel();
  101. }
  102. /**
  103. * Selection change listener. {@link module:engine/view/observer/mutationobserver~MutationObserver#flush Flush} mutations, check if
  104. * selection changes and fires {@link module:engine/view/document~Document#event:selectionChange} event on every change
  105. * and {@link module:engine/view/document~Document#event:selectionChangeDone} when selection stop changing.
  106. *
  107. * @private
  108. * @param {Document} domDocument DOM document.
  109. */
  110. _handleSelectionChange( domDocument ) {
  111. if ( !this.isEnabled ) {
  112. return;
  113. }
  114. // Ensure the mutation event will be before selection event on all browsers.
  115. this.mutationObserver.flush();
  116. // If there were mutations then the view will be re-rendered by the mutation observer and selection
  117. // will be updated, so selections will equal and event will not be fired, as expected.
  118. const domSelection = domDocument.defaultView.getSelection();
  119. const newViewSelection = this.domConverter.domSelectionToView( domSelection );
  120. // Do not convert selection if new view selection has no ranges in it.
  121. //
  122. // It means that the DOM selection was in some way incorrect. Ranges that were in the DOM selection could not be
  123. // converted to the view selection. This happens when the selection is moved outside an editable.
  124. if ( newViewSelection.rangeCount == 0 ) {
  125. return;
  126. }
  127. if ( this.selection.isEqual( newViewSelection ) && this.domConverter.isDomSelectionCorrect( domSelection ) ) {
  128. return;
  129. }
  130. // Ensure we are not in the infinite loop (#400).
  131. // This counter is reset each second. 60 selection changes in 1 second is enough high number
  132. // to be very difficult (impossible) to achieve using just keyboard keys (during normal editor use).
  133. if ( ++this._loopbackCounter > 60 ) {
  134. // Selection change observer detected an infinite rendering loop.
  135. // Most probably you try to put the selection in the position which is not allowed
  136. // by the browser and browser fixes it automatically what causes `selectionchange` event on
  137. // which a loopback through a model tries to re-render the wrong selection and again.
  138. //
  139. // @if CK_DEBUG // console.warn( 'Selection change observer detected an infinite rendering loop.' );
  140. return;
  141. }
  142. if ( this.selection.isSimilar( newViewSelection ) ) {
  143. // If selection was equal and we are at this point of algorithm, it means that it was incorrect.
  144. // Just re-render it, no need to fire any events, etc.
  145. this.view.forceRender();
  146. } else {
  147. const data = {
  148. oldSelection: this.selection,
  149. newSelection: newViewSelection,
  150. domSelection
  151. };
  152. // Prepare data for new selection and fire appropriate events.
  153. this.document.fire( 'selectionChange', data );
  154. // Call` #_fireSelectionChangeDoneDebounced` every time when `selectionChange` event is fired.
  155. // This function is debounced what means that `selectionChangeDone` event will be fired only when
  156. // defined int the function time will elapse since the last time the function was called.
  157. // So `selectionChangeDone` will be fired when selection will stop changing.
  158. this._fireSelectionChangeDoneDebounced( data );
  159. }
  160. }
  161. /**
  162. * Clears `SelectionObserver` internal properties connected with preventing infinite loop.
  163. *
  164. * @protected
  165. */
  166. _clearInfiniteLoop() {
  167. this._loopbackCounter = 0;
  168. }
  169. }
  170. /**
  171. * Fired when selection has changed. This event is fired only when the selection change was the only change that happened
  172. * in the document, and old selection is different then the new selection.
  173. *
  174. * Introduced by {@link module:engine/view/observer/selectionobserver~SelectionObserver}.
  175. *
  176. * Note that because {@link module:engine/view/observer/selectionobserver~SelectionObserver} is attached by the
  177. * {@link module:engine/view/view~View} this event is available by default.
  178. *
  179. * @see module:engine/view/observer/selectionobserver~SelectionObserver
  180. * @event module:engine/view/document~Document#event:selectionChange
  181. * @param {Object} data
  182. * @param {module:engine/view/documentselection~DocumentSelection} data.oldSelection Old View selection which is
  183. * {@link module:engine/view/document~Document#selection}.
  184. * @param {module:engine/view/selection~Selection} data.newSelection New View selection which is converted DOM selection.
  185. * @param {Selection} data.domSelection Native DOM selection.
  186. */
  187. /**
  188. * Fired when selection stops changing.
  189. *
  190. * Introduced by {@link module:engine/view/observer/selectionobserver~SelectionObserver}.
  191. *
  192. * Note that because {@link module:engine/view/observer/selectionobserver~SelectionObserver} is attached by the
  193. * {@link module:engine/view/view~View} this event is available by default.
  194. *
  195. * @see module:engine/view/observer/selectionobserver~SelectionObserver
  196. * @event module:engine/view/document~Document#event:selectionChangeDone
  197. * @param {Object} data
  198. * @param {module:engine/view/documentselection~DocumentSelection} data.oldSelection Old View selection which is
  199. * {@link module:engine/view/document~Document#selection}.
  200. * @param {module:engine/view/selection~Selection} data.newSelection New View selection which is converted DOM selection.
  201. * @param {Selection} data.domSelection Native DOM selection.
  202. */