Skip to content

Commit

Permalink
[WIP] Improve character deletion around shadow roots and decorators
Browse files Browse the repository at this point in the history
  • Loading branch information
etrepum committed Feb 9, 2025
1 parent c82e7bb commit 1fa74fb
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 29 deletions.
3 changes: 2 additions & 1 deletion packages/lexical-website/docs/concepts/traversals.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ non-negative.
is equivalent in purpose to a `RangeSelection`, and is what you would generally
use for depth first traversals.

* Constructed with `$getCaretRange(anchor, focus)` or `$caretRangeFromSelection(selection)`
* Constructed with `$getCaretRange(anchor, focus)`, `$caretRangeFromSelection(selection)`,
or `$extendCaretToRange(anchor)`
* The `anchor` is the start of the range, generally where the selection originated,
and it is "anchored" in place because when a selection grows or shrinks only the
`focus` will be moved
Expand Down
70 changes: 70 additions & 0 deletions packages/lexical/src/LexicalSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,18 @@ import {
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$extendCaretToRange,
$getCaretRange,
$isChildCaret,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
$isRootNode,
$isSiblingCaret,
$isTextNode,
$normalizeCaret,
$removeTextFromCaretRange,
$rewindSiblingCaret,
$setPointFromCaret,
$setSelection,
$updateRangeSelectionFromCaretRange,
Expand Down Expand Up @@ -1778,6 +1783,71 @@ export class RangeSelection implements BaseSelection {
if (this.forwardDeletion(anchor, anchorNode, isBackward)) {
return;
}
const direction = isBackward ? 'previous' : 'next';
const initialCaret = $caretFromPoint(anchor, direction);
const initialRange = $extendCaretToRange(initialCaret);
if (
initialRange
.getTextSlices()
.every((slice) => slice === null || slice.distance === 0)
) {
// There's no text in the direction of the deletion so we can explore our options
let state:
| {type: 'initial'}
| {
type: 'merge-next-block';
block: ElementNode;
} = {type: 'initial'};
for (const caret of initialRange.iterNodeCarets('shadowRoot')) {
if ($isChildCaret(caret)) {
if (caret.origin.isInline()) {
// fall through when descending an inline
} else if (caret.origin.isShadowRoot()) {
// Don't merge with a shadow root block
return;
} else if (state.type === 'merge-next-block') {
$updateRangeSelectionFromCaretRange(
this,
$getCaretRange(initialRange.anchor, caret),
);
return this.removeText();
}
} else if ($isSiblingCaret(caret)) {
if ($isElementNode(caret.origin)) {
if (!caret.origin.isInline()) {
state = {block: caret.origin, type: 'merge-next-block'};
} else if (!caret.origin.isParentOf(initialRange.anchor.origin)) {
break;
}
continue;
} else if ($isDecoratorNode(caret.origin)) {
if (caret.origin.isIsolated()) {
// do nothing, shouldn't delete an isolated decorator
} else if (
state.type === 'merge-next-block' &&
caret.origin.isKeyboardSelectable() &&
$isElementNode(initialRange.anchor.origin) &&
initialRange.anchor.origin.isEmpty()
) {
$removeTextFromCaretRange(
$getCaretRange(
initialRange.anchor,
$rewindSiblingCaret(caret),
),
);
const nodeSelection = $createNodeSelection();
nodeSelection.add(caret.origin.getKey());
$setSelection(nodeSelection);
} else {
caret.origin.remove();
}
// always stop when a decorator is encountered
return;
}
break;
}
}
}

// Handle the deletion around decorators.
const focus = this.focus;
Expand Down
12 changes: 11 additions & 1 deletion packages/lexical/src/caret/LexicalCaret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {LexicalNode, NodeKey} from '../LexicalNode';

import invariant from 'shared/invariant';

import {$isRootOrShadowRoot} from '../LexicalUtils';
import {$getRoot, $isRootOrShadowRoot} from '../LexicalUtils';
import {$isElementNode, type ElementNode} from '../nodes/LexicalElementNode';
import {$isRootNode} from '../nodes/LexicalRootNode';
import {TextNode} from '../nodes/LexicalTextNode';
Expand Down Expand Up @@ -1111,6 +1111,16 @@ export function $isTextPointCaretSlice<D extends CaretDirection>(
return caretOrSlice instanceof TextPointCaretSliceImpl;
}

/**
* Construct a CaretRange that starts at anchor and goes to the end of the
* document in the anchor caret's direction.
*/
export function $extendCaretToRange<D extends CaretDirection>(
anchor: PointCaret<D>,
): CaretRange<D> {
return $getCaretRange(anchor, $getSiblingCaret($getRoot(), anchor.direction));
}

/**
* Construct a CaretRange from anchor and focus carets pointing in the
* same direction. In order to get the expected behavior,
Expand Down
124 changes: 97 additions & 27 deletions packages/lexical/src/caret/LexicalCaretUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
type RangeSelection,
} from '../LexicalSelection';
import {
$getAncestor,
$getNodeByKeyOrThrow,
$setSelection,
INTERNAL_$isBlock,
Expand Down Expand Up @@ -207,6 +206,13 @@ function $getAnchorCandidates<D extends CaretDirection>(
return carets;
}

declare const CaretOriginAttachedBrand: unique symbol;
function $isCaretAttached<Caret extends PointCaret<CaretDirection>>(
caret: null | undefined | Caret,
): caret is Caret & {[CaretOriginAttachedBrand]: never} {
return !!caret && caret.origin.isAttached();
}

/**
* Remove all text and nodes in the given range. If the range spans multiple
* blocks then the remaining contents of the later block will be merged with
Expand Down Expand Up @@ -281,6 +287,7 @@ export function $removeTextFromCaretRange<D extends CaretDirection>(
} else if (slice.distance !== 0) {
sliceState = 'removeEmptySlices';
let nextCaret = slice.removeTextSlice();
const sliceOrigin = slice.caret.origin;
if (mode === 'segmented') {
const src = nextCaret.origin;
const plainTextNode = $createTextNode(src.getTextContent())
Expand All @@ -293,46 +300,57 @@ export function $removeTextFromCaretRange<D extends CaretDirection>(
nextCaret.offset,
);
}
if (anchorCandidates[0].isSameNodeCaret(slice.caret)) {
if (sliceOrigin.is(anchorCandidates[0].origin)) {
anchorCandidates[0] = nextCaret;
}
if (sliceOrigin.is(focusCandidates[0].origin)) {
focusCandidates[0] = nextCaret.getFlipped();
}
}
}

for (const candidates of [anchorCandidates, focusCandidates]) {
const deleteCount = candidates.findIndex((caret) =>
caret.origin.isAttached(),
);
candidates.splice(0, deleteCount);
let anchorCandidate: PointCaret<'next'> | undefined;
let focusCandidate: PointCaret<'previous'> | undefined;
for (const candidate of anchorCandidates) {
if ($isCaretAttached(candidate)) {
anchorCandidate = $normalizeCaret(candidate);
break;
}
}
for (const candidate of focusCandidates) {
if ($isCaretAttached(candidate)) {
focusCandidate = $normalizeCaret(candidate);
break;
}
}

const anchorCandidate = anchorCandidates.find((v) => v.origin.isAttached());
const focusCandidate = focusCandidates.find((v) => v.origin.isAttached());

// Merge blocks if necessary
const anchorBlock =
anchorCandidate && $getAncestor(anchorCandidate.origin, INTERNAL_$isBlock);
const focusBlock =
focusCandidate && $getAncestor(focusCandidate.origin, INTERNAL_$isBlock);
if (
$isElementNode(focusBlock) &&
seenStart.has(focusBlock.getKey()) &&
$isElementNode(anchorBlock)
) {
const mergeTargets = $getBlockMergeTargets(
anchorCandidate,
focusCandidate,
seenStart,
);
if (mergeTargets) {
const [anchorBlock, focusBlock] = mergeTargets;
// always merge blocks later in the document with
// blocks earlier in the document
$getChildCaret(anchorBlock, 'previous').splice(0, focusBlock.getChildren());
focusBlock.remove();
}

for (const caret of [anchorCandidate, focusCandidate]) {
if (caret && caret.origin.isAttached()) {
const anchor = $getCaretInDirection(
$normalizeCaret(caret),
initialRange.direction,
);
return $getCaretRange(anchor, anchor);
}
const bestCandidate: PointCaret<CaretDirection> | undefined =
$isCaretAttached(anchorCandidate)
? anchorCandidate
: $isCaretAttached(focusCandidate)
? focusCandidate
: anchorCandidates.find($isCaretAttached) ||
focusCandidates.find($isCaretAttached);
if (bestCandidate) {
const anchor = $getCaretInDirection(
$normalizeCaret(bestCandidate),
initialRange.direction,
);
return $getCaretRange(anchor, anchor);
}
invariant(
false,
Expand All @@ -341,6 +359,58 @@ export function $removeTextFromCaretRange<D extends CaretDirection>(
);
}

function $getBlockMergeTargets(
anchor: null | undefined | PointCaret<'next'>,
focus: null | undefined | PointCaret<'previous'>,
seenStart: Set<NodeKey>,
): null | [ElementNode, ElementNode] {
if (!anchor || !focus) {
return null;
}
const anchorParent = anchor.getParentAtCaret();
const focusParent = focus.getParentAtCaret();
if (!anchorParent || !focusParent) {
return null;
}
// TODO refactor when we have a better primitive for common ancestor
const anchorElements = anchorParent.getParents().reverse();
anchorElements.push(anchorParent);
const focusElements = focus.origin.getParents().reverse();
focusElements.push(focusParent);
const maxLen = Math.min(anchorElements.length, focusElements.length);
let commonAncestorCount: number;
for (
commonAncestorCount = 0;
commonAncestorCount < maxLen &&
anchorElements[commonAncestorCount] === focusElements[commonAncestorCount];
commonAncestorCount++
) {
// just traverse the ancestors
}
const $getBlock = (
arr: readonly ElementNode[],
isFocus: boolean,
): ElementNode | undefined => {
let block: ElementNode | undefined;
for (let i = commonAncestorCount; i < arr.length; i++) {
const ancestor = arr[i];
if (
!block &&
INTERNAL_$isBlock(ancestor) &&
(!isFocus || seenStart.has(ancestor.getKey()))
) {
block = ancestor;
} else if (ancestor.isShadowRoot()) {
return;
}
}
return block;
};
const anchorBlock = $getBlock(anchorElements, false);
const focusBlock = anchorBlock && $getBlock(focusElements, true);
return anchorBlock && focusBlock ? [anchorBlock, focusBlock] : null;
}

/**
* Return the deepest ChildCaret that has initialCaret's origin
* as an ancestor, or initialCaret if the origin is not an ElementNode
Expand Down
1 change: 1 addition & 0 deletions packages/lexical/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type {
TextPointCaretSliceTuple,
} from './caret/LexicalCaret';
export {
$extendCaretToRange,
$getAdjacentChildCaret,
$getCaretRange,
$getChildCaret,
Expand Down

0 comments on commit 1fa74fb

Please sign in to comment.