Selaa lähdekoodia

Fix: Text transformations will not remove existing formatting.

Szymon Cofalik 6 vuotta sitten
vanhempi
commit
e3ab4b88c3

+ 123 - 48
packages/ckeditor5-typing/src/texttransformation.js

@@ -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 = {
@@ -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: '→' },
@@ -36,16 +37,16 @@ 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.
@@ -53,7 +54,7 @@ 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' ],
@@ -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.
@@ -182,20 +233,42 @@ function expandGroupsAndRemoveDuplicates( definitions ) {
 }
 
 /**
- * Text transformation definition object.
+ * Text transformation definition object. Describes what should be replace with what.
+ *
+ * Input value ("replace from") can be passed either as a `String` or a `RegExp` instance.
+ *
+ * * If `String` is passed it will be simply checked if the end of the input matches it.
+ * * 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
+ * an input, it has to include `$` token to be correctly matched. See examples below.
+ *
+ * Output value ("replace 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 - an array with matches
+ * for the input value `RegExp`. See 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.
@@ -243,8 +316,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: `→`
@@ -263,21 +336,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
  */
@@ -313,7 +388,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.
  *			]
  *		}

+ 2 - 0
packages/ckeditor5-typing/src/textwatcher.js

@@ -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();
@@ -101,6 +102,7 @@ export default class TextWatcher {
 			 *
 			 * @event matched:data
 			 */
+
 			/**
 			 * Fired whenever the text watcher found a match for selection changes.
 			 *

+ 57 - 2
packages/ckeditor5-typing/tests/texttransformation.js

@@ -9,6 +9,7 @@ import global from '@ckeditor/ckeditor5-utils/src/dom/global';
 import TextTransformation from '../src/texttransformation';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
 
 describe( 'Text transformation feature', () => {
 	let editorElement, editor, model, doc;
@@ -83,6 +84,40 @@ describe( 'Text transformation feature', () => {
 			} );
 		} );
 
+		// https://github.com/ckeditor/ckeditor5-typing/issues/203.
+		it( 'should replace only the parts of content which changed', () => {
+			setData( model, '<paragraph>Foo "<$text bold="true">Bar</$text>[]</paragraph>' );
+
+			model.change( writer => {
+				writer.insertText( '"', doc.selection.focus );
+			} );
+
+			expect( getData( model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph>Foo “<$text bold="true">Bar</$text>”</paragraph>' );
+		} );
+
+		it( 'should keep styles of the replaced text #1', () => {
+			setData( model, '<paragraph>Foo <$text bold="true">"</$text>Bar[]</paragraph>' );
+
+			model.change( writer => {
+				writer.insertText( '"', { bold: true }, doc.selection.focus );
+			} );
+
+			expect( getData( model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph>Foo <$text bold="true">“</$text>Bar<$text bold="true">”</$text></paragraph>' );
+		} );
+
+		it( 'should keep styles of the replaced text #2', () => {
+			setData( model, '<paragraph>F<$text bold="true">oo "B</$text>ar[]</paragraph>' );
+
+			model.change( writer => {
+				writer.insertText( '"', doc.selection.focus );
+			} );
+
+			expect( getData( model, { withoutSelection: true } ) )
+				.to.equal( '<paragraph>F<$text bold="true">oo “B</$text>ar”</paragraph>' );
+		} );
+
 		function testTransformation( transformFrom, transformTo ) {
 			it( `should transform "${ transformFrom }" to "${ transformTo }"`, () => {
 				setData( model, '<paragraph>A foo[]</paragraph>' );
@@ -142,7 +177,7 @@ describe( 'Text transformation feature', () => {
 				typing: {
 					transformations: {
 						extra: [
-							{ from: /([a-z]+)@(example.com)$/, to: '$1.at.$2' }
+							{ from: /([a-z]+)(@)(example.com)$/, to: [ null, '.at.', null ] }
 						]
 					}
 				}
@@ -157,6 +192,26 @@ describe( 'Text transformation feature', () => {
 			} );
 		} );
 
+		it( 'should allow adding own rules with function as `to` value', () => {
+			return createEditorInstance( {
+				typing: {
+					transformations: {
+						extra: [
+							{ from: /(\. )([a-z])$/, to: matches => [ null, matches[ 1 ].toUpperCase() ] }
+						]
+					}
+				}
+			} ).then( () => {
+				setData( model, '<paragraph>Foo. []</paragraph>' );
+
+				model.enqueueChange( model.createBatch(), writer => {
+					writer.insertText( 'b', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>Foo. B</paragraph>' );
+			} );
+		} );
+
 		it( 'should not alter include rules adding own rules as extra', () => {
 			return createEditorInstance( {
 				typing: {
@@ -291,7 +346,7 @@ describe( 'Text transformation feature', () => {
 	function createEditorInstance( additionalConfig = {} ) {
 		return ClassicTestEditor
 			.create( editorElement, Object.assign( {
-				plugins: [ Paragraph, TextTransformation ]
+				plugins: [ Paragraph, Bold, TextTransformation ]
 			}, additionalConfig ) )
 			.then( newEditor => {
 				editor = newEditor;