8
0

inlineautoformatediting.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  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. * The inline autoformatting engine. It allows to format various inline patterns. For example,
  7. * it can be configured to make "foo" bold when typed `**foo**` (the `**` markers will be removed).
  8. *
  9. * The autoformatting operation is integrated with the undo manager,
  10. * so the autoformatting step can be undone if the user's intention was not to format the text.
  11. *
  12. * See the {@link module:autoformat/inlineautoformatediting~inlineAutoformatEditing `inlineAutoformatEditing`} documentation
  13. * to learn how to create custom inline autoformatters. You can also use
  14. * the {@link module:autoformat/autoformat~Autoformat} feature which enables a set of default autoformatters
  15. * (lists, headings, bold and italic).
  16. *
  17. * @module autoformat/inlineautoformatediting
  18. */
  19. /**
  20. * Enables autoformatting mechanism for a given {@link module:core/editor/editor~Editor}.
  21. *
  22. * It formats the matched text by applying the given model attribute or by running the provided formatting callback.
  23. * On every {@link module:engine/model/document~Document#event:change:data data change} in the model document
  24. * the autoformatting engine checks the text on the left of the selection
  25. * and executes the provided action if the text matches given criteria (regular expression or callback).
  26. *
  27. * @param {module:core/editor/editor~Editor} editor The editor instance.
  28. * @param {module:autoformat/autoformat~Autoformat} plugin The autoformat plugin 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. * inlineAutoformatEditing( editor, plugin, /(\*\*)([^\*]+?)(\*\*)$/g, formatCallback );
  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} formatCallback A callback to apply actual formatting.
  54. * It should return `false` if changes should not be applied (e.g. if a command is disabled).
  55. *
  56. * inlineAutoformatEditing( editor, plugin, /(\*\*)([^\*]+?)(\*\*)$/g, ( writer, rangesToFormat ) => {
  57. * const command = editor.commands.get( 'bold' );
  58. *
  59. * if ( !command.isEnabled ) {
  60. * return false;
  61. * }
  62. *
  63. * const validRanges = editor.model.schema.getValidRanges( rangesToFormat, 'bold' );
  64. *
  65. * for ( let range of validRanges ) {
  66. * writer.setAttribute( 'bold', true, range );
  67. * }
  68. * } );
  69. */
  70. export default function inlineAutoformatEditing( editor, plugin, testRegexpOrCallback, formatCallback ) {
  71. let regExp;
  72. let testCallback;
  73. if ( testRegexpOrCallback instanceof RegExp ) {
  74. regExp = testRegexpOrCallback;
  75. } else {
  76. testCallback = testRegexpOrCallback;
  77. }
  78. // A test callback run on changed text.
  79. testCallback = testCallback || ( text => {
  80. let result;
  81. const remove = [];
  82. const format = [];
  83. while ( ( result = regExp.exec( text ) ) !== null ) {
  84. // There should be full match and 3 capture groups.
  85. if ( result && result.length < 4 ) {
  86. break;
  87. }
  88. let {
  89. index,
  90. '1': leftDel,
  91. '2': content,
  92. '3': rightDel
  93. } = result;
  94. // Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
  95. const found = leftDel + content + rightDel;
  96. index += result[ 0 ].length - found.length;
  97. // Start and End offsets of delimiters to remove.
  98. const delStart = [
  99. index,
  100. index + leftDel.length
  101. ];
  102. const delEnd = [
  103. index + leftDel.length + content.length,
  104. index + leftDel.length + content.length + rightDel.length
  105. ];
  106. remove.push( delStart );
  107. remove.push( delEnd );
  108. format.push( [ index + leftDel.length, index + leftDel.length + content.length ] );
  109. }
  110. return {
  111. remove,
  112. format
  113. };
  114. } );
  115. editor.model.document.on( 'change:data', ( evt, batch ) => {
  116. if ( batch.type == 'transparent' || !plugin.isEnabled ) {
  117. return;
  118. }
  119. const model = editor.model;
  120. const selection = model.document.selection;
  121. // Do nothing if selection is not collapsed.
  122. if ( !selection.isCollapsed ) {
  123. return;
  124. }
  125. const changes = Array.from( model.document.differ.getChanges() );
  126. const entry = changes[ 0 ];
  127. // Typing is represented by only a single change.
  128. if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
  129. return;
  130. }
  131. const focus = selection.focus;
  132. const block = focus.parent;
  133. const { text, range } = getTextAfterCode( model.createRange( model.createPositionAt( block, 0 ), focus ), model );
  134. const testOutput = testCallback( text );
  135. const rangesToFormat = testOutputToRanges( range.start, testOutput.format, model );
  136. const rangesToRemove = testOutputToRanges( range.start, testOutput.remove, model );
  137. if ( !( rangesToFormat.length && rangesToRemove.length ) ) {
  138. return;
  139. }
  140. // Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
  141. model.enqueueChange( writer => {
  142. // Apply format.
  143. const hasChanged = formatCallback( writer, rangesToFormat );
  144. // Strict check on `false` to have backward compatibility (when callbacks were returning `undefined`).
  145. if ( hasChanged === false ) {
  146. return;
  147. }
  148. // Remove delimiters - use reversed order to not mix the offsets while removing.
  149. for ( const range of rangesToRemove.reverse() ) {
  150. writer.remove( range );
  151. }
  152. } );
  153. } );
  154. }
  155. // Converts output of the test function provided to the inlineAutoformatEditing and converts it to the model ranges
  156. // inside provided block.
  157. //
  158. // @private
  159. // @param {module:engine/model/position~Position} start
  160. // @param {Array.<Array>} arrays
  161. // @param {module:engine/model/model~Model} model
  162. function testOutputToRanges( start, arrays, model ) {
  163. return arrays
  164. .filter( array => ( array[ 0 ] !== undefined && array[ 1 ] !== undefined ) )
  165. .map( array => {
  166. return model.createRange( start.getShiftedBy( array[ 0 ] ), start.getShiftedBy( array[ 1 ] ) );
  167. } );
  168. }
  169. // Returns the last text line after the last code element from the given range.
  170. // It is similar to {@link module:typing/utils/getlasttextline.getLastTextLine `getLastTextLine()`},
  171. // but it ignores any text before the last `code`.
  172. //
  173. // @param {module:engine/model/range~Range} range
  174. // @param {module:engine/model/model~Model} model
  175. // @returns {module:typing/utils/getlasttextline~LastTextLineData}
  176. function getTextAfterCode( range, model ) {
  177. let start = range.start;
  178. const text = Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
  179. // Trim text to a last occurrence of an inline element and update range start.
  180. if ( !( node.is( 'text' ) || node.is( 'textProxy' ) ) || node.getAttribute( 'code' ) ) {
  181. start = model.createPositionAfter( node );
  182. return '';
  183. }
  184. return rangeText + node.data;
  185. }, '' );
  186. return { text, range: model.createRange( start, range.end ) };
  187. }