8
0
Quellcode durchsuchen

Merge pull request #190 from ckeditor/t/ckeditor5/1490

Feature: Introduced the text transformation feature. Additionally, the `TextWatcher` util was moved to this package from `@ckeditor/ckeditor5-mention`. Closes ckeditor/ckeditor5#1490.
Piotrek Koszuliński vor 6 Jahren
Ursprung
Commit
790d1acef2

+ 321 - 0
packages/ckeditor5-typing/src/texttransformation.js

@@ -0,0 +1,321 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module typing/texttransformation
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import TextWatcher from './textwatcher';
+
+// All named transformations.
+const TRANSFORMATIONS = {
+	// Common symbols:
+	copyright: { from: '(c)', to: '©' },
+	registeredTrademark: { from: '(r)', to: '®' },
+	trademark: { from: '(tm)', to: '™' },
+
+	// Mathematical:
+	oneHalf: { from: '1/2', to: '½' },
+	oneThird: { from: '1/3', to: '⅓' },
+	twoThirds: { from: '2/3', to: '⅔' },
+	oneForth: { from: '1/4', to: '¼' },
+	threeQuarters: { from: '3/4', to: '¾' },
+	lessThenOrEqual: { from: '<=', to: '≤' },
+	greaterThenOrEqual: { from: '>=', to: '≥' },
+	notEqual: { from: '!=', to: '≠' },
+	arrowLeft: { from: '<-', to: '←' },
+	arrowRight: { from: '->', to: '→' },
+
+	// Typography:
+	horizontalEllipsis: { from: '...', to: '…' },
+	enDash: { from: ' -- ', to: ' – ' },
+	emDash: { from: ' --- ', to: ' — ' },
+
+	// Quotations:
+	// English, US
+	quotesPrimary: { from: buildQuotesRegExp( '"' ), to: '$1“$2”' },
+	quotesSecondary: { from: buildQuotesRegExp( '\'' ), to: '$1‘$2’' },
+
+	// English, UK
+	quotesPrimaryEnGb: { from: buildQuotesRegExp( '\'' ), to: '$1‘$2’' },
+	quotesSecondaryEnGb: { from: buildQuotesRegExp( '"' ), to: '$1“$2”' },
+
+	// Polish
+	quotesPrimaryPl: { from: buildQuotesRegExp( '"' ), to: '$1„$2”' },
+	quotesSecondaryPl: { from: buildQuotesRegExp( '\'' ), to: '$1‚$2’' }
+};
+
+// Transformation groups.
+const TRANSFORMATION_GROUPS = {
+	symbols: [ 'copyright', 'registeredTrademark', 'trademark' ],
+	mathematical: [
+		'oneHalf', 'oneThird', 'twoThirds', 'oneForth', 'threeQuarters',
+		'lessThenOrEqual', 'greaterThenOrEqual', 'notEqual',
+		'arrowLeft', 'arrowRight'
+	],
+	typography: [ 'horizontalEllipsis', 'enDash', 'emDash' ],
+	quotes: [ 'quotesPrimary', 'quotesSecondary' ]
+};
+
+// Set of default transformations provided by the feature.
+const DEFAULT_TRANSFORMATIONS = [
+	'symbols',
+	'mathematical',
+	'typography',
+	'quotes'
+];
+
+/**
+ * The text transformation plugin.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TextTransformation extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TextTransformation';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	constructor( editor ) {
+		super( editor );
+
+		editor.config.define( 'typing', {
+			transformations: {
+				include: DEFAULT_TRANSFORMATIONS
+			}
+		} );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const model = editor.model;
+
+		const configuredTransformations = getConfiguredTransformations( editor.config.get( 'typing.transformations' ) );
+
+		for ( const transformation of configuredTransformations ) {
+			const { from, to } = transformation;
+
+			let testCallback;
+			let textReplacer;
+
+			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;
+			}
+
+			const watcher = new TextWatcher( editor.model, testCallback );
+
+			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 );
+
+				model.enqueueChange( model.createBatch(), writer => {
+					const replaceRange = writer.createRange( focus.getShiftedBy( -textToReplaceLength ), focus );
+
+					model.insertContent( writer.createText( textToInsert, selection.getAttributes() ), replaceRange );
+				} );
+			} );
+		}
+	}
+}
+
+// Returns a RegExp pattern string that detects a sentence inside a quote.
+//
+// @param {String} quoteCharacter a character to creat a pattern for.
+// @returns {String}
+function buildQuotesRegExp( quoteCharacter ) {
+	return new RegExp( `(^|\\s)${ quoteCharacter }([^${ quoteCharacter }]+)${ quoteCharacter }$` );
+}
+
+// Reads text transformation config and returns normalized array of transformations objects.
+//
+// @param {module:typing/texttransformation~TextTransformationDescription} config
+// @returns {Array.<module:typing/texttransformation~TextTransformationDescription>}
+function getConfiguredTransformations( config ) {
+	const extra = config.extra || [];
+	const remove = config.remove || [];
+	const isNotRemoved = transformation => !remove.includes( transformation );
+
+	const configured = config.include.concat( extra ).filter( isNotRemoved );
+
+	return expandGroupsAndRemoveDuplicates( configured )
+		.filter( isNotRemoved ) // Filter out 'remove' transformations as they might be set in group
+		.map( transformation => TRANSFORMATIONS[ transformation ] || transformation );
+}
+
+// Reads definitions and expands named groups if needed to transformation names.
+// This method also removes duplicated named transformations if any.
+//
+// @param {Array.<String|Object>} definitions
+// @returns {Array.<String|Object>}
+function expandGroupsAndRemoveDuplicates( definitions ) {
+	// Set is using to make sure that transformation names are not duplicated.
+	const definedTransformations = new Set();
+
+	for ( const transformationOrGroup of definitions ) {
+		if ( TRANSFORMATION_GROUPS[ transformationOrGroup ] ) {
+			for ( const transformation of TRANSFORMATION_GROUPS[ transformationOrGroup ] ) {
+				definedTransformations.add( transformation );
+			}
+		} else {
+			definedTransformations.add( transformationOrGroup );
+		}
+	}
+
+	return Array.from( definedTransformations );
+}
+
+/**
+ * Text transformation definition object.
+ *
+ *		const transformations = [
+ *			// Will replace foo with bar:
+ *			{ from: 'foo', to: 'bar' },
+ *
+ *			// Will remove @ from emails on example.com domain, e.g. from user@example.com -> user.at.example.com:
+ *			{ from: /([a-z-])@(example.com)$/i, to: '$1.at.$2' }
+ *		]
+ *
+ * **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.
+ *
+ * @typedef {Object} module:typing/texttransformation~TextTransformationDescription
+ * @property {String|RegExp} from The string or RegExp to transform.
+ * @property {String} to The text to transform compatible with `String.replace()`
+ */
+
+/**
+ * The configuration of the {@link module:typing/texttransformation~TextTransformation} feature.
+ *
+ * Read more in {@link module:typing/texttransformation~TextTransformationConfig}.
+ *
+ * @member {module:typing/texttransformation~TextTransformationConfig} module:typing/typing~TypingConfig#transformations
+ */
+
+/**
+ * The configuration of the text transformation feature.
+ *
+ *		ClassicEditor
+ *			.create( editorElement, {
+ *				typing: {
+ *					transformations: ... // Text transformation feature options.
+ *				}
+ *			} )
+ *			.then( ... )
+ *			.catch( ... );
+ *
+ * By default, the feature comes pre-configured
+ * (via {@link module:typing/texttransformation~TextTransformationConfig#include `config.typing.transformations.include`}) with the
+ * following groups of transformations:
+ *
+ * * Typography (group name: `typography`)
+ *   - `ellipsis`: transforms `...` to `…`
+ *   - `enDash`: transforms ` -- ` to ` – `
+ *   - `emDash`: transforms ` --- ` to ` — `
+ * * Quotations (group name: `quotations`)
+ *   - `quotesPrimary`: transforms `"Foo bar"` to `“Foo bar”`
+ *   - `quotesSecondary`: transforms `'Foo bar'` to `‘Foo bar’`
+ * * Symbols (group name: `symbols`)
+ *   - `trademark`: transforms `(tm)` to `™`
+ *   - `registeredTrademark`: transforms `(r)` to `®`
+ *   - `copyright`: transforms `(c)` to `©`
+ * * Mathematical (group name: `mathematical`)
+ *   - `oneHalf`: transforms `1/2`, to: `½`
+ *   - `oneThird`: transforms `1/3`, to: `⅓`
+ *   - `twoThirds`: transforms `2/3`, to: `⅔`
+ *   - `oneForth`: transforms `1/4`, to: `¼`
+ *   - `threeQuarters`: transforms `3/4`, to: `¾`
+ *   - `lessThenOrEqual`: transforms `<=`, to: `≤`
+ *   - `greaterThenOrEqual`: transforms `>=`, to: `≥`
+ *   - `notEqual`: transforms `!=`, to: `≠`
+ *   - `arrowLeft`: transforms `<-`, to: `←`
+ *   - `arrowRight`: transforms `->`, to: `→`
+ * * Misc:
+ *   - `quotesPrimaryEnGb`: transforms `'Foo bar'` to `‘Foo bar’`
+ *   - `quotesSecondaryEnGb`: transforms `"Foo bar"` to `“Foo bar”`
+ *   - `quotesPrimaryPl`: transforms `"Foo bar"` to `„Foo bar”`
+ *   - `quotesSecondaryPl`:  transforms `'Foo bar'` to `‚Foo bar’`
+ *
+ * In order to load additional transformations, use the
+ * {@link module:typing/texttransformation~TextTransformationConfig#extra `transformations.extra` option}.
+ *
+ * In order to narrow down the list of transformations, use the
+ * {@link module:typing/texttransformation~TextTransformationConfig#remove `transformations.remove` option}.
+ *
+ * In order to completely override the supported transformations, use the
+ * {@link module:typing/texttransformation~TextTransformationConfig#include `transformations.include` option}.
+ *
+ * Example:
+ *
+ *		const transformationsConfig = {
+ *			include: [
+ *				// Use only the 'quotes' and 'typography' groups.
+ *				'quotes',
+ *				'typography',
+
+ *				// Plus, some custom transformation.
+ *				{ from: 'CKS', to: 'CKSource }
+ *			],
+ *
+ *			// Remove the 'ellipsis' transformation loaded by the 'typography' group.
+ *			remove: [ 'ellipsis' ]
+ *		};
+ *
+ * @interface TextTransformationConfig
+ */
+
+/* eslint-disable max-len */
+/**
+ * The standard list of text transformations supported by the editor. By default it comes pre-configured with a couple dozen of them
+ * (see {@link module:typing/texttransformation~TextTransformationConfig} for the full list of them). You can override this list completely
+ * by setting this option or use the other two options
+ * ({@link module:typing/texttransformation~TextTransformationConfig#extra `transformations.extra`},
+ * {@link module:typing/texttransformation~TextTransformationConfig#remove `transformations.remove`}) to fine tune the default list.
+ *
+ * @member {Array.<module:typing/texttransformation~TextTransformationDescription>} module:typing/texttransformation~TextTransformationConfig#include
+ */
+
+/**
+ * The extra text transformations that are added to the transformations defined in
+ * {@link module:typing/texttransformation~TextTransformationConfig#include `transformations.include`}.
+ *
+ *		const transformationsConfig = {
+ *			extra: [
+ *				{ from: 'CKS', to: 'CKSource' }
+ *			]
+ *		};
+ *
+ * @member {Array.<module:typing/texttransformation~TextTransformationDescription>} module:typing/texttransformation~TextTransformationConfig#extra
+ */
+
+/**
+ * The text transformations names that are removed from transformations defined in
+ * {@link module:typing/texttransformation~TextTransformationConfig#include `transformations.include`} or
+ * {@link module:typing/texttransformation~TextTransformationConfig#extra `transformations.extra`}.
+ *
+ *		const transformationsConfig = {
+ *			remove: [
+ *				'ellipsis',    // Remove only 'ellipsis' from 'typography group.
+ *				'mathematical' // Remove all transformations from 'mathematical' group.
+ *			]
+ *		}
+ *
+ * @member {Array.<module:typing/texttransformation~TextTransformationDescription>} module:typing/texttransformation~TextTransformationConfig#remove
+ */
+/* eslint-enable max-len */

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

