Skip to content

Commit f36eaaf

Browse files
ajwildmarcbachmann
authored andcommitted
fix: Handle nodes and text when trimming whitespace from a selection
1 parent 060712e commit f36eaaf

File tree

3 files changed

+138
-9
lines changed

3 files changed

+138
-9
lines changed

spec/selection.spec.js

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -433,14 +433,16 @@ describe('Selection', function () {
433433
})
434434

435435
it('trims a range with special whitespaces', function () {
436-
// at the beginning we have U+2002, U+2005 and U+2006 in the end a normal whitespace
437-
const wordWithSpecialWhitespaces = createElement('<div>   bar </div>')
436+
// At the beginning we have U+2002, U+2005, U+2006, U+FEFF.
437+
// At the end a normal whitespace.
438+
// Note: U+200B is not handled by regular expression \s whitespace.
439+
const wordWithSpecialWhitespaces = createElement('<div>   bar </div>')
438440
const range = rangy.createRange()
439441
range.selectNodeContents(wordWithSpecialWhitespaces.firstChild)
440442
const selection = new Selection(wordWithSpecialWhitespaces, range)
441443
selection.trimRange()
442-
expect(selection.range.startOffset).to.equal(3)
443-
expect(selection.range.endOffset).to.equal(6)
444+
expect(selection.range.startOffset).to.equal(4)
445+
expect(selection.range.endOffset).to.equal(7)
444446
})
445447

