diff --git a/src/event/selection/resolveCaretPosition.ts b/src/event/selection/resolveCaretPosition.ts index 03a6376e..35981d0e 100644 --- a/src/event/selection/resolveCaretPosition.ts +++ b/src/event/selection/resolveCaretPosition.ts @@ -1,5 +1,5 @@ import {getUIValue} from '../../document' -import {hasOwnSelection} from '../../utils' +import {hasOwnSelection, isContentEditableFalse} from '../../utils' export function resolveCaretPosition({ target, @@ -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. @@ -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) { diff --git a/src/utils/edit/isContentEditable.ts b/src/utils/edit/isContentEditable.ts index e44298be..339a2aeb 100644 --- a/src/utils/edit/isContentEditable.ts +++ b/src/utils/edit/isContentEditable.ts @@ -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 } diff --git a/tests/pointer/select.ts b/tests/pointer/select.ts index 3b54070d..3af21996 100644 --- a/tests/pointer/select.ts +++ b/tests/pointer/select.ts @@ -1,11 +1,6 @@ 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( ``, ) @@ -13,7 +8,8 @@ test('single mousedown moves cursor to the end', async () => { 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 () => { @@ -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( + `
islandeditable
`, + ) + + 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( + `
editableisland
`, + ) + + 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( + `
beforeislandafter
`, + ) + 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 { diff --git a/tests/utils/edit/isContentEditable.ts b/tests/utils/edit/isContentEditable.ts index aab44bcd..9d63c1b1 100644 --- a/tests/utils/edit/isContentEditable.ts +++ b/tests/utils/edit/isContentEditable.ts @@ -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( @@ -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( + `
`, + ) + + 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) +})