Skip to content

Commit

Permalink
feat(keyboard): move cursor and delete content in contenteditable (#822)
Browse files Browse the repository at this point in the history
  • Loading branch information
ph-fritsche committed Jan 1, 2022
1 parent ef2f4e5 commit b83b259
Show file tree
Hide file tree
Showing 7 changed files with 620 additions and 48 deletions.
58 changes: 42 additions & 16 deletions src/keyboard/plugins/arrow.ts
Expand Up @@ -4,28 +4,54 @@
*/

import {behaviorPlugin} from '../types'
import {isElementType, setSelection} from '../../utils'
import {getNextCursorPosition, hasOwnSelection, setSelection} from '../../utils'
import {getUISelection} from '../../document'

export const keydownBehavior: behaviorPlugin[] = [
{
// TODO: implement for contentEditable
matches: (keyDef, element) =>
(keyDef.key === 'ArrowLeft' || keyDef.key === 'ArrowRight') &&
isElementType(element, ['input', 'textarea']),
matches: keyDef =>
keyDef.key === 'ArrowLeft' || keyDef.key === 'ArrowRight',
handle: (keyDef, element) => {
const selection = getUISelection(element as HTMLInputElement)
// TODO: implement shift

// TODO: implement shift/ctrl
setSelection({
focusNode: element,
focusOffset:
selection.startOffset === selection.endOffset
? selection.focusOffset + (keyDef.key === 'ArrowLeft' ? -1 : 1)
: keyDef.key === 'ArrowLeft'
? selection.startOffset
: selection.endOffset,
})
if (hasOwnSelection(element)) {
const selection = getUISelection(element as HTMLInputElement)

setSelection({
focusNode: element,
focusOffset:
selection.startOffset === selection.endOffset
? selection.focusOffset + (keyDef.key === 'ArrowLeft' ? -1 : 1)
: keyDef.key === 'ArrowLeft'
? selection.startOffset
: selection.endOffset,
})
} else {
const selection = element.ownerDocument.getSelection()

/* istanbul ignore if */
if (!selection) {
return
}

if (selection.isCollapsed) {
const nextPosition = getNextCursorPosition(
selection.focusNode as Node,
selection.focusOffset,
keyDef.key === 'ArrowLeft' ? -1 : 1,
)
if (nextPosition) {
setSelection({
focusNode: nextPosition.node,
focusOffset: nextPosition.offset,
})
}
} else {
selection[
keyDef.key === 'ArrowLeft' ? 'collapseToStart' : 'collapseToEnd'
]()
}
}
},
},
]
28 changes: 26 additions & 2 deletions src/utils/edit/prepareInput.ts
@@ -1,5 +1,6 @@
import {fireEvent} from '@testing-library/dom'
import {calculateNewValue, editInputElement, getInputRange} from '../../utils'
import {getNextCursorPosition} from '../focus/cursor'

export function prepareInput(
data: string,
Expand All @@ -16,11 +17,34 @@ export function prepareInput(
if ('startContainer' in inputRange) {
return {
commit: () => {
const del = !inputRange.collapsed
let del: boolean = false

if (del) {
if (!inputRange.collapsed) {
del = true
inputRange.deleteContents()
} else if (
['deleteContentBackward', 'deleteContentForward'].includes(inputType)
) {
const nextPosition = getNextCursorPosition(
inputRange.startContainer,
inputRange.startOffset,
inputType === 'deleteContentBackward' ? -1 : 1,
inputType,
)
if (nextPosition) {
del = true
const delRange = inputRange.cloneRange()
if (
delRange.comparePoint(nextPosition.node, nextPosition.offset) < 0
) {
delRange.setStart(nextPosition.node, nextPosition.offset)
} else {
delRange.setEnd(nextPosition.node, nextPosition.offset)
}
delRange.deleteContents()
}
}

if (data) {
if (inputRange.endContainer.nodeType === 3) {
const offset = inputRange.endOffset
Expand Down
171 changes: 171 additions & 0 deletions src/utils/focus/cursor.ts
@@ -0,0 +1,171 @@
import {isContentEditable, isElementType} from '..'

declare global {
interface Text {
nodeValue: string
}
}

export function getNextCursorPosition(
node: Node,
offset: number,
direction: -1 | 1,
inputType?: string,
):
| {
node: Node
offset: number
}
| undefined {
// The behavior at text node zero offset is inconsistent.
// When walking backwards:
// Firefox always moves to zero offset and jumps over last offset.
// Chrome jumps over zero offset per default but over last offset when Shift is pressed.
// The cursor always moves to zero offset if the focus area (contenteditable or body) ends there.
// When walking foward both ignore zero offset.
// When walking over input elements the cursor moves before or after that element.
// When walking over line breaks the cursor moves inside any following text node.

if (
isTextNode(node) &&
offset + direction >= 0 &&
offset + direction <= node.nodeValue.length
) {
return {node, offset: offset + direction}
}
const nextNode = getNextCharacterContentNode(node, offset, direction)
if (nextNode) {
if (isTextNode(nextNode)) {
return {
node: nextNode,
offset:
direction > 0
? Math.min(1, nextNode.nodeValue.length)
: Math.max(nextNode.nodeValue.length - 1, 0),
}
} else if (isElementType(nextNode, 'br')) {
const nextPlusOne = getNextCharacterContentNode(
nextNode,
undefined,
direction,
)
if (!nextPlusOne) {
// The behavior when there is no possible cursor position beyond the line break is inconsistent.
// In Chrome outside of contenteditable moving before a leading line break is possible.
// A leading line break can still be removed per deleteContentBackward.
// A trailing line break on the other hand is not removed by deleteContentForward.
if (direction < 0 && inputType === 'deleteContentBackward') {
return {
node: nextNode.parentNode as Node,
offset: getOffset(nextNode),
}
}
return undefined
} else if (isTextNode(nextPlusOne)) {
return {
node: nextPlusOne,
offset: direction > 0 ? 0 : nextPlusOne.nodeValue.length,
}
} else if (direction < 0 && isElementType(nextPlusOne, 'br')) {
return {
node: nextNode.parentNode as Node,
offset: getOffset(nextNode),
}
} else {
return {
node: nextPlusOne.parentNode as Node,
offset: getOffset(nextPlusOne) + (direction > 0 ? 0 : 1),
}
}
} else {
return {
node: nextNode.parentNode as Node,
offset: getOffset(nextNode) + (direction > 0 ? 1 : 0),
}
}
}
}

function getNextCharacterContentNode(
node: Node,
offset: number | undefined,
direction: -1 | 1,
) {
const nextOffset = Number(offset) + (direction < 0 ? -1 : 0)
if (
offset !== undefined &&
isElement(node) &&
nextOffset >= 0 &&
nextOffset < node.children.length
) {
node = node.children[nextOffset]
}
return walkNodes(
node,
direction === 1 ? 'next' : 'previous',
isTreatedAsCharacterContent,
)
}

function isTreatedAsCharacterContent(node: Node): node is Text | HTMLElement {
if (isTextNode(node)) {
return true
}
if (isElement(node)) {
if (isElementType(node, ['input', 'textarea'])) {
return (node as HTMLInputElement).type !== 'hidden'
} else if (isElementType(node, 'br')) {
return true
}
}
return false
}

function getOffset(node: Node) {
let i = 0
while (node.previousSibling) {
i++
node = node.previousSibling
}
return i
}

function isElement(node: Node): node is Element {
return node.nodeType === 1
}

function isTextNode(node: Node): node is Text {
return node.nodeType === 3
}

function walkNodes<T extends Node>(
node: Node,
direction: 'previous' | 'next',
callback: (node: Node) => node is T,
) {
for (;;) {
const sibling = node[`${direction}Sibling`]
if (sibling) {
node = getDescendant(sibling, direction === 'next' ? 'first' : 'last')
if (callback(node)) {
return node
}
} else if (
node.parentNode &&
(!isElement(node.parentNode) ||
(!isContentEditable(node.parentNode) &&
node.parentNode !== node.ownerDocument?.body))
) {
node = node.parentNode
} else {
break
}
}
}

function getDescendant(node: Node, direction: 'first' | 'last') {
while (node.hasChildNodes()) {
node = node[`${direction}Child`] as ChildNode
}
return node
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Expand Up @@ -19,6 +19,7 @@ export * from './edit/setFiles'

export * from './focus/blur'
export * from './focus/copySelection'
export * from './focus/cursor'
export * from './focus/focus'
export * from './focus/getActiveElement'
export * from './focus/getTabDestination'
Expand Down

0 comments on commit b83b259

Please sign in to comment.