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 bazbaz' + // should expand to : 'foofoo [bar<$text bold="true">bar] 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">barbaz${ stopCharacter }foobarbaz

`, + `

foo[<$text bold="true">barbaz]${ stopCharacter }foobarbaz

`, + { unit: 'word' } + ); + + test( + 'ignores attributes when in one word - case 2', + `

foo[]<$text bold="true">bar${ stopCharacter }foobarbaz

`, + `

foo[<$text bold="true">bar]${ stopCharacter }foobarbaz

`, + { unit: 'word' } + ); + + test( + 'ignores attributes when in one word - case 3', + `

foo[]<$text bold="true">bar<$text italic="true">bazbaz${ stopCharacter }foobarbaz

`, + `

foo[<$text bold="true">bar<$text italic="true">bazbaz]${ 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">barbaz[]

` + ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( + `

foobarbaz${ stopCharacter }[foo<$text bold="true">barbaz]

` + ); + 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">barbaz[]

` + ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( + `

foobarbaz${ stopCharacter }[<$text bold="true">barbaz]

` + ); + 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[]

bcd

', + '

a[

]bcd

', + { unit: 'word' } + ); + + test( + 'stops on the first position where text is allowed - inside inline element', + '

a[

]bcdef

', + '

a[

]bcdef

', + { unit: 'word' } + ); + + test( + 'extends over element when next node is a text', + '

a[]bc

', + '

a[]bc

', + { unit: 'word' } + ); + + test( + 'extends over element when next node is a text - backward', + '

ab[]c

', + '

ab[]c

', + { unit: 'word', direction: 'backward' } + ); + + it( 'shrinks over boundary of empty elements', () => { + setData( model, '

[

]

', { 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[b]

' ); + + modifySelection( model, doc.selection, { unit: 'word', direction: 'backward' } ); + + expect( stringify( doc.getRoot(), doc.selection ) ).to.equal( '

<$text bold="true">foo[]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', {