浏览代码

Merge branch 'master' into i/6304

Aleksander Nowodzinski 5 年之前
父节点
当前提交
e67a695d86

+ 5 - 1
packages/ckeditor5-typing/src/deletecommand.js

@@ -110,7 +110,11 @@ export default class DeleteCommand extends Command {
 				);
 				);
 			} );
 			} );
 
 
-			model.deleteContent( selection, { doNotResetEntireContent } );
+			model.deleteContent( selection, {
+				doNotResetEntireContent,
+				direction: this.direction
+			} );
+
 			this._buffer.input( changeCount );
 			this._buffer.input( changeCount );
 
 
 			writer.setSelection( selection );
 			writer.setSelection( selection );

+ 5 - 8
packages/ckeditor5-typing/src/inputcommand.js

@@ -83,25 +83,22 @@ export default class InputCommand extends Command {
 		const doc = model.document;
 		const doc = model.document;
 		const text = options.text || '';
 		const text = options.text || '';
 		const textInsertions = text.length;
 		const textInsertions = text.length;
-		const range = options.range || doc.selection.getFirstRange();
+		const selection = options.range ? model.createSelection( options.range ) : doc.selection;
 		const resultRange = options.resultRange;
 		const resultRange = options.resultRange;
 
 
 		model.enqueueChange( this._buffer.batch, writer => {
 		model.enqueueChange( this._buffer.batch, writer => {
-			const isCollapsedRange = range.isCollapsed;
-
 			this._buffer.lock();
 			this._buffer.lock();
 
 
-			model.deleteContent( model.createSelection( range ) );
+			model.deleteContent( selection );
 
 
 			if ( text ) {
 			if ( text ) {
-				model.insertContent( writer.createText( text, doc.selection.getAttributes() ), range.start );
+				model.insertContent( writer.createText( text, doc.selection.getAttributes() ), selection );
 			}
 			}
 
 
 			if ( resultRange ) {
 			if ( resultRange ) {
 				writer.setSelection( resultRange );
 				writer.setSelection( resultRange );
-			} else if ( isCollapsedRange ) {
-				// If range was collapsed just shift the selection by the number of inserted characters.
-				writer.setSelection( range.start.getShiftedBy( textInsertions ) );
+			} else if ( !selection.is( 'documentSelection' ) ) {
+				writer.setSelection( selection );
 			}
 			}
 
 
 			this._buffer.unlock();
 			this._buffer.unlock();

+ 47 - 38
packages/ckeditor5-typing/src/texttransformation.js

@@ -92,8 +92,6 @@ export default class TextTransformation extends Plugin {
 				include: DEFAULT_TRANSFORMATIONS
 				include: DEFAULT_TRANSFORMATIONS
 			}
 			}
 		} );
 		} );
-
-		this.editor = editor;
 	}
 	}
 
 
 	/**
 	/**
@@ -112,7 +110,7 @@ export default class TextTransformation extends Plugin {
 	}
 	}
 
 
 	/**
 	/**
-	 * Create new set of TextWatchers listening to the editor for typing and selection events.
+	 * Create new TextWatcher listening to the editor for typing and selection events.
 	 *
 	 *
 	 * @private
 	 * @private
 	 */
 	 */
