diff --git a/src/model/utils/modifyselection.js b/src/model/utils/modifyselection.js index 646520e45..485a0f35a 100644 --- a/src/model/utils/modifyselection.js +++ b/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 ); +} diff --git a/tests/model/utils/modifyselection.js b/tests/model/utils/modifyselection.js index f9b327958..137ca2fee 100644 --- a/tests/model/utils/modifyselection.js +++ b/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)', + '
[]
', + '[]
' + ); + + test( + 'does nothing on empty content (backward)', + '[]', + '[]', + { unit: 'word', direction: 'backward' } + ); + + test( + 'does nothing on root boundary', + 'foo[]
', + 'foo[]
', + { unit: 'word' } + ); + + test( + 'does nothing on root boundary (backward)', + '[]foo
', + '[]foo
', + { unit: 'word', direction: 'backward' } + ); + + for ( const char of ' ,.?!:;"-()'.split( '' ) ) { + testStopCharacter( char ); + } + + test( + 'extends whole word forward (non-collapsed)', + 'f[o]obar
', + 'f[oobar]
', + { unit: 'word' } + ); + + it( 'extends whole word backward (non-collapsed)', () => { + setData( model, 'foo ba[a]r
', { lastRangeBackward: true } ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( 'foo [baa]r
' ); + expect( doc.selection.isBackward ).to.true; + } ); + + test( + 'extends to element boundary', + 'fo[]oo
', + 'fo[oo]
', + { unit: 'word' } + ); + + it( 'extends to element boundary (backward)', () => { + setData( model, 'ff[]oo
' ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '[ff]oo
' ); + expect( doc.selection.isBackward ).to.true; + } ); + + test( + 'expands forward selection to the word start', + 'foo bar[b]az
', + 'foo [bar]baz
', + { unit: 'word', direction: 'backward' } + ); + + it( 'expands backward selection to the word end', () => { + setData( model, 'foo[b]ar baz
', { lastRangeBackward: true } ); + + modifySelection( model, doc.selection, { unit: 'word' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( 'foob[ar] baz
' ); + expect( doc.selection.isBackward ).to.false; + } ); + + test( + 'unicode support - combining mark forward', + 'foo[]b̂ar
', + 'foo[b̂ar]
', + { unit: 'word' } + ); + + it( 'unicode support - combining mark backward', () => { + setData( model, 'foob̂[]ar
' ); + + modifySelection( model, doc.selection, { direction: 'backward', unit: 'word' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '[foob̂]ar
' ); + expect( doc.selection.isBackward ).to.true; + } ); + + test( + 'unicode support - combining mark multiple', + 'fo[]o̻̐ͩbar
', + 'fo[o̻̐ͩbar]
', + { unit: 'word' } + ); + + it( 'unicode support - combining mark multiple backward', () => { + setData( model, 'foo̻̐ͩ[]bar
' ); + + modifySelection( model, doc.selection, { direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( 'fo[o̻̐ͩ]bar
' ); + expect( doc.selection.isBackward ).to.true; + } ); + + test( + 'unicode support - combining mark to the end', + 'f[o]o̻̐ͩ
', + 'f[oo̻̐ͩ]
', + { unit: 'word' } + ); + + test( + 'unicode support - surrogate pairs forward', + '[]foo\uD83D\uDCA9
', + '[foo\uD83D\uDCA9]
', + { unit: 'word' } + ); + + it( 'unicode support - surrogate pairs backward', () => { + setData( model, 'foo\uD83D\uDCA9[]
' ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '[foo\uD83D\uDCA9]
' ); + expect( doc.selection.isBackward ).to.true; + } ); + + function testStopCharacter( stopCharacter ) { + describe( `stop character: "${ stopCharacter }"`, () => { + test( + 'extends whole word forward', + `f[]oo${ stopCharacter }bar
`, + `f[oo]${ stopCharacter }bar
`, + { unit: 'word' } + ); + + it( 'extends whole word backward to the previous word', () => { + setData( model, `foo${ stopCharacter }ba[]r
`, { lastRangeBackward: true } ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( `foo${ stopCharacter }[ba]r
` ); + expect( doc.selection.isBackward ).to.true; + } ); + + it( 'extends whole word backward', () => { + setData( model, `fo[]o${ stopCharacter }bar
`, { lastRangeBackward: true } ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( `[fo]o${ stopCharacter }bar
` ); + expect( doc.selection.isBackward ).to.true; + } ); + + test( + 'ignores attributes when in one word - case 1', + `foo[]<$text bold="true">bar$text>baz${ stopCharacter }foobarbaz
`, + `foo[<$text bold="true">bar$text>baz]${ stopCharacter }foobarbaz
`, + { unit: 'word' } + ); + + test( + 'ignores attributes when in one word - case 2', + `foo[]<$text bold="true">bar$text>${ stopCharacter }foobarbaz
`, + `foo[<$text bold="true">bar$text>]${ stopCharacter }foobarbaz
`, + { unit: 'word' } + ); + + test( + 'ignores attributes when in one word - case 3', + `foo[]<$text bold="true">bar$text><$text italic="true">baz$text>baz${ stopCharacter }foobarbaz
`, + `foo[<$text bold="true">bar$text><$text italic="true">baz$text>baz]${ stopCharacter }foobarbaz
`, + { unit: 'word' } + ); + + it( 'extends whole word backward to the previous word ignoring attributes - case 1', () => { + setData( + model, + `foobarbaz${ stopCharacter }foo<$text bold="true">bar$text>baz[]
` + ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( + `foobarbaz${ stopCharacter }[foo<$text bold="true">bar$text>baz]
` + ); + expect( doc.selection.isBackward ).to.true; + } ); + + it( 'extends whole word backward to the previous word ignoring attributes - case 2', () => { + setData( + model, + `foobarbaz${ stopCharacter }<$text bold="true">bar$text>baz[]
` + ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( + `foobarbaz${ stopCharacter }[<$text bold="true">bar$text>baz]
` + ); + expect( doc.selection.isBackward ).to.true; + } ); + } ); + } + } ); + + describe( 'beyond element', () => { + test( + 'extends over boundary of empty elements', + '[]
', + '[
]
', + { unit: 'word' } + ); + + it( 'extends over boundary of empty elements (backward)', () => { + setData( model, '[]
' ); + + modifySelection( model, doc.selection, { direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '[
]
' ); + expect( doc.selection.isBackward ).to.true; + } ); + + test( + 'extends over boundary of non-empty elements', + 'a[]
bcd
', + 'a[
]bcd
', + { unit: 'word' } + ); + + it( 'extends over boundary of non-empty elements (backward)', () => { + setData( model, 'a
[]bcd
' ); + + modifySelection( model, doc.selection, { direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( 'a[
]bcd
' ); + expect( doc.selection.isBackward ).to.true; + } ); + + test( + 'extends over character after boundary', + 'a[
]bcd
', + 'a[
bcd]
', + { unit: 'word' } + ); + + it( 'extends over character after boundary (backward)', () => { + setData( model, 'abc[
]d
', { lastRangeBackward: true } ); + + modifySelection( model, doc.selection, { direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( 'ab[c
]d
' ); + expect( doc.selection.isBackward ).to.true; + } ); + + test( + 'stops on the first position where text is allowed - inside block', + 'a[]
a[
]
a[
]
a[
ab
ab[
[
]
', { lastRangeBackward: true } ); + + modifySelection( model, doc.selection, { unit: 'word' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '[]
' ); + expect( doc.selection.isBackward ).to.false; + } ); + + it( 'shrinks over boundary of empty elements (backward)', () => { + setData( model, '[
]
' ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '[]
' ); + expect( doc.selection.isBackward ).to.false; + } ); + + it( 'shrinks over boundary of non-empty elements', () => { + setData( model, 'a[
]b
', { lastRangeBackward: true } ); + + modifySelection( model, doc.selection, { unit: 'word' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( 'a
[]b
' ); + expect( doc.selection.isBackward ).to.false; + } ); + + test( + 'shrinks over boundary of non-empty elements (backward)', + 'a[
]b
', + 'a[]
b
', + { unit: 'word', direction: 'backward' } + ); + + it( 'updates selection attributes', () => { + setData( model, '<$text bold="true">foo$text>[b]
' ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '<$text bold="true">foo[]$text>b
' ); + 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', + 'x[]
y
z
', + 'x[
]y
z
', + { unit: 'word' } + ); + + test( + 'skips position at the end of an element which does not allow text - backward', + 'x
y
[]z
', + 'x
y[
]z
', + { unit: 'word', direction: 'backward' } + ); + + test( + 'skips position at the end of an element which does not allow text', + 'x[
y]
z
', + 'x[
y
]z
', + { unit: 'word' } + ); + + test( + 'skips position at the beginning of an element which does not allow text - backward', + 'x
[]y
z
', + 'x[
]y
z
', + { unit: 'word', direction: 'backward' } + ); + + test( + 'extends to an empty block after skipping incorrect position', + 'x[]
z
', + 'x[
]
z
', + { unit: 'word' } + ); + + test( + 'extends to an empty block after skipping incorrect position - backward', + 'x
[]z
', + 'x
[
]z
', + { unit: 'word', direction: 'backward' } + ); + } ); + } ); + describe( 'objects handling', () => { beforeEach( () => { model.schema.register( 'obj', {