Skip to content

Commit

Permalink
Fix selections with non-void non-editable focus (#5716)
Browse files Browse the repository at this point in the history
* Fix selections with non-void non-editable focus

"Non-void non-editable" refers to `contentEditable={false}` DOM nodes
that are rendered by a Slate element render but which are not void
elements. For instance, [the checkboxes in the checklists example][1].

[1]: https://github.com/ianstormtaylor/slate/blob/7e77a932f0489a9fff2d8a1957aa2dd9b324aa78/site/examples/check-lists.tsx#L153-L170

* fixup! Fix selections with non-void non-editable focus

Optimize leaf node search

* fixup! Fix selections with non-void non-editable focus

Rename `focusNodeSelectable` to `focusNodeIsSelectable`

A more accurate name given this PR's changes.

* fixup! Fix selections with non-void non-editable focus

Remove inapplicable `if` branch

* fixup! Fix selections with non-void non-editable focus

Improve comment
  • Loading branch information
TyMick authored Sep 12, 2024
1 parent 34c17af commit 10abeff
Show file tree
Hide file tree
Showing 4 changed files with 87 additions and 18 deletions.
5 changes: 5 additions & 0 deletions .changeset/brown-ears-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'slate-react': patch
---

Fix selections with non-void non-editable focus
8 changes: 3 additions & 5 deletions packages/slate-react/src/components/editable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,9 @@ export const Editable = forwardRef(
ReactEditor.hasEditableTarget(editor, anchorNode) ||
ReactEditor.isTargetInsideNonReadonlyVoid(editor, anchorNode)

const focusNodeSelectable =
ReactEditor.hasEditableTarget(editor, focusNode) ||
ReactEditor.isTargetInsideNonReadonlyVoid(editor, focusNode)
const focusNodeInEditor = ReactEditor.hasTarget(editor, focusNode)

if (anchorNodeSelectable && focusNodeSelectable) {
if (anchorNodeSelectable && focusNodeInEditor) {
const range = ReactEditor.toSlateRange(editor, domSelection, {
exactMatch: false,
suppressThrow: true,
Expand All @@ -279,7 +277,7 @@ export const Editable = forwardRef(
}

// Deselect the editor if the dom selection is not selectable in readonly mode
if (readOnly && (!anchorNodeSelectable || !focusNodeSelectable)) {
if (readOnly && (!anchorNodeSelectable || !focusNodeInEditor)) {
Transforms.deselect(editor)
}
}
Expand Down
74 changes: 61 additions & 13 deletions packages/slate-react/src/plugin/react-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
DOMText,
getSelection,
hasShadowRoot,
isAfter,
isBefore,
isDOMElement,
isDOMNode,
isDOMSelection,
Expand Down Expand Up @@ -244,6 +246,11 @@ export interface ReactEditorInterface {
options: {
exactMatch: boolean
suppressThrow: T
/**
* The direction to search for Slate leaf nodes if `domPoint` is
* non-editable and non-void.
*/
searchDirection?: 'forward' | 'backward'
}
) => T extends true ? Point | null : Point

Expand Down Expand Up @@ -681,9 +688,10 @@ export const ReactEditor: ReactEditorInterface = {
options: {
exactMatch: boolean
suppressThrow: T
searchDirection?: 'forward' | 'backward'
}
): T extends true ? Point | null : Point => {
const { exactMatch, suppressThrow } = options
const { exactMatch, suppressThrow, searchDirection = 'backward' } = options
const [nearestNode, nearestOffset] = exactMatch
? domPoint
: normalizeDOMPoint(domPoint)
Expand All @@ -702,6 +710,13 @@ export const ReactEditor: ReactEditorInterface = {
potentialVoidNode && editorEl.contains(potentialVoidNode)
? potentialVoidNode
: null
const potentialNonEditableNode = parentNode.closest(
'[contenteditable="false"]'
)
const nonEditableNode =
potentialNonEditableNode && editorEl.contains(potentialNonEditableNode)
? potentialNonEditableNode
: null
let leafNode = parentNode.closest('[data-slate-leaf]')
let domNode: DOMElement | null = null

Expand Down Expand Up @@ -778,6 +793,47 @@ export const ReactEditor: ReactEditorInterface = {
offset -= el.textContent!.length
})
}
} else if (nonEditableNode) {
// Find the edge of the nearest leaf in `searchDirection`
const getLeafNodes = (node: DOMElement | null | undefined) =>
node
? node.querySelectorAll(
// Exclude leaf nodes in nested editors
'[data-slate-leaf]:not(:scope [data-slate-editor] [data-slate-leaf])'
)
: []
const elementNode = nonEditableNode.closest(
'[data-slate-node="element"]'
)

if (searchDirection === 'forward') {
const leafNodes = [
...getLeafNodes(elementNode),
...getLeafNodes(elementNode?.nextElementSibling),
]
leafNode =
leafNodes.find(leaf => isAfter(nonEditableNode, leaf)) ?? null
} else {
const leafNodes = [
...getLeafNodes(elementNode?.previousElementSibling),
...getLeafNodes(elementNode),
]
leafNode =
leafNodes.findLast(leaf => isBefore(nonEditableNode, leaf)) ?? null
}

if (leafNode) {
textNode = leafNode.closest('[data-slate-node="text"]')!
domNode = leafNode
if (searchDirection === 'forward') {
offset = 0
} else {
offset = domNode.textContent!.length
domNode.querySelectorAll('[data-slate-zero-width]').forEach(el => {
offset -= el.textContent!.length
})
}
}
}

if (
Expand Down Expand Up @@ -978,18 +1034,6 @@ export const ReactEditor: ReactEditorInterface = {
focusOffset--
}

// COMPAT: Triple-clicking a word in chrome will sometimes place the focus
// inside a `contenteditable="false"` DOM node following the word, which
// will cause `toSlatePoint` to throw an error. (2023/03/07)
if (
'getAttribute' in focusNode &&
(focusNode as HTMLElement).getAttribute('contenteditable') === 'false' &&
(focusNode as HTMLElement).getAttribute('data-slate-void') !== 'true'
) {
focusNode = anchorNode
focusOffset = anchorNode.textContent?.length || 0
}

const anchor = ReactEditor.toSlatePoint(
editor,
[anchorNode, anchorOffset],
Expand All @@ -1002,11 +1046,15 @@ export const ReactEditor: ReactEditorInterface = {
return null as T extends true ? Range | null : Range
}

const focusBeforeAnchor =
isBefore(anchorNode, focusNode) ||
(anchorNode === focusNode && focusOffset < anchorOffset)
const focus = isCollapsed
? anchor
: ReactEditor.toSlatePoint(editor, [focusNode, focusOffset], {
exactMatch,
suppressThrow,
searchDirection: focusBeforeAnchor ? 'forward' : 'backward',
})
if (!focus) {
return null as T extends true ? Range | null : Range
Expand Down
18 changes: 18 additions & 0 deletions packages/slate-react/src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,21 @@ export const getActiveElement = () => {

return activeElement
}

/**
* @returns `true` if `otherNode` is before `node` in the document; otherwise, `false`.
*/
export const isBefore = (node: DOMNode, otherNode: DOMNode): boolean =>
Boolean(
node.compareDocumentPosition(otherNode) &
DOMNode.DOCUMENT_POSITION_PRECEDING
)

/**
* @returns `true` if `otherNode` is after `node` in the document; otherwise, `false`.
*/
export const isAfter = (node: DOMNode, otherNode: DOMNode): boolean =>
Boolean(
node.compareDocumentPosition(otherNode) &
DOMNode.DOCUMENT_POSITION_FOLLOWING
)

0 comments on commit 10abeff

Please sign in to comment.