Skip to content

Commit

Permalink
fix: Handle nodes and text when trimming whitespace from a selection
Browse files Browse the repository at this point in the history
  • Loading branch information
ajwild committed Dec 7, 2023
1 parent fa09d39 commit a2b34bb
Show file tree
Hide file tree
Showing 3 changed files with 141 additions and 12 deletions.
29 changes: 25 additions & 4 deletions spec/selection.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -437,14 +437,16 @@ describe('Selection', function () {
})

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

it('does trim if only a whitespace is selected', function () {
Expand Down Expand Up @@ -482,6 +484,25 @@ describe('Selection', function () {
expect(selection.toString()).to.equal('')
expect(this.wordWithWhitespace.innerHTML).to.equal(' foobar ')
})

it('handles nodes and characters', function () {
// Split word into three nodes: ` `, `foo`, `bar `
const range = createRange()
range.setStart(this.wordWithWhitespace.firstChild, 1)
range.setEnd(this.wordWithWhitespace.firstChild, 4)
const selection = new Selection(this.wordWithWhitespace, range)
selection.save()
selection.restore()

// Select specific characters within nodes across multiple nodes
const rangeTwo = createRange()
rangeTwo.setStart(this.wordWithWhitespace, 0) // Select first node (start)
rangeTwo.setEnd(this.wordWithWhitespace.childNodes[2], 2) // Select middle of last node
const selectionTwo = new Selection(this.wordWithWhitespace, rangeTwo)
selectionTwo.makeBold()

expect(this.wordWithWhitespace.innerHTML).to.equal(' <strong>fooba</strong>r ')
})
})

describe('inherits form Cursor', function () {
Expand Down
29 changes: 25 additions & 4 deletions src/selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import * as block from './block'
import config from './config'
import highlightSupport from './highlight-support'
import highlightText from './highlight-text'
import {toCharacterRange, rangeToHtml} from './util/dom'
import {
toCharacterRange,
rangeToHtml,
findStartExcludingWhitespace,
findEndExcludingWhitespace
} from './util/dom'

/**
* The Selection module provides a cross-browser abstraction layer for range
Expand Down Expand Up @@ -96,9 +101,25 @@ export default class Selection extends Cursor {
const textToTrim = this.range.toString()
const whitespacesOnTheLeft = textToTrim.search(/\S|$/)
const lastNonWhitespace = textToTrim.search(/\S[\s]+$/)
const whitespacesOnTheRight = lastNonWhitespace === -1 ? 0 : textToTrim.length - (lastNonWhitespace + 1)
this.range.setStart(this.range.startContainer, this.range.startOffset + whitespacesOnTheLeft)
this.range.setEnd(this.range.endContainer, this.range.endOffset - whitespacesOnTheRight)
const whitespacesOnTheRight = lastNonWhitespace === -1
? 0
: textToTrim.length - (lastNonWhitespace + 1)

const [startContainer, startOffset] = findStartExcludingWhitespace({
root: this.range.commonAncestorContainer,
startContainer: this.range.startContainer,
startOffset: this.range.startOffset,
whitespacesOnTheLeft
})
this.range.setStart(startContainer, startOffset)

const [endContainer, endOffset] = findEndExcludingWhitespace({
root: this.range.commonAncestorContainer,
endContainer: this.range.endContainer,
endOffset: this.range.endOffset,
whitespacesOnTheRight
})
this.range.setEnd(endContainer, endOffset)
}

unlink () {
Expand Down
95 changes: 91 additions & 4 deletions src/util/dom.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import NodeIterator from '../node-iterator'
import {textNode} from '../node-type'

/**
* @param {HTMLElement | Array | String} target
* @param {Document} document
Expand Down Expand Up @@ -228,21 +231,21 @@ export const createRangeFromCharacterRange = (element, actualStartIndex, actualE
let startNode, endNode, startOffset, endOffset

while (walker.nextNode()) {
const textNode = walker.currentNode
const nodeLength = textNode.nodeValue.length
const node = walker.currentNode
const nodeLength = node.nodeValue.length

if (currentIndex + nodeLength <= actualStartIndex) {
currentIndex += nodeLength
continue
}

if (!startNode) {
startNode = textNode
startNode = node
startOffset = actualStartIndex - currentIndex
}

if (currentIndex + nodeLength >= actualEndIndex) {
endNode = textNode
endNode = node
endOffset = actualEndIndex - currentIndex
break
}
Expand All @@ -260,3 +263,87 @@ export const createRangeFromCharacterRange = (element, actualStartIndex, actualE
}
}

export function findStartExcludingWhitespace ({root, startContainer, startOffset, whitespacesOnTheLeft}) {
const isTextNode = startContainer.nodeType === textNode
if (!isTextNode) {
return findStartExcludingWhitespace({
root,
startContainer: startContainer.childNodes[startOffset],
startOffset: 0,
whitespacesOnTheLeft
})
}

const offsetAfterWhitespace = startOffset + whitespacesOnTheLeft
if (startContainer.length > offsetAfterWhitespace) {
return [startContainer, offsetAfterWhitespace]
}

// Pass the root so that the iterator can traverse to siblings
const iterator = new NodeIterator(root)
// Set the position to the node which is selected
iterator.nextNode = startContainer
// Iterate once to avoid returning self
iterator.getNextTextNode()

const container = iterator.getNextTextNode()
if (!container) {
// No more text nodes - use the end of the last text node
const previousTextNode = iterator.getPreviousTextNode()
return [previousTextNode, previousTextNode.length]
}

return findStartExcludingWhitespace({
root,
startContainer: container,
startOffset: 0,
whitespacesOnTheLeft: offsetAfterWhitespace - startContainer.length
})
}

export function findEndExcludingWhitespace ({root, endContainer, endOffset, whitespacesOnTheRight}) {
const isTextNode = endContainer.nodeType === textNode
if (!isTextNode) {
const isFirstNode = !endContainer.childNodes[endOffset - 1]
const container = isFirstNode
? endContainer.childNodes[endOffset]
: endContainer.childNodes[endOffset - 1]
let offset = 0
if (!isFirstNode) {
offset = container.nodeType === textNode
? container.length
: container.childNodes.length
}
return findEndExcludingWhitespace({
root,
endContainer: container,
endOffset: offset,
whitespacesOnTheRight
})
}

const offsetBeforeWhitespace = endOffset - whitespacesOnTheRight
if (offsetBeforeWhitespace > 0) {
return [endContainer, offsetBeforeWhitespace]
}

// Pass the root so that the iterator can traverse to siblings
const iterator = new NodeIterator(root)
// Set the position to the node which is selected
iterator.previous = endContainer
// Iterate once to avoid returning self
iterator.getPreviousTextNode()

const container = iterator.getPreviousTextNode()
if (!container) {
// No more text nodes - use the start of the first text node
return [iterator.getNextTextNode(), 0]
}

return findEndExcludingWhitespace({
root,
endContainer: container,
endOffset: container.length,
whitespacesOnTheRight: whitespacesOnTheRight - endOffset
})
}

0 comments on commit a2b34bb

Please sign in to comment.