8
0

inlineautoformatengine.js 6.6 KB

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