Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Commit

Permalink
Merge pull request #209 from ckeditor/t/203
Browse files Browse the repository at this point in the history
Fix: Text transformations will not remove existing formatting. Closes #203. Closes #196.
  • Loading branch information
Reinmar authored Jul 9, 2019
2 parents dc3914e + 2b9ad49 commit 2279eee
Show file tree
Hide file tree
Showing 6 changed files with 221 additions and 65 deletions.
22 changes: 16 additions & 6 deletions docs/_snippets/features/text-transformation-extended.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ ClassicEditor
transformations: {
remove: [
// Don't use the transformations from the
// 'symbols' and 'mathematical' groups.
// 'symbols' and 'quotes' groups.
'symbols',
'mathematical',
'quotes',

// As well as the following transformations.
'arrowLeft',
Expand All @@ -33,10 +33,20 @@ ClassicEditor
{ from: ':tada:', to: '🎉' },

// You can also define patterns using regexp.
// Note: the pattern must end with `$`.
// The following (naive) rule will remove @ from emails.
// For example, user@example.com will become user.at.example.com.
{ from: /([a-z-]+)@([a-z]+\.[a-z]{2,})$/i, to: '$1.at.$2' }
// Note: the pattern must end with `$` and all its fragments must be wrapped
// with capturing groups.
// The following rule replaces ` "foo"` with ` «foo»`.
{
from: /(^|\s)(")([^"]*)(")$/,
to: [ null, '«', null, '»' ]
},

// Finally, you can define `to` as a callback.
// This (naive) rule will auto-capitalize first word after a period.
{
from: /(\. )([a-z])$/,
to: matches => [ null, matches[ 1 ].toUpperCase() ]
}
],
}
}
Expand Down
26 changes: 19 additions & 7 deletions docs/features/text-transformation.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ ClassicEditor
transformations: {
remove: [
// Don't use the transformations from the
// 'symbols' and 'mathematical' groups.
// 'symbols' and 'quotes' groups.
'symbols',
'mathematical',
'quotes',

// As well as the following transformations.
'arrowLeft',
Expand All @@ -117,10 +117,20 @@ ClassicEditor
{ from: ':tada:', to: '🎉' },

// You can also define patterns using regexp.
// Note: the pattern must end with `$`.
// The following (naive) rule will remove @ from emails.
// For example, user@example.com will become user.at.example.com.
{ from: /([a-z-]+)@([a-z]+\.[a-z]{2,})$/i, to: '$1.at.$2' }
// Note: the pattern must end with `$` and all its fragments must be wrapped
// with capturing groups.
// The following rule replaces ` "foo"` with ` «foo»`.
{
from: /(^|\s)(")([^"]*)(")$/,
to: [ null, '«', null, '»' ]
},

// Finally, you can define `to` as a callback.
// This (naive) rule will auto-capitalize first word after a period.
{
from: /(\. )([a-z])$/,
to: matches => [ null, matches[ 1 ].toUpperCase() ]
}
],
}
}
Expand All @@ -129,7 +139,9 @@ ClassicEditor
.catch( ... );
```

You can test the above configuration here:
You can read more about the format of transformation rules in {@link module:typing/texttransformation~TextTransformationDescription}.

Test the rules defined above:

{@snippet features/text-transformation-extended}

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"dependencies": {
"@ckeditor/ckeditor5-core": "^12.2.0",
"@ckeditor/ckeditor5-engine": "^13.2.0",
"@ckeditor/ckeditor5-utils": "^13.0.0"
"@ckeditor/ckeditor5-utils": "^13.0.0",
"lodash-es": "^4.17.10"
},
"devDependencies": {
"@ckeditor/ckeditor5-basic-styles": "^11.1.2",
Expand Down
174 changes: 125 additions & 49 deletions src/texttransformation.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
import TextWatcher from './textwatcher';
import { escapeRegExp } from 'lodash-es';

// All named transformations.
const TRANSFORMATIONS = {
Expand All @@ -23,8 +24,8 @@ const TRANSFORMATIONS = {
twoThirds: { from: '2/3', to: '⅔' },
oneForth: { from: '1/4', to: '¼' },
threeQuarters: { from: '3/4', to: '¾' },
lessThenOrEqual: { from: '<=', to: '≤' },
greaterThenOrEqual: { from: '>=', to: '≥' },
lessThanOrEqual: { from: '<=', to: '≤' },
greaterThanOrEqual: { from: '>=', to: '≥' },
notEqual: { from: '!=', to: '≠' },
arrowLeft: { from: '<-', to: '←' },
arrowRight: { from: '->', to: '→' },
Expand All @@ -36,24 +37,24 @@ const TRANSFORMATIONS = {

// Quotations:
// English, US
quotesPrimary: { from: buildQuotesRegExp( '"' ), to: '$1“$2”' },
quotesSecondary: { from: buildQuotesRegExp( '\'' ), to: '$1‘$2’' },
quotesPrimary: { from: buildQuotesRegExp( '"' ), to: [ null, '“', null, '”' ] },
quotesSecondary: { from: buildQuotesRegExp( '\'' ), to: [ null, '‘', null, '’' ] },

// English, UK
quotesPrimaryEnGb: { from: buildQuotesRegExp( '\'' ), to: '$1‘$2’' },
quotesSecondaryEnGb: { from: buildQuotesRegExp( '"' ), to: '$1“$2”' },
quotesPrimaryEnGb: { from: buildQuotesRegExp( '\'' ), to: [ null, '‘', null, '’' ] },
quotesSecondaryEnGb: { from: buildQuotesRegExp( '"' ), to: [ null, '“', null, '”' ] },

// Polish
quotesPrimaryPl: { from: buildQuotesRegExp( '"' ), to: '$1„$2”' },
quotesSecondaryPl: { from: buildQuotesRegExp( '\'' ), to: '$1‚$2’' }
quotesPrimaryPl: { from: buildQuotesRegExp( '"' ), to: [ null, '„', null, '”' ] },
quotesSecondaryPl: { from: buildQuotesRegExp( '\'' ), to: [ null, '‚', null, '’' ] }
};

// Transformation groups.
const TRANSFORMATION_GROUPS = {
symbols: [ 'copyright', 'registeredTrademark', 'trademark' ],
mathematical: [
'oneHalf', 'oneThird', 'twoThirds', 'oneForth', 'threeQuarters',
'lessThenOrEqual', 'greaterThenOrEqual', 'notEqual',
'lessThanOrEqual', 'greaterThanOrEqual', 'notEqual',
'arrowLeft', 'arrowRight'
],
typography: [ 'horizontalEllipsis', 'enDash', 'emDash' ],
Expand Down Expand Up @@ -104,43 +105,93 @@ export default class TextTransformation extends Plugin {
const configuredTransformations = getConfiguredTransformations( editor.config.get( 'typing.transformations' ) );

for ( const transformation of configuredTransformations ) {
const { from, to } = transformation;
const from = normalizeFrom( transformation.from );
const to = normalizeTo( transformation.to );

let testCallback;
let textReplacer;
const watcher = new TextWatcher( editor.model, text => from.test( text ) );

if ( from instanceof RegExp ) {
testCallback = text => from.test( text );
textReplacer = message => message.replace( from, to );
} else {
testCallback = text => text.endsWith( from );
textReplacer = message => message.slice( 0, message.length - from.length ) + to;
}
watcher.on( 'matched:data', ( evt, data ) => {
const matches = from.exec( data.text );
const replaces = to( matches.slice( 1 ) );

const watcher = new TextWatcher( editor.model, testCallback );
// Used `focus` to be in line with `TextWatcher#_getText()`.
const selectionParent = editor.model.document.selection.focus.parent;

