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:
5
.changeset/brown-ears-tap.md
Normal file
5
.changeset/brown-ears-tap.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'slate-react': patch
|
||||
---
|
||||
|
||||
Fix selections with non-void non-editable focus
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
)
|
||||
|
Reference in New Issue
Block a user