@@ -120,52 +118,59 @@ export default class TextTransformation extends Plugin {
 		const editor = this.editor;
 		const editor = this.editor;
 		const model = editor.model;
 		const model = editor.model;
 		const input = editor.plugins.get( 'Input' );
 		const input = editor.plugins.get( 'Input' );
+		const normalizedTransformations = normalizeTransformations( editor.config.get( 'typing.transformations' ) );
 
 
-		const configuredTransformations = getConfiguredTransformations( editor.config.get( 'typing.transformations' ) );
+		const testCallback = text => {
+			for ( const normalizedTransformation of normalizedTransformations ) {
+				const from = normalizedTransformation.from;
+				const match = from.test( text );
 
 
-		for ( const transformation of configuredTransformations ) {
-			const from = normalizeFrom( transformation.from );
-			const to = normalizeTo( transformation.to );
+				if ( match ) {
+					return { normalizedTransformation };
+				}
+			}
+		};
 
 
-			const watcher = new TextWatcher( editor.model, text => from.test( text ) );
+		const watcherCallback = ( evt, data ) => {
+			if ( !input.isInput( data.batch ) ) {
+				return;
+			}
 
 
-			const watcherCallback = ( evt, data ) => {
-				if ( !input.isInput( data.batch ) ) {
-					return;
-				}
+			const { from, to } = data.normalizedTransformation;
 
 
-				const matches = from.exec( data.text );
-				const replaces = to( matches.slice( 1 ) );
+			const matches = from.exec( data.text );
+			const replaces = to( matches.slice( 1 ) );
 
 
-				const matchedRange = data.range;
+			const matchedRange = data.range;
 
 
-				let changeIndex = matches.index;
+			let changeIndex = matches.index;
 
 
-				model.enqueueChange( writer => {
-					for ( let i = 1; i < matches.length; i++ ) {
-						const match = matches[ i ];
-						const replaceWith = replaces[ i - 1 ];
+			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;
+					if ( replaceWith == null ) {
+						changeIndex += match.length;
 
 
-							continue;
-						}
+						continue;
+					}
 
 
-						const replacePosition = matchedRange.start.getShiftedBy( changeIndex );
-						const replaceRange = model.createRange( replacePosition, replacePosition.getShiftedBy( match.length ) );
-						const attributes = getTextAttributesAfterPosition( replacePosition );
+					const replacePosition = matchedRange.start.getShiftedBy( changeIndex );
+					const replaceRange = model.createRange( replacePosition, replacePosition.getShiftedBy( match.length ) );
+					const attributes = getTextAttributesAfterPosition( replacePosition );
 
 
-						model.insertContent( writer.createText( replaceWith, attributes ), replaceRange );
+					model.insertContent( writer.createText( replaceWith, attributes ), replaceRange );
 
 
-						changeIndex += replaceWith.length;
-					}
-				} );
-			};
+					changeIndex += replaceWith.length;
+				}
+			} );
+		};
 
 
-			watcher.on( 'matched:data', watcherCallback );
-			watcher.bind( 'isEnabled' ).to( this );
-		}
+		const watcher = new TextWatcher( editor.model, testCallback );
+
+		watcher.on( 'matched:data', watcherCallback );
+		watcher.bind( 'isEnabled' ).to( this );
 	}
 	}
 }
 }
 
 