@@ -0,0 +1,146 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module typing/textwatcher
+ */
+
+import mix from '@ckeditor/ckeditor5-utils/src/mix';
+import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
+
+/**
+ * The text watcher feature.
+ *
+ * Fires {@link module:typing/textwatcher~TextWatcher#event:matched:data `matched:data`},
+ * {@link module:typing/textwatcher~TextWatcher#event:matched:selection `matched:selection`} and
+ * {@link module:typing/textwatcher~TextWatcher#event:unmatched `unmatched`} events on typing or selection changes.
+ *
+ * @private
+ */
+export default class TextWatcher {
+	/**
+	 * Creates a text watcher instance.
+	 * @param {module:engine/model/model~Model} model
+	 * @param {Function} testCallback The function used to match the text.
+	 */
+	constructor( model, testCallback ) {
+		this.model = model;
+		this.testCallback = testCallback;
+		this.hasMatch = false;
+
+		this._startListening();
+	}
+
+	/**
+	 * Starts listening to the editor for typing and selection events.
+	 *
+	 * @private
+	 */
+	_startListening() {
+		const model = this.model;
+		const document = model.document;
+
+		document.selection.on( 'change:range', ( evt, { directChange } ) => {
+			// Indirect changes (i.e. when the user types or external changes are applied) are handled in the document's change event.
+			if ( !directChange ) {
+				return;
+			}
+
+			// Act only on collapsed selection.
+			if ( !document.selection.isCollapsed ) {
+				if ( this.hasMatch ) {
+					this.fire( 'unmatched' );
+					this.hasMatch = false;
+				}
+
+				return;
+			}
+
+			this._evaluateTextBeforeSelection( 'selection' );
+		} );
+
+		document.on( 'change:data', ( evt, batch ) => {
+			if ( batch.type == 'transparent' ) {
+				return;
+			}
+
+			this._evaluateTextBeforeSelection( 'data' );
+		} );
+	}
+
+	/**
+	 * Checks the editor content for matched text.
+	 *
+	 * @fires matched:data
+	 * @fires matched:selection
+	 * @fires unmatched
+	 *
+	 * @private
+	 */
+	_evaluateTextBeforeSelection( suffix ) {
+		const text = this._getText();
+
+		const textHasMatch = this.testCallback( text );
+
+		if ( !textHasMatch && this.hasMatch ) {
+			/**
+			 * Fired whenever the text does not match anymore. Fired only when the text watcher found a match.
+			 *
+			 * @event unmatched
+			 */
+			this.fire( 'unmatched' );
+		}
+
+		this.hasMatch = textHasMatch;
+
+		if ( textHasMatch ) {
+			/**
+			 * Fired whenever the text watcher found a match for data changes.
+			 *
+			 * @event matched:data
+			 */
+			/**
+			 * Fired whenever the text watcher found a match for selection changes.
+			 *
+			 * @event matched:selection
+			 */
+			this.fire( `matched:${ suffix }`, { text } );
+		}
+	}
+
+	/**
+	 * Returns the text before the caret from the current selection block.
+	 *
+	 * @returns {String|undefined} The text from the block or undefined if the selection is not collapsed.
+	 * @private
+	 */
+	_getText() {
+		const model = this.model;
+		const document = model.document;
+		const selection = document.selection;
+
+		const rangeBeforeSelection = model.createRange( model.createPositionAt( selection.focus.parent, 0 ), selection.focus );
+
+		return _getText( rangeBeforeSelection );
+	}
+}
+
+// Returns the whole text from a given range by adding all data from the text nodes together.
+//
+// @param {module:engine/model/range~Range} range
+// @returns {String}
+function _getText( range ) {
+	return Array.from( range.getItems() ).reduce( ( rangeText, node ) => {
+		if ( node.is( 'softBreak' ) ) {
+			// Trim text to a softBreak.
+			return '';
+		}
+
+		return rangeText + node.data;
+	}, '' );
+}
+
+mix( TextWatcher, EmitterMixin );
+

