From 2c4cef54d38cde69d65f7649ee7c2285be2425b7 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 9 Feb 2021 13:23:09 +0000 Subject: [PATCH] Further revisions to text node offsets + selection --- .../src/__tests__/OutlineSelection-test.js | 207 ++++++++++++++++-- .../src/useOutlineInputEvents.js | 88 +++++--- packages/outline/src/OutlineReconciler.js | 9 +- packages/outline/src/OutlineSelection.js | 25 +-- scripts/build.js | 2 + 5 files changed, 257 insertions(+), 74 deletions(-) diff --git a/packages/outline-react/src/__tests__/OutlineSelection-test.js b/packages/outline-react/src/__tests__/OutlineSelection-test.js index 074260244..9f646bc44 100644 --- a/packages/outline-react/src/__tests__/OutlineSelection-test.js +++ b/packages/outline-react/src/__tests__/OutlineSelection-test.js @@ -146,15 +146,7 @@ function getNodeFromPath(path, editorElement) { return node; } -function setNativeSelection( - editorElement, - anchorPath, - anchorOffset, - focusPath, - focusOffset, -) { - const anchorNode = getNodeFromPath(anchorPath, editorElement); - const focusNode = getNodeFromPath(focusPath, editorElement); +function setNativeSelection(anchorNode, anchorOffset, focusNode, focusOffset) { const domSelection = window.getSelection(); const range = document.createRange(); range.setStart(anchorNode, anchorOffset); @@ -164,29 +156,176 @@ function setNativeSelection( document.dispatchEvent(new Event('selectionchange')); } -function moveNativeSelectionForward() { - const domSelection = window.getSelection(); - const {anchorNode} = domSelection; +function setNativeSelectionWithPaths( + editorElement, + anchorPath, + anchorOffset, + focusPath, + focusOffset, +) { + const anchorNode = getNodeFromPath(anchorPath, editorElement); + const focusNode = getNodeFromPath(focusPath, editorElement); + setNativeSelection(anchorNode, anchorOffset, focusNode, focusOffset); +} + +function getLastTextNode(startingNode) { + let node = startingNode; + + mainLoop: while (node !== null) { + if (node !== startingNode && node.nodeType === 3) { + return node; + } + const child = node.lastChild; + if (child !== null) { + node = child; + continue; + } + const previousSibling = node.previousSibling; + if (previousSibling !== null) { + node = previousSibling; + continue; + } + let parent = node.parentNode; + while (parent !== null) { + const parentSibling = parent.previousSibling; + if (parentSibling !== null) { + node = parentSibling; + continue mainLoop; + } + parent = parent.parentNode; + } + } + + return null; +} + +function getNextTextNode(startingNode) { + let node = startingNode; + + mainLoop: while (node !== null) { + if (node !== startingNode && node.nodeType === 3) { + return node; + } + const child = node.firstChild; + if (child !== null) { + node = child; + continue; + } + const nextSibling = node.nextSibling; + if (nextSibling !== null) { + node = nextSibling; + continue; + } + let parent = node.parentNode; + while (parent !== null) { + const parentSibling = parent.nextSibling; + if (parentSibling !== null) { + node = parentSibling; + continue mainLoop; + } + parent = parent.parentNode; + } + } + + return null; +} + +function moveNativeSelectionBackward() { + const domSelection = window.getSelection(); + const {anchorNode, anchorOffset} = domSelection; - // TODO move native selection one character using offset if (domSelection.isCollapsed) { const target = anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode; const keyDownEvent = new KeyboardEvent('keydown', { bubbles: true, + cancelable: true, + key: 'ArrowLeft', + }); + target.dispatchEvent(keyDownEvent); + if (!keyDownEvent.defaultPrevented) { + if (anchorNode.nodeType === 3) { + if (anchorOffset === 0) { + const lastTextNode = getLastTextNode(anchorNode); + + if (lastTextNode === null) { + throw new Error('moveNativeSelectionBackward: TODO'); + } else { + const textLength = lastTextNode.nodeValue.length; + setNativeSelection( + lastTextNode, + textLength, + lastTextNode, + textLength, + ); + } + } else { + setNativeSelection( + anchorNode, + anchorOffset - 1, + anchorNode, + anchorOffset - 1, + ); + } + } else { + throw new Error('moveNativeSelectionBackward: TODO'); + } + } + const keyUpEvent = new KeyboardEvent('keyup', { + bubbles: true, + cancelable: true, + key: 'ArrowLeft', + }); + target.dispatchEvent(keyUpEvent); + } else { + throw new Error('moveNativeSelectionBackward: TODO'); + } +} + +function moveNativeSelectionForward() { + const domSelection = window.getSelection(); + const {anchorNode, anchorOffset} = domSelection; + + if (domSelection.isCollapsed) { + const target = + anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode; + const keyDownEvent = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, key: 'ArrowRight', }); target.dispatchEvent(keyDownEvent); if (!keyDownEvent.defaultPrevented) { - // TODO + if (anchorNode.nodeType === 3) { + const text = anchorNode.nodeValue; + if (text.length === anchorOffset) { + const nextTextNode = getNextTextNode(anchorNode); + + if (nextTextNode === null) { + throw new Error('moveNativeSelectionForward: TODO'); + } else { + setNativeSelection(nextTextNode, 0, nextTextNode, 0); + } + } else { + setNativeSelection( + anchorNode, + anchorOffset + 1, + anchorNode, + anchorOffset + 1, + ); + } + } else { + throw new Error('moveNativeSelectionForward: TODO'); + } } const keyUpEvent = new KeyboardEvent('keyup', { bubbles: true, + cancelable: true, key: 'ArrowRight', }); target.dispatchEvent(keyUpEvent); } else { - // TODO + throw new Error('moveNativeSelectionForward: TODO'); } } @@ -226,7 +365,7 @@ function applySelectionInputs(inputs, update, editor) { break; } case 'move_native_selection': { - setNativeSelection( + setNativeSelectionWithPaths( editorElement, input.anchorPath, input.anchorOffset, @@ -328,7 +467,7 @@ describe('OutlineSelection tests', () => { }); ref.current.focus(); // Focus first element - setNativeSelection(ref.current, [0, 0, 0], 0, [0, 0, 0], 0); + setNativeSelectionWithPaths(ref.current, [0, 0, 0], 0, [0, 0, 0], 0); } function update(callback) { @@ -355,20 +494,46 @@ describe('OutlineSelection tests', () => { expect(acutalSelection.focusOffset).toBe(expectedSelection.focusOffset); } - test('Should correctly handle empty paragraph blocks', () => { + test('Should correctly handle empty paragraph blocks (left-arrow)', () => { // Insert initial block update((view) => { const paragraph = ParagraphNode.createParagraphNode(); - const text = Outline.createTextNode('123'); + const text = Outline.createTextNode(''); paragraph.append(text); view.getRoot().append(paragraph); }); // Focus first element - setNativeSelection(ref.current, [0, 0, 0], 0, [0, 0, 0], 0); + setNativeSelectionWithPaths(ref.current, [1, 0, 0], 0, [1, 0, 0], 0); expect(sanitizeHTML(container.innerHTML)).toBe( - '


123

', + '



', + ); + + moveNativeSelectionBackward(); + + assertSelection(ref.current, { + anchorPath: [0, 0, 0], + anchorOffset: 0, + focusPath: [0, 0, 0], + focusOffset: 0, + }); + }); + + test('Should correctly handle empty paragraph blocks (right-arrow)', () => { + // Insert initial block + update((view) => { + const paragraph = ParagraphNode.createParagraphNode(); + const text = Outline.createTextNode(''); + paragraph.append(text); + view.getRoot().append(paragraph); + }); + + // Focus first element + setNativeSelectionWithPaths(ref.current, [0, 0, 0], 0, [0, 0, 0], 0); + + expect(sanitizeHTML(container.innerHTML)).toBe( + '



', ); moveNativeSelectionForward(); diff --git a/packages/outline-react/src/useOutlineInputEvents.js b/packages/outline-react/src/useOutlineInputEvents.js index b0abb5269..0badfc248 100644 --- a/packages/outline-react/src/useOutlineInputEvents.js +++ b/packages/outline-react/src/useOutlineInputEvents.js @@ -271,48 +271,72 @@ function onKeyDown( if (isLeftArrow || isRightArrow || isDelete || isBackspace) { const anchorNode = selection.getAnchorNode(); const offset = selection.anchorOffset; + const textContent = anchorNode.getTextContent(); if (isLeftArrow || isBackspace) { - const prevSibling = anchorNode.getPreviousSibling(); - if (prevSibling !== null) { - if ( - offset === 0 && - (prevSibling.isImmutable() || prevSibling.isSegmented()) - ) { - if (isLeftArrow) { - announceNode(prevSibling); - } else if (!isModifierActive(event)) { - deleteBackward(selection); + const selectionAtStart = offset === 0; + + if (selectionAtStart) { + const prevSibling = anchorNode.getPreviousSibling(); + + if (prevSibling === null) { + // On empty text nodes, we always move native DOM selection + // to offset 1. Although it's at 1, we really mean that it + // is at 0 in our model. So when we encounter a left arrow + // we need to move selection to the previous block if + // we have no previous sibling. + if (isLeftArrow && textContent === '') { + const parent = anchorNode.getParentOrThrow(); + const parentSibling = parent.getPreviousSibling(); + + if (parentSibling instanceof BlockNode) { + const lastChild = parentSibling.getLastChild(); + if (lastChild instanceof TextNode) { + lastChild.select(); + shouldPreventDefault = true; + } + } + } + } else { + let targetPrevSibling = prevSibling; + if (prevSibling.isImmutable() || prevSibling.isSegmented()) { + if (isLeftArrow) { + announceNode(prevSibling); + targetPrevSibling = prevSibling.getPreviousSibling(); + } else if (!isModifierActive(event)) { + deleteBackward(selection); + shouldPreventDefault = true; + } + } + // Due to empty text nodes having an offset of 1, we need to + // account for this and move selection accordingly when right + // arrow is pressed. + if (isLeftArrow && targetPrevSibling instanceof TextNode) { shouldPreventDefault = true; + if (targetPrevSibling === prevSibling) { + const prevSibliongTextContent = targetPrevSibling.getTextContent(); + // We adjust the offset by 1, as we will have have moved between + // two adjacent nodes. + const endOffset = prevSibliongTextContent.length - 1; + targetPrevSibling.select(endOffset, endOffset); + } else { + // We don't adjust offset as the nodes are not adjacent (the target + // isn't the same as the prevSibling). + targetPrevSibling.select(); + } } } } } else { - const nextSibling = anchorNode.getNextSibling(); - const textContent = anchorNode.getTextContent(); - const selectionAtEnd = textContent.length === offset; - const selectionJustBeforeEnd = textContent.length === offset + 1; + const textContentLength = textContent.length; + const selectionAtEnd = textContentLength === offset; + const selectionJustBeforeEnd = textContentLength === offset + 1; - if (nextSibling === null) { - // When we are on an empty text node, native right arrow - // doesn't work correctly in some browsers. So to ensure it - // does work correctly, we can force it and prevent the native - // event so that our fix is always used. - if (isRightArrow && textContent === '' && selectionAtEnd) { - const parent = anchorNode.getParentOrThrow(); - const parentSibling = parent.getNextSibling(); + if (selectionAtEnd || selectionJustBeforeEnd) { + const nextSibling = anchorNode.getNextSibling(); - if (parentSibling instanceof BlockNode) { - const firstChild = parentSibling.getFirstChild(); - if (firstChild instanceof TextNode) { - firstChild.select(0, 0); - shouldPreventDefault = true; - } - } - } - } else { if ( - (selectionAtEnd || selectionJustBeforeEnd) && + nextSibling !== null && (nextSibling.isImmutable() || nextSibling.isSegmented()) ) { if (isRightArrow) { diff --git a/packages/outline/src/OutlineReconciler.js b/packages/outline/src/OutlineReconciler.js index 86ba62467..d71d3d457 100644 --- a/packages/outline/src/OutlineReconciler.js +++ b/packages/outline/src/OutlineReconciler.js @@ -536,13 +536,8 @@ function reconcileSelection(selection: Selection, editor: OutlineEditor): void { // selection and text entry works as expected, it also // means we need to adjust the offset to ensure native // selection works correctly and doesn't act buggy. - if (anchorDOM.previousSibling === null) { - anchorOffset = 1; - focusOffset = 1; - } else if (anchorDOM.nextSibling === null) { - anchorOffset = 0; - focusOffset = 0; - } + anchorOffset = 1; + focusOffset = 1; } domSelection.setBaseAndExtent( getTextNodeFromElement(anchorDOM), diff --git a/packages/outline/src/OutlineSelection.js b/packages/outline/src/OutlineSelection.js index ef165d024..535b3e03a 100644 --- a/packages/outline/src/OutlineSelection.js +++ b/packages/outline/src/OutlineSelection.js @@ -288,20 +288,17 @@ export function createSelection( // we need to adjust offsets to 0 when the text is // really empty. if (anchorNode.text === '') { - if (!editor.isComposing() && !editor.isPointerDown()) { - const anchorElement = editor.getElementByKey(anchorKey); - const focusElement = editor.getElementByKey(focusKey); - if (anchorNode === focusNode) { - if (anchorElement.previousSibling === null) { - if (anchorOffset !== 1) { - isDirty = true; - } - } else if (focusElement.nextSibling === null) { - if (anchorOffset !== 0) { - isDirty = true; - } - } - } + // When dealing with empty text nodes, we always + // render a special empty space character, and set + // the native DOM selection to offset 1 so that + // text entry works as expected. + if ( + anchorNode === focusNode && + anchorOffset !== 1 && + !editor.isComposing() && + !editor.isPointerDown() + ) { + isDirty = true; } anchorOffset = 0; } diff --git a/scripts/build.js b/scripts/build.js index ed4e32af1..b03f10632 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -36,6 +36,8 @@ const closureOptions = { if (isClean) { fs.removeSync(path.resolve('./packages/outline/dist')); + fs.removeSync(path.resolve('./packages/outline-react/dist')); + fs.removeSync(path.resolve('./packages/outline-extensions/dist')); } const wwwMappings = {