@@ -223,8 +228,8 @@ function buildQuotesRegExp( quoteCharacter ) {
 // Reads text transformation config and returns normalized array of transformations objects.
 // Reads text transformation config and returns normalized array of transformations objects.
 //
 //
 // @param {module:typing/texttransformation~TextTransformationDescription} config
 // @param {module:typing/texttransformation~TextTransformationDescription} config
-// @returns {Array.<module:typing/texttransformation~TextTransformationDescription>}
-function getConfiguredTransformations( config ) {
+// @returns {Array.<{from:String,to:Function}>}
+function normalizeTransformations( config ) {
 	const extra = config.extra || [];
 	const extra = config.extra || [];
 	const remove = config.remove || [];
 	const remove = config.remove || [];
 	const isNotRemoved = transformation => !remove.includes( transformation );
 	const isNotRemoved = transformation => !remove.includes( transformation );
@@ -233,7 +238,11 @@ function getConfiguredTransformations( config ) {
 
 
 	return expandGroupsAndRemoveDuplicates( configured )
 	return expandGroupsAndRemoveDuplicates( configured )
 		.filter( isNotRemoved ) // Filter out 'remove' transformations as they might be set in group
 		.filter( isNotRemoved ) // Filter out 'remove' transformations as they might be set in group
-		.map( transformation => TRANSFORMATIONS[ transformation ] || transformation );
+		.map( transformation => TRANSFORMATIONS[ transformation ] || transformation )
+		.map( transformation => ( {
+			from: normalizeFrom( transformation.from ),
+			to: normalizeTo( transformation.to )
+		} ) );
 }
 }
 
 
 // Reads definitions and expands named groups if needed to transformation names.
 // Reads definitions and expands named groups if needed to transformation names.

+ 62 - 25
packages/ckeditor5-typing/src/textwatcher.js

@@ -26,11 +26,37 @@ export default class TextWatcher {
 	 * Creates a text watcher instance.
 	 * Creates a text watcher instance.
 	 *
 	 *
 	 * @param {module:engine/model/model~Model} model
 	 * @param {module:engine/model/model~Model} model
-	 * @param {Function} testCallback The function used to match the text.
+	 * @param {Function} testCallback See {@link module:typing/textwatcher~TextWatcher#testCallback}.
 	 */
 	 */
 	constructor( model, testCallback ) {
 	constructor( model, testCallback ) {
+		/**
+		 * The editor's model.
+		 *
+		 * @readonly
+		 * @member {module:engine/model/model~Model}
+		 */
 		this.model = model;
 		this.model = model;
+
+		/**
+		 * The function used to match the text.
+		 *
+		 * The test callback can return 3 values:
+		 *
+		 * * `false` if there is no match,
+		 * * `true` if there is a match,
+		 * * an object if there is a match and we want to pass some additional information to the {@link #event:matched:data} event.
+		 *
+		 * @member {Function} #testCallback
+		 * @returns {Object} testResult
+		 */
 		this.testCallback = testCallback;
 		this.testCallback = testCallback;
+
+		/**
+		 * Whether there is a match currently.
+		 *
+		 * @readonly
+		 * @member {Boolean}
+		 */
 		this.hasMatch = false;
 		this.hasMatch = false;
 
 
 		/**
 		/**
@@ -119,40 +145,51 @@ export default class TextWatcher {
 
 
 		const { text, range } = getLastTextLine( rangeBeforeSelection, model );
 		const { text, range } = getLastTextLine( rangeBeforeSelection, model );
 
 
-		const textHasMatch = this.testCallback( text );
+		const testResult = 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
-			 */
+		if ( !testResult && this.hasMatch ) {
 			this.fire( 'unmatched' );
 			this.fire( 'unmatched' );
 		}
 		}
 
 
-		this.hasMatch = textHasMatch;
+		this.hasMatch = !!testResult;
 
 
-		if ( textHasMatch ) {
+		if ( testResult ) {
 			const eventData = Object.assign( data, { text, range } );
 			const eventData = Object.assign( data, { text, range } );
 
 
-			/**
-			 * Fired whenever the text watcher found a match for data changes.
-			 *
-			 * @event matched:data
-			 * @param {Object} data Event data.
-			 * @param {String} data.text The full text before selection.
-			 * @param {module:engine/model/batch~Batch} data.batch A batch associated with a change.
-			 */
-			/**
-			 * Fired whenever the text watcher found a match for selection changes.
-			 *
-			 * @event matched:selection
-			 * @param {Object} data Event data.
-			 * @param {String} data.text The full text before selection.
-			 */
+			// If the test callback returns an object with additional data, assign the data as well.
+			if ( typeof testResult == 'object' ) {
+				Object.assign( eventData, testResult );
+			}
+
 			this.fire( `matched:${ suffix }`, eventData );
 			this.fire( `matched:${ suffix }`, eventData );
 		}
 		}
 	}
 	}
 }
 }
 
 
 mix( TextWatcher, ObservableMixin );
 mix( TextWatcher, ObservableMixin );
+
+/**
+ * Fired whenever the text watcher found a match for data changes.
+ *
+ * @event matched:data
+ * @param {Object} data Event data.
+ * @param {String} data.text The full text before selection to which the regexp was applied.
+ * @param {module:engine/model/range~Range} data.range The range representing the position of the `data.text`.
+ * @param {Object} [data.testResult] The additional data returned from the {@link module:typing/textwatcher~TextWatcher#testCallback}.
+ */
+
+/**
+ * Fired whenever the text watcher found a match for selection changes.
+ *
+ * @event matched:selection
+ * @param {Object} data Event data.
+ * @param {String} data.text The full text before selection.
+ * @param {module:engine/model/range~Range} data.range The range representing the position of the `data.text`.
+ * @param {Object} [data.testResult] The additional data returned from the {@link module:typing/textwatcher~TextWatcher#testCallback}.
+ */
+
+/**
+ * Fired whenever the text does not match anymore. Fired only when the text watcher found a match.
+ *
+ * @event unmatched
+ */

+ 23 - 0
packages/ckeditor5-typing/tests/deletecommand.js

@@ -184,6 +184,29 @@ describe( 'DeleteCommand', () => {
 			expect( deleteOpts ).to.have.property( 'doNotResetEntireContent', false );
 			expect( deleteOpts ).to.have.property( 'doNotResetEntireContent', false );
 		} );
 		} );
 
 
