textwatcher.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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 typing/textwatcher
  7. */
  8. import mix from '@ckeditor/ckeditor5-utils/src/mix';
  9. import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
  10. import getLastTextLine from './utils/getlasttextline';
  11. /**
  12. * The text watcher feature.
  13. *
  14. * Fires the {@link module:typing/textwatcher~TextWatcher#event:matched:data `matched:data`},
  15. * {@link module:typing/textwatcher~TextWatcher#event:matched:selection `matched:selection`} and
  16. * {@link module:typing/textwatcher~TextWatcher#event:unmatched `unmatched`} events on typing or selection changes.
  17. *
  18. * @private
  19. * @mixes module:utils/observablemixin~ObservableMixin
  20. */
  21. export default class TextWatcher {
  22. /**
  23. * Creates a text watcher instance.
  24. *
  25. * @param {module:engine/model/model~Model} model
  26. * @param {Function} testCallback See {@link module:typing/textwatcher~TextWatcher#testCallback}.
  27. */
  28. constructor( model, testCallback ) {
  29. /**
  30. * The editor's model.
  31. *
  32. * @readonly
  33. * @member {module:engine/model/model~Model}
  34. */
  35. this.model = model;
  36. /**
  37. * The function used to match the text.
  38. *
  39. * The test callback can return 3 values:
  40. *
  41. * * `false` if there is no match,
  42. * * `true` if there is a match,
  43. * * an object if there is a match and we want to pass some additional information to the {@link #event:matched:data} event.
  44. *
  45. * @member {Function} #testCallback
  46. * @returns {Object} testResult
  47. */
  48. this.testCallback = testCallback;
  49. /**
  50. * Whether there is a match currently.
  51. *
  52. * @readonly
  53. * @member {Boolean}
  54. */
  55. this.hasMatch = false;
  56. /**
  57. * Flag indicating whether the `TextWatcher` instance is enabled or disabled.
  58. * A disabled TextWatcher will not evaluate text.
  59. *
  60. * To disable TextWatcher:
  61. *
  62. * const watcher = new TextWatcher( editor.model, testCallback );
  63. *
  64. * // After this a testCallback will not be called.
  65. * watcher.isEnabled = false;
  66. *
  67. * @observable
  68. * @member {Boolean} #isEnabled
  69. */
  70. this.set( 'isEnabled', true );
  71. // Toggle text watching on isEnabled state change.
  72. this.on( 'change:isEnabled', () => {
  73. if ( this.isEnabled ) {
  74. this._startListening();
  75. } else {
  76. this.stopListening( model.document.selection );
  77. this.stopListening( model.document );
  78. }
  79. } );
  80. this._startListening();
  81. }
  82. /**
  83. * Starts listening to the editor for typing and selection events.
  84. *
  85. * @private
  86. */
  87. _startListening() {
  88. const model = this.model;
  89. const document = model.document;
  90. this.listenTo( document.selection, 'change:range', ( evt, { directChange } ) => {
  91. // Indirect changes (i.e. when the user types or external changes are applied) are handled in the document's change event.
  92. if ( !directChange ) {
  93. return;
  94. }
  95. // Act only on collapsed selection.
  96. if ( !document.selection.isCollapsed ) {
  97. if ( this.hasMatch ) {
  98. this.fire( 'unmatched' );
  99. this.hasMatch = false;
  100. }
  101. return;
  102. }
  103. this._evaluateTextBeforeSelection( 'selection' );
  104. } );
  105. this.listenTo( document, 'change:data', ( evt, batch ) => {
  106. if ( batch.type == 'transparent' ) {
  107. return;
  108. }
  109. this._evaluateTextBeforeSelection( 'data', { batch } );
  110. } );
  111. }
  112. /**
  113. * Checks the editor content for matched text.
  114. *
  115. * @fires matched:data
  116. * @fires matched:selection
  117. * @fires unmatched
  118. *
  119. * @private
  120. * @param {'data'|'selection'} suffix A suffix used for generating the event name.
  121. * @param {Object} data Data object for event.
  122. */
  123. _evaluateTextBeforeSelection( suffix, data = {} ) {
  124. const model = this.model;
  125. const document = model.document;
  126. const selection = document.selection;
  127. const rangeBeforeSelection = model.createRange( model.createPositionAt( selection.focus.parent, 0 ), selection.focus );
  128. const { text, range } = getLastTextLine( rangeBeforeSelection, model );
  129. const testResult = this.testCallback( text );
  130. if ( !testResult && this.hasMatch ) {
  131. this.fire( 'unmatched' );
  132. }
  133. this.hasMatch = !!testResult;
  134. if ( testResult ) {
  135. const eventData = Object.assign( data, { text, range } );
  136. // If the test callback returns an object with additional data, assign the data as well.
  137. if ( typeof testResult == 'object' ) {
  138. Object.assign( eventData, testResult );
  139. }
  140. this.fire( `matched:${ suffix }`, eventData );
  141. }
  142. }
  143. }
  144. mix( TextWatcher, ObservableMixin );
  145. /**
  146. * Fired whenever the text watcher found a match for data changes.
  147. *
  148. * @event matched:data
  149. * @param {Object} data Event data.
  150. * @param {String} data.text The full text before selection to which the regexp was applied.
  151. * @param {module:engine/model/range~Range} data.range The range representing the position of the `data.text`.
  152. * @param {Object} [data.testResult] The additional data returned from the {@link module:typing/textwatcher~TextWatcher#testCallback}.
  153. */
  154. /**
  155. * Fired whenever the text watcher found a match for selection changes.
  156. *
  157. * @event matched:selection
  158. * @param {Object} data Event data.
  159. * @param {String} data.text The full text before selection.
  160. * @param {module:engine/model/range~Range} data.range The range representing the position of the `data.text`.
  161. * @param {Object} [data.testResult] The additional data returned from the {@link module:typing/textwatcher~TextWatcher#testCallback}.
  162. */
  163. /**
  164. * Fired whenever the text does not match anymore. Fired only when the text watcher found a match.
  165. *
  166. * @event unmatched
  167. */