+ 4 - 0
packages/ckeditor5-typing/tests/manual/texttransformation.html

@@ -0,0 +1,4 @@
+<div id="editor">
+	<h2>Let's type(tm</h2>
+	<p>Divide 1/2 should stay on selection change.</p>
+</div>

+ 33 - 0
packages/ckeditor5-typing/tests/manual/texttransformation.js

@@ -0,0 +1,33 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global console, window */
+
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import TextTransformation from '../../src/texttransformation';
+
+ClassicEditor
+	.create( global.document.querySelector( '#editor' ), {
+		plugins: [ ArticlePluginSet, TextTransformation ],
+		toolbar: [
+			'heading',
+			'|', 'bulletedList', 'numberedList', 'blockQuote',
+			'|', 'bold', 'italic', 'link',
+			'|', 'insertTable',
+			'|', 'undo', 'redo'
+		],
+		image: {
+			toolbar: [ 'imageStyle:full', 'imageStyle:side', '|', 'imageTextAlternative' ]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 33 - 0
packages/ckeditor5-typing/tests/manual/texttransformation.md

@@ -0,0 +1,33 @@
+## Text transformation
+
+The list of default transformations is available in the docs.
+
+Some of the transformations are:
+
+1. Symbols:
+
+    * Copyright: `(c)` to `©`.
+    * Registered treademark: `(r)` to `®`.
+    * Trade mark: `(tm)` to `™.`
+
+1. Mathematical:
+
+    * Fractions of 2, 3 & 4, like `½` to `½` or `3/4` to `¾`. (ps.: there's no `2/4` 😉)
+    * Arrows: `->`, `<-`.
+    * Operators: `<=` to `≤`, `>=` to `≥`, `!=` to `≠`.
+
+1. Typography:
+    
+    * Dashes: ` -- `, ` --- `.
+    * Ellipsis: `...` to `…`
+    
+1. Quotes:
+
+    * Primary quotes (english): `'Foo bar'` to `‘Foo bar’` 
+    * Secondary quotes (english): `"Foo bar's"` to `“Foo bar's”`
+
+### Testing
+
+* Check if the transformation works. Note that some might need a space to trigger (dashes).
+* Undo a text transformation and type - it should not re-transform it.
+* Change selection - the not transformed elements should stay. 

+ 94 - 0
packages/ckeditor5-typing/tests/texttransformation-integration.js

@@ -0,0 +1,94 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* global document */
+
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+
+import TextTransformation from '../src/texttransformation';
+
+describe( 'Text transformation feature - integration', () => {
+	let editorElement, editor, model, doc;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	describe( 'with undo', () => {
+		beforeEach( () => {
+			return ClassicTestEditor
+				.create( editorElement, { plugins: [ Paragraph, TextTransformation, UndoEditing ] } )
+				.then( newEditor => {
+					editor = newEditor;
+					model = editor.model;
+					doc = model.document;
+				} );
+		} );
+
+		it( 'should undo text transformation', () => {
+			editor.setData( '<p>foo</p>' );
+
+			model.enqueueChange( model.createBatch(), writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 'end' );
+				writer.insertText( '(c', doc.selection.focus );
+			} );
+
+			model.enqueueChange( model.createBatch(), writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 'end' );
+				writer.insertText( ')', doc.selection.focus );
+			} );
+
+			expect( editor.getData(), 'inserted text' ).to.equal( '<p>foo©</p>' );
+
+			editor.execute( 'undo' );
+
+			expect( editor.getData(), 'after undo' ).to.equal( '<p>foo(c)</p>' );
+
+			editor.execute( 'redo' );
+
+			expect( editor.getData(), 'after redo' ).to.equal( '<p>foo©</p>' );
+		} );
+
+		it( 'should allow to undo-redo steps', () => {
+			editor.setData( '<p></p>' );
+
+			model.enqueueChange( model.createBatch(), writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 'end' );
+				writer.insertText( 'foo bar baz(c', doc.selection.focus );
+			} );
+
+			model.enqueueChange( model.createBatch(), writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 'end' );
+				writer.insertText( ')', doc.selection.focus );
+			} );
+			expect( editor.getData() ).to.equal( '<p>foo bar baz©</p>' );
+
+			editor.execute( 'undo' );
+			expect( editor.getData() ).to.equal( '<p>foo bar baz(c)</p>' );
+
+			editor.execute( 'undo' );
+			expect( editor.getData() ).to.equal( '<p>foo bar baz(c</p>' );
+
+			editor.execute( 'redo' );
+			expect( editor.getData() ).to.equal( '<p>foo bar baz(c)</p>' );
+
+			editor.execute( 'redo' );
+			expect( editor.getData() ).to.equal( '<p>foo bar baz©</p>' );
+		} );
+	} );
+} );