watcher.on( 'matched:data', ( evt, data ) => {
const selection = editor.model.document.selection;
const focus = selection.focus;
const textToReplaceLength = data.text.length;
const textToInsert = textReplacer( data.text );
let changeIndex = matches.index;

model.enqueueChange( writer => {
for ( let i = 1; i < matches.length; i++ ) {
const match = matches[ i ];
const replaceWith = replaces[ i - 1 ];

if ( replaceWith == null ) {
changeIndex += match.length;

model.enqueueChange( model.createBatch(), writer => {
const replaceRange = writer.createRange( focus.getShiftedBy( -textToReplaceLength ), focus );
continue;
}

model.insertContent( writer.createText( textToInsert, selection.getAttributes() ), replaceRange );
const replacePosition = model.createPositionAt( selectionParent, changeIndex );
const replaceRange = model.createRange( replacePosition, replacePosition.getShiftedBy( match.length ) );
const attributes = getTextAttributesAfterPosition( replacePosition );

model.insertContent( writer.createText( replaceWith, attributes ), replaceRange );

changeIndex += replaceWith.length;
}
} );
} );
}
}
}

// Normalizes config `from` parameter value.
// The normalized value for `from` parameter is a RegExp instance. If passed `from` is already a RegExp instance it is returned unchanged.
//
// @param {String|RegExp} from
// @returns {RegExp}
function normalizeFrom( from ) {
if ( typeof from == 'string' ) {
return new RegExp( '(' + escapeRegExp( from ) + ')$' );
}

// `from` is already a regular expression.
return from;
}

