texttransformation.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  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 typing/texttransformation
  7. */
  8. import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
  9. import TextWatcher from './textwatcher';
  10. import { escapeRegExp } from 'lodash-es';
  11. // All named transformations.
  12. const TRANSFORMATIONS = {
  13. // Common symbols:
  14. copyright: { from: '(c)', to: '©' },
  15. registeredTrademark: { from: '(r)', to: '®' },
  16. trademark: { from: '(tm)', to: '™' },
  17. // Mathematical:
  18. oneHalf: { from: '1/2', to: '½' },
  19. oneThird: { from: '1/3', to: '⅓' },
  20. twoThirds: { from: '2/3', to: '⅔' },
  21. oneForth: { from: '1/4', to: '¼' },
  22. threeQuarters: { from: '3/4', to: '¾' },
  23. lessThanOrEqual: { from: '<=', to: '≤' },
  24. greaterThanOrEqual: { from: '>=', to: '≥' },
  25. notEqual: { from: '!=', to: '≠' },
  26. arrowLeft: { from: '<-', to: '←' },
  27. arrowRight: { from: '->', to: '→' },
  28. // Typography:
  29. horizontalEllipsis: { from: '...', to: '…' },
  30. enDash: { from: /(^| )(--)( )$/, to: [ null, '–', null ] },
  31. emDash: { from: /(^| )(---)( )$/, to: [ null, '—', null ] },
  32. // Quotations:
  33. // English, US
  34. quotesPrimary: { from: buildQuotesRegExp( '"' ), to: [ null, '“', null, '”' ] },
  35. quotesSecondary: { from: buildQuotesRegExp( '\'' ), to: [ null, '‘', null, '’' ] },
  36. // English, UK
  37. quotesPrimaryEnGb: { from: buildQuotesRegExp( '\'' ), to: [ null, '‘', null, '’' ] },
  38. quotesSecondaryEnGb: { from: buildQuotesRegExp( '"' ), to: [ null, '“', null, '”' ] },
  39. // Polish
  40. quotesPrimaryPl: { from: buildQuotesRegExp( '"' ), to: [ null, '„', null, '”' ] },
  41. quotesSecondaryPl: { from: buildQuotesRegExp( '\'' ), to: [ null, '‚', null, '’' ] }
  42. };
  43. // Transformation groups.
  44. const TRANSFORMATION_GROUPS = {
  45. symbols: [ 'copyright', 'registeredTrademark', 'trademark' ],
  46. mathematical: [
  47. 'oneHalf', 'oneThird', 'twoThirds', 'oneForth', 'threeQuarters',
  48. 'lessThanOrEqual', 'greaterThanOrEqual', 'notEqual',
  49. 'arrowLeft', 'arrowRight'
  50. ],
  51. typography: [ 'horizontalEllipsis', 'enDash', 'emDash' ],
  52. quotes: [ 'quotesPrimary', 'quotesSecondary' ]
  53. };
  54. // A set of default transformations provided by the feature.
  55. const DEFAULT_TRANSFORMATIONS = [
  56. 'symbols',
  57. 'mathematical',
  58. 'typography',
  59. 'quotes'
  60. ];
  61. /**
  62. * The text transformation plugin.
  63. *
  64. * @extends module:core/plugin~Plugin
  65. */
  66. export default class TextTransformation extends Plugin {
  67. /**
  68. * @inheritDoc
  69. */
  70. static get pluginName() {
  71. return 'TextTransformation';
  72. }
  73. /**
  74. * @inheritDoc
  75. */
  76. constructor( editor ) {
  77. super( editor );
  78. editor.config.define( 'typing', {
  79. transformations: {
  80. include: DEFAULT_TRANSFORMATIONS
  81. }
  82. } );
  83. }
  84. /**
  85. * @inheritDoc
  86. */
  87. init() {
  88. const model = this.editor.model;
  89. const modelSelection = model.document.selection;
  90. modelSelection.on( 'change:range', () => {
  91. // Disable plugin when selection is inside a code block.
  92. this.isEnabled = !modelSelection.anchor.parent.is( 'element', 'codeBlock' );
  93. } );
  94. this._enableTransformationWatchers();
  95. }
  96. /**
  97. * Create new TextWatcher listening to the editor for typing and selection events.
  98. *
  99. * @private
  100. */
  101. _enableTransformationWatchers() {
  102. const editor = this.editor;
  103. const model = editor.model;
  104. const input = editor.plugins.get( 'Input' );
  105. const normalizedTransformations = normalizeTransformations( editor.config.get( 'typing.transformations' ) );
  106. const testCallback = text => {
  107. for ( const normalizedTransformation of normalizedTransformations ) {
  108. const from = normalizedTransformation.from;
  109. const match = from.test( text );
  110. if ( match ) {
  111. return { normalizedTransformation };
  112. }
  113. }
  114. };
  115. const watcherCallback = ( evt, data ) => {
  116. if ( !input.isInput( data.batch ) ) {
  117. return;
  118. }
  119. const { from, to } = data.normalizedTransformation;
  120. const matches = from.exec( data.text );
  121. const replaces = to( matches.slice( 1 ) );
  122. const matchedRange = data.range;
  123. let changeIndex = matches.index;
  124. model.enqueueChange( writer => {
  125. for ( let i = 1; i < matches.length; i++ ) {
  126. const match = matches[ i ];
  127. const replaceWith = replaces[ i - 1 ];
  128. if ( replaceWith == null ) {
  129. changeIndex += match.length;
  130. continue;
  131. }
  132. const replacePosition = matchedRange.start.getShiftedBy( changeIndex );
  133. const replaceRange = model.createRange( replacePosition, replacePosition.getShiftedBy( match.length ) );
  134. const attributes = getTextAttributesAfterPosition( replacePosition );
  135. model.insertContent( writer.createText( replaceWith, attributes ), replaceRange );
  136. changeIndex += replaceWith.length;
  137. }
  138. } );
  139. };
  140. const watcher = new TextWatcher( editor.model, testCallback );
  141. watcher.on( 'matched:data', watcherCallback );
  142. watcher.bind( 'isEnabled' ).to( this );
  143. }
  144. }
  145. // Normalizes the configuration `from` parameter value.
  146. // The normalized value for the `from` parameter is a RegExp instance. If the passed `from` is already a RegExp instance,
  147. // it is returned unchanged.
  148. //
  149. // @param {String|RegExp} from
  150. // @returns {RegExp}
  151. function normalizeFrom( from ) {
  152. if ( typeof from == 'string' ) {
  153. return new RegExp( `(${ escapeRegExp( from ) })$` );
  154. }
  155. // `from` is already a regular expression.
  156. return from;
  157. }
  158. // Normalizes the configuration `to` parameter value.
  159. // The normalized value for the `to` parameter is a function that takes an array and returns an array. See more in the
  160. // configuration description. If the passed `to` is already a function, it is returned unchanged.
  161. //
  162. // @param {String|Array.<null|String>|Function} to
  163. // @returns {Function}
  164. function normalizeTo( to ) {
  165. if ( typeof to == 'string' ) {
  166. return () => [ to ];
  167. } else if ( to instanceof Array ) {
  168. return () => to;
  169. }
  170. // `to` is already a function.
  171. return to;
  172. }
  173. // For given `position` returns attributes for the text that is after that position.
  174. // The text can be in the same text node as the position (`foo[]bar`) or in the next text node (`foo[]<$text bold="true">bar</$text>`).
  175. //
  176. // @param {module:engine/model/position~Position} position
  177. // @returns {Iterable.<*>}
  178. function getTextAttributesAfterPosition( position ) {
  179. const textNode = position.textNode ? position.textNode : position.nodeAfter;
  180. return textNode.getAttributes();
  181. }
  182. // Returns a RegExp pattern string that detects a sentence inside a quote.
  183. //
  184. // @param {String} quoteCharacter The character to create a pattern for.
  185. // @returns {String}
  186. function buildQuotesRegExp( quoteCharacter ) {
  187. return new RegExp( `(^|\\s)(${ quoteCharacter })([^${ quoteCharacter }]*)(${ quoteCharacter })$` );
  188. }
  189. // Reads text transformation config and returns normalized array of transformations objects.
  190. //
  191. // @param {module:typing/texttransformation~TextTransformationDescription} config
  192. // @returns {Array.<{from:String,to:Function}>}
  193. function normalizeTransformations( config ) {
  194. const extra = config.extra || [];
  195. const remove = config.remove || [];
  196. const isNotRemoved = transformation => !remove.includes( transformation );
  197. const configured = config.include.concat( extra ).filter( isNotRemoved );
  198. return expandGroupsAndRemoveDuplicates( configured )
  199. .filter( isNotRemoved ) // Filter out 'remove' transformations as they might be set in group
  200. .map( transformation => TRANSFORMATIONS[ transformation ] || transformation )
  201. .map( transformation => ( {
  202. from: normalizeFrom( transformation.from ),
  203. to: normalizeTo( transformation.to )
  204. } ) );
  205. }
  206. // Reads definitions and expands named groups if needed to transformation names.
  207. // This method also removes duplicated named transformations if any.
  208. //
  209. // @param {Array.<String|Object>} definitions
  210. // @returns {Array.<String|Object>}
  211. function expandGroupsAndRemoveDuplicates( definitions ) {
  212. // Set is using to make sure that transformation names are not duplicated.
  213. const definedTransformations = new Set();
  214. for ( const transformationOrGroup of definitions ) {
  215. if ( TRANSFORMATION_GROUPS[ transformationOrGroup ] ) {
  216. for ( const transformation of TRANSFORMATION_GROUPS[ transformationOrGroup ] ) {
  217. definedTransformations.add( transformation );
  218. }
  219. } else {
  220. definedTransformations.add( transformationOrGroup );
  221. }
  222. }
  223. return Array.from( definedTransformations );
  224. }
  225. /**
  226. * The text transformation definition object. It describes what should be replaced with what.
  227. *
  228. * The input value (`from`) can be passed either as a string or as a regular expression.
  229. *
  230. * * If a string is passed, it will be simply checked if the end of the input matches it.
  231. * * If a regular expression is passed, its entire length must be covered with capturing groups (e.g. `/(foo)(bar)$/`).
  232. * Also, since it is compared against the end of the input, it has to end with `$` to be correctly matched.
  233. * See examples below.
  234. *
  235. * The output value (`to`) can be passed as a string, as an array or as a function.
  236. *
  237. * * If a string is passed, it will be used as a replacement value as-is. Note that a string output value can be used only if
  238. * the input value is a string, too.
  239. * * If an array is passed, it has to have the same number of elements as there are capturing groups in the input value regular expression.
  240. * Each capture group will be replaced with a corresponding string from the passed array. If a given capturing group should not be replaced,
  241. * use `null` instead of passing a string.
  242. * * If a function is used, it should return an array as described above. The function is passed one parameter &mdash; an array with matches
  243. * by the regular expression. See the examples below.
  244. *
  245. * A simple string-to-string replacement:
  246. *
  247. * { from: '(c)', to: '©' }
  248. *
  249. * Change quote styles using a regular expression. Note how all the parts are in separate capturing groups and the space at the beginning
  250. * and the text inside quotes are not replaced (`null` passed as the first and the third value in the `to` parameter):
  251. *
  252. * {
  253. * from: /(^|\s)(")([^"]*)(")$/,
  254. * to: [ null, '“', null, '”' ]
  255. * }
  256. *
  257. * Automatic uppercase after a dot using a callback:
  258. *
  259. * {
  260. * from: /(\. )([a-z])$/,
  261. * to: matches => [ null, matches[ 1 ].toUpperCase() ]
  262. * }
  263. *
  264. * @typedef {Object} module:typing/texttransformation~TextTransformationDescription
  265. * @property {String|RegExp} from The string or regular expression to transform.
  266. * @property {String} to The text to transform compatible with `String.replace()`.
  267. */
  268. /**
  269. * The configuration of the {@link module:typing/texttransformation~TextTransformation} feature.
  270. *
  271. * Read more in {@link module:typing/texttransformation~TextTransformationConfig}.
  272. *
  273. * @member {module:typing/texttransformation~TextTransformationConfig} module:typing/typing~TypingConfig#transformations
  274. */
  275. /**
  276. * The configuration of the text transformation feature.
  277. *
  278. * ClassicEditor
  279. * .create( editorElement, {
  280. * typing: {
  281. * transformations: ... // Text transformation feature options.
  282. * }
  283. * } )
  284. * .then( ... )
  285. * .catch( ... );
  286. *
  287. * By default, the feature comes pre-configured
  288. * (via {@link module:typing/texttransformation~TextTransformationConfig#include `config.typing.transformations.include`}) with the
  289. * following groups of transformations:
  290. *
  291. * * Typography (group name: `typography`)
  292. * - `ellipsis`: transforms `...` to `…`
  293. * - `enDash`: transforms ` -- ` to ` – `
  294. * - `emDash`: transforms ` --- ` to ` — `
  295. * * Quotations (group name: `quotes`)
  296. * - `quotesPrimary`: transforms `"Foo bar"` to `“Foo bar”`
  297. * - `quotesSecondary`: transforms `'Foo bar'` to `‘Foo bar’`
  298. * * Symbols (group name: `symbols`)
  299. * - `trademark`: transforms `(tm)` to `™`
  300. * - `registeredTrademark`: transforms `(r)` to `®`
  301. * - `copyright`: transforms `(c)` to `©`
  302. * * Mathematical (group name: `mathematical`)
  303. * - `oneHalf`: transforms `1/2` to: `½`
  304. * - `oneThird`: transforms `1/3` to: `⅓`
  305. * - `twoThirds`: transforms `2/3` to: `⅔`
  306. * - `oneForth`: transforms `1/4` to: `¼`
  307. * - `threeQuarters`: transforms `3/4` to: `¾`
  308. * - `lessThanOrEqual`: transforms `<=` to: `≤`
  309. * - `greaterThanOrEqual`: transforms `>=` to: `≥`
  310. * - `notEqual`: transforms `!=` to: `≠`
  311. * - `arrowLeft`: transforms `<-` to: `←`
  312. * - `arrowRight`: transforms `->` to: `→`
  313. * * Misc:
  314. * - `quotesPrimaryEnGb`: transforms `'Foo bar'` to `‘Foo bar’`
  315. * - `quotesSecondaryEnGb`: transforms `"Foo bar"` to `“Foo bar”`
  316. * - `quotesPrimaryPl`: transforms `"Foo bar"` to `„Foo bar”`
  317. * - `quotesSecondaryPl`: transforms `'Foo bar'` to `‚Foo bar’`
  318. *
  319. * In order to load additional transformations, use the
  320. * {@link module:typing/texttransformation~TextTransformationConfig#extra `transformations.extra` option}.
  321. *
  322. * In order to narrow down the list of transformations, use the
  323. * {@link module:typing/texttransformation~TextTransformationConfig#remove `transformations.remove` option}.
  324. *
  325. * In order to completely override the supported transformations, use the
  326. * {@link module:typing/texttransformation~TextTransformationConfig#include `transformations.include` option}.
  327. *
  328. * Examples:
  329. *
  330. * const transformationsConfig = {
  331. * include: [
  332. * // Use only the 'quotes' and 'typography' groups.
  333. * 'quotes',
  334. * 'typography',
  335. *
  336. * // Plus, some custom transformation.
  337. * { from: 'CKE', to: 'CKEditor' }
  338. * ]
  339. * };
  340. *
  341. * const transformationsConfig = {
  342. * // Remove the 'ellipsis' transformation loaded by the 'typography' group.
  343. * remove: [ 'ellipsis' ]
  344. * }
  345. *
  346. * @interface TextTransformationConfig
  347. */
  348. /* eslint-disable max-len */
  349. /**
  350. * The standard list of text transformations supported by the editor. By default it comes pre-configured with a couple dozen of them
  351. * (see {@link module:typing/texttransformation~TextTransformationConfig} for the full list). You can override this list completely
  352. * by setting this option or use the other two options
  353. * ({@link module:typing/texttransformation~TextTransformationConfig#extra `transformations.extra`},
  354. * {@link module:typing/texttransformation~TextTransformationConfig#remove `transformations.remove`}) to fine-tune the default list.
  355. *
  356. * @member {Array.<module:typing/texttransformation~TextTransformationDescription>} module:typing/texttransformation~TextTransformationConfig#include
  357. */
  358. /**
  359. * Additional text transformations that are added to the transformations defined in
  360. * {@link module:typing/texttransformation~TextTransformationConfig#include `transformations.include`}.
  361. *
  362. * const transformationsConfig = {
  363. * extra: [
  364. * { from: 'CKE', to: 'CKEditor' }
  365. * ]
  366. * };
  367. *
  368. * @member {Array.<module:typing/texttransformation~TextTransformationDescription>} module:typing/texttransformation~TextTransformationConfig#extra
  369. */
  370. /**
  371. * The text transformation names that are removed from transformations defined in
  372. * {@link module:typing/texttransformation~TextTransformationConfig#include `transformations.include`} or
  373. * {@link module:typing/texttransformation~TextTransformationConfig#extra `transformations.extra`}.
  374. *
  375. * const transformationsConfig = {
  376. * remove: [
  377. * 'ellipsis', // Remove only 'ellipsis' from the 'typography' group.
  378. * 'mathematical' // Remove all transformations from the 'mathematical' group.
  379. * ]
  380. * }
  381. *
  382. * @member {Array.<module:typing/texttransformation~TextTransformationDescription>} module:typing/texttransformation~TextTransformationConfig#remove
  383. */
  384. /* eslint-enable max-len */