8
0

inlineautoformatengine.js 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. /**
  2. * @license Copyright (c) 2003-2017, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. /**
  6. * @module autoformat/inlineautoformatengine
  7. */
  8. import LiveRange from '@ckeditor/ckeditor5-engine/src/model/liverange';
  9. import getSchemaValidRanges from '@ckeditor/ckeditor5-core/src/command/helpers/getschemavalidranges';
  10. /**
  11. * The inline autoformatting engine. Allows to format various inline patterns. For example,
  12. * it can be configured to make "foo" bold when typed `**foo**` (the `**` markers will be removed).
  13. *
  14. * The autoformatting operation is integrated with the undo manager,
  15. * so the autoformatting step can be undone, if the user's intention wasn't to format the text.
  16. *
  17. * See the constructors documentation to learn how to create custom inline autoformatters. You can also use
  18. * the {@link module:autoformat/autoformat~Autoformat} feature which enables a set of default autoformatters
  19. * (lists, headings, bold and italic).
  20. */
  21. export default class InlineAutoformatEngine {
  22. /**
  23. * Enables autoformatting mechanism on a given {@link module:core/editor/editor~Editor}.
  24. *
  25. * It formats the matched text by applying given model attribute or by running the provided formatting callback.
  26. * Each time data model changes text from given node (from the beginning of the current node to the collapsed
  27. * selection location) will be tested.
  28. *
  29. * @param {module:core/editor/editor~Editor} editor Editor instance.
  30. * @param {Function|RegExp} testRegexpOrCallback RegExp or callback to execute on text.
  31. * Provided RegExp *must* have three capture groups. First and third capture groups
  32. * should match opening/closing delimiters. Second capture group should match text to format.
  33. *
  34. * // Matches `**bold text**` pattern.
  35. * // There are three capturing groups:
  36. * // - first to match starting `**` delimiter,
  37. * // - second to match text to format,
  38. * // - third to match ending `**` delimiter.
  39. * new InlineAutoformatEngine( this.editor, /(\*\*)([^\*]+?)(\*\*)$/g, 'bold' );
  40. *
  41. * When function is provided instead of RegExp, it will be executed with text to match as a parameter. Function
  42. * should return proper "ranges" to delete and format.
  43. *
  44. * {
  45. * remove: [
  46. * [ 0, 1 ], // Remove first letter from the given text.
  47. * [ 5, 6 ] // Remove 6th letter from the given text.
  48. * ],
  49. * format: [
  50. * [ 1, 5 ] // Format all letters from 2nd to 5th.
  51. * ]
  52. * }
  53. *
  54. * @param {Function|String} attributeOrCallback Name of attribute to apply on matching text or callback for manual
  55. * formatting.
  56. *
  57. * // Use attribute name:
  58. * new InlineAutoformatEngine( this.editor, /(\*\*)([^\*]+?)(\*\*)$/g, 'bold' );
  59. *
  60. * // Use formatting callback:
  61. * new InlineAutoformatEngine( this.editor, /(\*\*)([^\*]+?)(\*\*)$/g, ( batch, validRanges ) => {
  62. * for ( let range of validRanges ) {
  63. * batch.setAttribute( range, command, true );
  64. * }
  65. * } );
  66. */
  67. constructor( editor, testRegexpOrCallback, attributeOrCallback ) {
  68. this.editor = editor;
  69. let regExp;
  70. let command;
  71. let testCallback;
  72. let formatCallback;
  73. if ( testRegexpOrCallback instanceof RegExp ) {
  74. regExp = testRegexpOrCallback;
  75. } else {
  76. testCallback = testRegexpOrCallback;
  77. }
  78. if ( typeof attributeOrCallback == 'string' ) {
  79. command = attributeOrCallback;
  80. } else {
  81. formatCallback = attributeOrCallback;
  82. }
  83. // A test callback run on changed text.
  84. testCallback = testCallback || ( text => {
  85. let result;
  86. const remove = [];
  87. const format = [];
  88. while ( ( result = regExp.exec( text ) ) !== null ) {
  89. // There should be full match and 3 capture groups.
  90. if ( result && result.length < 4 ) {
  91. break;
  92. }
  93. let {
  94. index,
  95. '1': leftDel,
  96. '2': content,
  97. '3': rightDel
  98. } = result;
  99. // Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
  100. const found = leftDel + content + rightDel;
  101. index += result[ 0 ].length - found.length;
  102. // Start and End offsets of delimiters to remove.
  103. const delStart = [
  104. index,
  105. index + leftDel.length
  106. ];
  107. const delEnd = [
  108. index + leftDel.length + content.length,
  109. index + leftDel.length + content.length + rightDel.length
  110. ];
  111. remove.push( delStart );
  112. remove.push( delEnd );
  113. format.push( [ index + leftDel.length, index + leftDel.length + content.length ] );
  114. }
  115. return {
  116. remove,
  117. format
  118. };
  119. } );
  120. // A format callback run on matched text.
  121. formatCallback = formatCallback || ( ( batch, validRanges ) => {
  122. for ( const range of validRanges ) {
  123. batch.setAttribute( range, command, true );
  124. }
  125. } );
  126. editor.document.on( 'change', ( evt, type ) => {
  127. if ( type !== 'insert' ) {
  128. return;
  129. }
  130. const selection = this.editor.document.selection;
  131. if ( !selection.isCollapsed || !selection.focus || !selection.focus.parent ) {
  132. return;
  133. }
  134. const block = selection.focus.parent;
  135. const text = getText( block ).slice( 0, selection.focus.offset );
  136. const ranges = testCallback( text );
  137. const rangesToFormat = [];
  138. // Apply format before deleting text.
  139. ranges.format.forEach( range => {
  140. if ( range[ 0 ] === undefined || range[ 1 ] === undefined ) {
  141. return;
  142. }
  143. rangesToFormat.push( LiveRange.createFromParentsAndOffsets(
  144. block, range[ 0 ],
  145. block, range[ 1 ]
  146. ) );
  147. } );
  148. const rangesToRemove = [];
  149. // Reverse order to not mix the offsets while removing.
  150. ranges.remove.slice().reverse().forEach( range => {
  151. if ( range[ 0 ] === undefined || range[ 1 ] === undefined ) {
  152. return;
  153. }
  154. rangesToRemove.push( LiveRange.createFromParentsAndOffsets(
  155. block, range[ 0 ],
  156. block, range[ 1 ]
  157. ) );
  158. } );
  159. if ( !( rangesToFormat.length && rangesToRemove.length ) ) {
  160. return;
  161. }
  162. const batch = editor.document.batch();
  163. editor.document.enqueueChanges( () => {
  164. const validRanges = getSchemaValidRanges( command, rangesToFormat, editor.document.schema );
  165. // Apply format.
  166. formatCallback( batch, validRanges );
  167. // Remove delimiters.
  168. for ( const range of rangesToRemove ) {
  169. batch.remove( range );
  170. }
  171. } );
  172. } );
  173. }
  174. }
  175. // Returns whole text from parent element by adding all data from text nodes together.
  176. //
  177. // @private
  178. // @param {module:engine/model/element~Element} element
  179. // @returns {String}
  180. function getText( element ) {
  181. return Array.from( element.getChildren() ).reduce( ( a, b ) => a + b.data, '' );
  182. }