+		it( 'should pass the "direction" option to Model#deleteContent method', () => {
+			const spy = sinon.spy();
+			const forwardCommand = new DeleteCommand( editor, 'forward' );
+			editor.commands.add( 'forwardDelete', forwardCommand );
+
+			editor.model.on( 'deleteContent', spy );
+			setData( model, '<paragraph>foo[]bar</paragraph>' );
+
+			editor.execute( 'delete' );
+
+			expect( spy.callCount ).to.equal( 1 );
+
+			let deleteOpts = spy.args[ 0 ][ 1 ][ 1 ];
+			expect( deleteOpts ).to.have.property( 'direction', 'backward' );
+
+			editor.execute( 'forwardDelete' );
+
+			expect( spy.callCount ).to.equal( 2 );
+
+			deleteOpts = spy.args[ 1 ][ 1 ][ 1 ];
+			expect( deleteOpts ).to.have.property( 'direction', 'forward' );
+		} );
+
 		it( 'leaves an empty paragraph after removing the whole content from editor', () => {
 		it( 'leaves an empty paragraph after removing the whole content from editor', () => {
 			setData( model, '<heading1>[Header 1</heading1><paragraph>Some text.]</paragraph>' );
 			setData( model, '<heading1>[Header 1</heading1><paragraph>Some text.]</paragraph>' );
 
 

+ 83 - 30
packages/ckeditor5-typing/tests/inputcommand.js

@@ -29,8 +29,8 @@ describe( 'InputCommand', () => {
 				buffer = inputCommand.buffer;
 				buffer = inputCommand.buffer;
 				buffer.size = 0;
 				buffer.size = 0;
 
 
-				model.schema.register( 'p', { inheritAllFrom: '$block' } );
-				model.schema.register( 'h1', { inheritAllFrom: '$block' } );
+				model.schema.register( 'paragraph', { inheritAllFrom: '$block' } );
+				model.schema.register( 'heading1', { inheritAllFrom: '$block' } );
 			} );
 			} );
 	} );
 	} );
 
 