// Normalizes config `to` parameter value.
// The normalized value for `to` parameter is a function that takes an array and returns an array. See more in configuration description.
// If passed `to` is already a function it is returned unchanged.
//
// @param {String|Array.<null|String>|Function} to
// @returns {Function}
function normalizeTo( to ) {
if ( typeof to == 'string' ) {
return () => [ to ];
} else if ( to instanceof Array ) {
return () => to;
}

// `to` is already a function.
return to;
}

// For given `position` returns attributes for the text that is after that position.
// 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>`).
//
// @param {module:engine/model/position~Position} position
// @returns {Iterable.<*>}
function getTextAttributesAfterPosition( position ) {
const textNode = position.textNode ? position.textNode : position.nodeAfter;

return textNode.getAttributes();
}

// Returns a RegExp pattern string that detects a sentence inside a quote.
//
// @param {String} quoteCharacter a character to creat a pattern for.
// @param {String} quoteCharacter The character to create a pattern for.
// @returns {String}
function buildQuotesRegExp( quoteCharacter ) {
return new RegExp( `(^|\\s)${ quoteCharacter }([^${ quoteCharacter }]+)${ quoteCharacter }$` );
return new RegExp( `(^|\\s)(${ quoteCharacter })([^${ quoteCharacter }]*)(${ quoteCharacter })$` );
}

// Reads text transformation config and returns normalized array of transformations objects.
Expand Down Expand Up @@ -182,20 +233,43 @@ function expandGroupsAndRemoveDuplicates( definitions ) {
}

