Skip to content

Commit a2b34bb

Browse files
committed
fix: Handle nodes and text when trimming whitespace from a selection
1 parent fa09d39 commit a2b34bb

File tree

3 files changed

+141
-12
lines changed

3 files changed

+141
-12
lines changed

spec/selection.spec.js

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -437,14 +437,16 @@ describe('Selection', function () {
437437
})
438438

439439
it('trims a range with special whitespaces', function () {
440-
// at the beginning we have U+2002, U+2005 and U+2006 in the end a normal whitespace
441-
const wordWithSpecialWhitespaces = createElement('<div>   bar </div>')
440+
// At the beginning we have U+2002, U+2005, U+2006, U+FEFF.
441+
// At the end a normal whitespace.
442+
// Note: U+200B is not handled by regular expression \s whitespace.
443+
const wordWithSpecialWhitespaces = createElement('<div>   bar </div>')
442444
const range = createRange()
443445
range.selectNodeContents(wordWithSpecialWhitespaces.firstChild)
444446
const selection = new Selection(wordWithSpecialWhitespaces, range)
445447
selection.trimRange()
446-
expect(selection.range.startOffset).to.equal(3)
447-
expect(selection.range.endOffset).to.equal(6)
448+
expect(selection.range.startOffset).to.equal(4)
449+
expect(selection.range.endOffset).to.equal(7)
448450
})
449451

450452
it('does trim if only a whitespace is selected', function () {
@@ -482,6 +484,25 @@ describe('Selection', function () {
482484
expect(selection.toString()).to.equal('')
483485
expect(this.wordWithWhitespace.innerHTML).to.equal(' foobar ')
484486
})
487+
488+
it('handles nodes and characters', function () {
489+
// Split word into three nodes: ` `, `foo`, `bar `
490+
const range = createRange()
491+
range.setStart(this.wordWithWhitespace.firstChild, 1)
492+
range.setEnd(this.wordWithWhitespace.firstChild, 4)
493+
const selection = new Selection(this.wordWithWhitespace, range)
494+
selection.save()
495+
selection.restore()
496+
497+
// Select specific characters within nodes across multiple nodes
498+
const rangeTwo = createRange()
499+
rangeTwo.setStart(this.wordWithWhitespace, 0) // Select first node (start)
500+
rangeTwo.setEnd(this.wordWithWhitespace.childNodes[2], 2) // Select middle of last node
501+
const selectionTwo = new Selection(this.wordWithWhitespace, rangeTwo)
502+
selectionTwo.makeBold()
503+
504+
expect(this.wordWithWhitespace.innerHTML).to.equal(' <strong>fooba</strong>r ')
505+
})
485506
})
486507

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

src/selection.js

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import * as block from './block'
55
import config from './config'
66
import highlightSupport from './highlight-support'
77
import highlightText from './highlight-text'
8-
import {toCharacterRange, rangeToHtml} from './util/dom'
8+
import {
9+
toCharacterRange,
10+
rangeToHtml,
11+
findStartExcludingWhitespace,
12+
findEndExcludingWhitespace
13+
} from './util/dom'
914

