1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-21 14:41:23 +02:00

Fix selections with non-void non-editable focus (#5716)

* 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]: 7e77a932f0/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
This commit is contained in:
Ty Mick
2024-09-12 10:53:44 -07:00
committed by GitHub
parent 34c17af979
commit 10abeff84f
4 changed files with 87 additions and 18 deletions

View File

@@ -0,0 +1,5 @@
---
'slate-react': patch
---
Fix selections with non-void non-editable focus

View File

@@ -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,
@@ -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)
}
}

View File

@@ -20,6 +20,8 @@ import {
DOMText,
getSelection,
hasShadowRoot,
isAfter,
isBefore,
isDOMElement,
isDOMNode,
isDOMSelection,
@@ -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
@@ -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)
@@ -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
@@ -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 (
@@ -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],
@@ -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

View File

@@ -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
)