Skip to content

Commit 97f0f28

Browse files
committed
fix: Fix newline behavior
- Do not remove newlines in between child nodes of editable blocks - Trim newlines at the start and end of editable blocks - Do not require two shift+return when appending a new line at the end of an editable block
1 parent 11039c7 commit 97f0f28

File tree

5 files changed

+98
-86
lines changed

5 files changed

+98
-86
lines changed

spec/content.spec.js

Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -558,96 +558,99 @@ describe('Content', function () {
558558
})
559559

560560
describe('extractContent()', function () {
561-
let host
562-
563-
beforeEach(function () {
564-
host = createElement('<div></div>')
565-
})
566-
567561
it('extracts the content', function () {
568-
host.innerHTML = 'a'
569-
const result = content.extractContent(host)
562+
const element = createElement('<div>a</div>')
563+
const result = content.extractContent(element)
570564
// escape to show invisible characters
571565
expect(escape(result)).toEqual('a')
572566
})
573567

574568
it('extracts the content from a document fragment', function () {
575-
host.innerHTML = 'a<span>b</span>c'
576-
const element = host
569+
const element = createElement('<div>a<span>b</span>c</div>')
577570
const fragment = document.createDocumentFragment()
578-
Array.from(element.childNodes).forEach((child) => {
579-
fragment.appendChild(child.cloneNode(true))
580-
})
571+
for (const child of element.childNodes) fragment.appendChild(child.cloneNode(true))
581572
expect(content.extractContent(fragment)).toEqual('a<span>b</span>c')
582573
})
583574

584575
it('replaces a zeroWidthSpace with a <br> tag', function () {
585-
host.innerHTML = 'a\u200B'
586-
const result = content.extractContent(host)
587-
expect(result).toEqual('a<br>')
576+
const element = createElement('<div>a\u200Bb</div>')
577+
const result = content.extractContent(element)
578+
expect(result).toEqual('a<br>b')
579+
})
580+
581+
it('removes text nodes and line breaks at the end', function () {
582+
const element = createElement('<div>a\u200B</div>')
583+
const result = content.extractContent(element)
584+
expect(result).toEqual('a')
585+
586+
const element2 = createElement('<div>b<br></div>')
587+
const result2 = content.extractContent(element2)
588+
expect(result2).toEqual('b')
588589
})
589590

590591
it('removes zeroWidthNonBreakingSpaces', function () {
591-
host.innerHTML = 'a\uFEFF'
592-
const result = content.extractContent(host)
592+
const element = createElement('<div>a\uFEFFb</div>')
593+
const result = content.extractContent(element)
593594
// escape to show invisible characters
594-
expect(escape(result)).toEqual('a')
595+
expect(escape(result)).toEqual('ab')
595596
})
596597

597598
it('removes a marked linebreak', function () {
598-
host.innerHTML = '<br data-editable="remove">'
599-
const result = content.extractContent(host)
600-
expect(result).toEqual('')
599+
const element = createElement('<div>Foo <br data-editable="remove">Bar</div>')
600+
const result = content.extractContent(element)
601+
expect(result).toEqual('Foo Bar')
601602
})
602603

603604
it('removes two nested marked spans', function () {
604-
host.innerHTML = '<span data-editable="unwrap"><span data-editable="unwrap">a</span></span>'
605-
const result = content.extractContent(host)
605+
const element = createElement('<div><span data-editable="unwrap"><span data-editable="unwrap">a</span></span></div>')
606+
const result = content.extractContent(element)
606607
expect(result).toEqual('a')
607608
})
608609

609610
it('removes two adjacent marked spans', function () {
610-
host.innerHTML = '<span data-editable="remove"></span><span data-editable="remove"></span>'
611-
const result = content.extractContent(host)
611+
const element = createElement('<div><span data-editable="remove"></span><span data-editable="remove"></span></div>')
612+
const result = content.extractContent(element)
612613
expect(result).toEqual('')
613614
})
614615

615616
it('unwraps two marked spans around text', function () {
616-
host.innerHTML = '|<span data-editable="unwrap">a</span>|<span data-editable="unwrap">b</span>|'
617-
const result = content.extractContent(host)
617+
const element = createElement('<div>|<span data-editable="unwrap">a</span>|<span data-editable="unwrap">b</span>|</div>')
618+
const result = content.extractContent(element)
618619
expect(result).toEqual('|a|b|')
619620
})
620621

621622
it('unwraps a "ui-unwrap" span', function () {
622-
host.innerHTML = 'a<span data-editable="ui-unwrap">b</span>c'
623-
const result = content.extractContent(host)
623+
const element = createElement('<div>a<span data-editable="ui-unwrap">b</span>c</div>')
624+
const result = content.extractContent(element)
624625
expect(result).toEqual('abc')
625626
})
626627

627628
it('removes a "ui-remove" span', function () {
628-
host.innerHTML = 'a<span data-editable="ui-remove">b</span>c'
629-
const result = content.extractContent(host)
629+
const element = createElement('<div>a<span data-editable="ui-remove">b</span>c</div>')
630+
const result = content.extractContent(element)
630631
expect(result).toEqual('ac')
631632
})
632633

633634
describe('called with keepUiElements', function () {
634635

635636
it('does not unwrap a "ui-unwrap" span', function () {
636-
host.innerHTML = 'a<span data-editable="ui-unwrap">b</span>c'
637-
const result = content.extractContent(host, true)
637+
const element = createElement('<div>a<span data-editable="ui-unwrap">b</span>c</div>')
638+
const result = content.extractContent(element, true)
638639
expect(result).toEqual('a<span data-editable="ui-unwrap">b</span>c')
639640
})
640641

641642
it('does not remove a "ui-remove" span', function () {
642-
host.innerHTML = 'a<span data-editable="ui-remove">b</span>c'
643-
const result = content.extractContent(host, true)
643+
const element = createElement('<div>a<span data-editable="ui-remove">b</span>c</div>')
644+
const result = content.extractContent(element, true)
644645
expect(result).toEqual('a<span data-editable="ui-remove">b</span>c')
645646
})
646647
})
647648

648649
describe('with ranges', function () {
650+
let host
649651

650652
beforeEach(function () {
653+
host = createElement('<div></div>')
651654
document.body.appendChild(host)
652655
this.range = rangy.createRange()
653656
})

src/content.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ export function extractContent (element, keepUiElements) {
9292
clone.innerHTML = innerHtml
9393
unwrapInternalNodes(clone, keepUiElements)
9494

95+
// Remove line breaks at the beginning of a content block
96+
removeWhitespaces(clone, 'firstChild')
97+
98+
// Remove line breaks at the end of a content block
99+
removeWhitespaces(clone, 'lastChild')
100+
95101
return clone.innerHTML
96102
}
97103

@@ -132,6 +138,21 @@ export function cloneRangeContents (range) {
132138
return fragment
133139
}
134140

141+
function removeWhitespaces (node, type) {
142+
let elem
143+
while ((elem = node[type])) {
144+
if (elem.nodeType === nodeType.textNode) {
145+
if (/^\s+$/.test(elem.textContent)) node.removeChild(elem)
146+
else break
147+
} else if (elem.nodeName === 'BR') {
148+
elem.remove()
149+
} else {
150+
if (elem[type]) removeWhitespaces(elem, type)
151+
break
152+
}
153+
}
154+
}
155+
135156
// Remove elements that were inserted for internal or user interface purposes
136157
//
137158
// @param {DOM node}

src/create-default-behavior.js

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,29 @@ export default function createDefaultBehavior (editable) {
4444
},
4545

4646
newline (element, cursor) {
47-
47+
// When the cursor is at the text end, we'll need to add an empty text node
48+
// after the br tag to ensure that the cursor shows up on the next line
4849
if (cursor.isAtTextEnd()) {
49-
const trailingBr = document.createElement('br')
50-
trailingBr.setAttribute('data-editable', 'remove')
51-
cursor.insertBefore(trailingBr)
50+
// We need to wrap the newline, so it can get deleted
51+
// together with the null escape character
52+
const spanWithTextNode = document.createElement('span')
53+
spanWithTextNode.setAttribute('data-editable', 'unwrap')
54+
55+
// The null escape character gets wrapped in another span,
56+
// so it gets removed automatically.
57+
// contenteditable=false prevents a focus of the span
58+
// and therefore also prevents content from getting written into it.
59+
// If this attribute is defined on the parent wrapper element,
60+
// the cursor positioning behaves weird with deletion of the newline
61+
const spacer = document.createElement('span')
62+
spacer.setAttribute('data-editable', 'remove')
63+
spacer.setAttribute('contenteditable', 'false')
64+
spacer.appendChild(document.createTextNode('\u0000'))
65+
66+
spanWithTextNode.appendChild(document.createElement('br'))
67+
spanWithTextNode.appendChild(spacer)
68+
69+
cursor.insertBefore(spanWithTextNode)
5270
} else {
5371
cursor.insertBefore(document.createElement('br'))
5472
}

src/cursor.js

Lines changed: 12 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {elementNode, documentFragmentNode} from './node-type'
88
import error from './util/error'
99
import * as rangeSaveRestore from './range-save-restore'
1010
// import printRange from './util/print_range'
11-
import NodeIterator from './node-iterator'
1211
import {closest} from './util/dom'
1312

1413
/**
@@ -95,28 +94,7 @@ export default class Cursor {
9594
const hostRange = this.win.document.createRange()
9695
hostRange.selectNodeContents(this.host)
9796
const hostCoords = hostRange.getBoundingClientRect()
98-
99-
let cursorCoords
100-
if (this.range.nativeRange.startContainer.nodeType === elementNode) {
101-
const container = this.range.nativeRange.startContainer
102-
if ((container.children.length - 1) >= this.range.nativeRange.startOffset) {
103-
const elem = container.children[this.range.nativeRange.startOffset]
104-
const iterator = new NodeIterator(elem)
105-
const textNode = iterator.getNextTextNode()
106-
if (textNode) {
107-
const cursorRange = this.win.document.createRange()
108-
cursorRange.setStart(textNode, 0)
109-
cursorRange.collapse(true)
110-
cursorCoords = cursorRange.getBoundingClientRect()
111-
} else {
112-
cursorCoords = hostCoords
113-
}
114-
} else {
115-
cursorCoords = hostCoords
116-
}
117-
} else {
118-
cursorCoords = this.getBoundingClientRect()
119-
}
97+
const cursorCoords = getCursorBoundingClientRect(this.range.nativeRange, this.win)
12098

12199
return hostCoords.bottom === cursorCoords.bottom
122100
}
@@ -125,29 +103,7 @@ export default class Cursor {
125103
const hostRange = this.win.document.createRange()
126104
hostRange.selectNodeContents(this.host)
127105
const hostCoords = hostRange.getBoundingClientRect()
128-
129-
let cursorCoords
130-
if (this.range.nativeRange.startContainer.nodeType === elementNode) {
131-
const container = this.range.nativeRange.startContainer
132-
if ((container.children.length - 1) >= this.range.nativeRange.startOffset) {
133-
const elem = container.children[this.range.nativeRange.startOffset]
134-
const iterator = new NodeIterator(elem)
135-
const textNode = iterator.getPreviousTextNode()
136-
if (textNode) {
137-
const cursorRange = this.win.document.createRange()
138-
cursorRange.setStart(textNode, 0)
139-
cursorRange.collapse(true)
140-
cursorCoords = cursorRange.getBoundingClientRect()
141-
} else {
142-
cursorCoords = hostCoords
143-
}
144-
} else {
145-
cursorCoords = hostCoords
146-
}
147-
} else {
148-
cursorCoords = this.range.nativeRange.getBoundingClientRect()
149-
}
150-
106+
const cursorCoords = getCursorBoundingClientRect(this.range.nativeRange, this.win)
151107
return hostCoords.top === cursorCoords.top
152108
}
153109

@@ -388,3 +344,13 @@ export default class Cursor {
388344
this.host.dispatchEvent(event)
389345
}
390346
}
347+
348+
function getCursorBoundingClientRect (range, win) {
349+
if (range.startContainer.nodeType !== elementNode) return range.getBoundingClientRect()
350+
const el = win.document.createElement('span')
351+
el.setAttribute('doc-editable', 'remove')
352+
range.insertNode(el)
353+
const coords = el.getBoundingClientRect()
354+
el.remove()
355+
return coords
356+
}

src/keyboard.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import rangy from 'rangy'
33
import {contenteditableSpanBug} from './feature-detection'
44
import * as nodeType from './node-type'
55
import eventable from './eventable'
6+
import {unwrapInternalNodes} from './content'
67

78
/**
89
* The Keyboard module defines an event API for key events.
@@ -36,14 +37,17 @@ export default class Keyboard {
3637
return this.notify(target, 'esc', event)
3738

3839
case this.key.backspace:
40+
unwrapInternalNodes(target, true)
3941
this.preventContenteditableBug(target, event)
4042
return this.notify(target, 'backspace', event)
4143

4244
case this.key.delete:
45+
unwrapInternalNodes(target, true)
4346
this.preventContenteditableBug(target, event)
4447
return this.notify(target, 'delete', event)
4548

4649
case this.key.enter:
50+
unwrapInternalNodes(target, true)
4751
if (event.shiftKey) return this.notify(target, 'shiftEnter', event)
4852
return this.notify(target, 'enter', event)
4953

0 commit comments

Comments
 (0)