1015
/**
1116
* The Selection module provides a cross-browser abstraction layer for range
@@ -96,9 +101,25 @@ export default class Selection extends Cursor {
96101
const textToTrim = this.range.toString()
97102
const whitespacesOnTheLeft = textToTrim.search(/\S|$/)
98103
const lastNonWhitespace = textToTrim.search(/\S[\s]+$/)
99-
const whitespacesOnTheRight = lastNonWhitespace === -1 ? 0 : textToTrim.length - (lastNonWhitespace + 1)
100-
this.range.setStart(this.range.startContainer, this.range.startOffset + whitespacesOnTheLeft)
101-
this.range.setEnd(this.range.endContainer, this.range.endOffset - whitespacesOnTheRight)
104+
const whitespacesOnTheRight = lastNonWhitespace === -1
105+
? 0
106+
: textToTrim.length - (lastNonWhitespace + 1)
107+
108+
const [startContainer, startOffset] = findStartExcludingWhitespace({
109+
root: this.range.commonAncestorContainer,
110+
startContainer: this.range.startContainer,
111+
startOffset: this.range.startOffset,
112+
whitespacesOnTheLeft
113+
})
114+
this.range.setStart(startContainer, startOffset)
115+
116+
const [endContainer, endOffset] = findEndExcludingWhitespace({
117+
root: this.range.commonAncestorContainer,
118+
endContainer: this.range.endContainer,
119+
endOffset: this.range.endOffset,
120+
whitespacesOnTheRight
121+
})
122+
this.range.setEnd(endContainer, endOffset)
102123
}
103124

104125
unlink () {

src/util/dom.js

Lines changed: 91 additions & 4 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
@@ -228,21 +231,21 @@ export const createRangeFromCharacterRange = (element, actualStartIndex, actualE
228231
let startNode, endNode, startOffset, endOffset
229232

230233
while (walker.nextNode()) {
231-
const textNode = walker.currentNode
232-
const nodeLength = textNode.nodeValue.length
234+
const node = walker.currentNode
235+
const nodeLength = node.nodeValue.length
233236

234237
if (currentIndex + nodeLength <= actualStartIndex) {
235238
currentIndex += nodeLength
236239
continue
237240
}
238241

239242
if (!startNode) {
240-
startNode = textNode
243+
startNode = node
241244
startOffset = actualStartIndex - currentIndex
242245
}
243246

244247
if (currentIndex + nodeLength >= actualEndIndex) {
245-
endNode = textNode
248+
endNode = node
246249
endOffset = actualEndIndex - currentIndex
247250
break
248251
}
@@ -260,3 +263,87 @@ export const createRangeFromCharacterRange = (element, actualStartIndex, actualE
260263
}
261264
}
262265

266+
export function findStartExcludingWhitespace ({root, startContainer, startOffset, whitespacesOnTheLeft}) {
267+
const isTextNode = startContainer.nodeType === textNode
268+
if (!isTextNode) {
269+
return findStartExcludingWhitespace({
270+
root,
271+
startContainer: startContainer.childNodes[startOffset],
272+
startOffset: 0,
273+
whitespacesOnTheLeft
274+
})
275+
}
276+
277+
const offsetAfterWhitespace = startOffset + whitespacesOnTheLeft
278+
if (startContainer.length > offsetAfterWhitespace) {
279+
return [startContainer, offsetAfterWhitespace]
280+
}
281+
282+
// Pass the root so that the iterator can traverse to siblings
283+
const iterator = new NodeIterator(root)
284+
// Set the position to the node which is selected
285+
iterator.nextNode = startContainer
286+
// Iterate once to avoid returning self
287+
iterator.getNextTextNode()
288+
289+
const container = iterator.getNextTextNode()
290+
if (!container) {
291+
// No more text nodes - use the end of the last text node
292+
const previousTextNode = iterator.getPreviousTextNode()
293+
return [previousTextNode, previousTextNode.length]
294+
}
295+
296+
return findStartExcludingWhitespace({
297+
root,
298+
startContainer: container,
299+
startOffset: 0,
300+
whitespacesOnTheLeft: offsetAfterWhitespace - startContainer.length
301+
})
302+
}
303+
304+
export function findEndExcludingWhitespace ({root, endContainer, endOffset, whitespacesOnTheRight}) {
305+
const isTextNode = endContainer.nodeType === textNode
306+
if (!isTextNode) {
307+
const isFirstNode = !endContainer.childNodes[endOffset - 1]
308+
const container = isFirstNode
309+
? endContainer.childNodes[endOffset]
310+
: endContainer.childNodes[endOffset - 1]
311+
let offset = 0
312+
if (!isFirstNode) {
313+
offset = container.nodeType === textNode
314+
? container.length
315+
: container.childNodes.length
316+
}
317+
return findEndExcludingWhitespace({
318+
root,
319+
endContainer: container,
320+
endOffset: offset,
321+
whitespacesOnTheRight
322+
})
323+
}
324+
325+
const offsetBeforeWhitespace = endOffset - whitespacesOnTheRight
326+
if (offsetBeforeWhitespace > 0) {
327+
return [endContainer, offsetBeforeWhitespace]
328+
}
329+
330+
// Pass the root so that the iterator can traverse to siblings
331+
const iterator = new NodeIterator(root)
332+
// Set the position to the node which is selected
333+
iterator.previous = endContainer
334+
// Iterate once to avoid returning self
335+
iterator.getPreviousTextNode()
336+
337+
const container = iterator.getPreviousTextNode()
338+
if (!container) {
339+
// No more text nodes - use the start of the first text node
340+
return [iterator.getNextTextNode(), 0]
341+
}
342+
343+
return findEndExcludingWhitespace({
344+
root,
345+
endContainer: container,
346+
endOffset: container.length,
347+
whitespacesOnTheRight: whitespacesOnTheRight - endOffset
348+
})
349+
}

0 commit comments

Comments
 (0)