textwatcher.js 4.3 KB

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