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;
}
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(
'<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();

View File

@ -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) {

View File

@ -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),

View File

@ -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;
}

View File

@ -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 = {