inlineautoformatediting.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  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 autoformat/inlineautoformatediting
  7. */
  8. import getLastTextLine from '@ckeditor/ckeditor5-typing/src/utils/getlasttextline';
  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 InlineAutoformatEditing {
  21. /**
  22. * @inheritDoc
  23. */
  24. static get pluginName() {
  25. return 'InlineAutoformatEditing';
  26. }
  27. /**
  28. * Enables autoformatting mechanism for a given {@link module:core/editor/editor~Editor}.
  29. *
  30. * It formats the matched text by applying the given model attribute or by running the provided formatting callback.
  31. * On every change applied to the model the autoformatting engine checks the text on the left of the selection
  32. * and executes the provided action if the text matches given criteria (regular expression or callback).
  33. *
  34. * @param {module:core/editor/editor~Editor} editor The editor instance.
  35. * @param {Function|RegExp} testRegexpOrCallback The regular expression or callback to execute on text.
  36. * Provided regular expression *must* have three capture groups. The first and the third capture group
  37. * should match opening and closing delimiters. The second capture group should match the text to format.
  38. *
  39. * // Matches the `**bold text**` pattern.
  40. * // There are three capturing groups:
  41. * // - The first to match the starting `**` delimiter.
  42. * // - The second to match the text to format.
  43. * // - The third to match the ending `**` delimiter.
  44. * new InlineAutoformatEditing( editor, /(\*\*)([^\*]+?)(\*\*)$/g, 'bold' );
  45. *
  46. * When a function is provided instead of the regular expression, it will be executed with the text to match as a parameter.
  47. * The function should return proper "ranges" to delete and format.
  48. *
  49. * {
  50. * remove: [
  51. * [ 0, 1 ], // Remove the first letter from the given text.
  52. * [ 5, 6 ] // Remove the 6th letter from the given text.
  53. * ],
  54. * format: [
  55. * [ 1, 5 ] // Format all letters from 2nd to 5th.
  56. * ]
  57. * }
  58. *
  59. * @param {Function|String} attributeOrCallback The name of attribute to apply on matching text or a callback for manual
  60. * formatting. If callback is passed it should return `false` if changes should not be applied (e.g. if a command is disabled).
  61. *
  62. * // Use attribute name:
  63. * new InlineAutoformatEditing( editor, /(\*\*)([^\*]+?)(\*\*)$/g, 'bold' );
  64. *
  65. * // Use formatting callback:
  66. * new InlineAutoformatEditing( editor, /(\*\*)([^\*]+?)(\*\*)$/g, ( writer, rangesToFormat ) => {
  67. * const command = editor.commands.get( 'bold' );
  68. *
  69. * if ( !command.isEnabled ) {
  70. * return false;
  71. * }
  72. *
  73. * const validRanges = editor.model.schema.getValidRanges( rangesToFormat, 'bold' );
  74. *
  75. * for ( let range of validRanges ) {
  76. * writer.setAttribute( 'bold', true, range );
  77. * }
  78. * } );
  79. */
  80. constructor( editor, testRegexpOrCallback, attributeOrCallback ) {
  81. let regExp;
  82. let attributeKey;
  83. let testCallback;
  84. let formatCallback;
  85. if ( testRegexpOrCallback instanceof RegExp ) {
  86. regExp = testRegexpOrCallback;
  87. } else {
  88. testCallback = testRegexpOrCallback;
  89. }
  90. if ( typeof attributeOrCallback == 'string' ) {
  91. attributeKey = attributeOrCallback;
  92. } else {
  93. formatCallback = attributeOrCallback;
  94. }
  95. // A test callback run on changed text.
  96. testCallback = testCallback || ( text => {
  97. let result;
  98. const remove = [];
  99. const format = [];
  100. while ( ( result = regExp.exec( text ) ) !== null ) {
  101. // There should be full match and 3 capture groups.
  102. if ( result && result.length < 4 ) {
  103. break;
  104. }
  105. let {
  106. index,
  107. '1': leftDel,
  108. '2': content,
  109. '3': rightDel
  110. } = result;
  111. // Real matched string - there might be some non-capturing groups so we need to recalculate starting index.
  112. const found = leftDel + content + rightDel;
  113. index += result[ 0 ].length - found.length;
  114. // Start and End offsets of delimiters to remove.
  115. const delStart = [
  116. index,
  117. index + leftDel.length
  118. ];
  119. const delEnd = [
  120. index + leftDel.length + content.length,
  121. index + leftDel.length + content.length + rightDel.length
  122. ];
  123. remove.push( delStart );
  124. remove.push( delEnd );
  125. format.push( [ index + leftDel.length, index + leftDel.length + content.length ] );
  126. }
  127. return {
  128. remove,
  129. format
  130. };
  131. } );
  132. // A format callback run on matched text.
  133. formatCallback = formatCallback || ( ( writer, rangesToFormat ) => {
  134. const validRanges = editor.model.schema.getValidRanges( rangesToFormat, attributeKey );
  135. for ( const range of validRanges ) {
  136. writer.setAttribute( attributeKey, true, range );
  137. }
  138. // After applying attribute to the text, remove given attribute from the selection.
  139. // This way user is able to type a text without attribute used by auto formatter.
  140. writer.removeSelectionAttribute( attributeKey );
  141. } );
  142. editor.model.document.on( 'change', ( evt, batch ) => {
  143. if ( batch.type == 'transparent' ) {
  144. return;
  145. }
  146. const model = editor.model;
  147. const selection = model.document.selection;
  148. // Do nothing if selection is not collapsed.
  149. if ( !selection.isCollapsed ) {
  150. return;
  151. }
  152. const changes = Array.from( model.document.differ.getChanges() );
  153. const entry = changes[ 0 ];
  154. // Typing is represented by only a single change.
  155. if ( changes.length != 1 || entry.type !== 'insert' || entry.name != '$text' || entry.length != 1 ) {
  156. return;
  157. }
  158. const focus = selection.focus;
  159. const block = focus.parent;
  160. const { text, range } = getLastTextLine( model.createRange( model.createPositionAt( block, 0 ), focus ), model );
  161. const testOutput = testCallback( text );
  162. const rangesToFormat = testOutputToRanges( range.start, testOutput.format, model );
  163. const rangesToRemove = testOutputToRanges( range.start, testOutput.remove, model );
  164. if ( !( rangesToFormat.length && rangesToRemove.length ) ) {
  165. return;
  166. }
  167. // Use enqueueChange to create new batch to separate typing batch from the auto-format changes.
  168. model.enqueueChange( writer => {
  169. // Apply format.
  170. const hasChanged = formatCallback( writer, rangesToFormat );
  171. // Strict check on `false` to have backward compatibility (when callbacks were returning `undefined`).
  172. if ( hasChanged === false ) {
  173. return;
  174. }
  175. // Remove delimiters - use reversed order to not mix the offsets while removing.
  176. for ( const range of rangesToRemove.reverse() ) {
  177. writer.remove( range );
  178. }
  179. } );
  180. } );
  181. }
  182. }
  183. // Converts output of the test function provided to the InlineAutoformatEditing and converts it to the model ranges
  184. // inside provided block.
  185. //
  186. // @private
  187. // @param {module:engine/model/position~Position} start
  188. // @param {Array.<Array>} arrays
  189. // @param {module:engine/model/model~Model} model
  190. function testOutputToRanges( start, arrays, model ) {
  191. return arrays
  192. .filter( array => ( array[ 0 ] !== undefined && array[ 1 ] !== undefined ) )
  193. .map( array => {
  194. return model.createRange( start.getShiftedBy( array[ 0 ] ), start.getShiftedBy( array[ 1 ] ) );
  195. } );
  196. }