8
0
Эх сурвалжийг харах

Merge pull request #1287 from ckeditor/t/ckeditor5-typing/92

Feature: Add support for the `'word'` unit in the `modifySelection()` helper.
Piotrek Koszuliński 7 жил өмнө
parent
commit
c426138f19

+ 74 - 2
packages/ckeditor5-engine/src/model/utils/modifyselection.js

@@ -13,6 +13,8 @@ import Range from '../range';
 import { isInsideSurrogatePair, isInsideCombinedSymbol } from '@ckeditor/ckeditor5-utils/src/unicode';
 import DocumentSelection from '../documentselection';
 
+const wordBoundaryCharacters = ' ,.?!:;"-()';
+
 /**
  * Modifies the selection. Currently, the supported modifications are:
  *
@@ -31,6 +33,7 @@ import DocumentSelection from '../documentselection';
  *  For example `𨭎` is represented in `String` by `\uD862\uDF4E`. Both `\uD862` and `\uDF4E` do not have any meaning
  *  outside the pair (are rendered as ? when alone). Position between them would be incorrect. In this case, selection
  *  extension will include whole "surrogate pair".
+ *  * `'word'` - moves selection by a whole word.
  *
  * **Note:** if you extend a forward selection in a backward direction you will in fact shrink it.
  *
@@ -39,7 +42,7 @@ import DocumentSelection from '../documentselection';
  * @param {module:engine/model/selection~Selection} selection The selection to modify.
  * @param {Object} [options]
  * @param {'forward'|'backward'} [options.direction='forward'] The direction in which the selection should be modified.
- * @param {'character'|'codePoint'} [options.unit='character'] The unit by which selection should be modified.
+ * @param {'character'|'codePoint'|'word'} [options.unit='character'] The unit by which selection should be modified.
  */
 export default function modifySelection( model, selection, options = {} ) {
 	const schema = model.schema;
@@ -79,11 +82,17 @@ export default function modifySelection( model, selection, options = {} ) {
 }
 
 // Checks whether the selection can be extended to the the walker's next value (next position).
+// @param {{ walker, unit, isForward, schema }} data
+// @param {module:engine/view/treewalker~TreeWalkerValue} value
 function tryExtendingTo( data, value ) {
 	// If found text, we can certainly put the focus in it. Let's just find a correct position
 	// based on the unit.
 	if ( value.type == 'text' ) {
-		return getCorrectPosition( data.walker, data.unit );
+		if ( data.unit === 'word' ) {
+			return getCorrectWordBreakPosition( data.walker, data.isForward );
+		}
+
+		return getCorrectPosition( data.walker, data.unit, data.isForward );
 	}
 
 	// Entering an element.
@@ -117,6 +126,9 @@ function tryExtendingTo( data, value ) {
 
 // Finds a correct position by walking in a text node and checking whether selection can be extended to given position
 // or should be extended further.
+//
+// @param {module:engine/model/treewalker~TreeWalker} walker
+// @param {String} unit The unit by which selection should be modified.
 function getCorrectPosition( walker, unit ) {
 	const textNode = walker.position.textNode;
 
@@ -134,6 +146,45 @@ function getCorrectPosition( walker, unit ) {
 	return walker.position;
 }
 
+// Finds a correct position of a word break by walking in a text node and checking whether selection can be extended to given position
+// or should be extended further.
+//
+// @param {module:engine/model/treewalker~TreeWalker} walker
+// @param {Boolean} isForward Is the direction in which the selection should be modified is forward.
+function getCorrectWordBreakPosition( walker, isForward ) {
+	let textNode = walker.position.textNode;
+
+	if ( textNode ) {
+		let offset = walker.position.offset - textNode.startOffset;
+
+		while ( !isAtWordBoundary( textNode.data, offset, isForward ) && !isAtNodeBoundary( textNode, offset, isForward ) ) {
+			walker.next();
+
+			// Check of adjacent text nodes with different attributes (like BOLD).
+			// Example          : 'foofoo []bar<$text bold="true">bar</$text> bazbaz'
+			// should expand to : 'foofoo [bar<$text bold="true">bar</$text>] bazbaz'.
+			const nextNode = isForward ? walker.position.nodeAfter : walker.position.nodeBefore;
+
+			if ( nextNode ) {
+				// Check boundary char of an adjacent text node.
+				const boundaryChar = nextNode.data.charAt( isForward ? 0 : nextNode.data.length - 1 );
+
+				// Go to the next node if the character at the boundary of that node belongs to the same word.
+				if ( !wordBoundaryCharacters.includes( boundaryChar ) ) {
+					// If adjacent text node belongs to the same word go to it & reset values.
+					walker.next();
+
+					textNode = walker.position.textNode;
+				}
+			}
+
+			offset = walker.position.offset - textNode.startOffset;
+		}
+	}
+
+	return walker.position;
+}
+
 function getSearchRange( start, isForward ) {
 	const root = start.root;
 	const searchEnd = Position.createAt( root, isForward ? 'end' : 0 );
@@ -144,3 +195,24 @@ function getSearchRange( start, isForward ) {
 		return new Range( searchEnd, start );
 	}
 }
+
+// Checks if selection is on word boundary.
+//
+// @param {String} data The text node value to investigate.
+// @param {Number} offset Position offset.
+// @param {Boolean} isForward Is the direction in which the selection should be modified is forward.
+function isAtWordBoundary( data, offset, isForward ) {
+	// The offset to check depends on direction.
+	const offsetToCheck = offset + ( isForward ? 0 : -1 );
+
+	return wordBoundaryCharacters.includes( data.charAt( offsetToCheck ) );
+}
+
+// Checks if selection is on node boundary.
+//
+// @param {module:engine/model/text~Text} textNode The text node to investigate.
+// @param {Number} offset Position offset.
+// @param {Boolean} isForward Is the direction in which the selection should be modified is forward.
+function isAtNodeBoundary( textNode, offset, isForward ) {
+	return offset === ( isForward ? textNode.endOffset : 0 );
+}

+ 394 - 0
packages/ckeditor5-engine/tests/model/utils/modifyselection.js

@@ -411,6 +411,400 @@ describe( 'DataController utils', () => {
 			} );
 		} );
 
+		describe( 'unit=word', () => {
+			describe( 'within element', () => {
+				test(
+					'does nothing on empty content',
+					'[]',
+					'[]',
+					{ unit: 'word' }
+				);
+
+				test(
+					'does nothing on empty content (with empty element)',
+					'<p>[]</p>',
+					'<p>[]</p>'
+				);
+
+				test(
+					'does nothing on empty content (backward)',
+					'[]',
+					'[]',
+					{ unit: 'word', direction: 'backward' }
+				);
+
+				test(
+					'does nothing on root boundary',
+					'<p>foo[]</p>',
+					'<p>foo[]</p>',
+					{ unit: 'word' }
+				);
+
+				test(
+					'does nothing on root boundary (backward)',
+					'<p>[]foo</p>',
+					'<p>[]foo</p>',
+					{ unit: 'word', direction: 'backward' }
+				);
+
+				for ( const char of ' ,.?!:;"-()'.split( '' ) ) {
+					testStopCharacter( char );
+				}
+
+				test(
+					'extends whole word forward (non-collapsed)',
+					'<p>f[o]obar</p>',
+					'<p>f[oobar]</p>',
+					{ unit: 'word' }
+				);
+
+				it( 'extends whole word backward (non-collapsed)', () => {
+					setData( model, '<p>foo ba[a]r</p>', { lastRangeBackward: true } );
+
+					modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p>foo [baa]r</p>' );
+					expect( doc.selection.isBackward ).to.true;
+				} );
+
+				test(
+					'extends to element boundary',
+					'<p>fo[]oo</p>',
+					'<p>fo[oo]</p>',
+					{ unit: 'word' }
+				);
+
+				it( 'extends to element boundary (backward)', () => {
+					setData( model, '<p>ff[]oo</p>' );
+
+					modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p>[ff]oo</p>' );
+					expect( doc.selection.isBackward ).to.true;
+				} );
+
+				test(
+					'expands forward selection to the word start',
+					'<p>foo bar[b]az</p>',
+					'<p>foo [bar]baz</p>',
+					{ unit: 'word', direction: 'backward' }
+				);
+
+				it( 'expands backward selection to the word end', () => {
+					setData( model, '<p>foo[b]ar baz</p>', { lastRangeBackward: true } );
+
+					modifySelection( model, doc.selection, { unit: 'word' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p>foob[ar] baz</p>' );
+					expect( doc.selection.isBackward ).to.false;
+				} );
+
+				test(
+					'unicode support - combining mark forward',
+					'<p>foo[]b̂ar</p>',
+					'<p>foo[b̂ar]</p>',
+					{ unit: 'word' }
+				);
+
+				it( 'unicode support - combining mark backward', () => {
+					setData( model, '<p>foob̂[]ar</p>' );
+
+					modifySelection( model, doc.selection, { direction: 'backward', unit: 'word' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p>[foob̂]ar</p>' );
+					expect( doc.selection.isBackward ).to.true;
+				} );
+
+				test(
+					'unicode support - combining mark multiple',
+					'<p>fo[]o̻̐ͩbar</p>',
+					'<p>fo[o̻̐ͩbar]</p>',
+					{ unit: 'word' }
+				);
+
+				it( 'unicode support - combining mark multiple backward', () => {
+					setData( model, '<p>foo̻̐ͩ[]bar</p>' );
+
+					modifySelection( model, doc.selection, { direction: 'backward' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p>fo[o̻̐ͩ]bar</p>' );
+					expect( doc.selection.isBackward ).to.true;
+				} );
+
+				test(
+					'unicode support - combining mark to the end',
+					'<p>f[o]o̻̐ͩ</p>',
+					'<p>f[oo̻̐ͩ]</p>',
+					{ unit: 'word' }
+				);
+
+				test(
+					'unicode support - surrogate pairs forward',
+					'<p>[]foo\uD83D\uDCA9</p>',
+					'<p>[foo\uD83D\uDCA9]</p>',
+					{ unit: 'word' }
+				);
+
+				it( 'unicode support - surrogate pairs backward', () => {
+					setData( model, '<p>foo\uD83D\uDCA9[]</p>' );
+
+					modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p>[foo\uD83D\uDCA9]</p>' );
+					expect( doc.selection.isBackward ).to.true;
+				} );
+
+				function testStopCharacter( stopCharacter ) {
+					describe( `stop character: "${ stopCharacter }"`, () => {
+						test(
+							'extends whole word forward',
+							`<p>f[]oo${ stopCharacter }bar</p>`,
+							`<p>f[oo]${ stopCharacter }bar</p>`,
+							{ unit: 'word' }
+						);
+
+						it( 'extends whole word backward to the previous word', () => {
+							setData( model, `<p>foo${ stopCharacter }ba[]r</p>`, { lastRangeBackward: true } );
+
+							modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } );
+
+							expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( `<p>foo${ stopCharacter }[ba]r</p>` );
+							expect( doc.selection.isBackward ).to.true;
+						} );
+
+						it( 'extends whole word backward', () => {
+							setData( model, `<p>fo[]o${ stopCharacter }bar</p>`, { lastRangeBackward: true } );
+
+							modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } );
+
+							expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( `<p>[fo]o${ stopCharacter }bar</p>` );
+							expect( doc.selection.isBackward ).to.true;
+						} );
+
+						test(
+							'ignores attributes when in one word - case 1',
+							`<p>foo[]<$text bold="true">bar</$text>baz${ stopCharacter }foobarbaz</p>`,
+							`<p>foo[<$text bold="true">bar</$text>baz]${ stopCharacter }foobarbaz</p>`,
+							{ unit: 'word' }
+						);
+
+						test(
+							'ignores attributes when in one word - case 2',
+							`<p>foo[]<$text bold="true">bar</$text>${ stopCharacter }foobarbaz</p>`,
+							`<p>foo[<$text bold="true">bar</$text>]${ stopCharacter }foobarbaz</p>`,
+							{ unit: 'word' }
+						);
+
+						test(
+							'ignores attributes when in one word - case 3',
+							`<p>foo[]<$text bold="true">bar</$text><$text italic="true">baz</$text>baz${ stopCharacter }foobarbaz</p>`,
+							`<p>foo[<$text bold="true">bar</$text><$text italic="true">baz</$text>baz]${ stopCharacter }foobarbaz</p>`,
+							{ unit: 'word' }
+						);
+
+						it( 'extends whole word backward to the previous word ignoring attributes - case 1', () => {
+							setData(
+								model,
+								`<p>foobarbaz${ stopCharacter }foo<$text bold="true">bar</$text>baz[]</p>`
+							);
+
+							modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } );
+
+							expect( stringify( doc.getRoot(), doc.selection ) ).to.equal(
+								`<p>foobarbaz${ stopCharacter }[foo<$text bold="true">bar</$text>baz]</p>`
+							);
+							expect( doc.selection.isBackward ).to.true;
+						} );
+
+						it( 'extends whole word backward to the previous word ignoring attributes - case 2', () => {
+							setData(
+								model,
+								`<p>foobarbaz${ stopCharacter }<$text bold="true">bar</$text>baz[]</p>`
+							);
+
+							modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } );
+
+							expect( stringify( doc.getRoot(), doc.selection ) ).to.equal(
+								`<p>foobarbaz${ stopCharacter }[<$text bold="true">bar</$text>baz]</p>`
+							);
+							expect( doc.selection.isBackward ).to.true;
+						} );
+					} );
+				}
+			} );
+
+			describe( 'beyond element', () => {
+				test(
+					'extends over boundary of empty elements',
+					'<p>[]</p><p></p><p></p>',
+					'<p>[</p><p>]</p><p></p>',
+					{ unit: 'word' }
+				);
+
+				it( 'extends over boundary of empty elements (backward)', () => {
+					setData( model, '<p></p><p></p><p>[]</p>' );
+
+					modifySelection( model, doc.selection, { direction: 'backward' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p></p><p>[</p><p>]</p>' );
+					expect( doc.selection.isBackward ).to.true;
+				} );
+
+				test(
+					'extends over boundary of non-empty elements',
+					'<p>a[]</p><p>bcd</p>',
+					'<p>a[</p><p>]bcd</p>',
+					{ unit: 'word' }
+				);
+
+				it( 'extends over boundary of non-empty elements (backward)', () => {
+					setData( model, '<p>a</p><p>[]bcd</p>' );
+
+					modifySelection( model, doc.selection, { direction: 'backward' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p>a[</p><p>]bcd</p>' );
+					expect( doc.selection.isBackward ).to.true;
+				} );
+
+				test(
+					'extends over character after boundary',
+					'<p>a[</p><p>]bcd</p>',
+					'<p>a[</p><p>bcd]</p>',
+					{ unit: 'word' }
+				);
+
+				it( 'extends over character after boundary (backward)', () => {
+					setData( model, '<p>abc[</p><p>]d</p>', { lastRangeBackward: true } );
+
+					modifySelection( model, doc.selection, { direction: 'backward' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p>ab[c</p><p>]d</p>' );
+					expect( doc.selection.isBackward ).to.true;
+				} );
+
+				test(
+					'stops on the first position where text is allowed - inside block',
+					'<p>a[]</p><p><x>bcd</x></p>',
+					'<p>a[</p><p>]<x>bcd</x></p>',
+					{ unit: 'word' }
+				);
+
+				test(
+					'stops on the first position where text is allowed - inside inline element',
+					'<p>a[</p><p>]<x>bcd</x>ef</p>',
+					'<p>a[</p><p><x>]bcd</x>ef</p>',
+					{ unit: 'word' }
+				);
+
+				test(
+					'extends over element when next node is a text',
+					'<p><x>a[]</x>bc</p>',
+					'<p><x>a[</x>]bc</p>',
+					{ unit: 'word' }
+				);
+
+				test(
+					'extends over element when next node is a text - backward',
+					'<p>ab<x>[]c</x></p>',
+					'<p>ab[<x>]c</x></p>',
+					{ unit: 'word', direction: 'backward' }
+				);
+
+				it( 'shrinks over boundary of empty elements', () => {
+					setData( model, '<p>[</p><p>]</p>', { lastRangeBackward: true } );
+
+					modifySelection( model, doc.selection, { unit: 'word' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p></p><p>[]</p>' );
+					expect( doc.selection.isBackward ).to.false;
+				} );
+
+				it( 'shrinks over boundary of empty elements (backward)', () => {
+					setData( model, '<p>[</p><p>]</p>' );
+
+					modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p>[]</p><p></p>' );
+					expect( doc.selection.isBackward ).to.false;
+				} );
+
+				it( 'shrinks over boundary of non-empty elements', () => {
+					setData( model, '<p>a[</p><p>]b</p>', { lastRangeBackward: true } );
+
+					modifySelection( model, doc.selection, { unit: 'word' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p>a</p><p>[]b</p>' );
+					expect( doc.selection.isBackward ).to.false;
+				} );
+
+				test(
+					'shrinks over boundary of non-empty elements (backward)',
+					'<p>a[</p><p>]b</p>',
+					'<p>a[]</p><p>b</p>',
+					{ unit: 'word', direction: 'backward' }
+				);
+
+				it( 'updates selection attributes', () => {
+					setData( model, '<p><$text bold="true">foo</$text>[b]</p>' );
+
+					modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } );
+
+					expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<p><$text bold="true">foo[]</$text>b</p>' );
+					expect( doc.selection.getAttribute( 'bold' ) ).to.equal( true );
+				} );
+			} );
+
+			describe( 'beyond element – skipping incorrect positions', () => {
+				beforeEach( () => {
+					model.schema.register( 'quote' );
+					model.schema.extend( 'quote', { allowIn: '$root' } );
+					model.schema.extend( '$block', { allowIn: 'quote' } );
+				} );
+
+				test(
+					'skips position at the beginning of an element which does not allow text',
+					'<p>x[]</p><quote><p>y</p></quote><p>z</p>',
+					'<p>x[</p><quote><p>]y</p></quote><p>z</p>',
+					{ unit: 'word' }
+				);
+
+				test(
+					'skips position at the end of an element which does not allow text - backward',
+					'<p>x</p><quote><p>y</p></quote><p>[]z</p>',
+					'<p>x</p><quote><p>y[</p></quote><p>]z</p>',
+					{ unit: 'word', direction: 'backward' }
+				);
+
+				test(
+					'skips position at the end of an element which does not allow text',
+					'<p>x[</p><quote><p>y]</p></quote><p>z</p>',
+					'<p>x[</p><quote><p>y</p></quote><p>]z</p>',
+					{ unit: 'word' }
+				);
+
+				test(
+					'skips position at the beginning of an element which does not allow text - backward',
+					'<p>x</p><quote><p>[]y</p></quote><p>z</p>',
+					'<p>x[</p><quote><p>]y</p></quote><p>z</p>',
+					{ unit: 'word', direction: 'backward' }
+				);
+
+				test(
+					'extends to an empty block after skipping incorrect position',
+					'<p>x[]</p><quote><p></p></quote><p>z</p>',
+					'<p>x[</p><quote><p>]</p></quote><p>z</p>',
+					{ unit: 'word' }
+				);
+
+				test(
+					'extends to an empty block after skipping incorrect position - backward',
+					'<p>x</p><quote><p></p></quote><p>[]z</p>',
+					'<p>x</p><quote><p>[</p></quote><p>]z</p>',
+					{ unit: 'word', direction: 'backward' }
+				);
+			} );
+		} );
+
 		describe( 'objects handling', () => {
 			beforeEach( () => {
 				model.schema.register( 'obj', {