textwatcher.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  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 {module:typing/textwatcher~TextWatcher#testCallback} testCallback
  27. */
  28. constructor( model, testCallback ) {
  29. this.model = model;
  30. /**
  31. * The function used to match the text.
  32. *
  33. * @member {Function} #testCallback
  34. * @returns {Object} textMatcher
  35. * @returns {Boolean} textMatcher.match The value indicates if text matches the pattern.
  36. * @returns {Any} [textMatcher.data] Additional data that can be returned from the callback.
  37. */
  38. this.testCallback = testCallback;
  39. this.hasMatch = false;
  40. /**
  41. * Flag indicating whether the `TextWatcher` instance is enabled or disabled.
  42. * A disabled TextWatcher will not evaluate text.
  43. *
  44. * To disable TextWatcher:
  45. *
  46. * const watcher = new TextWatcher( editor.model, testCallback );
  47. *
  48. * // After this a testCallback will not be called.
  49. * watcher.isEnabled = false;
  50. *
  51. * @observable
  52. * @member {Boolean} #isEnabled
  53. */
  54. this.set( 'isEnabled', true );
  55. // Toggle text watching on isEnabled state change.
  56. this.on( 'change:isEnabled', () => {
  57. if ( this.isEnabled ) {
  58. this._startListening();
  59. } else {
  60. this.stopListening( model.document.selection );
  61. this.stopListening( model.document );
  62. }
  63. } );
  64. this._startListening();
  65. }
  66. /**
  67. * Starts listening to the editor for typing and selection events.
  68. *
  69. * @private
  70. */
  71. _startListening() {
  72. const model = this.model;
  73. const document = model.document;
  74. this.listenTo( document.selection, 'change:range', ( evt, { directChange } ) => {
  75. // Indirect changes (i.e. when the user types or external changes are applied) are handled in the document's change event.
  76. if ( !directChange ) {
  77. return;
  78. }
  79. // Act only on collapsed selection.
  80. if ( !document.selection.isCollapsed ) {
  81. if ( this.hasMatch ) {
  82. this.fire( 'unmatched' );
  83. this.hasMatch = false;
  84. }
  85. return;
  86. }
  87. this._evaluateTextBeforeSelection( 'selection' );
  88. } );
  89. this.listenTo( document, 'change:data', ( evt, batch ) => {
  90. if ( batch.type == 'transparent' ) {
  91. return;
  92. }
  93. this._evaluateTextBeforeSelection( 'data', { batch } );
  94. } );
  95. }
  96. /**
  97. * Checks the editor content for matched text.
  98. *
  99. * @fires matched:data
  100. * @fires matched:selection
  101. * @fires unmatched
  102. *
  103. * @private
  104. * @param {'data'|'selection'} suffix A suffix used for generating the event name.
  105. * @param {Object} data Data object for event.
  106. */
  107. _evaluateTextBeforeSelection( suffix, data = {} ) {
  108. const model = this.model;
  109. const document = model.document;
  110. const selection = document.selection;
  111. const rangeBeforeSelection = model.createRange( model.createPositionAt( selection.focus.parent, 0 ), selection.focus );
  112. const { text, range } = getLastTextLine( rangeBeforeSelection, model );
  113. const textMatcher = this.testCallback( text );
  114. if ( !textMatcher && this.hasMatch ) {
  115. /**
  116. * Fired whenever the text does not match anymore. Fired only when the text watcher found a match.
  117. *
  118. * @event unmatched
  119. */
  120. this.fire( 'unmatched' );
  121. }
  122. this.hasMatch = textMatcher && textMatcher.match;
  123. if ( this.hasMatch ) {
  124. // If text matches and testCallback() returns additional data, pass the data to the eventData object.
  125. const additionalData = textMatcher.data;
  126. const eventData = Object.assign( data, { text, range, ...additionalData } );
  127. /**
  128. * Fired whenever the text watcher found a match for data changes.
  129. *
  130. * @event matched:data
  131. * @param {Object} data Event data.
  132. * @param {String} data.text The full text before selection.
  133. * @param {module:engine/model/batch~Batch} data.batch A batch associated with a change.
  134. */
  135. /**
  136. * Fired whenever the text watcher found a match for selection changes.
  137. *
  138. * @event matched:selection
  139. * @param {Object} data Event data.
  140. * @param {String} data.text The full text before selection.
  141. */
  142. this.fire( `matched:${ suffix }`, eventData );
  143. }
  144. }
  145. }
  146. mix( TextWatcher, ObservableMixin );