mirror of
https://github.com/facebook/lexical.git
synced 2025-08-06 00:21:47 +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;
|
||||
}
|
||||
|
||||
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();
|
||||
|
@ -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) {
|
||||
|
@ -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),
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
Reference in New Issue
Block a user