Further revisions to text node offsets + selection

This commit is contained in:
Dominic Gannaway
2021-02-09 13:23:09 +00:00
committed by acywatson
parent 4ef3f5dc0c
commit 2c4cef54d3
5 changed files with 257 additions and 74 deletions

View File

@ -146,15 +146,7 @@ function getNodeFromPath(path, editorElement) {
return node; return node;
} }
function setNativeSelection( function setNativeSelection(anchorNode, anchorOffset, focusNode, focusOffset) {
editorElement,
anchorPath,
anchorOffset,
focusPath,
focusOffset,
) {
const anchorNode = getNodeFromPath(anchorPath, editorElement);
const focusNode = getNodeFromPath(focusPath, editorElement);
const domSelection = window.getSelection(); const domSelection = window.getSelection();
const range = document.createRange(); const range = document.createRange();
range.setStart(anchorNode, anchorOffset); range.setStart(anchorNode, anchorOffset);
@ -164,29 +156,176 @@ function setNativeSelection(
document.dispatchEvent(new Event('selectionchange')); document.dispatchEvent(new Event('selectionchange'));
} }
function moveNativeSelectionForward() { function setNativeSelectionWithPaths(
const domSelection = window.getSelection(); editorElement,
const {anchorNode} = domSelection; 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) { if (domSelection.isCollapsed) {
const target = const target =
anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode; anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentNode;
const keyDownEvent = new KeyboardEvent('keydown', { const keyDownEvent = new KeyboardEvent('keydown', {
bubbles: true, 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', key: 'ArrowRight',
}); });
target.dispatchEvent(keyDownEvent); target.dispatchEvent(keyDownEvent);
if (!keyDownEvent.defaultPrevented) { 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', { const keyUpEvent = new KeyboardEvent('keyup', {
bubbles: true, bubbles: true,
cancelable: true,
key: 'ArrowRight', key: 'ArrowRight',
}); });
target.dispatchEvent(keyUpEvent); target.dispatchEvent(keyUpEvent);
} else { } else {
// TODO throw new Error('moveNativeSelectionForward: TODO');
} }
} }
@ -226,7 +365,7 @@ function applySelectionInputs(inputs, update, editor) {
break; break;
} }
case 'move_native_selection': { case 'move_native_selection': {
setNativeSelection( setNativeSelectionWithPaths(
editorElement, editorElement,
input.anchorPath, input.anchorPath,
input.anchorOffset, input.anchorOffset,
@ -328,7 +467,7 @@ describe('OutlineSelection tests', () => {
}); });
ref.current.focus(); ref.current.focus();
// Focus first element // 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) { function update(callback) {
@ -355,20 +494,46 @@ describe('OutlineSelection tests', () => {
expect(acutalSelection.focusOffset).toBe(expectedSelection.focusOffset); 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 // Insert initial block
update((view) => { update((view) => {
const paragraph = ParagraphNode.createParagraphNode(); const paragraph = ParagraphNode.createParagraphNode();
const text = Outline.createTextNode('123'); const text = Outline.createTextNode('');
paragraph.append(text); paragraph.append(text);
view.getRoot().append(paragraph); view.getRoot().append(paragraph);
}); });
// Focus first element // 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( expect(sanitizeHTML(container.innerHTML)).toBe(
'<div contenteditable="true" data-outline-editor="true"><p><span data-text="true"><br></span></p><p><span data-text="true">123</span></p></div>', '<div contenteditable="true" data-outline-editor="true"><p><span data-text="true"><br></span></p><p><span data-text="true"><br></span></p></div>',
);
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(
'<div contenteditable="true" data-outline-editor="true"><p><span data-text="true"><br></span></p><p><span data-text="true"><br></span></p></div>',
); );
moveNativeSelectionForward(); moveNativeSelectionForward();

View File

@ -271,48 +271,72 @@ function onKeyDown(
if (isLeftArrow || isRightArrow || isDelete || isBackspace) { if (isLeftArrow || isRightArrow || isDelete || isBackspace) {
const anchorNode = selection.getAnchorNode(); const anchorNode = selection.getAnchorNode();
const offset = selection.anchorOffset; const offset = selection.anchorOffset;
const textContent = anchorNode.getTextContent();
if (isLeftArrow || isBackspace) { if (isLeftArrow || isBackspace) {
const selectionAtStart = offset === 0;
if (selectionAtStart) {
const prevSibling = anchorNode.getPreviousSibling(); const prevSibling = anchorNode.getPreviousSibling();
if (prevSibling !== null) {
if ( if (prevSibling === null) {
offset === 0 && // On empty text nodes, we always move native DOM selection
(prevSibling.isImmutable() || prevSibling.isSegmented()) // 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) { if (isLeftArrow) {
announceNode(prevSibling); announceNode(prevSibling);
targetPrevSibling = prevSibling.getPreviousSibling();
} else if (!isModifierActive(event)) { } else if (!isModifierActive(event)) {
deleteBackward(selection); deleteBackward(selection);
shouldPreventDefault = true; shouldPreventDefault = true;
} }
} }
} // Due to empty text nodes having an offset of 1, we need to
} else { // account for this and move selection accordingly when right
const nextSibling = anchorNode.getNextSibling(); // arrow is pressed.
const textContent = anchorNode.getTextContent(); if (isLeftArrow && targetPrevSibling instanceof TextNode) {
const selectionAtEnd = textContent.length === offset;
const selectionJustBeforeEnd = textContent.length === 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 (parentSibling instanceof BlockNode) {
const firstChild = parentSibling.getFirstChild();
if (firstChild instanceof TextNode) {
firstChild.select(0, 0);
shouldPreventDefault = true; 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 { } else {
const textContentLength = textContent.length;
const selectionAtEnd = textContentLength === offset;
const selectionJustBeforeEnd = textContentLength === offset + 1;
if (selectionAtEnd || selectionJustBeforeEnd) {
const nextSibling = anchorNode.getNextSibling();
if ( if (
(selectionAtEnd || selectionJustBeforeEnd) && nextSibling !== null &&
(nextSibling.isImmutable() || nextSibling.isSegmented()) (nextSibling.isImmutable() || nextSibling.isSegmented())
) { ) {
if (isRightArrow) { if (isRightArrow) {

View File

@ -536,13 +536,8 @@ function reconcileSelection(selection: Selection, editor: OutlineEditor): void {
// selection and text entry works as expected, it also // selection and text entry works as expected, it also
// means we need to adjust the offset to ensure native // means we need to adjust the offset to ensure native
// selection works correctly and doesn't act buggy. // selection works correctly and doesn't act buggy.
if (anchorDOM.previousSibling === null) {
anchorOffset = 1; anchorOffset = 1;
focusOffset = 1; focusOffset = 1;
} else if (anchorDOM.nextSibling === null) {
anchorOffset = 0;
focusOffset = 0;
}
} }
domSelection.setBaseAndExtent( domSelection.setBaseAndExtent(
getTextNodeFromElement(anchorDOM), getTextNodeFromElement(anchorDOM),

View File

@ -288,21 +288,18 @@ export function createSelection(
// we need to adjust offsets to 0 when the text is // we need to adjust offsets to 0 when the text is
// really empty. // really empty.
if (anchorNode.text === '') { if (anchorNode.text === '') {
if (!editor.isComposing() && !editor.isPointerDown()) { // When dealing with empty text nodes, we always
const anchorElement = editor.getElementByKey(anchorKey); // render a special empty space character, and set
const focusElement = editor.getElementByKey(focusKey); // the native DOM selection to offset 1 so that
if (anchorNode === focusNode) { // text entry works as expected.
if (anchorElement.previousSibling === null) { if (
if (anchorOffset !== 1) { anchorNode === focusNode &&
anchorOffset !== 1 &&
!editor.isComposing() &&
!editor.isPointerDown()
) {
isDirty = true; isDirty = true;
} }
} else if (focusElement.nextSibling === null) {
if (anchorOffset !== 0) {
isDirty = true;
}
}
}
}
anchorOffset = 0; anchorOffset = 0;
} }
if (focusNode.text === '') { if (focusNode.text === '') {

View File

@ -36,6 +36,8 @@ const closureOptions = {
if (isClean) { if (isClean) {
fs.removeSync(path.resolve('./packages/outline/dist')); 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 = { const wwwMappings = {