@@ -63,22 +63,22 @@ describe( 'InputCommand', () => {
 
 
 	describe( 'execute()', () => {
 	describe( 'execute()', () => {
 		it( 'uses enqueueChange', () => {
 		it( 'uses enqueueChange', () => {
-			setData( model, '<p>foo[]bar</p>' );
+			setData( model, '<paragraph>foo[]bar</paragraph>' );
 
 
 			model.enqueueChange( () => {
 			model.enqueueChange( () => {
 				editor.execute( 'input', { text: 'x' } );
 				editor.execute( 'input', { text: 'x' } );
 
 
 				// We expect that command is executed in enqueue changes block. Since we are already in
 				// We expect that command is executed in enqueue changes block. Since we are already in
 				// an enqueued block, the command execution will be postponed. Hence, no changes.
 				// an enqueued block, the command execution will be postponed. Hence, no changes.
-				expect( getData( model ) ).to.be.equal( '<p>foo[]bar</p>' );
+				expect( getData( model ) ).to.be.equal( '<paragraph>foo[]bar</paragraph>' );
 			} );
 			} );
 
 
 			// After all enqueued changes are done, the command execution is reflected.
 			// After all enqueued changes are done, the command execution is reflected.
-			expect( getData( model ) ).to.be.equal( '<p>foox[]bar</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>foox[]bar</paragraph>' );
 		} );
 		} );
 
 
 		it( 'should lock and unlock buffer', () => {
 		it( 'should lock and unlock buffer', () => {
-			setData( model, '<p>foo[]bar</p>' );
+			setData( model, '<paragraph>foo[]bar</paragraph>' );
 
 
 			const spyLock = testUtils.sinon.spy( buffer, 'lock' );
 			const spyLock = testUtils.sinon.spy( buffer, 'lock' );
 			const spyUnlock = testUtils.sinon.spy( buffer, 'unlock' );
 			const spyUnlock = testUtils.sinon.spy( buffer, 'unlock' );
@@ -92,102 +92,102 @@ describe( 'InputCommand', () => {
 		} );
 		} );
 
 
 		it( 'inserts text for collapsed range', () => {
 		it( 'inserts text for collapsed range', () => {
-			setData( model, '<p>foo[]</p>' );
+			setData( model, '<paragraph>foo[]</paragraph>' );
 
 
 			editor.execute( 'input', {
 			editor.execute( 'input', {
 				text: 'bar',
 				text: 'bar',
 				range: doc.selection.getFirstRange()
 				range: doc.selection.getFirstRange()
 			} );
 			} );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<p>foobar[]</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>foobar[]</paragraph>' );
 			expect( buffer.size ).to.be.equal( 3 );
 			expect( buffer.size ).to.be.equal( 3 );
 		} );
 		} );
 
 
 		it( 'replaces text for range within single element on the beginning', () => {
 		it( 'replaces text for range within single element on the beginning', () => {
-			setData( model, '<p>[fooba]r</p>' );
+			setData( model, '<paragraph>[fooba]r</paragraph>' );
 
 
 			editor.execute( 'input', {
 			editor.execute( 'input', {
 				text: 'rab',
 				text: 'rab',
 				range: doc.selection.getFirstRange()
 				range: doc.selection.getFirstRange()
 			} );
 			} );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<p>rab[]r</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>rab[]r</paragraph>' );
 			expect( buffer.size ).to.be.equal( 3 );
 			expect( buffer.size ).to.be.equal( 3 );
 		} );
 		} );
 
 
 		it( 'replaces text for range within single element in the middle', () => {
 		it( 'replaces text for range within single element in the middle', () => {
-			setData( model, '<p>fo[oba]r</p>' );
+			setData( model, '<paragraph>fo[oba]r</paragraph>' );
 
 
 			editor.execute( 'input', {
 			editor.execute( 'input', {
 				text: 'bazz',
 				text: 'bazz',
 				range: doc.selection.getFirstRange()
 				range: doc.selection.getFirstRange()
 			} );
 			} );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<p>fobazz[]r</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>fobazz[]r</paragraph>' );
 			expect( buffer.size ).to.be.equal( 4 );
 			expect( buffer.size ).to.be.equal( 4 );
 		} );
 		} );
 
 
 		it( 'replaces text for range within single element on the end', () => {
 		it( 'replaces text for range within single element on the end', () => {
-			setData( model, '<p>fooba[r]</p>' );
+			setData( model, '<paragraph>fooba[r]</paragraph>' );
 
 
 			editor.execute( 'input', {
 			editor.execute( 'input', {
 				text: 'zzz',
 				text: 'zzz',
 				range: doc.selection.getFirstRange()
 				range: doc.selection.getFirstRange()
 			} );
 			} );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<p>foobazzz[]</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>foobazzz[]</paragraph>' );
 			expect( buffer.size ).to.be.equal( 3 );
 			expect( buffer.size ).to.be.equal( 3 );
 		} );
 		} );
 
 
 		it( 'replaces text for range within multiple elements', () => {
 		it( 'replaces text for range within multiple elements', () => {
-			setData( model, '<h1>F[OO</h1><p>b]ar</p>' );
+			setData( model, '<heading1>F[OO</heading1><paragraph>b]ar</paragraph>' );
 
 
 			editor.execute( 'input', {
 			editor.execute( 'input', {
 				text: 'unny c',
 				text: 'unny c',
 				range: doc.selection.getFirstRange()
 				range: doc.selection.getFirstRange()
 			} );
 			} );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<h1>Funny c[]ar</h1>' );
+			expect( getData( model ) ).to.be.equal( '<heading1>Funny c[]ar</heading1>' );
 			expect( buffer.size ).to.be.equal( 6 );
 			expect( buffer.size ).to.be.equal( 6 );
 		} );
 		} );
 
 
 		it( 'uses current selection when range is not given', () => {
 		it( 'uses current selection when range is not given', () => {
-			setData( model, '<p>foob[ar]</p>' );
+			setData( model, '<paragraph>foob[ar]</paragraph>' );
 
 
 			editor.execute( 'input', {
 			editor.execute( 'input', {
 				text: 'az'
 				text: 'az'
 			} );
 			} );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<p>foobaz[]</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>foobaz[]</paragraph>' );
 			expect( buffer.size ).to.be.equal( 2 );
 			expect( buffer.size ).to.be.equal( 2 );
 		} );
 		} );
 
 
 		it( 'only removes content when empty text given', () => {
 		it( 'only removes content when empty text given', () => {
-			setData( model, '<p>[fo]obar</p>' );
+			setData( model, '<paragraph>[fo]obar</paragraph>' );
 
 
 			editor.execute( 'input', {
 			editor.execute( 'input', {
 				text: '',
 				text: '',
 				range: doc.selection.getFirstRange()
 				range: doc.selection.getFirstRange()
 			} );
 			} );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<p>[]obar</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>[]obar</paragraph>' );
 			expect( buffer.size ).to.be.equal( 0 );
 			expect( buffer.size ).to.be.equal( 0 );
 		} );
 		} );
 
 
 		it( 'should set selection according to passed resultRange (collapsed)', () => {
 		it( 'should set selection according to passed resultRange (collapsed)', () => {
-			setData( model, '<p>[foo]bar</p>' );
+			setData( model, '<paragraph>[foo]bar</paragraph>' );
 
 
 			editor.execute( 'input', {
 			editor.execute( 'input', {
 				text: 'new',
 				text: 'new',
 				resultRange: editor.model.createRange( editor.model.createPositionFromPath( doc.getRoot(), [ 0, 5 ] ) )
 				resultRange: editor.model.createRange( editor.model.createPositionFromPath( doc.getRoot(), [ 0, 5 ] ) )
 			} );
 			} );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<p>newba[]r</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>newba[]r</paragraph>' );
 			expect( buffer.size ).to.be.equal( 3 );
 			expect( buffer.size ).to.be.equal( 3 );
 		} );
 		} );
 
 
 		it( 'should set selection according to passed resultRange (non-collapsed)', () => {
 		it( 'should set selection according to passed resultRange (non-collapsed)', () => {
-			setData( model, '<p>[foo]bar</p>' );
+			setData( model, '<paragraph>[foo]bar</paragraph>' );
 
 
 			editor.execute( 'input', {
 			editor.execute( 'input', {
 				text: 'new',
 				text: 'new',
@@ -197,30 +197,30 @@ describe( 'InputCommand', () => {
 				)
 				)
 			} );
 			} );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<p>new[bar]</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>new[bar]</paragraph>' );
 			expect( buffer.size ).to.be.equal( 3 );
 			expect( buffer.size ).to.be.equal( 3 );
 		} );
 		} );
 
 
 		it( 'only removes content when no text given (with default non-collapsed range)', () => {
 		it( 'only removes content when no text given (with default non-collapsed range)', () => {
-			setData( model, '<p>[fo]obar</p>' );
+			setData( model, '<paragraph>[fo]obar</paragraph>' );
 
 
 			editor.execute( 'input' );
 			editor.execute( 'input' );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<p>[]obar</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>[]obar</paragraph>' );
 			expect( buffer.size ).to.be.equal( 0 );
 			expect( buffer.size ).to.be.equal( 0 );
 		} );
 		} );
 
 
 		it( 'does not change selection and content when no text given (with default collapsed range)', () => {
 		it( 'does not change selection and content when no text given (with default collapsed range)', () => {
-			setData( model, '<p>fo[]obar</p>' );
+			setData( model, '<paragraph>fo[]obar</paragraph>' );
 
 
 			editor.execute( 'input' );
 			editor.execute( 'input' );
 
 
-			expect( getData( model, { selection: true } ) ).to.be.equal( '<p>fo[]obar</p>' );
+			expect( getData( model ) ).to.be.equal( '<paragraph>fo[]obar</paragraph>' );
 			expect( buffer.size ).to.be.equal( 0 );
 			expect( buffer.size ).to.be.equal( 0 );
 		} );
 		} );
 
 
 		it( 'does not create insert delta when no text given', () => {
 		it( 'does not create insert delta when no text given', () => {
-			setData( model, '<p>foo[]bar</p>' );
+			setData( model, '<paragraph>foo[]bar</paragraph>' );
 
 
 			const version = doc.version;
 			const version = doc.version;
 
 
@@ -228,9 +228,62 @@ describe( 'InputCommand', () => {
 
 
 			expect( doc.version ).to.equal( version );
 			expect( doc.version ).to.equal( version );
 		} );
 		} );
+
+		it( 'handles multi-range selection', () => {
+			model.schema.register( 'object', {
+				allowWhere: '$block',
+				allowContentOf: '$block',
+				isObject: true
+			} );
+
+			setData(
+				model,
+				'<paragraph>x</paragraph>' +
+				'[<object>y</object>]' +
+				'<paragraph>y</paragraph>' +
+				'[<object>y</object>]' +
+				'<paragraph>z</paragraph>'
+			);
+
+			// deleteContent() does not support multi-range selections yet, so we need to mock it here.
+			// See https://github.com/ckeditor/ckeditor5/issues/6328.
+			model.on( 'deleteContent', ( evt, args ) => {
+				const [ selection ] = args;
+
+				if ( selection.rangeCount != 2 ) {
+					return;
+				}
+
+				evt.stop();
+
+				model.change( writer => {
+					let rangeSelection;
+
+					for ( const range of selection.getRanges() ) {
+						rangeSelection = writer.createSelection( range );
+
+						model.deleteContent( rangeSelection );
+					}
+
+					writer.setSelection( rangeSelection );
+				} );
+			}, { priority: 'high' } );
+
+			editor.execute( 'input', {
+				text: 'foo'
+			} );
+
+			expect( getData( model ) ).to.be.equal(
+				'<paragraph>x</paragraph>' +
+				'<paragraph></paragraph>' +
+				'<paragraph>y</paragraph>' +
+				'<paragraph>foo[]</paragraph>' +
+				'<paragraph>z</paragraph>'
+			);
+		} );
 	} );
 	} );
 
 
-	describe( 'destroy', () => {
+	describe( 'destroy()', () => {
 		it( 'should destroy change buffer', () => {
 		it( 'should destroy change buffer', () => {
 			const command = editor.commands.get( 'input' );
 			const command = editor.commands.get( 'input' );
 			const destroy = command._buffer.destroy = testUtils.sinon.spy();
 			const destroy = command._buffer.destroy = testUtils.sinon.spy();

+ 4 - 4
packages/ckeditor5-typing/tests/manual/texttransformation.md

@@ -17,17 +17,17 @@ Some of the transformations are:
     * Operators: `<=` to `≤`, `>=` to `≥`, `!=` to `≠`.
     * Operators: `<=` to `≤`, `>=` to `≥`, `!=` to `≠`.
 
 
 1. Typography:
 1. Typography:
-    
+
     * Dashes: ` -- `, ` --- `.
     * Dashes: ` -- `, ` --- `.
     * Ellipsis: `...` to `…`
     * Ellipsis: `...` to `…`
-    
+
 1. Quotes:
 1. Quotes:
 
 
-    * Primary quotes (english): `'Foo bar'` to `‘Foo bar’` 
+    * Primary quotes (english): `'Foo bar'` to `‘Foo bar’`
     * Secondary quotes (english): `"Foo bar's"` to `“Foo bar's”`
     * Secondary quotes (english): `"Foo bar's"` to `“Foo bar's”`
 
 
 ### Testing
 ### Testing
 
 
 * Check if the transformation works. Note that some might need a space to trigger (dashes).
 * 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.
 * Undo a text transformation and type - it should not re-transform it.
-* Change selection - the not transformed elements should stay. 
+* Change selection - the not transformed elements should stay.

+ 28 - 9
packages/ckeditor5-typing/tests/textwatcher.js

@@ -141,17 +141,36 @@ describe( 'TextWatcher', () => {
 	} );
 	} );
 
 
 	describe( 'events', () => {
 	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() );
+		describe( '"matched:data"', () => {
+			it( 'should be fired 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 );
 			} );
 			} );
 
 
-			sinon.assert.calledOnce( testCallbackStub );
-			sinon.assert.calledOnce( matchedDataSpy );
-			sinon.assert.notCalled( matchedSelectionSpy );
-			sinon.assert.notCalled( unmatchedSpy );
+			it( 'should be fired with additional data when test callback returns true for model data changes', () => {
+				const additionalData = { abc: 'xyz' };
+
+				testCallbackStub.returns( additionalData );
+
+				model.change( writer => {
+					writer.insertText( '@', doc.selection.getFirstPosition() );
+				} );
+
+				sinon.assert.calledOnce( testCallbackStub );
+				sinon.assert.calledOnce( matchedDataSpy );
+				sinon.assert.notCalled( matchedSelectionSpy );
+				sinon.assert.notCalled( unmatchedSpy );
+
+				expect( matchedDataSpy.firstCall.args[ 1 ] ).to.deep.include( additionalData );
+			} );
 		} );
 		} );
 
 
 		it( 'should not fire "matched:data" event when watcher is disabled' +
 		it( 'should not fire "matched:data" event when watcher is disabled' +