texttransformation.js 14 KB

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