/**
* Text transformation definition object.
* Text transformation definition object. Describes what should be replaced with what.
*
* The input value (`from`) can be passed either as a string or a regexp.
*
* * If a string is passed it will be simply checked if the end of the input matches it.
* * If a regexp is passed, its entire length must be covered with capturing groups (e.g. `/(foo)(bar)$/`).
* Also, since it is compared against the end of the input, it has to end with `$` to be correctly matched.
* See examples below.
*
* The output value (`to`) can be passed either as a string or an array or a function.
*
* const transformations = [
* // Will replace foo with bar:
* { from: 'foo', to: 'bar' },
* * 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
* the input value is a string too.
* * If an array is passed it has to have the same number of elements as there are capturing groups in the input value regexp.
* Each capture group will be replaced by a corresponding string from the passed array. If given capturing group should not be replaced,
* use `null` instead of passing a string.
* * If a function is used, it should return an array as described above. The function is passed one parameter &mdash; an array with matches
* by the regexp. See the examples below.
*
* Simple string-to-string replacement:
*
* { from: '(c)', to: '©' }
*
* Change quotes styles using regular expression. Note how all the parts are in separate capturing groups and the space at the beginning and
* the text inside quotes are not replaced (`null` passed as the first and the third value in `to` parameter):
*
* {
* from: /(^|\s)(")([^"]*)(")$/,
* to: [ null, '“', null, '”' ]
* }
*
* // The following (naive) rule will remove @ from emails.
* // For example, user@example.com will become user.at.example.com.
* { from: /([a-z-]+)@([a-z]+\.[a-z]{2,})$/i, to: '$1.at.$2' }
* ]
* Automatic uppercase after a dot using a callback:
*
* **Note:** The text watcher always evaluates the end of the input (the typed text). If you're passing a RegExp object you must
* include `$` token to match the end of string.
* {
* from: /(\. )([a-z])$/,
* to: matches => [ null, matches[ 1 ].toUpperCase() ]
* }
*
* @typedef {Object} module:typing/texttransformation~TextTransformationDescription
* @property {String|RegExp} from The string or RegExp to transform.
Expand Down Expand Up @@ -230,7 +304,7 @@ function expandGroupsAndRemoveDuplicates( definitions ) {
* - `ellipsis`: transforms `...` to `…`
* - `enDash`: transforms ` -- ` to ` – `
* - `emDash`: transforms ` --- ` to ` — `
* * Quotations (group name: `quotations`)
* * Quotations (group name: `quotes`)
* - `quotesPrimary`: transforms `"Foo bar"` to `“Foo bar”`
* - `quotesSecondary`: transforms `'Foo bar'` to `‘Foo bar’`
* * Symbols (group name: `symbols`)
Expand All @@ -243,8 +317,8 @@ function expandGroupsAndRemoveDuplicates( definitions ) {
* - `twoThirds`: transforms `2/3`, to: `⅔`
* - `oneForth`: transforms `1/4`, to: `¼`
* - `threeQuarters`: transforms `3/4`, to: `¾`
* - `lessThenOrEqual`: transforms `<=`, to: `≤`
* - `greaterThenOrEqual`: transforms `>=`, to: `≥`
* - `lessThanOrEqual`: transforms `<=`, to: `≤`
* - `greaterThanOrEqual`: transforms `>=`, to: `≥`
* - `notEqual`: transforms `!=`, to: `≠`
* - `arrowLeft`: transforms `<-`, to: `←`
* - `arrowRight`: transforms `->`, to: `→`
Expand All @@ -263,21 +337,23 @@ function expandGroupsAndRemoveDuplicates( definitions ) {
* In order to completely override the supported transformations, use the
* {@link module:typing/texttransformation~TextTransformationConfig#include `transformations.include` option}.
*
* Example:
* Examples:
*
* const transformationsConfig = {
* include: [
* // Use only the 'quotes' and 'typography' groups.
* 'quotes',
* 'typography',
*
* // Plus, some custom transformation.
* { from: 'CKE', to: 'CKEditor }
* ],
* { from: 'CKE', to: 'CKEditor' }
* ]
* };
*
* const transformationsConfig = {
* // Remove the 'ellipsis' transformation loaded by the 'typography' group.
* remove: [ 'ellipsis' ]
* };
* }
*
* @interface TextTransformationConfig
*/
Expand Down Expand Up @@ -313,7 +389,7 @@ function expandGroupsAndRemoveDuplicates( definitions ) {
*
* const transformationsConfig = {
* remove: [
* 'ellipsis', // Remove only 'ellipsis' from 'typography group.
* 'ellipsis', // Remove only 'ellipsis' from 'typography' group.
* 'mathematical' // Remove all transformations from 'mathematical' group.
* ]
* }
Expand Down
2 changes: 2 additions & 0 deletions src/textwatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export default class TextWatcher {
* @fires unmatched
*
* @private
* @param {'data'|'selection'} suffix Suffix used for generating event name.
*/
_evaluateTextBeforeSelection( suffix ) {
const text = this._getText();
Expand All @@ -101,6 +102,7 @@ export default class TextWatcher {
*
* @event matched:data
*/

/**
* Fired whenever the text watcher found a match for selection changes.
*
Expand Down
Loading

0 comments on commit 2279eee

Please sign in to comment.