8
0

texttransformation.js 17 KB

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