diff --git a/.changeset/brown-ears-tap.md b/.changeset/brown-ears-tap.md new file mode 100644 index 0000000000..82c8d0895a --- /dev/null +++ b/.changeset/brown-ears-tap.md @@ -0,0 +1,5 @@ +--- +'slate-react': patch +--- + +Fix selections with non-void non-editable focus diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index 065e14ffef..f1945d53ae 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -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) } } diff --git a/packages/slate-react/src/plugin/react-editor.ts b/packages/slate-react/src/plugin/react-editor.ts index d2430211a0..a221f7e6d0 100644 --- a/packages/slate-react/src/plugin/react-editor.ts +++ b/packages/slate-react/src/plugin/react-editor.ts @@ -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 diff --git a/packages/slate-react/src/utils/dom.ts b/packages/slate-react/src/utils/dom.ts index 32f53975ee..d600faee9c 100644 --- a/packages/slate-react/src/utils/dom.ts +++ b/packages/slate-react/src/utils/dom.ts @@ -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 + )