mirror of
https://github.com/facebook/lexical.git
synced 2025-08-06 08:30:33 +08:00
Revise more architecture around selection (#541)
* Revise more architecture around selection * Fix tests * Fix bug * Fix bug * Fix codes
This commit is contained in:

committed by
acywatson

parent
7dd75ea0fa
commit
e7b98f0850
@ -19,7 +19,7 @@ import React, {useCallback, useLayoutEffect, useMemo, useRef} from 'react';
|
||||
import {startTransition, useEffect, useState} from 'react';
|
||||
// $FlowFixMe
|
||||
import {createPortal} from 'react-dom';
|
||||
import {TextNode, isTextNode} from 'outline';
|
||||
import {TextNode} from 'outline';
|
||||
import useEvent from './useEvent';
|
||||
|
||||
type MentionMatch = {
|
||||
@ -391,14 +391,18 @@ function getPossibleMentionMatch(text): MentionMatch | null {
|
||||
}
|
||||
|
||||
function getTextUpToAnchor(selection: Selection): string | null {
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const anchor = selection.anchor;
|
||||
if (anchor.type !== 'character') {
|
||||
return null;
|
||||
}
|
||||
const anchorNode = anchor.getNode();
|
||||
// We should not be attempting to extract mentions out of nodes
|
||||
// that are already being used for other core things. This is
|
||||
// especially true for immutable nodes, which can't be mutated at all.
|
||||
if (!isTextNode(anchorNode) || !anchorNode.isSimpleText()) {
|
||||
if (!anchorNode.isSimpleText()) {
|
||||
return null;
|
||||
}
|
||||
const anchorOffset = selection.anchor.offset;
|
||||
const anchorOffset = anchor.offset;
|
||||
return anchorNode.getTextContent().slice(0, anchorOffset);
|
||||
}
|
||||
|
||||
@ -469,14 +473,18 @@ function createMentionNodeFromSearchResult(
|
||||
if (selection == null || !selection.isCollapsed()) {
|
||||
return;
|
||||
}
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const anchor = selection.anchor;
|
||||
if (anchor.type !== 'character') {
|
||||
return;
|
||||
}
|
||||
const anchorNode = anchor.getNode();
|
||||
// We should not be attempting to extract mentions out of nodes
|
||||
// that are already being used for other core things. This is
|
||||
// especially true for immutable nodes, which can't be mutated at all.
|
||||
if (!isTextNode(anchorNode) || !anchorNode.isSimpleText()) {
|
||||
if (!anchorNode.isSimpleText()) {
|
||||
return;
|
||||
}
|
||||
const selectionOffset = selection.anchor.offset;
|
||||
const selectionOffset = anchor.offset;
|
||||
const textContent = anchorNode.getTextContent().slice(0, selectionOffset);
|
||||
const characterOffset = match.replaceableString.length;
|
||||
|
||||
|
@ -50,29 +50,33 @@ export default function useTypeahead(editor: OutlineEditor): void {
|
||||
if (currentTypeaheadNode !== null) {
|
||||
const selection = view.getSelection();
|
||||
if (selection !== null) {
|
||||
let anchorNode = selection.anchor.getNode();
|
||||
let anchorNodeOffset = selection.anchor.offset;
|
||||
if (anchorNode.getKey() === currentTypeaheadNode.getKey()) {
|
||||
anchorNode = anchorNode.getPreviousSibling();
|
||||
if (isTextNode(anchorNode)) {
|
||||
anchorNodeOffset = anchorNode.getTextContent().length;
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
if (anchor.type === 'character' && focus.type === 'character') {
|
||||
let anchorNode = anchor.getNode();
|
||||
let anchorNodeOffset = anchor.offset;
|
||||
if (anchorNode.getKey() === currentTypeaheadNode.getKey()) {
|
||||
anchorNode = anchorNode.getPreviousSibling();
|
||||
if (isTextNode(anchorNode)) {
|
||||
anchorNodeOffset = anchorNode.getTextContent().length;
|
||||
}
|
||||
}
|
||||
}
|
||||
let focusNode = selection.focus.getNode();
|
||||
let focusNodeOffset = selection.focus.offset;
|
||||
if (focusNode.getKey() === currentTypeaheadNode.getKey()) {
|
||||
focusNode = focusNode.getPreviousSibling();
|
||||
if (isTextNode(focusNode)) {
|
||||
focusNodeOffset = focusNode.getTextContent().length;
|
||||
let focusNode = focus.getNode();
|
||||
let focusNodeOffset = focus.offset;
|
||||
if (focusNode.getKey() === currentTypeaheadNode.getKey()) {
|
||||
focusNode = focusNode.getPreviousSibling();
|
||||
if (isTextNode(focusNode)) {
|
||||
focusNodeOffset = focusNode.getTextContent().length;
|
||||
}
|
||||
}
|
||||
if (isTextNode(focusNode) && isTextNode(anchorNode)) {
|
||||
selection.setTextNodeRange(
|
||||
anchorNode,
|
||||
anchorNodeOffset,
|
||||
focusNode,
|
||||
focusNodeOffset,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isTextNode(focusNode) && isTextNode(anchorNode)) {
|
||||
selection.setBaseAndExtent(
|
||||
anchorNode,
|
||||
anchorNodeOffset,
|
||||
focusNode,
|
||||
focusNodeOffset,
|
||||
);
|
||||
}
|
||||
}
|
||||
currentTypeaheadNode.remove();
|
||||
|
@ -7,7 +7,13 @@
|
||||
* @flow strict-local
|
||||
*/
|
||||
|
||||
import type {BlockNode, ViewModel, View, OutlineEditor} from 'outline';
|
||||
import type {
|
||||
BlockNode,
|
||||
ViewModel,
|
||||
View,
|
||||
OutlineEditor,
|
||||
Selection,
|
||||
} from 'outline';
|
||||
|
||||
import {isBlockNode, isTextNode} from 'outline';
|
||||
|
||||
@ -69,10 +75,26 @@ export default function TreeView({
|
||||
return <pre {...styles}>{content}</pre>;
|
||||
}
|
||||
|
||||
function printSelection(selection: Selection): string {
|
||||
let res = '';
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
const anchorOffset = anchor.offset;
|
||||
const focusOffset = focus.offset;
|
||||
|
||||
res = `\n ├ anchor { key: ${anchor.key}, offset: ${
|
||||
anchorOffset === null ? 'null' : anchorOffset
|
||||
}, type: ${anchor.type} }`;
|
||||
res += `\n └ focus { key: ${focus.key}, offset: ${
|
||||
focusOffset === null ? 'null' : focusOffset
|
||||
}, type: ${focus.type} }`;
|
||||
return res;
|
||||
}
|
||||
|
||||
function generateContent(viewModel: ViewModel): string {
|
||||
let res = ' root\n';
|
||||
|
||||
viewModel.read((view: View) => {
|
||||
const selectionString = viewModel.read((view: View) => {
|
||||
const selection = view.getSelection();
|
||||
let selectedNodes = null;
|
||||
if (selection !== null) {
|
||||
@ -100,9 +122,11 @@ function generateContent(viewModel: ViewModel): string {
|
||||
typeDisplay,
|
||||
});
|
||||
});
|
||||
|
||||
return selection === null ? 'null' : printSelection(selection);
|
||||
});
|
||||
|
||||
return res;
|
||||
return res + '\n selection' + selectionString;
|
||||
}
|
||||
|
||||
function visitTree(view: View, currentNode: BlockNode, visitor, indent = []) {
|
||||
@ -238,34 +262,36 @@ function printSelectedCharsLine({
|
||||
function getSelectionStartEnd(node, selection): [number, number] {
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
const anchorNode = anchor.getNode();
|
||||
const focusNode = focus.getNode();
|
||||
const textContent = node.getTextContent(true);
|
||||
const textLength = textContent.length;
|
||||
|
||||
let start = -1;
|
||||
let end = -1;
|
||||
// Only one node is being selected.
|
||||
if (
|
||||
anchorNode === focusNode &&
|
||||
node === anchorNode &&
|
||||
anchor.offset !== focus.offset
|
||||
) {
|
||||
[start, end] =
|
||||
anchor.offset < focus.offset
|
||||
? [anchor.offset, focus.offset]
|
||||
: [focus.offset, anchor.offset];
|
||||
} else if (node === anchorNode) {
|
||||
[start, end] = anchorNode.isBefore(focusNode)
|
||||
? [anchor.offset, textLength]
|
||||
: [0, anchor.offset];
|
||||
} else if (node === focusNode) {
|
||||
[start, end] = focusNode.isBefore(anchorNode)
|
||||
? [focus.offset, textLength]
|
||||
: [0, focus.offset];
|
||||
} else {
|
||||
// Node is within selection but not the anchor nor focus.
|
||||
[start, end] = [0, textLength];
|
||||
if (anchor.type === 'character' && focus.type === 'character') {
|
||||
const anchorNode = anchor.getNode();
|
||||
const focusNode = focus.getNode();
|
||||
if (
|
||||
anchorNode === focusNode &&
|
||||
node === anchorNode &&
|
||||
anchor.offset !== focus.offset
|
||||
) {
|
||||
[start, end] =
|
||||
anchor.offset < focus.offset
|
||||
? [anchor.offset, focus.offset]
|
||||
: [focus.offset, anchor.offset];
|
||||
} else if (node === anchorNode) {
|
||||
[start, end] = anchorNode.isBefore(focusNode)
|
||||
? [anchor.offset, textLength]
|
||||
: [0, anchor.offset];
|
||||
} else if (node === focusNode) {
|
||||
[start, end] = focusNode.isBefore(anchorNode)
|
||||
? [focus.offset, textLength]
|
||||
: [0, focus.offset];
|
||||
} else {
|
||||
// Node is within selection but not the anchor nor focus.
|
||||
[start, end] = [0, textLength];
|
||||
}
|
||||
}
|
||||
|
||||
// Account for non-single width characters.
|
||||
|
@ -162,6 +162,9 @@ function shouldOverrideBrowserDefault(
|
||||
): boolean {
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
if (anchor.type !== 'character' || focus.type !== 'character') {
|
||||
return true;
|
||||
}
|
||||
const anchorOffset = anchor.offset;
|
||||
const focusOffset = focus.offset;
|
||||
const anchorTextContentSize = anchor.getNode().getTextContentSize();
|
||||
@ -336,19 +339,21 @@ export function onKeyDownForRichText(
|
||||
} else if (isTab(event)) {
|
||||
// Handle code blocks
|
||||
const anchor = selection.anchor;
|
||||
const anchorNode = anchor.getNode();
|
||||
const parentBlock = anchorNode.getParentBlockOrThrow();
|
||||
if (parentBlock.canInsertTab()) {
|
||||
if (event.shiftKey) {
|
||||
const textContent = anchorNode.getTextContent();
|
||||
const character = textContent[anchor.offset - 1];
|
||||
if (character === '\t') {
|
||||
deleteBackward(selection);
|
||||
if (anchor.type === 'character') {
|
||||
const anchorNode = anchor.getNode();
|
||||
const parentBlock = anchorNode.getParentBlockOrThrow();
|
||||
if (parentBlock.canInsertTab()) {
|
||||
if (event.shiftKey) {
|
||||
const textContent = anchorNode.getTextContent();
|
||||
const character = textContent[anchor.offset - 1];
|
||||
if (character === '\t') {
|
||||
deleteBackward(selection);
|
||||
}
|
||||
} else {
|
||||
insertText(selection, '\t');
|
||||
}
|
||||
} else {
|
||||
insertText(selection, '\t');
|
||||
event.preventDefault();
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
} else if (isSelectAll(event)) {
|
||||
event.preventDefault();
|
||||
|
@ -62,7 +62,7 @@ export default function useOutlineDragonSupport(editor: OutlineEditor) {
|
||||
setSelStart = blockStart;
|
||||
setSelEnd = blockStart + blockLength;
|
||||
// If the offset is more than the end, make it the end
|
||||
selection.setBaseAndExtent(
|
||||
selection.setTextNodeRange(
|
||||
anchorNode,
|
||||
setSelStart,
|
||||
anchorNode,
|
||||
@ -89,7 +89,7 @@ export default function useOutlineDragonSupport(editor: OutlineEditor) {
|
||||
setSelEnd > anchorNodeTextLength
|
||||
? anchorNodeTextLength
|
||||
: setSelEnd;
|
||||
selection.setBaseAndExtent(
|
||||
selection.setTextNodeRange(
|
||||
anchorNode,
|
||||
setSelStart,
|
||||
anchorNode,
|
||||
|
@ -70,7 +70,7 @@ function getMergeAction(
|
||||
const anchor = selection.anchor;
|
||||
const anchorKey = anchor.key;
|
||||
const prevAnchorKey = prevSelection.anchor.key;
|
||||
if (anchorKey !== prevAnchorKey) {
|
||||
if (anchorKey !== prevAnchorKey || anchor.type !== 'character') {
|
||||
return NO_MERGE;
|
||||
}
|
||||
const anchorOffset = anchor.offset;
|
||||
|
@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import type {NodeKey, ParsedNode} from './OutlineNode';
|
||||
import type {Selection} from './OutlineSelection';
|
||||
|
||||
import {isTextNode, TextNode, isLineBreakNode} from '.';
|
||||
import {
|
||||
@ -16,7 +17,7 @@ import {
|
||||
wrapInTextNodes,
|
||||
updateDirectionIfNeeded,
|
||||
} from './OutlineNode';
|
||||
import {Selection} from './OutlineSelection';
|
||||
import {makeSelection, getSelection, setPointValues} from './OutlineSelection';
|
||||
import {errorOnReadOnly} from './OutlineView';
|
||||
import {
|
||||
IS_DIRECTIONLESS,
|
||||
@ -138,6 +139,19 @@ export class BlockNode extends OutlineNode {
|
||||
|
||||
// Mutators
|
||||
|
||||
selectEnd(): Selection {
|
||||
errorOnReadOnly();
|
||||
const selection = getSelection();
|
||||
const key = this.__key;
|
||||
if (selection === null) {
|
||||
return makeSelection(key, null, key, null, 'end', 'end');
|
||||
} else {
|
||||
setPointValues(selection.anchor, key, null, 'end');
|
||||
setPointValues(selection.focus, key, null, 'end');
|
||||
selection.isDirty = true;
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
clear(): BlockNode {
|
||||
errorOnReadOnly();
|
||||
const writableSelf = this.getWritable();
|
||||
|
@ -53,13 +53,13 @@ export type ParsedNodeMap = Map<NodeKey, ParsedNode>;
|
||||
type ParsedSelection = {
|
||||
anchor: {
|
||||
key: NodeKey,
|
||||
offset: number,
|
||||
type: 'character' | 'start' | 'end',
|
||||
offset: null | number,
|
||||
type: 'character' | 'before' | 'end',
|
||||
},
|
||||
focus: {
|
||||
key: NodeKey,
|
||||
offset: number,
|
||||
type: 'character' | 'start' | 'end',
|
||||
offset: null | number,
|
||||
type: 'character' | 'before' | 'end',
|
||||
},
|
||||
};
|
||||
// export type NodeMapType = {root: RootNode, [key: NodeKey]: OutlineNode};
|
||||
|
@ -9,7 +9,10 @@
|
||||
|
||||
import type {NodeKey, NodeMapType} from './OutlineNode';
|
||||
import type {OutlineEditor, EditorThemeClasses} from './OutlineEditor';
|
||||
import type {Selection as OutlineSelection} from './OutlineSelection';
|
||||
import type {
|
||||
Selection as OutlineSelection,
|
||||
PointType,
|
||||
} from './OutlineSelection';
|
||||
import type {TextNode} from './OutlineTextNode';
|
||||
import type {Node as ReactNode} from 'react';
|
||||
|
||||
@ -496,36 +499,50 @@ function reconcileSelection(
|
||||
const focusNode = focus.getNode();
|
||||
const anchorDOM = getElementByKeyOrThrow(editor, anchorKey);
|
||||
const focusDOM = getElementByKeyOrThrow(editor, focusKey);
|
||||
let nextAnchorNode;
|
||||
let nextFocusNode;
|
||||
let nextAnchorOffset;
|
||||
let nextFocusOffset;
|
||||
|
||||
// Get the underlying DOM text nodes from the representative
|
||||
// Outline text nodes (we use elements for text nodes).
|
||||
const anchorDOMTarget = getDOMTextNode(anchorDOM);
|
||||
const focusDOMTarget = getDOMTextNode(focusDOM);
|
||||
if (anchor.type === 'character') {
|
||||
const nextSelectionAnchorOffset = anchor.offset;
|
||||
nextAnchorNode = getDOMTextNode(anchorDOM);
|
||||
nextAnchorOffset =
|
||||
isImmutableOrInertOrSegmented(anchorNode) ||
|
||||
anchorNode.getTextContent() !== ''
|
||||
? nextSelectionAnchorOffset
|
||||
: nextSelectionAnchorOffset + 1;
|
||||
} else {
|
||||
nextAnchorNode = anchorDOM;
|
||||
// TODO
|
||||
nextAnchorOffset = 0;
|
||||
}
|
||||
if (focus.type === 'character') {
|
||||
const nextSelectionFocusOffset = focus.offset;
|
||||
nextFocusNode = getDOMTextNode(focusDOM);
|
||||
nextFocusOffset =
|
||||
isImmutableOrInertOrSegmented(focusNode) ||
|
||||
focusNode.getTextContent() !== ''
|
||||
? nextSelectionFocusOffset
|
||||
: nextSelectionFocusOffset + 1;
|
||||
} else {
|
||||
nextFocusNode = focusDOM;
|
||||
// TODO
|
||||
nextFocusOffset = 0;
|
||||
}
|
||||
// If we can't get an underlying text node for selection, then
|
||||
// we should avoid setting selection to something incorrect.
|
||||
if (focusDOMTarget === null || anchorDOMTarget === null) {
|
||||
if (nextAnchorNode === null || nextFocusNode === null) {
|
||||
return;
|
||||
}
|
||||
const nextSelectionAnchorOffset = anchor.offset;
|
||||
const nextSelectionFocusOffset = focus.offset;
|
||||
const nextAnchorOffset =
|
||||
isImmutableOrInertOrSegmented(anchorNode) ||
|
||||
anchorNode.getTextContent() !== ''
|
||||
? nextSelectionAnchorOffset
|
||||
: nextSelectionAnchorOffset + 1;
|
||||
const nextFocusOffset =
|
||||
isImmutableOrInertOrSegmented(focusNode) ||
|
||||
focusNode.getTextContent() !== ''
|
||||
? nextSelectionFocusOffset
|
||||
: nextSelectionFocusOffset + 1;
|
||||
|
||||
// Diff against the native DOM selection to ensure we don't do
|
||||
// an unnecessary selection update.
|
||||
if (
|
||||
anchorOffset === nextAnchorOffset &&
|
||||
focusOffset === nextFocusOffset &&
|
||||
anchorDOMNode === anchorDOMTarget &&
|
||||
focusDOMNode === focusDOMTarget
|
||||
anchorDOMNode === nextAnchorNode &&
|
||||
focusDOMNode === nextFocusNode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@ -534,9 +551,9 @@ function reconcileSelection(
|
||||
// a "selectionchange" event, although it will be asynchronous.
|
||||
try {
|
||||
domSelection.setBaseAndExtent(
|
||||
anchorDOMTarget,
|
||||
nextAnchorNode,
|
||||
nextAnchorOffset,
|
||||
focusDOMTarget,
|
||||
nextFocusNode,
|
||||
nextFocusOffset,
|
||||
);
|
||||
} catch {
|
||||
@ -590,26 +607,18 @@ export function getElementByKeyOrThrow(
|
||||
return element;
|
||||
}
|
||||
|
||||
function mergeAdjacentTextNodes(textNodes: Array<TextNode>): void {
|
||||
// We're checking `selection !== null` later before we use these
|
||||
// so initializing to 0 is safe and saves us an extra check below
|
||||
const compositionKey = getCompositionKey();
|
||||
let anchorOffset = 0;
|
||||
let focusOffset = 0;
|
||||
let anchorKey;
|
||||
let focusKey;
|
||||
|
||||
if (activeSelection !== null) {
|
||||
anchorOffset = activeSelection.anchor.offset;
|
||||
focusOffset = activeSelection.focus.offset;
|
||||
anchorKey = activeSelection.anchor.key;
|
||||
focusKey = activeSelection.focus.key;
|
||||
}
|
||||
|
||||
function mergeAdjacentTextNodes(
|
||||
textNodes: Array<TextNode>,
|
||||
anchor: null | PointType,
|
||||
focus: null | PointType,
|
||||
): void {
|
||||
// Merge all text nodes into the first node
|
||||
const writableMergeToNode = textNodes[0].getWritable();
|
||||
const key = writableMergeToNode.__key;
|
||||
const compositionKey = getCompositionKey();
|
||||
let textLength = writableMergeToNode.getTextContentSize();
|
||||
let selectionIsDirty = false;
|
||||
|
||||
for (let i = 1; i < textNodes.length; i++) {
|
||||
const textNode = textNodes[i];
|
||||
const siblingText = textNode.getTextContent();
|
||||
@ -617,19 +626,21 @@ function mergeAdjacentTextNodes(textNodes: Array<TextNode>): void {
|
||||
if (compositionKey === textNodeKey) {
|
||||
setCompositionKey(key);
|
||||
}
|
||||
if (activeSelection !== null && textNodeKey === anchorKey) {
|
||||
activeSelection.anchor.offset = textLength + anchorOffset;
|
||||
activeSelection.anchor.key = key;
|
||||
if (anchor !== null && textNodeKey === anchor.key) {
|
||||
anchor.offset = textLength + anchor.offset;
|
||||
anchor.key = key;
|
||||
selectionIsDirty = true;
|
||||
}
|
||||
if (activeSelection !== null && textNodeKey === focusKey) {
|
||||
activeSelection.focus.offset = textLength + focusOffset;
|
||||
activeSelection.focus.key = key;
|
||||
if (focus !== null && textNodeKey === focus.key) {
|
||||
focus.offset = textLength + focus.offset;
|
||||
focus.key = key;
|
||||
selectionIsDirty = true;
|
||||
}
|
||||
writableMergeToNode.spliceText(textLength, 0, siblingText);
|
||||
textLength += siblingText.length;
|
||||
textNode.remove();
|
||||
}
|
||||
if (activeSelection !== null) {
|
||||
if (selectionIsDirty && activeSelection !== null) {
|
||||
activeSelection.isDirty = true;
|
||||
}
|
||||
}
|
||||
@ -638,6 +649,14 @@ function normalizeTextNodes(block: BlockNode): void {
|
||||
const children = block.getChildren();
|
||||
let toNormalize = [];
|
||||
let lastTextNodeFlags: number | null = null;
|
||||
let anchor = null;
|
||||
let focus = null;
|
||||
|
||||
if (activeSelection !== null) {
|
||||
anchor = activeSelection.anchor;
|
||||
focus = activeSelection.focus;
|
||||
}
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
|
||||
@ -654,20 +673,20 @@ function normalizeTextNodes(block: BlockNode): void {
|
||||
lastTextNodeFlags = flags;
|
||||
} else {
|
||||
if (toNormalize.length > 1) {
|
||||
mergeAdjacentTextNodes(toNormalize);
|
||||
mergeAdjacentTextNodes(toNormalize, anchor, focus);
|
||||
}
|
||||
toNormalize = [child];
|
||||
lastTextNodeFlags = flags;
|
||||
}
|
||||
} else {
|
||||
if (toNormalize.length > 1) {
|
||||
mergeAdjacentTextNodes(toNormalize);
|
||||
mergeAdjacentTextNodes(toNormalize, anchor, focus);
|
||||
}
|
||||
toNormalize = [];
|
||||
lastTextNodeFlags = null;
|
||||
}
|
||||
}
|
||||
if (toNormalize.length > 1) {
|
||||
mergeAdjacentTextNodes(toNormalize);
|
||||
mergeAdjacentTextNodes(toNormalize, anchor, focus);
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ import {getDOMTextNode, isSelectionWithinEditor} from './OutlineUtils';
|
||||
import invariant from 'shared/invariant';
|
||||
import {ZERO_WIDTH_JOINER_CHAR} from './OutlineConstants';
|
||||
|
||||
declare type CharacterPointType = {
|
||||
type CharacterPointType = {
|
||||
key: NodeKey,
|
||||
offset: number,
|
||||
type: 'character',
|
||||
@ -34,33 +34,36 @@ declare type CharacterPointType = {
|
||||
getNode: () => TextNode,
|
||||
};
|
||||
|
||||
type BlockStartPointType = {
|
||||
key: NodeKey,
|
||||
offset: 0,
|
||||
type: 'start',
|
||||
is: (PointType) => boolean,
|
||||
getNode: () => BlockNode,
|
||||
};
|
||||
|
||||
type BlockEndPointType = {
|
||||
key: NodeKey,
|
||||
offset: 1,
|
||||
offset: null,
|
||||
type: 'end',
|
||||
is: (PointType) => boolean,
|
||||
getNode: () => BlockNode,
|
||||
};
|
||||
|
||||
type PointType = CharacterPointType | BlockStartPointType | BlockEndPointType;
|
||||
type BeforeNodePointType = {
|
||||
key: NodeKey,
|
||||
offset: null,
|
||||
type: 'before',
|
||||
is: (PointType) => boolean,
|
||||
getNode: () => BlockNode,
|
||||
};
|
||||
|
||||
export type PointType =
|
||||
| CharacterPointType
|
||||
| BeforeNodePointType
|
||||
| BlockEndPointType;
|
||||
|
||||
class Point {
|
||||
key: NodeKey;
|
||||
offset: number;
|
||||
type: 'character' | 'start' | 'end';
|
||||
type: 'character' | 'before' | 'end';
|
||||
|
||||
constructor(
|
||||
key: NodeKey,
|
||||
offset: number,
|
||||
type: 'character' | 'start' | 'end',
|
||||
type: 'character' | 'before' | 'end',
|
||||
) {
|
||||
this.key = key;
|
||||
this.offset = offset;
|
||||
@ -81,18 +84,18 @@ class Point {
|
||||
|
||||
function createPoint(
|
||||
key: NodeKey,
|
||||
offset: number,
|
||||
type: 'character' | 'start' | 'end',
|
||||
offset: null | number,
|
||||
type: 'character' | 'before' | 'end',
|
||||
): PointType {
|
||||
// $FlowFixMe: intentionally cast as we use a class for perf reasons
|
||||
return new Point(key, offset, type);
|
||||
}
|
||||
|
||||
function setPointValues(
|
||||
export function setPointValues(
|
||||
point: PointType,
|
||||
key: NodeKey,
|
||||
offset: number,
|
||||
type: 'character' | 'start' | 'end',
|
||||
offset: null | number,
|
||||
type: 'character' | 'before' | 'end',
|
||||
): void {
|
||||
point.key = key;
|
||||
// $FlowFixMe: internal utility function
|
||||
@ -126,7 +129,7 @@ export class Selection {
|
||||
}
|
||||
return anchorNode.getNodesBetween(focusNode);
|
||||
}
|
||||
setBaseAndExtent(
|
||||
setTextNodeRange(
|
||||
anchorNode: TextNode,
|
||||
anchorOffset: number,
|
||||
focusNode: TextNode,
|
||||
@ -144,8 +147,8 @@ export class Selection {
|
||||
const firstNode = nodes[0];
|
||||
const lastNode = nodes[nodes.length - 1];
|
||||
const isBefore = firstNode === this.anchor.getNode();
|
||||
const anchorOffset = this.anchor.offset;
|
||||
const focusOffset = this.focus.offset;
|
||||
const anchorOffset = this.anchor.offset || 0;
|
||||
const focusOffset = this.focus.offset || 0;
|
||||
let textContent = '';
|
||||
nodes.forEach((node) => {
|
||||
if (isTextNode(node)) {
|
||||
@ -399,10 +402,20 @@ function resolveSelectionPoints(
|
||||
editor._compositionKey !== resolvedAnchorPoint.key &&
|
||||
lastSelection !== null
|
||||
) {
|
||||
resolvedAnchorPoint.key = lastSelection.anchor.key;
|
||||
resolvedAnchorPoint.offset = lastSelection.anchor.offset;
|
||||
resolvedFocusPoint.key = lastSelection.focus.key;
|
||||
resolvedFocusPoint.offset = lastSelection.focus.offset;
|
||||
const lastAnchor = lastSelection.anchor;
|
||||
const lastFocus = lastSelection.focus;
|
||||
setPointValues(
|
||||
resolvedAnchorPoint,
|
||||
lastAnchor.key,
|
||||
lastAnchor.offset,
|
||||
lastAnchor.type,
|
||||
);
|
||||
setPointValues(
|
||||
resolvedFocusPoint,
|
||||
lastFocus.key,
|
||||
lastFocus.offset,
|
||||
lastFocus.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -414,11 +427,11 @@ function resolveSelectionPoints(
|
||||
// when it current exists outside the editor.
|
||||
export function makeSelection(
|
||||
anchorKey: NodeKey,
|
||||
anchorOffset: number,
|
||||
anchorOffset: null | number,
|
||||
focusKey: NodeKey,
|
||||
focusOffset: number,
|
||||
anchorType: 'character' | 'start' | 'end',
|
||||
focusType: 'character' | 'start' | 'end',
|
||||
focusOffset: null | number,
|
||||
anchorType: 'character' | 'before' | 'end',
|
||||
focusType: 'character' | 'before' | 'end',
|
||||
): Selection {
|
||||
const viewModel = getActiveViewModel();
|
||||
const selection = new Selection(
|
||||
@ -506,13 +519,13 @@ export function createSelectionFromParse(
|
||||
parsedSelection: null | {
|
||||
anchor: {
|
||||
key: string,
|
||||
offset: number,
|
||||
type: 'character' | 'start' | 'end',
|
||||
offset: null | number,
|
||||
type: 'character' | 'before' | 'end',
|
||||
},
|
||||
focus: {
|
||||
key: string,
|
||||
offset: number,
|
||||
type: 'character' | 'start' | 'end',
|
||||
offset: null | number,
|
||||
type: 'character' | 'before' | 'end',
|
||||
},
|
||||
},
|
||||
): null | Selection {
|
||||
|
@ -424,17 +424,14 @@ export class TextNode extends OutlineNode {
|
||||
}
|
||||
select(_anchorOffset?: number, _focusOffset?: number): Selection {
|
||||
errorOnReadOnly();
|
||||
if (this.isImmutable()) {
|
||||
return this.selectNext(0, 0);
|
||||
}
|
||||
let anchorOffset = _anchorOffset;
|
||||
let focusOffset = _focusOffset;
|
||||
const selection = getSelection();
|
||||
const text = this.getTextContent();
|
||||
const key = this.__key;
|
||||
if (key === null) {
|
||||
invariant(
|
||||
false,
|
||||
'select: TODO? validate nodes have keys in a more generic way',
|
||||
);
|
||||
}
|
||||
if (typeof text === 'string') {
|
||||
const lastOffset = text.length;
|
||||
if (anchorOffset === undefined) {
|
||||
@ -464,7 +461,7 @@ export class TextNode extends OutlineNode {
|
||||
) {
|
||||
setCompositionKey(key);
|
||||
}
|
||||
selection.setBaseAndExtent(this, anchorOffset, this, focusOffset);
|
||||
selection.setTextNodeRange(this, anchorOffset, this, focusOffset);
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
@ -504,7 +501,7 @@ export class TextNode extends OutlineNode {
|
||||
invariant(false, 'spliceText: selection not found');
|
||||
}
|
||||
const newOffset = offset + handledTextLength;
|
||||
selection.setBaseAndExtent(
|
||||
selection.setTextNodeRange(
|
||||
writableSelf,
|
||||
newOffset,
|
||||
writableSelf,
|
||||
@ -586,27 +583,25 @@ export class TextNode extends OutlineNode {
|
||||
if (selection !== null) {
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
const anchorOffset = anchor.offset;
|
||||
const focusOffset = focus.offset;
|
||||
|
||||
if (
|
||||
anchor.key === key &&
|
||||
anchor.type === 'character' &&
|
||||
anchorOffset > textSize &&
|
||||
anchorOffset <= nextTextSize
|
||||
anchor.offset > textSize &&
|
||||
anchor.offset <= nextTextSize
|
||||
) {
|
||||
anchor.key = siblingKey;
|
||||
anchor.offset = anchorOffset - textSize;
|
||||
anchor.offset -= textSize;
|
||||
selection.isDirty = true;
|
||||
}
|
||||
if (
|
||||
focus.key === key &&
|
||||
focus.type === 'character' &&
|
||||
focusOffset > textSize &&
|
||||
focusOffset <= nextTextSize
|
||||
focus.offset > textSize &&
|
||||
focus.offset <= nextTextSize
|
||||
) {
|
||||
focus.key = siblingKey;
|
||||
focus.offset = focusOffset - textSize;
|
||||
focus.offset -= textSize;
|
||||
selection.isDirty = true;
|
||||
}
|
||||
}
|
||||
|
@ -56,13 +56,13 @@ export type ParsedViewModel = {
|
||||
_selection: null | {
|
||||
anchor: {
|
||||
key: string,
|
||||
offset: number,
|
||||
type: 'character' | 'start' | 'end',
|
||||
offset: null | number,
|
||||
type: 'character' | 'before' | 'end',
|
||||
},
|
||||
focus: {
|
||||
key: string,
|
||||
offset: number,
|
||||
type: 'character' | 'start' | 'end',
|
||||
offset: null | number,
|
||||
type: 'character' | 'before' | 'end',
|
||||
},
|
||||
},
|
||||
_nodeMap: Array<[NodeKey, ParsedNode]>,
|
||||
|
@ -44,10 +44,15 @@ export function getNodesInRange(selection: Selection): {
|
||||
range: Array<NodeKey>,
|
||||
nodeMap: Array<[NodeKey, OutlineNode]>,
|
||||
} {
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const focusNode = selection.focus.getNode();
|
||||
const anchorOffset = selection.anchor.offset;
|
||||
const focusOffset = selection.focus.offset;
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
if (anchor.type !== 'character' || focus.type !== 'character') {
|
||||
invariant(false, 'Selection block node not yet implemented.');
|
||||
}
|
||||
const anchorNode = anchor.getNode();
|
||||
const focusNode = focus.getNode();
|
||||
const anchorOffset = anchor.offset;
|
||||
const focusOffset = focus.offset;
|
||||
let startOffset;
|
||||
let endOffset;
|
||||
|
||||
@ -163,14 +168,19 @@ export function extractSelection(selection: Selection): Array<OutlineNode> {
|
||||
const selectedNodes = selection.getNodes();
|
||||
const selectedNodesLength = selectedNodes.length;
|
||||
const lastIndex = selectedNodesLength - 1;
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
let firstNode = selectedNodes[0];
|
||||
let lastNode = selectedNodes[lastIndex];
|
||||
|
||||
if (!isTextNode(firstNode) || !isTextNode(lastNode)) {
|
||||
invariant(false, 'formatText: firstNode/lastNode not a text node');
|
||||
}
|
||||
const anchorOffset = selection.anchor.offset;
|
||||
const focusOffset = selection.focus.offset;
|
||||
if (anchor.type !== 'character' || focus.type !== 'character') {
|
||||
invariant(false, 'Selection block node not yet implemented.');
|
||||
}
|
||||
const anchorOffset = anchor.offset;
|
||||
const focusOffset = focus.offset;
|
||||
let startOffset;
|
||||
let endOffset;
|
||||
|
||||
@ -212,16 +222,21 @@ export function formatText(
|
||||
if (!isTextNode(firstNode) || !isTextNode(lastNode)) {
|
||||
invariant(false, 'formatText: firstNode/lastNode not a text node');
|
||||
}
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
if (anchor.type !== 'character' || focus.type !== 'character') {
|
||||
invariant(false, 'Selection block node not yet implemented.');
|
||||
}
|
||||
const firstNodeText = firstNode.getTextContent();
|
||||
const firstNodeTextLength = firstNodeText.length;
|
||||
const currentBlock = firstNode.getParentBlockOrThrow();
|
||||
const focusOffset = selection.focus.offset;
|
||||
const focusOffset = focus.offset;
|
||||
let firstNextFlags = firstNode.getTextNodeFormatFlags(
|
||||
formatType,
|
||||
null,
|
||||
forceFormat,
|
||||
);
|
||||
let anchorOffset = selection.anchor.offset;
|
||||
let anchorOffset = anchor.offset;
|
||||
let startOffset;
|
||||
let endOffset;
|
||||
|
||||
@ -320,7 +335,7 @@ export function formatText(
|
||||
selectedNode.setFlags(selectedNextFlags);
|
||||
}
|
||||
}
|
||||
selection.setBaseAndExtent(firstNode, startOffset, lastNode, endOffset);
|
||||
selection.setTextNodeRange(firstNode, startOffset, lastNode, endOffset);
|
||||
}
|
||||
}
|
||||
|
||||
@ -340,7 +355,7 @@ export function insertParagraph(selection: Selection): void {
|
||||
const textContentLength = textContent.length;
|
||||
const nodesToMove = anchorNode.getNextSiblings().reverse();
|
||||
const currentBlock = anchorNode.getParentBlockOrThrow();
|
||||
let anchorOffset = selection.anchor.offset;
|
||||
let anchorOffset = anchor.offset;
|
||||
|
||||
if (anchorOffset === 0) {
|
||||
nodesToMove.push(anchorNode);
|
||||
@ -827,15 +842,20 @@ export function insertRichText(selection: Selection, text: string): void {
|
||||
export function insertText(selection: Selection, text: string): void {
|
||||
const selectedNodes = selection.getNodes();
|
||||
const selectedNodesLength = selectedNodes.length;
|
||||
const anchorOffset = selection.anchor.offset;
|
||||
const focusOffset = selection.focus.offset;
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
let firstNode = selectedNodes[0];
|
||||
if (!isTextNode(firstNode)) {
|
||||
invariant(false, 'insertText: firstNode not a a text node');
|
||||
}
|
||||
if (anchor.type !== 'character' || focus.type !== 'character') {
|
||||
invariant(false, 'Selection block node not yet implemented.');
|
||||
}
|
||||
const anchorOffset = anchor.offset;
|
||||
const focusOffset = focus.offset;
|
||||
const firstNodeText = firstNode.getTextContent();
|
||||
const firstNodeTextLength = firstNodeText.length;
|
||||
const isBefore = firstNode === selection.anchor.getNode();
|
||||
const isBefore = firstNode === anchor.getNode();
|
||||
|
||||
if (firstNode.isSegmented() || !firstNode.canInsertTextAtEnd()) {
|
||||
if (selection.isCollapsed() && focusOffset === firstNodeTextLength) {
|
||||
@ -992,7 +1012,7 @@ export function selectAll(selection: Selection): void {
|
||||
const firstTextNode = root.getFirstTextNode();
|
||||
const lastTextNode = root.getLastTextNode();
|
||||
if (firstTextNode !== null && lastTextNode !== null) {
|
||||
selection.setBaseAndExtent(
|
||||
selection.setTextNodeRange(
|
||||
firstTextNode,
|
||||
0,
|
||||
lastTextNode,
|
||||
|
@ -900,10 +900,10 @@ describe('OutlineSelection tests', () => {
|
||||
'<span data-outline-text="true"></span>' +
|
||||
'</p></div>',
|
||||
expectedSelection: {
|
||||
anchorPath: [0, 1, 0],
|
||||
anchorOffset: 3,
|
||||
focusPath: [0, 1, 0],
|
||||
focusOffset: 3,
|
||||
anchorPath: [0, 2, 0],
|
||||
anchorOffset: 0,
|
||||
focusPath: [0, 2, 0],
|
||||
focusOffset: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -9,35 +9,34 @@
|
||||
"7": "updateDOM: prevInnerDOM is null or undefined",
|
||||
"8": "updateDOM: innerDOM is null or undefined",
|
||||
"9": "setTextContent: can only be used on non-immutable text nodes",
|
||||
"10": "select: TODO? validate nodes have keys in a more generic way",
|
||||
"11": "spliceText: can only be used on non-immutable text nodes",
|
||||
"12": "spliceText: TODO? validate nodes have keys in a more generic way",
|
||||
"13": "spliceText: selection not found",
|
||||
"14": "splitText: can only be used on non-immutable text nodes",
|
||||
"15": "decorate: base method not extended",
|
||||
"16": "OutlineNode: Node type %s does not implement deserialize().",
|
||||
"17": "OutlineNode: Node type %s does not implement .clone().",
|
||||
"18": "Expected node %s to have a parent.",
|
||||
"19": "Expected node %s to have a parent block.",
|
||||
"20": "Expected node %s to have a top parent block.",
|
||||
"21": "getNodesBetween: ancestor is null",
|
||||
"22": "getLatest: node not found",
|
||||
"23": "createDOM: base method not extended",
|
||||
"24": "updateDOM: base method not extended",
|
||||
"25": "setFlags: can only be used on non-immutable nodes",
|
||||
"26": "selectNext: found invalid sibling",
|
||||
"27": "Expected node with key %s to exist but it's not in the nodeMap.",
|
||||
"28": "createNodeFromParse: type \"%s\" + not found",
|
||||
"29": "Point.getNode: node not found",
|
||||
"30": "resolveNonLineBreakOrInertNode: resolved node not a text node",
|
||||
"31": "Editor.update() cannot be used within a text node transform.",
|
||||
"32": "Cannot use method in read-only mode.",
|
||||
"33": "Unable to find an active view model. View methods or node methods can only be used synchronously during the callback of editor.update() or viewModel.read().",
|
||||
"34": "Unable to find an active editor. View methods or node methods can only be used synchronously during the callback of editor.update() or viewModel.read().",
|
||||
"35": "updateEditor: selection has been lost because the previously selected nodes have been removed and selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.",
|
||||
"36": "createNode: node does not exist in nodeMap",
|
||||
"37": "reconcileNode: prevNode or nextNode does not exist in nodeMap",
|
||||
"38": "reconcileNode: parentDOM is null",
|
||||
"39": "storeDOMWithNodeKey: key was null",
|
||||
"40": "Reconciliation: could not find DOM element for node key \"${key}\""
|
||||
"10": "spliceText: can only be used on non-immutable text nodes",
|
||||
"11": "spliceText: TODO? validate nodes have keys in a more generic way",
|
||||
"12": "spliceText: selection not found",
|
||||
"13": "splitText: can only be used on non-immutable text nodes",
|
||||
"14": "decorate: base method not extended",
|
||||
"15": "OutlineNode: Node type %s does not implement deserialize().",
|
||||
"16": "OutlineNode: Node type %s does not implement .clone().",
|
||||
"17": "Expected node %s to have a parent.",
|
||||
"18": "Expected node %s to have a parent block.",
|
||||
"19": "Expected node %s to have a top parent block.",
|
||||
"20": "getNodesBetween: ancestor is null",
|
||||
"21": "getLatest: node not found",
|
||||
"22": "createDOM: base method not extended",
|
||||
"23": "updateDOM: base method not extended",
|
||||
"24": "setFlags: can only be used on non-immutable nodes",
|
||||
"25": "selectNext: found invalid sibling",
|
||||
"26": "Expected node with key %s to exist but it's not in the nodeMap.",
|
||||
"27": "createNodeFromParse: type \"%s\" + not found",
|
||||
"28": "Point.getNode: node not found",
|
||||
"29": "resolveNonLineBreakOrInertNode: resolved node not a text node",
|
||||
"30": "Editor.update() cannot be used within a text node transform.",
|
||||
"31": "Cannot use method in read-only mode.",
|
||||
"32": "Unable to find an active view model. View methods or node methods can only be used synchronously during the callback of editor.update() or viewModel.read().",
|
||||
"33": "Unable to find an active editor. View methods or node methods can only be used synchronously during the callback of editor.update() or viewModel.read().",
|
||||
"34": "updateEditor: selection has been lost because the previously selected nodes have been removed and selection wasn't moved to another node. Ensure selection changes after removing/replacing a selected node.",
|
||||
"35": "createNode: node does not exist in nodeMap",
|
||||
"36": "reconcileNode: prevNode or nextNode does not exist in nodeMap",
|
||||
"37": "reconcileNode: parentDOM is null",
|
||||
"38": "storeDOMWithNodeKey: key was null",
|
||||
"39": "Reconciliation: could not find DOM element for node key \"${key}\""
|
||||
}
|
||||
|
Reference in New Issue
Block a user