inlineautoformatediting.js 7.7 KB

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