446448
it('does trim if only a whitespace is selected', function () {
@@ -460,7 +462,7 @@ describe('Selection', function () {
460462
})
461463

462464
it('does not apply tags to whitespace when toggling', function () {
463-
const range = createRange()
465+
const range = rangy.createRange()
464466
range.setStart(this.wordWithWhitespace.firstChild, 0)
465467
range.setEnd(this.wordWithWhitespace.firstChild, 1)
466468
const selection = new Selection(this.wordWithWhitespace, range)
@@ -470,14 +472,33 @@ describe('Selection', function () {
470472
})
471473

472474
it('does not apply tags to whitespace when wrapping', function () {
473-
const range = createRange()
475+
const range = rangy.createRange()
474476
range.setStart(this.wordWithWhitespace.firstChild, 0)
475477
range.setEnd(this.wordWithWhitespace.firstChild, 1)
476478
const selection = new Selection(this.wordWithWhitespace, range)
477479
selection.makeBold()
478480
expect(selection.toString()).to.equal('')
479481
expect(this.wordWithWhitespace.innerHTML).to.equal(' foobar ')
480482
})
483+
484+
it('handles nodes and characters', function () {
485+
// Split word into three nodes: ` `, `foo`, `bar `
486+
const range = rangy.createRange()
487+
range.setStart(this.wordWithWhitespace.firstChild, 1)
488+
range.setEnd(this.wordWithWhitespace.firstChild, 4)
489+
const selection = new Selection(this.wordWithWhitespace, range)
490+
selection.save()
491+
selection.restore()
492+
493+
// Select specific characters within nodes across multiple nodes
494+
const rangeTwo = rangy.createRange()
495+
rangeTwo.setStart(this.wordWithWhitespace, 0) // Select first node (start)
496+
rangeTwo.setEnd(this.wordWithWhitespace.childNodes[2], 2) // Select middle of last node
497+
const selectionTwo = new Selection(this.wordWithWhitespace, rangeTwo)
498+
selectionTwo.makeBold()
499+
500+
expect(this.wordWithWhitespace.innerHTML).to.equal(' <strong>fooba</strong>r ')
501+
})
481502
})
482503

483504
describe('inherits form Cursor', function () {

src/selection.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import * as block from './block'
77
import config from './config'
88
import highlightSupport from './highlight-support'
99
import highlightText from './highlight-text'
10+
import {
11+
findStartExcludingWhitespace,
12+
findEndExcludingWhitespace
13+
} from './util/dom'
1014

1115
/**
1216
* The Selection module provides a cross-browser abstraction layer for range
@@ -98,9 +102,25 @@ export default class Selection extends Cursor {
98102
const textToTrim = this.range.toString()
99103
const whitespacesOnTheLeft = textToTrim.search(/\S|$/)
100104
const lastNonWhitespace = textToTrim.search(/\S[\s]+$/)
101-
const whitespacesOnTheRight = lastNonWhitespace === -1 ? 0 : textToTrim.length - (lastNonWhitespace + 1)
102-
this.range.setStart(this.range.startContainer, this.range.startOffset + whitespacesOnTheLeft)
103-
this.range.setEnd(this.range.endContainer, this.range.endOffset - whitespacesOnTheRight)
105+
const whitespacesOnTheRight = lastNonWhitespace === -1
106+
? 0
107+
: textToTrim.length - (lastNonWhitespace + 1)
108+
109+
const [startContainer, startOffset] = findStartExcludingWhitespace({
110+
root: this.range.commonAncestorContainer,
111+
startContainer: this.range.startContainer,
112+
startOffset: this.range.startOffset,
113+
whitespacesOnTheLeft
114+
})
115+
this.range.setStart(startContainer, startOffset)
116+
117+
const [endContainer, endOffset] = findEndExcludingWhitespace({
118+
root: this.range.commonAncestorContainer,
119+
endContainer: this.range.endContainer,
120+
endOffset: this.range.endOffset,
121+
whitespacesOnTheRight
122+
})
123+
this.range.setEnd(endContainer, endOffset)
104124
}
105125

106126
unlink () {

src/util/dom.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import NodeIterator from '../node-iterator'
2+
import {textNode} from '../node-type'
3+
14
/**
25
* @param {HTMLElement | Array | String} target
36
* @param {Document} document
@@ -42,3 +45,88 @@ export const closest = (elem, selector) => {
4245
if (!elem.closest) elem = elem.parentNode
4346
if (elem && elem.closest) return elem.closest(selector)
4447
}
48+
49+
export function findStartExcludingWhitespace ({root, startContainer, startOffset, whitespacesOnTheLeft}) {
50+
const isTextNode = startContainer.nodeType === textNode
51+
if (!isTextNode) {
52+
return findStartExcludingWhitespace({
53+
root,
54+
startContainer: startContainer.childNodes[startOffset],
55+
startOffset: 0,
56+
whitespacesOnTheLeft
57+
})
58+
}
59+
60+
const offsetAfterWhitespace = startOffset + whitespacesOnTheLeft
61+
if (startContainer.length > offsetAfterWhitespace) {
62+
return [startContainer, offsetAfterWhitespace]
63+
}
64+
65+
// Pass the root so that the iterator can traverse to siblings
66+
const iterator = new NodeIterator(root)
67+
// Set the position to the node which is selected
68+
iterator.nextNode = startContainer
69+
// Iterate once to avoid returning self
70+
iterator.getNextTextNode()
71+
72+
const container = iterator.getNextTextNode()
73+
if (!container) {
74+
// No more text nodes - use the end of the last text node
75+
const previousTextNode = iterator.getPreviousTextNode()
76+
return [previousTextNode, previousTextNode.length]
77+
}
78+
79+
return findStartExcludingWhitespace({
80+
root,
81+
startContainer: container,
82+
startOffset: 0,
83+
whitespacesOnTheLeft: offsetAfterWhitespace - startContainer.length
84+
})
85+
}
86+
87+
export function findEndExcludingWhitespace ({root, endContainer, endOffset, whitespacesOnTheRight}) {
88+
const isTextNode = endContainer.nodeType === textNode
89+
if (!isTextNode) {
90+
const isFirstNode = !endContainer.childNodes[endOffset - 1]
91+
const container = isFirstNode
92+
? endContainer.childNodes[endOffset]
93+
: endContainer.childNodes[endOffset - 1]
94+
let offset = 0
95+
if (!isFirstNode) {
96+
offset = container.nodeType === textNode
97+
? container.length
98+
: container.childNodes.length
99+
}
100+
return findEndExcludingWhitespace({
101+
root,
102+
endContainer: container,
103+
endOffset: offset,
104+
whitespacesOnTheRight
105+
})
106+
}
107+
108+
const offsetBeforeWhitespace = endOffset - whitespacesOnTheRight
109+
if (offsetBeforeWhitespace > 0) {
110+
return [endContainer, offsetBeforeWhitespace]
111+
}
112+
113+
// Pass the root so that the iterator can traverse to siblings
114+
const iterator = new NodeIterator(root)
115+
// Set the position to the node which is selected
116+
iterator.previous = endContainer
117+
// Iterate once to avoid returning self
118+
iterator.getPreviousTextNode()
119+
120+
const container = iterator.getPreviousTextNode()
121+
if (!container) {
122+
// No more text nodes - use the start of the first text node
123+
return [iterator.getNextTextNode(), 0]
124+
}
125+
126+
return findEndExcludingWhitespace({
127+
root,
128+
endContainer: container,
129+
endOffset: container.length,
130+
whitespacesOnTheRight: whitespacesOnTheRight - endOffset
131+
})
132+
}

0 commit comments

Comments
 (0)