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(
- '
',
+ '',
+ );
+
+ 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 = {