+ 303 - 0
packages/ckeditor5-typing/tests/texttransformation.js

@@ -0,0 +1,303 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+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';
+
+describe( 'Text transformation feature', () => {
+	let editorElement, editor, model, doc;
+
+	beforeEach( () => {
+		editorElement = global.document.createElement( 'div' );
+		global.document.body.appendChild( editorElement );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		if ( editor ) {
+			return editor.destroy();
+		}
+	} );
+
+	it( 'should be loaded', () => {
+		return createEditorInstance().then( () => {
+			expect( editor.plugins.get( TextTransformation ) ).to.instanceOf( TextTransformation );
+		} );
+	} );
+
+	it( 'has proper name', () => {
+		return createEditorInstance().then( () => {
+			expect( TextTransformation.pluginName ).to.equal( 'TextTransformation' );
+		} );
+	} );
+
+	describe( 'transformations', () => {
+		beforeEach( createEditorInstance );
+
+		it( 'should not work for selection changes', () => {
+			setData( model, '<paragraph>foo bar(tm) baz[]</paragraph>' );
+
+			model.change( writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 11 );
+			} );
+
+			expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>foo bar(tm) baz</paragraph>' );
+		} );
+
+		describe( 'symbols', () => {
+			testTransformation( '(c)', '©' );
+			testTransformation( '(r)', '®' );
+			testTransformation( '(tm)', '™' );
+		} );
+
+		describe( 'mathematical', () => {
+			testTransformation( '1/2', '½' );
+			testTransformation( '<=', '≤' );
+		} );
+
+		describe( 'typography', () => {
+			testTransformation( '...', '…' );
+			testTransformation( ' -- ', ' – ' );
+			testTransformation( ' --- ', ' — ' );
+		} );
+
+		describe( 'quotations', () => {
+			describe( 'english US', () => {
+				describe( 'primary', () => {
+					testTransformation( ' "Foo 1992 — bar(1) baz: xyz."', ' “Foo 1992 — bar(1) baz: xyz.”' );
+					testTransformation( '\' foo "bar"', '\' foo “bar”' );
+					testTransformation( 'Foo "Bar bar\'s it\'s a baz"', 'Foo “Bar bar\'s it\'s a baz”' );
+				} );
+
+				describe( 'secondary', () => {
+					testTransformation( ' \'Foo 1992 — bar(1) baz: xyz.\'', ' ‘Foo 1992 — bar(1) baz: xyz.’' );
+					testTransformation( '" foo \'bar\'', '" foo ‘bar’' );
+				} );
+			} );
+		} );
+
+		function testTransformation( transformFrom, transformTo ) {
+			it( `should transform "${ transformFrom }" to "${ transformTo }"`, () => {
+				setData( model, '<paragraph>A foo[]</paragraph>' );
+
+				const letters = transformFrom.split( '' );
+
+				for ( const letter of letters ) {
+					model.enqueueChange( model.createBatch(), writer => {
+						writer.insertText( letter, doc.selection.focus );
+					} );
+				}
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( `<paragraph>A foo${ transformTo }</paragraph>` );
+			} );
+
+			it( `should not transform "${ transformFrom }" to "${ transformTo }" inside text`, () => {
+				setData( model, '<paragraph>[]</paragraph>' );
+
+				// Insert text - should not be transformed.
+				model.enqueueChange( model.createBatch(), writer => {
+					writer.insertText( `foo ${ transformFrom } bar`, doc.selection.focus );
+				} );
+
+				// Enforce text watcher check after insertion.
+				model.enqueueChange( model.createBatch(), writer => {
+					writer.insertText( ' ', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( `<paragraph>foo ${ transformFrom } bar </paragraph>` );
+			} );
+		}
+	} );
+
+	describe( 'configuration', () => {
+		it( 'should allow adding own rules with string pattern', () => {
+			return createEditorInstance( {
+				typing: {
+					transformations: {
+						extra: [
+							{ from: 'CKE', to: 'CKEditor' }
+						]
+					}
+				}
+			} ).then( () => {
+				setData( model, '<paragraph>[]</paragraph>' );
+
+				model.enqueueChange( model.createBatch(), writer => {
+					writer.insertText( 'CKE', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>CKEditor</paragraph>' );
+			} );
+		} );
+
+		it( 'should allow adding own rules with RegExp object', () => {
+			return createEditorInstance( {
+				typing: {
+					transformations: {
+						extra: [
+							{ from: /([a-z]+)@(example.com)$/, to: '$1.at.$2' }
+						]
+					}
+				}
+			} ).then( () => {
+				setData( model, '<paragraph>[]</paragraph>' );
+
+				model.enqueueChange( model.createBatch(), writer => {
+					writer.insertText( 'user@example.com', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>user.at.example.com</paragraph>' );
+			} );
+		} );
+
+		it( 'should not alter include rules adding own rules as extra', () => {
+			return createEditorInstance( {
+				typing: {
+					transformations: {
+						extra: [
+							{ from: 'CKE', to: 'CKEditor' }
+						]
+					}
+				}
+			} ).then( () => {
+				setData( model, '<paragraph>[]</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( 'CKE', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>CKEditor</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '(tm)', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>CKEditor™</paragraph>' );
+			} );
+		} );
+
+		it( 'should overwrite all rules when defining include rules', () => {
+			return createEditorInstance( {
+				typing: {
+					transformations: {
+						include: [
+							{ from: 'CKE', to: 'CKEditor' }
+						]
+					}
+				}
+			} ).then( () => {
+				setData( model, '<paragraph>[]</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( 'CKE', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>CKEditor</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '(tm)', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>CKEditor(tm)</paragraph>' );
+			} );
+		} );
+
+		it( 'should remove rules from group when defining remove rules', () => {
+			return createEditorInstance( {
+				typing: {
+					transformations: {
+						include: [ 'symbols' ],
+						remove: [ 'trademark' ]
+					}
+				}
+			} ).then( () => {
+				setData( model, '<paragraph>[]</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '(tm)', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>(tm)</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '(r)', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>(tm)®</paragraph>' );
+			} );
+		} );
+
+		it( 'should remove all rules from group when group is in remove', () => {
+			return createEditorInstance( {
+				typing: {
+					transformations: {
+						include: [ 'symbols', 'typography' ],
+						remove: [ 'symbols' ]
+					}
+				}
+			} ).then( () => {
+				setData( model, '<paragraph>[]</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '(tm)', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>(tm)</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '...', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>(tm)…</paragraph>' );
+			} );
+		} );
+
+		it( 'should not fail for unknown rule name', () => {
+			return createEditorInstance( {
+				typing: {
+					transformations: {
+						include: [ 'symbols', 'typo' ]
+					}
+				}
+			} );
+		} );
+
+		it( 'should not fail for re-declared include rules config', () => {
+			return createEditorInstance( {
+				typing: {
+					transformations: {
+						extra: [ 'trademark' ]
+					}
+				}
+			} ).then( () => {
+				setData( model, '<paragraph>[]</paragraph>' );
+
+				model.change( writer => {
+					writer.insertText( '(tm)', doc.selection.focus );
+				} );
+
+				expect( getData( model, { withoutSelection: true } ) ).to.equal( '<paragraph>™</paragraph>' );
+			} );
+		} );
+	} );
+
+	function createEditorInstance( additionalConfig = {} ) {
+		return ClassicTestEditor
+			.create( editorElement, Object.assign( {
+				plugins: [ Paragraph, TextTransformation ]
+			}, additionalConfig ) )
+			.then( newEditor => {
+				editor = newEditor;
+
+				model = editor.model;
+				doc = model.document;
+			} );
+	}
+} );

+ 193 - 0
packages/ckeditor5-typing/tests/textwatcher.js

@@ -0,0 +1,193 @@
+/**
+ * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import TextWatcher from '../src/textwatcher';
+
+describe( 'TextWatcher', () => {
+	let editor, model, doc;
+	let watcher, matchedDataSpy, matchedSelectionSpy, unmatchedSpy, testCallbackStub;
+
+	testUtils.createSinonSandbox();
+
+	beforeEach( () => {
+		return ModelTestEditor.create()
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				doc = model.document;
+
+				testCallbackStub = sinon.stub();
+				matchedDataSpy = sinon.spy();
+				matchedSelectionSpy = sinon.spy();
+				unmatchedSpy = sinon.spy();
+
+				model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+
+				setData( model, '<paragraph>foo []</paragraph>' );
+
+				watcher = new TextWatcher( model, testCallbackStub, () => {} );
+				watcher.on( 'matched:data', matchedDataSpy );
+				watcher.on( 'matched:selection', matchedSelectionSpy );
+				watcher.on( 'unmatched', unmatchedSpy );
+			} );
+	} );
+
+	afterEach( () => {
+		sinon.restore();
+
+		if ( editor ) {
+			return editor.destroy();
+		}
+	} );
+
+	describe( 'testCallback', () => {
+		it( 'should evaluate text before caret for data changes', () => {
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledWithExactly( testCallbackStub, 'foo @' );
+		} );
+
+		it( 'should not evaluate text for not collapsed selection', () => {
+			model.change( writer => {
+				const start = writer.createPositionAt( doc.getRoot().getChild( 0 ), 0 );
+
+				writer.setSelection( writer.createRange( start, start.getShiftedBy( 1 ) ) );
+			} );
+
+			sinon.assert.notCalled( testCallbackStub );
+		} );
+
+		it( 'should evaluate text for selection changes', () => {
+			model.change( writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 1 );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledWithExactly( testCallbackStub, 'f' );
+		} );
+
+		it( 'should evaluate text before caret up to <softBreak>', () => {
+			model.schema.register( 'softBreak', {
+				allowWhere: '$text',
+				isInline: true
+			} );
+
+			model.change( writer => {
+				writer.insertElement( 'softBreak', doc.selection.getFirstPosition() );
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledWithExactly( testCallbackStub, '@' );
+		} );
+
+		it( 'should not evaluate text for transparent batches', () => {
+			model.enqueueChange( 'transparent', writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.notCalled( testCallbackStub );
+		} );
+	} );
+
+	describe( 'events', () => {
+		it( 'should fire "matched:data" event when test callback returns true for model data changes', () => {
+			testCallbackStub.returns( true );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledOnce( matchedDataSpy );
+			sinon.assert.notCalled( matchedSelectionSpy );
+			sinon.assert.notCalled( unmatchedSpy );
+		} );
+
+		it( 'should fire "matched:selection" event when test callback returns true for model data changes', () => {
+			testCallbackStub.returns( true );
+
+			model.enqueueChange( 'transparent', writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			model.change( writer => {
+				writer.setSelection( doc.getRoot().getChild( 0 ), 0 );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.notCalled( matchedDataSpy );
+			sinon.assert.calledOnce( matchedSelectionSpy );
+			sinon.assert.notCalled( unmatchedSpy );
+		} );
+
+		it( 'should not fire "matched" event when test callback returns false', () => {
+			testCallbackStub.returns( false );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.notCalled( matchedDataSpy );
+			sinon.assert.notCalled( matchedSelectionSpy );
+			sinon.assert.notCalled( unmatchedSpy );
+		} );
+
+		it( 'should fire "unmatched" event when test callback returns false when it was previously matched', () => {
+			testCallbackStub.returns( true );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledOnce( matchedDataSpy );
+			sinon.assert.notCalled( unmatchedSpy );
+
+			testCallbackStub.returns( false );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledTwice( testCallbackStub );
+			sinon.assert.calledOnce( matchedDataSpy );
+			sinon.assert.calledOnce( unmatchedSpy );
+		} );
+
+		it( 'should fire "umatched" event when selection is expanded', () => {
+			testCallbackStub.returns( true );
+
+			model.change( writer => {
+				writer.insertText( '@', doc.selection.getFirstPosition() );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledOnce( matchedDataSpy );
+			sinon.assert.notCalled( matchedSelectionSpy );
+			sinon.assert.notCalled( unmatchedSpy );
+
+			model.change( writer => {
+				const start = writer.createPositionAt( doc.getRoot().getChild( 0 ), 0 );
+
+				writer.setSelection( writer.createRange( start, start.getShiftedBy( 1 ) ) );
+			} );
+
+			sinon.assert.calledOnce( testCallbackStub );
+			sinon.assert.calledOnce( matchedDataSpy );
+			sinon.assert.notCalled( matchedSelectionSpy );
+			sinon.assert.calledOnce( unmatchedSpy );
+		} );
+	} );
+} );
+