mirror of
https://github.com/facebook/lexical.git
synced 2025-08-06 16:39:33 +08:00
Further revisions to text node offsets + selection
This commit is contained in:

committed by
acywatson

parent
4ef3f5dc0c
commit
2c4cef54d3
@ -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();
|
||||||
|
@ -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) {
|
||||||
|
@ -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),
|
||||||
|
@ -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 === '') {
|
||||||
|
@ -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 = {
|
||||||
|
Reference in New Issue
Block a user