Skip to content

Commit b83b259

Browse files
authored
feat(keyboard): move cursor and delete content in contenteditable (#822)
1 parent ef2f4e5 commit b83b259

File tree

7 files changed

+620
-48
lines changed

7 files changed

+620
-48
lines changed

src/keyboard/plugins/arrow.ts

+42-16
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,54 @@
44
*/
55

66
import {behaviorPlugin} from '../types'
7-
import {isElementType, setSelection} from '../../utils'
7+
import {getNextCursorPosition, hasOwnSelection, setSelection} from '../../utils'
88
import {getUISelection} from '../../document'
99

1010
export const keydownBehavior: behaviorPlugin[] = [
1111
{
12-
// TODO: implement for contentEditable
13-
matches: (keyDef, element) =>
14-
(keyDef.key === 'ArrowLeft' || keyDef.key === 'ArrowRight') &&
15-
isElementType(element, ['input', 'textarea']),
12+
matches: keyDef =>
13+
keyDef.key === 'ArrowLeft' || keyDef.key === 'ArrowRight',
1614
handle: (keyDef, element) => {
17-
const selection = getUISelection(element as HTMLInputElement)
15+
// TODO: implement shift
1816

19-
// TODO: implement shift/ctrl
20-
setSelection({
21-
focusNode: element,
22-
focusOffset:
23-
selection.startOffset === selection.endOffset
24-
? selection.focusOffset + (keyDef.key === 'ArrowLeft' ? -1 : 1)
25-
: keyDef.key === 'ArrowLeft'
26-
? selection.startOffset
27-
: selection.endOffset,
28-
})
17+
if (hasOwnSelection(element)) {
18+
const selection = getUISelection(element as HTMLInputElement)
19+
20+
setSelection({
21+
focusNode: element,
22+
focusOffset:
23+
selection.startOffset === selection.endOffset
24+
? selection.focusOffset + (keyDef.key === 'ArrowLeft' ? -1 : 1)
25+
: keyDef.key === 'ArrowLeft'
26+
? selection.startOffset
27+
: selection.endOffset,
28+
})
29+
} else {
30+
const selection = element.ownerDocument.getSelection()
31+
32+
/* istanbul ignore if */
33+
if (!selection) {
34+
return
35+
}
36+
37+
if (selection.isCollapsed) {
38+
const nextPosition = getNextCursorPosition(
39+
selection.focusNode as Node,
40+
selection.focusOffset,
41+
keyDef.key === 'ArrowLeft' ? -1 : 1,
42+
)
43+
if (nextPosition) {
44+
setSelection({
45+
focusNode: nextPosition.node,
46+
focusOffset: nextPosition.offset,
47+
})
48+
}
49+
} else {
50+
selection[
51+
keyDef.key === 'ArrowLeft' ? 'collapseToStart' : 'collapseToEnd'
52+
]()
53+
}
54+
}
2955
},
3056
},
3157
]

src/utils/edit/prepareInput.ts

+26-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {fireEvent} from '@testing-library/dom'
22
import {calculateNewValue, editInputElement, getInputRange} from '../../utils'
3+
import {getNextCursorPosition} from '../focus/cursor'
34

45
export function prepareInput(
56
data: string,
@@ -16,11 +17,34 @@ export function prepareInput(
1617
if ('startContainer' in inputRange) {
1718
return {
1819
commit: () => {
19-
const del = !inputRange.collapsed
20+
let del: boolean = false
2021

21-
if (del) {
22+
if (!inputRange.collapsed) {
23+
del = true
2224
inputRange.deleteContents()
25+
} else if (
26+
['deleteContentBackward', 'deleteContentForward'].includes(inputType)
27+
) {
28+
const nextPosition = getNextCursorPosition(
29+
inputRange.startContainer,
30+
inputRange.startOffset,
31+
inputType === 'deleteContentBackward' ? -1 : 1,
32+
inputType,
33+
)
34+
if (nextPosition) {
35+
del = true
36+
const delRange = inputRange.cloneRange()
37+
if (
38+
delRange.comparePoint(nextPosition.node, nextPosition.offset) < 0
39+
) {
40+
delRange.setStart(nextPosition.node, nextPosition.offset)
41+
} else {
42+
delRange.setEnd(nextPosition.node, nextPosition.offset)
43+
}
44+
delRange.deleteContents()
45+
}
2346
}
47+
2448
if (data) {
2549
if (inputRange.endContainer.nodeType === 3) {
2650
const offset = inputRange.endOffset

src/utils/focus/cursor.ts

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import {isContentEditable, isElementType} from '..'
2+
3+
declare global {
4+
interface Text {
5+
nodeValue: string
6+
}
7+
}
8+
9+
export function getNextCursorPosition(
10+
node: Node,
11+
offset: number,
12+
direction: -1 | 1,
13+
inputType?: string,
14+
):
15+
| {
16+
node: Node
17+
offset: number
18+
}
19+
| undefined {
20+
// The behavior at text node zero offset is inconsistent.
21+
// When walking backwards:
22+
// Firefox always moves to zero offset and jumps over last offset.
23+
// Chrome jumps over zero offset per default but over last offset when Shift is pressed.
24+
// The cursor always moves to zero offset if the focus area (contenteditable or body) ends there.
25+
// When walking foward both ignore zero offset.
26+
// When walking over input elements the cursor moves before or after that element.
27+
// When walking over line breaks the cursor moves inside any following text node.
28+
29+
if (
30+
isTextNode(node) &&
31+
offset + direction >= 0 &&
32+
offset + direction <= node.nodeValue.length
33+
) {
34+
return {node, offset: offset + direction}
35+
}
36+
const nextNode = getNextCharacterContentNode(node, offset, direction)
37+
if (nextNode) {
38+
if (isTextNode(nextNode)) {
39+
return {
40+
node: nextNode,
41+
offset:
42+
direction > 0
43+
? Math.min(1, nextNode.nodeValue.length)
44+
: Math.max(nextNode.nodeValue.length - 1, 0),
45+
}
46+
} else if (isElementType(nextNode, 'br')) {
47+
const nextPlusOne = getNextCharacterContentNode(
48+
nextNode,
49+
undefined,
50+
direction,
51+
)
52+
if (!nextPlusOne) {
53+
// The behavior when there is no possible cursor position beyond the line break is inconsistent.
54+
// In Chrome outside of contenteditable moving before a leading line break is possible.
55+
// A leading line break can still be removed per deleteContentBackward.
56+
// A trailing line break on the other hand is not removed by deleteContentForward.
57+
if (direction < 0 && inputType === 'deleteContentBackward') {
58+
return {
59+
node: nextNode.parentNode as Node,
60+
offset: getOffset(nextNode),
61+
}
62+
}
63+
return undefined
64+
} else if (isTextNode(nextPlusOne)) {
65+
return {
66+
node: nextPlusOne,
67+
offset: direction > 0 ? 0 : nextPlusOne.nodeValue.length,
68+
}
69+
} else if (direction < 0 && isElementType(nextPlusOne, 'br')) {
70+
return {
71+
node: nextNode.parentNode as Node,
72+
offset: getOffset(nextNode),
73+
}
74+
} else {
75+
return {
76+
node: nextPlusOne.parentNode as Node,
77+
offset: getOffset(nextPlusOne) + (direction > 0 ? 0 : 1),
78+
}
79+
}
80+
} else {
81+
return {
82+
node: nextNode.parentNode as Node,
83+
offset: getOffset(nextNode) + (direction > 0 ? 1 : 0),
84+
}
85+
}
86+
}
87+
}
88+
89+
function getNextCharacterContentNode(
90+
node: Node,
91+
offset: number | undefined,
92+
direction: -1 | 1,
93+
) {
94+
const nextOffset = Number(offset) + (direction < 0 ? -1 : 0)
95+
if (
96+
offset !== undefined &&
97+
isElement(node) &&
98+
nextOffset >= 0 &&
99+
nextOffset < node.children.length
100+
) {
101+
node = node.children[nextOffset]
102+
}
103+
return walkNodes(
104+
node,
105+
direction === 1 ? 'next' : 'previous',
106+
isTreatedAsCharacterContent,
107+
)
108+
}
109+
110+
function isTreatedAsCharacterContent(node: Node): node is Text | HTMLElement {
111+
if (isTextNode(node)) {
112+
return true
113+
}
114+
if (isElement(node)) {
115+
if (isElementType(node, ['input', 'textarea'])) {
116+
return (node as HTMLInputElement).type !== 'hidden'
117+
} else if (isElementType(node, 'br')) {
118+
return true
119+
}
120+
}
121+
return false
122+
}
123+
124+
function getOffset(node: Node) {
125+
let i = 0
126+
while (node.previousSibling) {
127+
i++
128+
node = node.previousSibling
129+
}
130+
return i
131+
}
132+
133+
function isElement(node: Node): node is Element {
134+
return node.nodeType === 1
135+
}
136+
137+
function isTextNode(node: Node): node is Text {
138+
return node.nodeType === 3
139+
}
140+
141+
function walkNodes<T extends Node>(
142+
node: Node,
143+
direction: 'previous' | 'next',
144+
callback: (node: Node) => node is T,
145+
) {
146+
for (;;) {
147+
const sibling = node[`${direction}Sibling`]
148+
if (sibling) {
149+
node = getDescendant(sibling, direction === 'next' ? 'first' : 'last')
150+
if (callback(node)) {
151+
return node
152+
}
153+
} else if (
154+
node.parentNode &&
155+
(!isElement(node.parentNode) ||
156+
(!isContentEditable(node.parentNode) &&
157+
node.parentNode !== node.ownerDocument?.body))
158+
) {
159+
node = node.parentNode
160+
} else {
161+
break
162+
}
163+
}
164+
}
165+
166+
function getDescendant(node: Node, direction: 'first' | 'last') {
167+
while (node.hasChildNodes()) {
168+
node = node[`${direction}Child`] as ChildNode
169+
}
170+
return node
171+
}

src/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export * from './edit/setFiles'
1919

2020
export * from './focus/blur'
2121
export * from './focus/copySelection'
22+
export * from './focus/cursor'
2223
export * from './focus/focus'
2324
export * from './focus/getActiveElement'
2425
export * from './focus/getTabDestination'

0 commit comments

Comments
 (0)