Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions src/event/selection/resolveCaretPosition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {getUIValue} from '../../document'
import {hasOwnSelection} from '../../utils'
import {hasOwnSelection, isContentEditableFalse} from '../../utils'

export function resolveCaretPosition({
target,
Expand Down Expand Up @@ -34,9 +34,9 @@ function findNodeAtTextOffset(
offset: number | undefined,
isRoot = true,
): {
node: Node
offset: number
} {
node: Node
offset: number
} {
// When clicking after the content the browser behavior can be complicated:
// 1. If there is textContent after the last element child,
// the cursor is moved there.
Expand All @@ -46,17 +46,23 @@ function findNodeAtTextOffset(

let i = offset === undefined ? node.childNodes.length - 1 : 0
const step = offset === undefined ? -1 : +1
let minIndex =
isRoot && offset === undefined ? Math.max(node.childNodes.length - 1, 0) : 0

while (
offset === undefined
? i >= (isRoot ? Math.max(node.childNodes.length - 1, 0) : 0)
: i <= node.childNodes.length
) {
while (offset === undefined ? i >= minIndex : i <= node.childNodes.length) {
if (offset && i === node.childNodes.length) {
throw new Error('The given offset is out of bounds.')
}
const c = node.childNodes.item(i)

if (isContentEditableFalse(c)) {
if (offset === undefined && isRoot) {
minIndex = 0
}
i += step
continue
}

const text = String(c.textContent)
if (text.length) {
if (offset !== undefined && text.length < offset) {
Expand Down
12 changes: 12 additions & 0 deletions src/utils/edit/isContentEditable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ export function getContentEditable(node: Node): Element | null {
)
}

export function isContentEditableFalse(node: Node): node is HTMLElement {
if (node.nodeType !== 1) {
return false
}
const element = node as HTMLElement
// jsdom is not supporting contentEditable (see isContentEditable)
return (
element.contentEditable === 'false' ||
element.getAttribute('contenteditable') === 'false'
)
}

function getElement(node: Node) {
return node.nodeType === 1 ? (node as Element) : node.parentElement
}
67 changes: 60 additions & 7 deletions tests/pointer/select.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import {setup} from '#testHelpers'

// On an unprevented mousedown the browser moves the cursor to the closest character.
// As we have no layout, we are not able to determine the correct character.
// So we try an approximation:
// We treat any mousedown as if it happened on the space after the last character.

test('single mousedown moves cursor to the end', async () => {
test('single mousedown places (collapsed) cursor', async () => {
const {element, user} = setup<HTMLInputElement>(
`<input value="foo bar baz"/>`,
)

await user.pointer({keys: '[MouseLeft>]', target: element})

expect(element).toHaveFocus()
expect(element).toHaveProperty('selectionStart', 11)
expect(element).toHaveProperty('selectionStart', expect.any(Number))
expect(element.selectionStart).toEqual(element.selectionEnd)
})

test('move focus to closest focusable element', async () => {
Expand Down Expand Up @@ -339,6 +335,63 @@ test('`node` overrides the text offset approximation', async () => {
).rejects.toThrowError('out of bound')
})

describe('contenteditable="false" islands', () => {
test('skip islands at the start of content', async () => {
const {element, user} = setup(
`<div contenteditable><span contenteditable="false">island</span>editable</div>`,
)

await user.pointer({keys: '[MouseLeft>]', target: element})

expect(element).toHaveFocus()
expect(document.getSelection()).toHaveProperty(
'focusNode',
element.lastChild,
)
expect(document.getSelection()).toHaveProperty('focusOffset', 8) // "editable".length
})

test('skip islands at the end of content', async () => {
const {element, user} = setup(
`<div contenteditable>editable<span contenteditable="false">island</span></div>`,
)

await user.pointer({keys: '[MouseLeft>]', target: element})

expect(element).toHaveFocus()
expect(document.getSelection()).toHaveProperty(
'focusNode',
element.firstChild,
)
expect(document.getSelection()).toHaveProperty('focusOffset', 8) // "editable".length
})

test('drag selection skips contenteditable=false island', async () => {
const {element, user} = setup(
`<div contenteditable>before<span contenteditable="false">island</span>after</div>`,
)
const before = element.firstChild as Text
const island = element.querySelector(
'[contenteditable="false"]',
) as HTMLSpanElement

await user.pointer({
keys: '[MouseLeft>]',
target: element,
offset: 0,
})
await user.pointer({offset: 11})

const range = document.getSelection()?.getRangeAt(0)
expect(range).toHaveProperty('startContainer', before)
expect(range).toHaveProperty('startOffset', 0)
expect(range).toHaveProperty('endContainer', element.lastChild)
expect(range).toHaveProperty('endOffset', 5)
expect(island.contains(range?.startContainer ?? null)).toBe(false)
expect(island.contains(range?.endContainer ?? null)).toBe(false)
})
})

describe('focus control when clicking label', () => {
test('click event on label moves focus to control', async () => {
const {
Expand Down
14 changes: 13 additions & 1 deletion tests/utils/edit/isContentEditable.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {setup} from '#testHelpers'
import {isContentEditable} from '#src/utils'
import {isContentEditable, isContentEditableFalse} from '#src/utils'

test('report if element is contenteditable', async () => {
const {elements} = setup(
Expand All @@ -11,3 +11,15 @@ test('report if element is contenteditable', async () => {
expect(isContentEditable(elements[2])).toBe(true)
expect(isContentEditable(elements[3])).toBe(true)
})

test('report if element is contenteditable=false', () => {
const {elements} = setup(
`<div contenteditable="false"></div><div contenteditable="true"></div><div contenteditable><span contenteditable="false"></span><span></span></div>`,
)

expect(isContentEditableFalse(elements[0])).toBe(true)
expect(isContentEditableFalse(elements[1])).toBe(false)
expect(isContentEditableFalse(elements[2].firstChild as Node)).toBe(true)
expect(isContentEditableFalse(elements[2].lastChild as Node)).toBe(false)
expect(isContentEditableFalse(document.createTextNode('x'))).toBe(false)
})