mirror of
https://github.com/ianstormtaylor/slate.git
synced 2025-08-21 22:45:18 +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.hasEditableTarget(editor, anchorNode) ||
|
||||||
ReactEditor.isTargetInsideNonReadonlyVoid(editor, anchorNode)
|
ReactEditor.isTargetInsideNonReadonlyVoid(editor, anchorNode)
|
||||||
|
|
||||||
const focusNodeSelectable =
|
const focusNodeInEditor = ReactEditor.hasTarget(editor, focusNode)
|
||||||
ReactEditor.hasEditableTarget(editor, focusNode) ||
|
|
||||||
ReactEditor.isTargetInsideNonReadonlyVoid(editor, focusNode)
|
|
||||||
|
|
||||||
if (anchorNodeSelectable && focusNodeSelectable) {
|
if (anchorNodeSelectable && focusNodeInEditor) {
|
||||||
const range = ReactEditor.toSlateRange(editor, domSelection, {
|
const range = ReactEditor.toSlateRange(editor, domSelection, {
|
||||||
exactMatch: false,
|
exactMatch: false,
|
||||||
suppressThrow: true,
|
suppressThrow: true,
|
||||||
@@ -279,7 +277,7 @@ export const Editable = forwardRef(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Deselect the editor if the dom selection is not selectable in readonly mode
|
// Deselect the editor if the dom selection is not selectable in readonly mode
|
||||||
if (readOnly && (!anchorNodeSelectable || !focusNodeSelectable)) {
|
if (readOnly && (!anchorNodeSelectable || !focusNodeInEditor)) {
|
||||||
Transforms.deselect(editor)
|
Transforms.deselect(editor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -20,6 +20,8 @@ import {
|
|||||||
DOMText,
|
DOMText,
|
||||||
getSelection,
|
getSelection,
|
||||||
hasShadowRoot,
|
hasShadowRoot,
|
||||||
|
isAfter,
|
||||||
|
isBefore,
|
||||||
isDOMElement,
|
isDOMElement,
|
||||||
isDOMNode,
|
isDOMNode,
|
||||||
isDOMSelection,
|
isDOMSelection,
|
||||||
@@ -244,6 +246,11 @@ export interface ReactEditorInterface {
|
|||||||
options: {
|
options: {
|
||||||
exactMatch: boolean
|
exactMatch: boolean
|
||||||
suppressThrow: T
|
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
|
) => T extends true ? Point | null : Point
|
||||||
|
|
||||||
@@ -681,9 +688,10 @@ export const ReactEditor: ReactEditorInterface = {
|
|||||||
options: {
|
options: {
|
||||||
exactMatch: boolean
|
exactMatch: boolean
|
||||||
suppressThrow: T
|
suppressThrow: T
|
||||||
|
searchDirection?: 'forward' | 'backward'
|
||||||
}
|
}
|
||||||
): T extends true ? Point | null : Point => {
|
): T extends true ? Point | null : Point => {
|
||||||
const { exactMatch, suppressThrow } = options
|
const { exactMatch, suppressThrow, searchDirection = 'backward' } = options
|
||||||
const [nearestNode, nearestOffset] = exactMatch
|
const [nearestNode, nearestOffset] = exactMatch
|
||||||
? domPoint
|
? domPoint
|
||||||
: normalizeDOMPoint(domPoint)
|
: normalizeDOMPoint(domPoint)
|
||||||
@@ -702,6 +710,13 @@ export const ReactEditor: ReactEditorInterface = {
|
|||||||
potentialVoidNode && editorEl.contains(potentialVoidNode)
|
potentialVoidNode && editorEl.contains(potentialVoidNode)
|
||||||
? potentialVoidNode
|
? potentialVoidNode
|
||||||
: null
|
: null
|
||||||
|
const potentialNonEditableNode = parentNode.closest(
|
||||||
|
'[contenteditable="false"]'
|
||||||
|
)
|
||||||
|
const nonEditableNode =
|
||||||
|
potentialNonEditableNode && editorEl.contains(potentialNonEditableNode)
|
||||||
|
? potentialNonEditableNode
|
||||||
|
: null
|
||||||
let leafNode = parentNode.closest('[data-slate-leaf]')
|
let leafNode = parentNode.closest('[data-slate-leaf]')
|
||||||
let domNode: DOMElement | null = null
|
let domNode: DOMElement | null = null
|
||||||
|
|
||||||
@@ -778,6 +793,47 @@ export const ReactEditor: ReactEditorInterface = {
|
|||||||
offset -= el.textContent!.length
|
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 (
|
if (
|
||||||
@@ -978,18 +1034,6 @@ export const ReactEditor: ReactEditorInterface = {
|
|||||||
focusOffset--
|
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(
|
const anchor = ReactEditor.toSlatePoint(
|
||||||
editor,
|
editor,
|
||||||
[anchorNode, anchorOffset],
|
[anchorNode, anchorOffset],
|
||||||
@@ -1002,11 +1046,15 @@ export const ReactEditor: ReactEditorInterface = {
|
|||||||
return null as T extends true ? Range | null : Range
|
return null as T extends true ? Range | null : Range
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const focusBeforeAnchor =
|
||||||
|
isBefore(anchorNode, focusNode) ||
|
||||||
|
(anchorNode === focusNode && focusOffset < anchorOffset)
|
||||||
const focus = isCollapsed
|
const focus = isCollapsed
|
||||||
? anchor
|
? anchor
|
||||||
: ReactEditor.toSlatePoint(editor, [focusNode, focusOffset], {
|
: ReactEditor.toSlatePoint(editor, [focusNode, focusOffset], {
|
||||||
exactMatch,
|
exactMatch,
|
||||||
suppressThrow,
|
suppressThrow,
|
||||||
|
searchDirection: focusBeforeAnchor ? 'forward' : 'backward',
|
||||||
})
|
})
|
||||||
if (!focus) {
|
if (!focus) {
|
||||||
return null as T extends true ? Range | null : Range
|
return null as T extends true ? Range | null : Range
|
||||||
|
@@ -337,3 +337,21 @@ export const getActiveElement = () => {
|
|||||||
|
|
||||||
return activeElement
|
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