diff --git a/.changeset/angry-brooms-whisper.md b/.changeset/angry-brooms-whisper.md new file mode 100644 index 000000000..b6da743fc --- /dev/null +++ b/.changeset/angry-brooms-whisper.md @@ -0,0 +1,5 @@ +--- +'slate-react': minor +--- + +Forward ref from Editable component diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index 1b6f9a325..7a7a50116 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -8,6 +8,8 @@ import React, { useReducer, useRef, useState, + forwardRef, + ForwardedRef, } from 'react' import { JSX } from 'react' import scrollIntoView from 'scroll-into-view-if-needed' @@ -130,1627 +132,1659 @@ export type EditableProps = { * Editable. */ -export const Editable = (props: EditableProps) => { - const defaultRenderPlaceholder = useCallback( - (props: RenderPlaceholderProps) => , - [] - ) - const { - autoFocus, - decorate = defaultDecorate, - onDOMBeforeInput: propsOnDOMBeforeInput, - placeholder, - readOnly = false, - renderElement, - renderLeaf, - renderPlaceholder = defaultRenderPlaceholder, - scrollSelectionIntoView = defaultScrollSelectionIntoView, - style: userStyle = {}, - as: Component = 'div', - disableDefaultStyles = false, - ...attributes - } = props - const editor = useSlate() - // Rerender editor when composition status changed - const [isComposing, setIsComposing] = useState(false) - const ref = useRef(null) - const deferredOperations = useRef([]) - const [placeholderHeight, setPlaceholderHeight] = useState< - number | undefined - >() - const processing = useRef(false) +export const Editable = forwardRef( + (props: EditableProps, forwardedRef: ForwardedRef) => { + const defaultRenderPlaceholder = useCallback( + (props: RenderPlaceholderProps) => , + [] + ) + const { + autoFocus, + decorate = defaultDecorate, + onDOMBeforeInput: propsOnDOMBeforeInput, + placeholder, + readOnly = false, + renderElement, + renderLeaf, + renderPlaceholder = defaultRenderPlaceholder, + scrollSelectionIntoView = defaultScrollSelectionIntoView, + style: userStyle = {}, + as: Component = 'div', + disableDefaultStyles = false, + ...attributes + } = props + const editor = useSlate() + // Rerender editor when composition status changed + const [isComposing, setIsComposing] = useState(false) + const ref = useRef(null) + const deferredOperations = useRef([]) + const [placeholderHeight, setPlaceholderHeight] = useState< + number | undefined + >() + const processing = useRef(false) - const { onUserInput, receivedUserInput } = useTrackUserInput() + const { onUserInput, receivedUserInput } = useTrackUserInput() - const [, forceRender] = useReducer(s => s + 1, 0) - EDITOR_TO_FORCE_RENDER.set(editor, forceRender) + const [, forceRender] = useReducer(s => s + 1, 0) + EDITOR_TO_FORCE_RENDER.set(editor, forceRender) - // Update internal state on each render. - IS_READ_ONLY.set(editor, readOnly) + // Update internal state on each render. + IS_READ_ONLY.set(editor, readOnly) - // Keep track of some state for the event handler logic. - const state = useMemo( - () => ({ - isDraggingInternally: false, - isUpdatingSelection: false, - latestElement: null as DOMElement | null, - hasMarkPlaceholder: false, - }), - [] - ) + // Keep track of some state for the event handler logic. + const state = useMemo( + () => ({ + isDraggingInternally: false, + isUpdatingSelection: false, + latestElement: null as DOMElement | null, + hasMarkPlaceholder: false, + }), + [] + ) - // The autoFocus TextareaHTMLAttribute doesn't do anything on a div, so it - // needs to be manually focused. - useEffect(() => { - if (ref.current && autoFocus) { - ref.current.focus() - } - }, [autoFocus]) + // The autoFocus TextareaHTMLAttribute doesn't do anything on a div, so it + // needs to be manually focused. + useEffect(() => { + if (ref.current && autoFocus) { + ref.current.focus() + } + }, [autoFocus]) - /** - * The AndroidInputManager object has a cyclical dependency on onDOMSelectionChange - * - * It is defined as a reference to simplify hook dependencies and clarify that - * it needs to be initialized. - */ - const androidInputManagerRef = useRef< - AndroidInputManager | null | undefined - >() + /** + * The AndroidInputManager object has a cyclical dependency on onDOMSelectionChange + * + * It is defined as a reference to simplify hook dependencies and clarify that + * it needs to be initialized. + */ + const androidInputManagerRef = useRef< + AndroidInputManager | null | undefined + >() - // Listen on the native `selectionchange` event to be able to update any time - // the selection changes. This is required because React's `onSelect` is leaky - // and non-standard so it doesn't fire until after a selection has been - // released. This causes issues in situations where another change happens - // while a selection is being dragged. - const onDOMSelectionChange = useMemo( - () => - throttle(() => { + // Listen on the native `selectionchange` event to be able to update any time + // the selection changes. This is required because React's `onSelect` is leaky + // and non-standard so it doesn't fire until after a selection has been + // released. This causes issues in situations where another change happens + // while a selection is being dragged. + const onDOMSelectionChange = useMemo( + () => + throttle(() => { + const el = ReactEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if (!processing.current && IS_WEBKIT && root instanceof ShadowRoot) { + processing.current = true + + const active = getActiveElement() + + if (active) { + document.execCommand('indent') + } else { + Transforms.deselect(editor) + } + + processing.current = false + return + } + + const androidInputManager = androidInputManagerRef.current + if ( + (IS_ANDROID || !ReactEditor.isComposing(editor)) && + (!state.isUpdatingSelection || androidInputManager?.isFlushing()) && + !state.isDraggingInternally + ) { + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const { activeElement } = root + const el = ReactEditor.toDOMNode(editor, editor) + const domSelection = getSelection(root) + + if (activeElement === el) { + state.latestElement = activeElement + IS_FOCUSED.set(editor, true) + } else { + IS_FOCUSED.delete(editor) + } + + if (!domSelection) { + return Transforms.deselect(editor) + } + + const { anchorNode, focusNode } = domSelection + + const anchorNodeSelectable = + ReactEditor.hasEditableTarget(editor, anchorNode) || + ReactEditor.isTargetInsideNonReadonlyVoid(editor, anchorNode) + + const focusNodeSelectable = + ReactEditor.hasEditableTarget(editor, focusNode) || + ReactEditor.isTargetInsideNonReadonlyVoid(editor, focusNode) + + if (anchorNodeSelectable && focusNodeSelectable) { + const range = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: false, + suppressThrow: true, + }) + + if (range) { + if ( + !ReactEditor.isComposing(editor) && + !androidInputManager?.hasPendingChanges() && + !androidInputManager?.isFlushing() + ) { + Transforms.select(editor, range) + } else { + androidInputManager?.handleUserSelect(range) + } + } + } + + // Deselect the editor if the dom selection is not selectable in readonly mode + if (readOnly && (!anchorNodeSelectable || !focusNodeSelectable)) { + Transforms.deselect(editor) + } + } + }, 100), + [editor, readOnly, state] + ) + + const scheduleOnDOMSelectionChange = useMemo( + () => debounce(onDOMSelectionChange, 0), + [onDOMSelectionChange] + ) + + androidInputManagerRef.current = useAndroidInputManager({ + node: ref, + onDOMSelectionChange, + scheduleOnDOMSelectionChange, + }) + + useIsomorphicLayoutEffect(() => { + // Update element-related weak maps with the DOM element ref. + let window + if (ref.current && (window = getDefaultView(ref.current))) { + EDITOR_TO_WINDOW.set(editor, window) + EDITOR_TO_ELEMENT.set(editor, ref.current) + NODE_TO_ELEMENT.set(editor, ref.current) + ELEMENT_TO_NODE.set(ref.current, editor) + } else { + NODE_TO_ELEMENT.delete(editor) + } + + // Make sure the DOM selection state is in sync. + const { selection } = editor + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const domSelection = getSelection(root) + + if ( + !domSelection || + !ReactEditor.isFocused(editor) || + androidInputManagerRef.current?.hasPendingAction() + ) { + return + } + + const setDomSelection = (forceChange?: boolean) => { + const hasDomSelection = domSelection.type !== 'None' + + // If the DOM selection is properly unset, we're done. + if (!selection && !hasDomSelection) { + return + } + + // Get anchorNode and focusNode + const focusNode = domSelection.focusNode + let anchorNode + + // COMPAT: In firefox the normal seletion way does not work + // (https://github.com/ianstormtaylor/slate/pull/5486#issue-1820720223) + if (IS_FIREFOX && domSelection.rangeCount > 1) { + const firstRange = domSelection.getRangeAt(0) + const lastRange = domSelection.getRangeAt(domSelection.rangeCount - 1) + + // Right to left + if (firstRange.startContainer === focusNode) { + anchorNode = lastRange.endContainer + } else { + // Left to right + anchorNode = firstRange.startContainer + } + } else { + anchorNode = domSelection.anchorNode + } + + // verify that the dom selection is in the editor + const editorElement = EDITOR_TO_ELEMENT.get(editor)! + let hasDomSelectionInEditor = false + if ( + editorElement.contains(anchorNode) && + editorElement.contains(focusNode) + ) { + hasDomSelectionInEditor = true + } + + // If the DOM selection is in the editor and the editor selection is already correct, we're done. + if ( + hasDomSelection && + hasDomSelectionInEditor && + selection && + !forceChange + ) { + const slateRange = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: true, + + // domSelection is not necessarily a valid Slate range + // (e.g. when clicking on contentEditable:false element) + suppressThrow: true, + }) + + if (slateRange && Range.equals(slateRange, selection)) { + if (!state.hasMarkPlaceholder) { + return + } + + // Ensure selection is inside the mark placeholder + if ( + anchorNode?.parentElement?.hasAttribute( + 'data-slate-mark-placeholder' + ) + ) { + return + } + } + } + + // when is being controlled through external value + // then its children might just change - DOM responds to it on its own + // but Slate's value is not being updated through any operation + // and thus it doesn't transform selection on its own + if (selection && !ReactEditor.hasRange(editor, selection)) { + editor.selection = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: false, + suppressThrow: true, + }) + return + } + + // Otherwise the DOM selection is out of sync, so update it. + state.isUpdatingSelection = true + + const newDomRange: DOMRange | null = + selection && ReactEditor.toDOMRange(editor, selection) + + if (newDomRange) { + if (ReactEditor.isComposing(editor) && !IS_ANDROID) { + domSelection.collapseToEnd() + } else if (Range.isBackward(selection!)) { + domSelection.setBaseAndExtent( + newDomRange.endContainer, + newDomRange.endOffset, + newDomRange.startContainer, + newDomRange.startOffset + ) + } else { + domSelection.setBaseAndExtent( + newDomRange.startContainer, + newDomRange.startOffset, + newDomRange.endContainer, + newDomRange.endOffset + ) + } + scrollSelectionIntoView(editor, newDomRange) + } else { + domSelection.removeAllRanges() + } + + return newDomRange + } + + // In firefox if there is more then 1 range and we call setDomSelection we remove the ability to select more cells in a table + if (domSelection.rangeCount <= 1) { + setDomSelection() + } + + const ensureSelection = + androidInputManagerRef.current?.isFlushing() === 'action' + + if (!IS_ANDROID || !ensureSelection) { + setTimeout(() => { + state.isUpdatingSelection = false + }) + return + } + + let timeoutId: ReturnType | null = null + const animationFrameId = requestAnimationFrame(() => { + if (ensureSelection) { + const ensureDomSelection = (forceChange?: boolean) => { + try { + const el = ReactEditor.toDOMNode(editor, editor) + el.focus() + + setDomSelection(forceChange) + } catch (e) { + // Ignore, dom and state might be out of sync + } + } + + // Compat: Android IMEs try to force their selection by manually re-applying it even after we set it. + // This essentially would make setting the slate selection during an update meaningless, so we force it + // again here. We can't only do it in the setTimeout after the animation frame since that would cause a + // visible flicker. + ensureDomSelection() + + timeoutId = setTimeout(() => { + // COMPAT: While setting the selection in an animation frame visually correctly sets the selection, + // it doesn't update GBoards spellchecker state. We have to manually trigger a selection change after + // the animation frame to ensure it displays the correct state. + ensureDomSelection(true) + state.isUpdatingSelection = false + }) + } + }) + + return () => { + cancelAnimationFrame(animationFrameId) + if (timeoutId) { + clearTimeout(timeoutId) + } + } + }) + + // Listen on the native `beforeinput` event to get real "Level 2" events. This + // is required because React's `beforeinput` is fake and never really attaches + // to the real event sadly. (2019/11/01) + // https://github.com/facebook/react/issues/11211 + const onDOMBeforeInput = useCallback( + (event: InputEvent) => { const el = ReactEditor.toDOMNode(editor, editor) const root = el.getRootNode() - if (!processing.current && IS_WEBKIT && root instanceof ShadowRoot) { - processing.current = true + if (processing?.current && IS_WEBKIT && root instanceof ShadowRoot) { + const ranges = event.getTargetRanges() + const range = ranges[0] - const active = getActiveElement() + const newRange = new window.Range() - if (active) { - document.execCommand('indent') - } else { - Transforms.deselect(editor) - } + newRange.setStart(range.startContainer, range.startOffset) + newRange.setEnd(range.endContainer, range.endOffset) - processing.current = false - return - } + // Translate the DOM Range into a Slate Range + const slateRange = ReactEditor.toSlateRange(editor, newRange, { + exactMatch: false, + suppressThrow: false, + }) - const androidInputManager = androidInputManagerRef.current - if ( - (IS_ANDROID || !ReactEditor.isComposing(editor)) && - (!state.isUpdatingSelection || androidInputManager?.isFlushing()) && - !state.isDraggingInternally - ) { - const root = ReactEditor.findDocumentOrShadowRoot(editor) - const { activeElement } = root - const el = ReactEditor.toDOMNode(editor, editor) - const domSelection = getSelection(root) + Transforms.select(editor, slateRange) - if (activeElement === el) { - state.latestElement = activeElement - IS_FOCUSED.set(editor, true) - } else { - IS_FOCUSED.delete(editor) - } - - if (!domSelection) { - return Transforms.deselect(editor) - } - - const { anchorNode, focusNode } = domSelection - - const anchorNodeSelectable = - ReactEditor.hasEditableTarget(editor, anchorNode) || - ReactEditor.isTargetInsideNonReadonlyVoid(editor, anchorNode) - - const focusNodeSelectable = - ReactEditor.hasEditableTarget(editor, focusNode) || - ReactEditor.isTargetInsideNonReadonlyVoid(editor, focusNode) - - if (anchorNodeSelectable && focusNodeSelectable) { - const range = ReactEditor.toSlateRange(editor, domSelection, { - exactMatch: false, - suppressThrow: true, - }) - - if (range) { - if ( - !ReactEditor.isComposing(editor) && - !androidInputManager?.hasPendingChanges() && - !androidInputManager?.isFlushing() - ) { - Transforms.select(editor, range) - } else { - androidInputManager?.handleUserSelect(range) - } - } - } - - // Deselect the editor if the dom selection is not selectable in readonly mode - if (readOnly && (!anchorNodeSelectable || !focusNodeSelectable)) { - Transforms.deselect(editor) - } - } - }, 100), - [editor, readOnly, state] - ) - - const scheduleOnDOMSelectionChange = useMemo( - () => debounce(onDOMSelectionChange, 0), - [onDOMSelectionChange] - ) - - androidInputManagerRef.current = useAndroidInputManager({ - node: ref, - onDOMSelectionChange, - scheduleOnDOMSelectionChange, - }) - - useIsomorphicLayoutEffect(() => { - // Update element-related weak maps with the DOM element ref. - let window - if (ref.current && (window = getDefaultView(ref.current))) { - EDITOR_TO_WINDOW.set(editor, window) - EDITOR_TO_ELEMENT.set(editor, ref.current) - NODE_TO_ELEMENT.set(editor, ref.current) - ELEMENT_TO_NODE.set(ref.current, editor) - } else { - NODE_TO_ELEMENT.delete(editor) - } - - // Make sure the DOM selection state is in sync. - const { selection } = editor - const root = ReactEditor.findDocumentOrShadowRoot(editor) - const domSelection = getSelection(root) - - if ( - !domSelection || - !ReactEditor.isFocused(editor) || - androidInputManagerRef.current?.hasPendingAction() - ) { - return - } - - const setDomSelection = (forceChange?: boolean) => { - const hasDomSelection = domSelection.type !== 'None' - - // If the DOM selection is properly unset, we're done. - if (!selection && !hasDomSelection) { - return - } - - // Get anchorNode and focusNode - const focusNode = domSelection.focusNode - let anchorNode - - // COMPAT: In firefox the normal seletion way does not work - // (https://github.com/ianstormtaylor/slate/pull/5486#issue-1820720223) - if (IS_FIREFOX && domSelection.rangeCount > 1) { - const firstRange = domSelection.getRangeAt(0) - const lastRange = domSelection.getRangeAt(domSelection.rangeCount - 1) - - // Right to left - if (firstRange.startContainer === focusNode) { - anchorNode = lastRange.endContainer - } else { - // Left to right - anchorNode = firstRange.startContainer - } - } else { - anchorNode = domSelection.anchorNode - } - - // verify that the dom selection is in the editor - const editorElement = EDITOR_TO_ELEMENT.get(editor)! - let hasDomSelectionInEditor = false - if ( - editorElement.contains(anchorNode) && - editorElement.contains(focusNode) - ) { - hasDomSelectionInEditor = true - } - - // If the DOM selection is in the editor and the editor selection is already correct, we're done. - if ( - hasDomSelection && - hasDomSelectionInEditor && - selection && - !forceChange - ) { - const slateRange = ReactEditor.toSlateRange(editor, domSelection, { - exactMatch: true, - - // domSelection is not necessarily a valid Slate range - // (e.g. when clicking on contentEditable:false element) - suppressThrow: true, - }) - - if (slateRange && Range.equals(slateRange, selection)) { - if (!state.hasMarkPlaceholder) { - return - } - - // Ensure selection is inside the mark placeholder - if ( - anchorNode?.parentElement?.hasAttribute( - 'data-slate-mark-placeholder' - ) - ) { - return - } - } - } - - // when is being controlled through external value - // then its children might just change - DOM responds to it on its own - // but Slate's value is not being updated through any operation - // and thus it doesn't transform selection on its own - if (selection && !ReactEditor.hasRange(editor, selection)) { - editor.selection = ReactEditor.toSlateRange(editor, domSelection, { - exactMatch: false, - suppressThrow: true, - }) - return - } - - // Otherwise the DOM selection is out of sync, so update it. - state.isUpdatingSelection = true - - const newDomRange: DOMRange | null = - selection && ReactEditor.toDOMRange(editor, selection) - - if (newDomRange) { - if (ReactEditor.isComposing(editor) && !IS_ANDROID) { - domSelection.collapseToEnd() - } else if (Range.isBackward(selection!)) { - domSelection.setBaseAndExtent( - newDomRange.endContainer, - newDomRange.endOffset, - newDomRange.startContainer, - newDomRange.startOffset - ) - } else { - domSelection.setBaseAndExtent( - newDomRange.startContainer, - newDomRange.startOffset, - newDomRange.endContainer, - newDomRange.endOffset - ) - } - scrollSelectionIntoView(editor, newDomRange) - } else { - domSelection.removeAllRanges() - } - - return newDomRange - } - - // In firefox if there is more then 1 range and we call setDomSelection we remove the ability to select more cells in a table - if (domSelection.rangeCount <= 1) { - setDomSelection() - } - - const ensureSelection = - androidInputManagerRef.current?.isFlushing() === 'action' - - if (!IS_ANDROID || !ensureSelection) { - setTimeout(() => { - state.isUpdatingSelection = false - }) - return - } - - let timeoutId: ReturnType | null = null - const animationFrameId = requestAnimationFrame(() => { - if (ensureSelection) { - const ensureDomSelection = (forceChange?: boolean) => { - try { - const el = ReactEditor.toDOMNode(editor, editor) - el.focus() - - setDomSelection(forceChange) - } catch (e) { - // Ignore, dom and state might be out of sync - } - } - - // Compat: Android IMEs try to force their selection by manually re-applying it even after we set it. - // This essentially would make setting the slate selection during an update meaningless, so we force it - // again here. We can't only do it in the setTimeout after the animation frame since that would cause a - // visible flicker. - ensureDomSelection() - - timeoutId = setTimeout(() => { - // COMPAT: While setting the selection in an animation frame visually correctly sets the selection, - // it doesn't update GBoards spellchecker state. We have to manually trigger a selection change after - // the animation frame to ensure it displays the correct state. - ensureDomSelection(true) - state.isUpdatingSelection = false - }) - } - }) - - return () => { - cancelAnimationFrame(animationFrameId) - if (timeoutId) { - clearTimeout(timeoutId) - } - } - }) - - // Listen on the native `beforeinput` event to get real "Level 2" events. This - // is required because React's `beforeinput` is fake and never really attaches - // to the real event sadly. (2019/11/01) - // https://github.com/facebook/react/issues/11211 - const onDOMBeforeInput = useCallback( - (event: InputEvent) => { - const el = ReactEditor.toDOMNode(editor, editor) - const root = el.getRootNode() - - if (processing?.current && IS_WEBKIT && root instanceof ShadowRoot) { - const ranges = event.getTargetRanges() - const range = ranges[0] - - const newRange = new window.Range() - - newRange.setStart(range.startContainer, range.startOffset) - newRange.setEnd(range.endContainer, range.endOffset) - - // Translate the DOM Range into a Slate Range - const slateRange = ReactEditor.toSlateRange(editor, newRange, { - exactMatch: false, - suppressThrow: false, - }) - - Transforms.select(editor, slateRange) - - event.preventDefault() - event.stopImmediatePropagation() - return - } - onUserInput() - - if ( - !readOnly && - ReactEditor.hasEditableTarget(editor, event.target) && - !isDOMEventHandled(event, propsOnDOMBeforeInput) - ) { - // COMPAT: BeforeInput events aren't cancelable on android, so we have to handle them differently using the android input manager. - if (androidInputManagerRef.current) { - return androidInputManagerRef.current.handleDOMBeforeInput(event) - } - - // Some IMEs/Chrome extensions like e.g. Grammarly set the selection immediately before - // triggering a `beforeinput` expecting the change to be applied to the immediately before - // set selection. - scheduleOnDOMSelectionChange.flush() - onDOMSelectionChange.flush() - - const { selection } = editor - const { inputType: type } = event - const data = (event as any).dataTransfer || event.data || undefined - - const isCompositionChange = - type === 'insertCompositionText' || type === 'deleteCompositionText' - - // COMPAT: use composition change events as a hint to where we should insert - // composition text if we aren't composing to work around https://github.com/ianstormtaylor/slate/issues/5038 - if (isCompositionChange && ReactEditor.isComposing(editor)) { - return - } - - let native = false - if ( - type === 'insertText' && - selection && - Range.isCollapsed(selection) && - // Only use native character insertion for single characters a-z or space for now. - // Long-press events (hold a + press 4 = ä) to choose a special character otherwise - // causes duplicate inserts. - event.data && - event.data.length === 1 && - /[a-z ]/i.test(event.data) && - // Chrome has issues correctly editing the start of nodes: https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 - // When there is an inline element, e.g. a link, and you select - // right after it (the start of the next node). - selection.anchor.offset !== 0 - ) { - native = true - - // Skip native if there are marks, as - // `insertText` will insert a node, not just text. - if (editor.marks) { - native = false - } - - // Chrome also has issues correctly editing the end of anchor elements: https://bugs.chromium.org/p/chromium/issues/detail?id=1259100 - // Therefore we don't allow native events to insert text at the end of anchor nodes. - const { anchor } = selection - - const [node, offset] = ReactEditor.toDOMPoint(editor, anchor) - const anchorNode = node.parentElement?.closest('a') - - const window = ReactEditor.getWindow(editor) - - if ( - native && - anchorNode && - ReactEditor.hasDOMNode(editor, anchorNode) - ) { - // Find the last text node inside the anchor. - const lastText = window?.document - .createTreeWalker(anchorNode, NodeFilter.SHOW_TEXT) - .lastChild() as DOMText | null - - if (lastText === node && lastText.textContent?.length === offset) { - native = false - } - } - - // Chrome has issues with the presence of tab characters inside elements with whiteSpace = 'pre' - // causing abnormal insert behavior: https://bugs.chromium.org/p/chromium/issues/detail?id=1219139 - if ( - native && - node.parentElement && - window?.getComputedStyle(node.parentElement)?.whiteSpace === 'pre' - ) { - const block = Editor.above(editor, { - at: anchor.path, - match: n => Element.isElement(n) && Editor.isBlock(editor, n), - }) - - if (block && Node.string(block[0]).includes('\t')) { - native = false - } - } - } - - // COMPAT: For the deleting forward/backward input types we don't want - // to change the selection because it is the range that will be deleted, - // and those commands determine that for themselves. - if (!type.startsWith('delete') || type.startsWith('deleteBy')) { - const [targetRange] = (event as any).getTargetRanges() - - if (targetRange) { - const range = ReactEditor.toSlateRange(editor, targetRange, { - exactMatch: false, - suppressThrow: false, - }) - - if (!selection || !Range.equals(selection, range)) { - native = false - - const selectionRef = - !isCompositionChange && - editor.selection && - Editor.rangeRef(editor, editor.selection) - - Transforms.select(editor, range) - - if (selectionRef) { - EDITOR_TO_USER_SELECTION.set(editor, selectionRef) - } - } - } - } - - // Composition change types occur while a user is composing text and can't be - // cancelled. Let them through and wait for the composition to end. - if (isCompositionChange) { - return - } - - if (!native) { event.preventDefault() - } - - // COMPAT: If the selection is expanded, even if the command seems like - // a delete forward/backward command it should delete the selection. - if ( - selection && - Range.isExpanded(selection) && - type.startsWith('delete') - ) { - const direction = type.endsWith('Backward') ? 'backward' : 'forward' - Editor.deleteFragment(editor, { direction }) + event.stopImmediatePropagation() return } - - switch (type) { - case 'deleteByComposition': - case 'deleteByCut': - case 'deleteByDrag': { - Editor.deleteFragment(editor) - break - } - - case 'deleteContent': - case 'deleteContentForward': { - Editor.deleteForward(editor) - break - } - - case 'deleteContentBackward': { - Editor.deleteBackward(editor) - break - } - - case 'deleteEntireSoftLine': { - Editor.deleteBackward(editor, { unit: 'line' }) - Editor.deleteForward(editor, { unit: 'line' }) - break - } - - case 'deleteHardLineBackward': { - Editor.deleteBackward(editor, { unit: 'block' }) - break - } - - case 'deleteSoftLineBackward': { - Editor.deleteBackward(editor, { unit: 'line' }) - break - } - - case 'deleteHardLineForward': { - Editor.deleteForward(editor, { unit: 'block' }) - break - } - - case 'deleteSoftLineForward': { - Editor.deleteForward(editor, { unit: 'line' }) - break - } - - case 'deleteWordBackward': { - Editor.deleteBackward(editor, { unit: 'word' }) - break - } - - case 'deleteWordForward': { - Editor.deleteForward(editor, { unit: 'word' }) - break - } - - case 'insertLineBreak': - Editor.insertSoftBreak(editor) - break - - case 'insertParagraph': { - Editor.insertBreak(editor) - break - } - - case 'insertFromComposition': - case 'insertFromDrop': - case 'insertFromPaste': - case 'insertFromYank': - case 'insertReplacementText': - case 'insertText': { - if (type === 'insertFromComposition') { - // COMPAT: in Safari, `compositionend` is dispatched after the - // `beforeinput` for "insertFromComposition". But if we wait for it - // then we will abort because we're still composing and the selection - // won't be updated properly. - // https://www.w3.org/TR/input-events-2/ - if (ReactEditor.isComposing(editor)) { - setIsComposing(false) - IS_COMPOSING.set(editor, false) - } - } - - // use a weak comparison instead of 'instanceof' to allow - // programmatic access of paste events coming from external windows - // like cypress where cy.window does not work realibly - if (data?.constructor.name === 'DataTransfer') { - ReactEditor.insertData(editor, data) - } else if (typeof data === 'string') { - // Only insertText operations use the native functionality, for now. - // Potentially expand to single character deletes, as well. - if (native) { - deferredOperations.current.push(() => - Editor.insertText(editor, data) - ) - } else { - Editor.insertText(editor, data) - } - } - - break - } - } - - // Restore the actual user section if nothing manually set it. - const toRestore = EDITOR_TO_USER_SELECTION.get(editor)?.unref() - EDITOR_TO_USER_SELECTION.delete(editor) + onUserInput() if ( - toRestore && - (!editor.selection || !Range.equals(editor.selection, toRestore)) + !readOnly && + ReactEditor.hasEditableTarget(editor, event.target) && + !isDOMEventHandled(event, propsOnDOMBeforeInput) ) { - Transforms.select(editor, toRestore) + // COMPAT: BeforeInput events aren't cancelable on android, so we have to handle them differently using the android input manager. + if (androidInputManagerRef.current) { + return androidInputManagerRef.current.handleDOMBeforeInput(event) + } + + // Some IMEs/Chrome extensions like e.g. Grammarly set the selection immediately before + // triggering a `beforeinput` expecting the change to be applied to the immediately before + // set selection. + scheduleOnDOMSelectionChange.flush() + onDOMSelectionChange.flush() + + const { selection } = editor + const { inputType: type } = event + const data = (event as any).dataTransfer || event.data || undefined + + const isCompositionChange = + type === 'insertCompositionText' || type === 'deleteCompositionText' + + // COMPAT: use composition change events as a hint to where we should insert + // composition text if we aren't composing to work around https://github.com/ianstormtaylor/slate/issues/5038 + if (isCompositionChange && ReactEditor.isComposing(editor)) { + return + } + + let native = false + if ( + type === 'insertText' && + selection && + Range.isCollapsed(selection) && + // Only use native character insertion for single characters a-z or space for now. + // Long-press events (hold a + press 4 = ä) to choose a special character otherwise + // causes duplicate inserts. + event.data && + event.data.length === 1 && + /[a-z ]/i.test(event.data) && + // Chrome has issues correctly editing the start of nodes: https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 + // When there is an inline element, e.g. a link, and you select + // right after it (the start of the next node). + selection.anchor.offset !== 0 + ) { + native = true + + // Skip native if there are marks, as + // `insertText` will insert a node, not just text. + if (editor.marks) { + native = false + } + + // Chrome also has issues correctly editing the end of anchor elements: https://bugs.chromium.org/p/chromium/issues/detail?id=1259100 + // Therefore we don't allow native events to insert text at the end of anchor nodes. + const { anchor } = selection + + const [node, offset] = ReactEditor.toDOMPoint(editor, anchor) + const anchorNode = node.parentElement?.closest('a') + + const window = ReactEditor.getWindow(editor) + + if ( + native && + anchorNode && + ReactEditor.hasDOMNode(editor, anchorNode) + ) { + // Find the last text node inside the anchor. + const lastText = window?.document + .createTreeWalker(anchorNode, NodeFilter.SHOW_TEXT) + .lastChild() as DOMText | null + + if ( + lastText === node && + lastText.textContent?.length === offset + ) { + native = false + } + } + + // Chrome has issues with the presence of tab characters inside elements with whiteSpace = 'pre' + // causing abnormal insert behavior: https://bugs.chromium.org/p/chromium/issues/detail?id=1219139 + if ( + native && + node.parentElement && + window?.getComputedStyle(node.parentElement)?.whiteSpace === 'pre' + ) { + const block = Editor.above(editor, { + at: anchor.path, + match: n => Element.isElement(n) && Editor.isBlock(editor, n), + }) + + if (block && Node.string(block[0]).includes('\t')) { + native = false + } + } + } + + // COMPAT: For the deleting forward/backward input types we don't want + // to change the selection because it is the range that will be deleted, + // and those commands determine that for themselves. + if (!type.startsWith('delete') || type.startsWith('deleteBy')) { + const [targetRange] = (event as any).getTargetRanges() + + if (targetRange) { + const range = ReactEditor.toSlateRange(editor, targetRange, { + exactMatch: false, + suppressThrow: false, + }) + + if (!selection || !Range.equals(selection, range)) { + native = false + + const selectionRef = + !isCompositionChange && + editor.selection && + Editor.rangeRef(editor, editor.selection) + + Transforms.select(editor, range) + + if (selectionRef) { + EDITOR_TO_USER_SELECTION.set(editor, selectionRef) + } + } + } + } + + // Composition change types occur while a user is composing text and can't be + // cancelled. Let them through and wait for the composition to end. + if (isCompositionChange) { + return + } + + if (!native) { + event.preventDefault() + } + + // COMPAT: If the selection is expanded, even if the command seems like + // a delete forward/backward command it should delete the selection. + if ( + selection && + Range.isExpanded(selection) && + type.startsWith('delete') + ) { + const direction = type.endsWith('Backward') ? 'backward' : 'forward' + Editor.deleteFragment(editor, { direction }) + return + } + + switch (type) { + case 'deleteByComposition': + case 'deleteByCut': + case 'deleteByDrag': { + Editor.deleteFragment(editor) + break + } + + case 'deleteContent': + case 'deleteContentForward': { + Editor.deleteForward(editor) + break + } + + case 'deleteContentBackward': { + Editor.deleteBackward(editor) + break + } + + case 'deleteEntireSoftLine': { + Editor.deleteBackward(editor, { unit: 'line' }) + Editor.deleteForward(editor, { unit: 'line' }) + break + } + + case 'deleteHardLineBackward': { + Editor.deleteBackward(editor, { unit: 'block' }) + break + } + + case 'deleteSoftLineBackward': { + Editor.deleteBackward(editor, { unit: 'line' }) + break + } + + case 'deleteHardLineForward': { + Editor.deleteForward(editor, { unit: 'block' }) + break + } + + case 'deleteSoftLineForward': { + Editor.deleteForward(editor, { unit: 'line' }) + break + } + + case 'deleteWordBackward': { + Editor.deleteBackward(editor, { unit: 'word' }) + break + } + + case 'deleteWordForward': { + Editor.deleteForward(editor, { unit: 'word' }) + break + } + + case 'insertLineBreak': + Editor.insertSoftBreak(editor) + break + + case 'insertParagraph': { + Editor.insertBreak(editor) + break + } + + case 'insertFromComposition': + case 'insertFromDrop': + case 'insertFromPaste': + case 'insertFromYank': + case 'insertReplacementText': + case 'insertText': { + if (type === 'insertFromComposition') { + // COMPAT: in Safari, `compositionend` is dispatched after the + // `beforeinput` for "insertFromComposition". But if we wait for it + // then we will abort because we're still composing and the selection + // won't be updated properly. + // https://www.w3.org/TR/input-events-2/ + if (ReactEditor.isComposing(editor)) { + setIsComposing(false) + IS_COMPOSING.set(editor, false) + } + } + + // use a weak comparison instead of 'instanceof' to allow + // programmatic access of paste events coming from external windows + // like cypress where cy.window does not work realibly + if (data?.constructor.name === 'DataTransfer') { + ReactEditor.insertData(editor, data) + } else if (typeof data === 'string') { + // Only insertText operations use the native functionality, for now. + // Potentially expand to single character deletes, as well. + if (native) { + deferredOperations.current.push(() => + Editor.insertText(editor, data) + ) + } else { + Editor.insertText(editor, data) + } + } + + break + } + } + + // Restore the actual user section if nothing manually set it. + const toRestore = EDITOR_TO_USER_SELECTION.get(editor)?.unref() + EDITOR_TO_USER_SELECTION.delete(editor) + + if ( + toRestore && + (!editor.selection || !Range.equals(editor.selection, toRestore)) + ) { + Transforms.select(editor, toRestore) + } } - } - }, - [ - editor, - onDOMSelectionChange, - onUserInput, - propsOnDOMBeforeInput, - readOnly, - scheduleOnDOMSelectionChange, - ] - ) - - const callbackRef = useCallback( - (node: HTMLDivElement | null) => { - if (node == null) { - onDOMSelectionChange.cancel() - scheduleOnDOMSelectionChange.cancel() - - EDITOR_TO_ELEMENT.delete(editor) - NODE_TO_ELEMENT.delete(editor) - - if (ref.current && HAS_BEFORE_INPUT_SUPPORT) { - // @ts-ignore The `beforeinput` event isn't recognized. - ref.current.removeEventListener('beforeinput', onDOMBeforeInput) - } - } else { - // Attach a native DOM event handler for `beforeinput` events, because React's - // built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose - // real `beforeinput` events sadly... (2019/11/04) - // https://github.com/facebook/react/issues/11211 - if (HAS_BEFORE_INPUT_SUPPORT) { - // @ts-ignore The `beforeinput` event isn't recognized. - node.addEventListener('beforeinput', onDOMBeforeInput) - } - } - - ref.current = node - }, - [ - onDOMSelectionChange, - scheduleOnDOMSelectionChange, - editor, - onDOMBeforeInput, - ] - ) - - useIsomorphicLayoutEffect(() => { - const window = ReactEditor.getWindow(editor) - - // Attach a native DOM event handler for `selectionchange`, because React's - // built-in `onSelect` handler doesn't fire for all selection changes. It's - // a leaky polyfill that only fires on keypresses or clicks. Instead, we - // want to fire for any change to the selection inside the editor. - // (2019/11/04) https://github.com/facebook/react/issues/5785 - window.document.addEventListener( - 'selectionchange', - scheduleOnDOMSelectionChange + }, + [ + editor, + onDOMSelectionChange, + onUserInput, + propsOnDOMBeforeInput, + readOnly, + scheduleOnDOMSelectionChange, + ] ) - // Listen for dragend and drop globally. In Firefox, if a drop handler - // initiates an operation that causes the originally dragged element to - // unmount, that element will not emit a dragend event. (2024/06/21) - const stoppedDragging = () => { - state.isDraggingInternally = false - } - window.document.addEventListener('dragend', stoppedDragging) - window.document.addEventListener('drop', stoppedDragging) + const callbackRef = useCallback( + (node: HTMLDivElement | null) => { + if (node == null) { + onDOMSelectionChange.cancel() + scheduleOnDOMSelectionChange.cancel() - return () => { - window.document.removeEventListener( + EDITOR_TO_ELEMENT.delete(editor) + NODE_TO_ELEMENT.delete(editor) + + if (ref.current && HAS_BEFORE_INPUT_SUPPORT) { + // @ts-ignore The `beforeinput` event isn't recognized. + ref.current.removeEventListener('beforeinput', onDOMBeforeInput) + } + } else { + // Attach a native DOM event handler for `beforeinput` events, because React's + // built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose + // real `beforeinput` events sadly... (2019/11/04) + // https://github.com/facebook/react/issues/11211 + if (HAS_BEFORE_INPUT_SUPPORT) { + // @ts-ignore The `beforeinput` event isn't recognized. + node.addEventListener('beforeinput', onDOMBeforeInput) + } + } + + ref.current = node + if (typeof forwardedRef === 'function') { + forwardedRef(node) + } else if (forwardedRef) { + forwardedRef.current = node + } + }, + [ + onDOMSelectionChange, + scheduleOnDOMSelectionChange, + editor, + onDOMBeforeInput, + forwardedRef, + ] + ) + + useIsomorphicLayoutEffect(() => { + const window = ReactEditor.getWindow(editor) + + // Attach a native DOM event handler for `selectionchange`, because React's + // built-in `onSelect` handler doesn't fire for all selection changes. It's + // a leaky polyfill that only fires on keypresses or clicks. Instead, we + // want to fire for any change to the selection inside the editor. + // (2019/11/04) https://github.com/facebook/react/issues/5785 + window.document.addEventListener( 'selectionchange', scheduleOnDOMSelectionChange ) - window.document.removeEventListener('dragend', stoppedDragging) - window.document.removeEventListener('drop', stoppedDragging) - } - }, [scheduleOnDOMSelectionChange, state]) - const decorations = decorate([editor, []]) - - const showPlaceholder = - placeholder && - editor.children.length === 1 && - Array.from(Node.texts(editor)).length === 1 && - Node.string(editor) === '' && - !isComposing - - const placeHolderResizeHandler = useCallback( - (placeholderEl: HTMLElement | null) => { - if (placeholderEl && showPlaceholder) { - setPlaceholderHeight(placeholderEl.getBoundingClientRect()?.height) - } else { - setPlaceholderHeight(undefined) + // Listen for dragend and drop globally. In Firefox, if a drop handler + // initiates an operation that causes the originally dragged element to + // unmount, that element will not emit a dragend event. (2024/06/21) + const stoppedDragging = () => { + state.isDraggingInternally = false } - }, - [showPlaceholder] - ) + window.document.addEventListener('dragend', stoppedDragging) + window.document.addEventListener('drop', stoppedDragging) - if (showPlaceholder) { - const start = Editor.start(editor, []) - decorations.push({ - [PLACEHOLDER_SYMBOL]: true, - placeholder, - onPlaceholderResize: placeHolderResizeHandler, - anchor: start, - focus: start, - }) - } + return () => { + window.document.removeEventListener( + 'selectionchange', + scheduleOnDOMSelectionChange + ) + window.document.removeEventListener('dragend', stoppedDragging) + window.document.removeEventListener('drop', stoppedDragging) + } + }, [scheduleOnDOMSelectionChange, state]) - const { marks } = editor - state.hasMarkPlaceholder = false + const decorations = decorate([editor, []]) - if (editor.selection && Range.isCollapsed(editor.selection) && marks) { - const { anchor } = editor.selection - const leaf = Node.leaf(editor, anchor.path) - const { text, ...rest } = leaf + const showPlaceholder = + placeholder && + editor.children.length === 1 && + Array.from(Node.texts(editor)).length === 1 && + Node.string(editor) === '' && + !isComposing - // While marks isn't a 'complete' text, we can still use loose Text.equals - // here which only compares marks anyway. - if (!Text.equals(leaf, marks as Text, { loose: true })) { - state.hasMarkPlaceholder = true - - const unset = Object.fromEntries( - Object.keys(rest).map(mark => [mark, null]) - ) + const placeHolderResizeHandler = useCallback( + (placeholderEl: HTMLElement | null) => { + if (placeholderEl && showPlaceholder) { + setPlaceholderHeight(placeholderEl.getBoundingClientRect()?.height) + } else { + setPlaceholderHeight(undefined) + } + }, + [showPlaceholder] + ) + if (showPlaceholder) { + const start = Editor.start(editor, []) decorations.push({ - [MARK_PLACEHOLDER_SYMBOL]: true, - ...unset, - ...marks, - - anchor, - focus: anchor, + [PLACEHOLDER_SYMBOL]: true, + placeholder, + onPlaceholderResize: placeHolderResizeHandler, + anchor: start, + focus: start, }) } - } - // Update EDITOR_TO_MARK_PLACEHOLDER_MARKS in setTimeout useEffect to ensure we don't set it - // before we receive the composition end event. - useEffect(() => { - setTimeout(() => { - const { selection } = editor - if (selection) { - const { anchor } = selection - const text = Node.leaf(editor, anchor.path) + const { marks } = editor + state.hasMarkPlaceholder = false - // While marks isn't a 'complete' text, we can still use loose Text.equals - // here which only compares marks anyway. - if (marks && !Text.equals(text, marks as Text, { loose: true })) { - EDITOR_TO_PENDING_INSERTION_MARKS.set(editor, marks) - return - } + if (editor.selection && Range.isCollapsed(editor.selection) && marks) { + const { anchor } = editor.selection + const leaf = Node.leaf(editor, anchor.path) + const { text, ...rest } = leaf + + // While marks isn't a 'complete' text, we can still use loose Text.equals + // here which only compares marks anyway. + if (!Text.equals(leaf, marks as Text, { loose: true })) { + state.hasMarkPlaceholder = true + + const unset = Object.fromEntries( + Object.keys(rest).map(mark => [mark, null]) + ) + + decorations.push({ + [MARK_PLACEHOLDER_SYMBOL]: true, + ...unset, + ...marks, + + anchor, + focus: anchor, + }) } + } - EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor) + // Update EDITOR_TO_MARK_PLACEHOLDER_MARKS in setTimeout useEffect to ensure we don't set it + // before we receive the composition end event. + useEffect(() => { + setTimeout(() => { + const { selection } = editor + if (selection) { + const { anchor } = selection + const text = Node.leaf(editor, anchor.path) + + // While marks isn't a 'complete' text, we can still use loose Text.equals + // here which only compares marks anyway. + if (marks && !Text.equals(text, marks as Text, { loose: true })) { + EDITOR_TO_PENDING_INSERTION_MARKS.set(editor, marks) + return + } + } + + EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor) + }) }) - }) - - return ( - - - - ) => { - // COMPAT: Certain browsers don't support the `beforeinput` event, so we - // fall back to React's leaky polyfill instead just for it. It - // only works for the `insertText` input type. - if ( - !HAS_BEFORE_INPUT_SUPPORT && - !readOnly && - !isEventHandled(event, attributes.onBeforeInput) && - ReactEditor.hasSelectableTarget(editor, event.target) - ) { - event.preventDefault() - if (!ReactEditor.isComposing(editor)) { - const text = (event as any).data as string - Editor.insertText(editor, text) - } - } - }, - [attributes.onBeforeInput, editor, readOnly] - )} - onInput={useCallback( - (event: React.FormEvent) => { - if (isEventHandled(event, attributes.onInput)) { - return - } - - if (androidInputManagerRef.current) { - androidInputManagerRef.current.handleInput() - return - } - - // Flush native operations, as native events will have propogated - // and we can correctly compare DOM text values in components - // to stop rendering, so that browser functions like autocorrect - // and spellcheck work as expected. - for (const op of deferredOperations.current) { - op() - } - deferredOperations.current = [] - }, - [attributes.onInput] - )} - onBlur={useCallback( - (event: React.FocusEvent) => { - if ( - readOnly || - state.isUpdatingSelection || - !ReactEditor.hasSelectableTarget(editor, event.target) || - isEventHandled(event, attributes.onBlur) - ) { - return - } - - // COMPAT: If the current `activeElement` is still the previous - // one, this is due to the window being blurred when the tab - // itself becomes unfocused, so we want to abort early to allow to - // editor to stay focused when the tab becomes focused again. - const root = ReactEditor.findDocumentOrShadowRoot(editor) - if (state.latestElement === root.activeElement) { - return - } - - const { relatedTarget } = event - const el = ReactEditor.toDOMNode(editor, editor) - - // COMPAT: The event should be ignored if the focus is returning - // to the editor from an embedded editable element (eg. an - // element inside a void node). - if (relatedTarget === el) { - return - } - - // COMPAT: The event should be ignored if the focus is moving from - // the editor to inside a void node's spacer element. - if ( - isDOMElement(relatedTarget) && - relatedTarget.hasAttribute('data-slate-spacer') - ) { - return - } - - // COMPAT: The event should be ignored if the focus is moving to a - // non- editable section of an element that isn't a void node (eg. - // a list item of the check list example). - if ( - relatedTarget != null && - isDOMNode(relatedTarget) && - ReactEditor.hasDOMNode(editor, relatedTarget) - ) { - const node = ReactEditor.toSlateNode(editor, relatedTarget) - - if (Element.isElement(node) && !editor.isVoid(node)) { - return - } - } - - // COMPAT: Safari doesn't always remove the selection even if the content- - // editable element no longer has focus. Refer to: - // https://stackoverflow.com/questions/12353247/force-contenteditable-div-to-stop-accepting-input-after-it-loses-focus-under-web - if (IS_WEBKIT) { - const domSelection = getSelection(root) - domSelection?.removeAllRanges() - } - - IS_FOCUSED.delete(editor) - }, - [ - readOnly, - state.isUpdatingSelection, - state.latestElement, - editor, - attributes.onBlur, - ] - )} - onClick={useCallback( - (event: React.MouseEvent) => { - if ( - ReactEditor.hasTarget(editor, event.target) && - !isEventHandled(event, attributes.onClick) && - isDOMNode(event.target) - ) { - const node = ReactEditor.toSlateNode(editor, event.target) - const path = ReactEditor.findPath(editor, node) - - // At this time, the Slate document may be arbitrarily different, - // because onClick handlers can change the document before we get here. - // Therefore we must check that this path actually exists, - // and that it still refers to the same node. - if ( - !Editor.hasPath(editor, path) || - Node.get(editor, path) !== node - ) { - return - } - - if (event.detail === TRIPLE_CLICK && path.length >= 1) { - let blockPath = path - if ( - !(Element.isElement(node) && Editor.isBlock(editor, node)) - ) { - const block = Editor.above(editor, { - match: n => - Element.isElement(n) && Editor.isBlock(editor, n), - at: path, - }) - - blockPath = block?.[1] ?? path.slice(0, 1) - } - - const range = Editor.range(editor, blockPath) - Transforms.select(editor, range) - return - } - - if (readOnly) { - return - } - - const start = Editor.start(editor, path) - const end = Editor.end(editor, path) - const startVoid = Editor.void(editor, { at: start }) - const endVoid = Editor.void(editor, { at: end }) - - if ( - startVoid && - endVoid && - Path.equals(startVoid[1], endVoid[1]) - ) { - const range = Editor.range(editor, start) - Transforms.select(editor, range) - } - } - }, - [editor, attributes.onClick, readOnly] - )} - onCompositionEnd={useCallback( - (event: React.CompositionEvent) => { - if (ReactEditor.hasSelectableTarget(editor, event.target)) { - if (ReactEditor.isComposing(editor)) { - Promise.resolve().then(() => { - setIsComposing(false) - IS_COMPOSING.set(editor, false) - }) - } - - androidInputManagerRef.current?.handleCompositionEnd(event) - - if ( - isEventHandled(event, attributes.onCompositionEnd) || - IS_ANDROID - ) { - return - } - - // COMPAT: In Chrome, `beforeinput` events for compositions - // aren't correct and never fire the "insertFromComposition" - // type that we need. So instead, insert whenever a composition - // ends since it will already have been committed to the DOM. - if ( - !IS_WEBKIT && - !IS_FIREFOX_LEGACY && - !IS_IOS && - !IS_WECHATBROWSER && - !IS_UC_MOBILE && - event.data - ) { - const placeholderMarks = - EDITOR_TO_PENDING_INSERTION_MARKS.get(editor) - EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor) - - // Ensure we insert text with the marks the user was actually seeing - if (placeholderMarks !== undefined) { - EDITOR_TO_USER_MARKS.set(editor, editor.marks) - editor.marks = placeholderMarks - } - - Editor.insertText(editor, event.data) - - const userMarks = EDITOR_TO_USER_MARKS.get(editor) - EDITOR_TO_USER_MARKS.delete(editor) - if (userMarks !== undefined) { - editor.marks = userMarks - } - } - } - }, - [attributes.onCompositionEnd, editor] - )} - onCompositionUpdate={useCallback( - (event: React.CompositionEvent) => { - if ( - ReactEditor.hasSelectableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCompositionUpdate) - ) { - if (!ReactEditor.isComposing(editor)) { - setIsComposing(true) - IS_COMPOSING.set(editor, true) - } - } - }, - [attributes.onCompositionUpdate, editor] - )} - onCompositionStart={useCallback( - (event: React.CompositionEvent) => { - if (ReactEditor.hasSelectableTarget(editor, event.target)) { - androidInputManagerRef.current?.handleCompositionStart(event) - - if ( - isEventHandled(event, attributes.onCompositionStart) || - IS_ANDROID - ) { - return - } - - setIsComposing(true) - - const { selection } = editor - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor) - return - } - } - }, - [attributes.onCompositionStart, editor] - )} - onCopy={useCallback( - (event: React.ClipboardEvent) => { - if ( - ReactEditor.hasSelectableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCopy) && - !isDOMEventTargetInput(event) - ) { - event.preventDefault() - ReactEditor.setFragmentData( - editor, - event.clipboardData, - 'copy' - ) - } - }, - [attributes.onCopy, editor] - )} - onCut={useCallback( - (event: React.ClipboardEvent) => { - if ( - !readOnly && - ReactEditor.hasSelectableTarget(editor, event.target) && - !isEventHandled(event, attributes.onCut) && - !isDOMEventTargetInput(event) - ) { - event.preventDefault() - ReactEditor.setFragmentData( - editor, - event.clipboardData, - 'cut' - ) - const { selection } = editor - - if (selection) { - if (Range.isExpanded(selection)) { - Editor.deleteFragment(editor) - } else { - const node = Node.parent(editor, selection.anchor.path) - if (Editor.isVoid(editor, node)) { - Transforms.delete(editor) - } - } - } - } - }, - [readOnly, editor, attributes.onCut] - )} - onDragOver={useCallback( - (event: React.DragEvent) => { - if ( - ReactEditor.hasTarget(editor, event.target) && - !isEventHandled(event, attributes.onDragOver) - ) { - // Only when the target is void, call `preventDefault` to signal - // that drops are allowed. Editable content is droppable by - // default, and calling `preventDefault` hides the cursor. - const node = ReactEditor.toSlateNode(editor, event.target) - - if (Element.isElement(node) && Editor.isVoid(editor, node)) { - event.preventDefault() - } - } - }, - [attributes.onDragOver, editor] - )} - onDragStart={useCallback( - (event: React.DragEvent) => { - if ( - !readOnly && - ReactEditor.hasTarget(editor, event.target) && - !isEventHandled(event, attributes.onDragStart) - ) { - const node = ReactEditor.toSlateNode(editor, event.target) - const path = ReactEditor.findPath(editor, node) - const voidMatch = - (Element.isElement(node) && Editor.isVoid(editor, node)) || - Editor.void(editor, { at: path, voids: true }) - - // If starting a drag on a void node, make sure it is selected - // so that it shows up in the selection's fragment. - if (voidMatch) { - const range = Editor.range(editor, path) - Transforms.select(editor, range) - } - - state.isDraggingInternally = true - - ReactEditor.setFragmentData( - editor, - event.dataTransfer, - 'drag' - ) - } - }, - [readOnly, editor, attributes.onDragStart, state] - )} - onDrop={useCallback( - (event: React.DragEvent) => { - if ( - !readOnly && - ReactEditor.hasTarget(editor, event.target) && - !isEventHandled(event, attributes.onDrop) - ) { - event.preventDefault() - - // Keep a reference to the dragged range before updating selection - const draggedRange = editor.selection - - // Find the range where the drop happened - const range = ReactEditor.findEventRange(editor, event) - const data = event.dataTransfer - - Transforms.select(editor, range) - - if (state.isDraggingInternally) { - if ( - draggedRange && - !Range.equals(draggedRange, range) && - !Editor.void(editor, { at: range, voids: true }) - ) { - Transforms.delete(editor, { - at: draggedRange, - }) - } - } - - ReactEditor.insertData(editor, data) - - // When dragging from another source into the editor, it's possible - // that the current editor does not have focus. - if (!ReactEditor.isFocused(editor)) { - ReactEditor.focus(editor) - } - } - }, - [readOnly, editor, attributes.onDrop, state] - )} - onDragEnd={useCallback( - (event: React.DragEvent) => { - if ( - !readOnly && - state.isDraggingInternally && - attributes.onDragEnd && - ReactEditor.hasTarget(editor, event.target) - ) { - attributes.onDragEnd(event) - } - }, - [readOnly, state, attributes, editor] - )} - onFocus={useCallback( - (event: React.FocusEvent) => { - if ( - !readOnly && - !state.isUpdatingSelection && - ReactEditor.hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onFocus) - ) { - const el = ReactEditor.toDOMNode(editor, editor) - const root = ReactEditor.findDocumentOrShadowRoot(editor) - state.latestElement = root.activeElement - - // COMPAT: If the editor has nested editable elements, the focus - // can go to them. In Firefox, this must be prevented because it - // results in issues with keyboard navigation. (2017/03/30) - if (IS_FIREFOX && event.target !== el) { - el.focus() - return - } - - IS_FOCUSED.set(editor, true) - } - }, - [readOnly, state, editor, attributes.onFocus] - )} - onKeyDown={useCallback( - (event: React.KeyboardEvent) => { - if ( - !readOnly && - ReactEditor.hasEditableTarget(editor, event.target) - ) { - androidInputManagerRef.current?.handleKeyDown(event) - - const { nativeEvent } = event - - // COMPAT: The composition end event isn't fired reliably in all browsers, - // so we sometimes might end up stuck in a composition state even though we - // aren't composing any more. - if ( - ReactEditor.isComposing(editor) && - nativeEvent.isComposing === false - ) { - IS_COMPOSING.set(editor, false) - setIsComposing(false) - } - - if ( - isEventHandled(event, attributes.onKeyDown) || - ReactEditor.isComposing(editor) - ) { - return - } - - const { selection } = editor - const element = - editor.children[ - selection !== null ? selection.focus.path[0] : 0 - ] - const isRTL = getDirection(Node.string(element)) === 'rtl' - - // COMPAT: Since we prevent the default behavior on - // `beforeinput` events, the browser doesn't think there's ever - // any history stack to undo or redo, so we have to manage these - // hotkeys ourselves. (2019/11/06) - if (Hotkeys.isRedo(nativeEvent)) { - event.preventDefault() - const maybeHistoryEditor: any = editor - - if (typeof maybeHistoryEditor.redo === 'function') { - maybeHistoryEditor.redo() - } - - return - } - - if (Hotkeys.isUndo(nativeEvent)) { - event.preventDefault() - const maybeHistoryEditor: any = editor - - if (typeof maybeHistoryEditor.undo === 'function') { - maybeHistoryEditor.undo() - } - - return - } - - // COMPAT: Certain browsers don't handle the selection updates - // properly. In Chrome, the selection isn't properly extended. - // And in Firefox, the selection isn't properly collapsed. - // (2017/10/17) - if (Hotkeys.isMoveLineBackward(nativeEvent)) { - event.preventDefault() - Transforms.move(editor, { unit: 'line', reverse: true }) - return - } - - if (Hotkeys.isMoveLineForward(nativeEvent)) { - event.preventDefault() - Transforms.move(editor, { unit: 'line' }) - return - } - - if (Hotkeys.isExtendLineBackward(nativeEvent)) { - event.preventDefault() - Transforms.move(editor, { - unit: 'line', - edge: 'focus', - reverse: true, - }) - return - } - - if (Hotkeys.isExtendLineForward(nativeEvent)) { - event.preventDefault() - Transforms.move(editor, { unit: 'line', edge: 'focus' }) - return - } - - // COMPAT: If a void node is selected, or a zero-width text node - // adjacent to an inline is selected, we need to handle these - // hotkeys manually because browsers won't be able to skip over - // the void node with the zero-width space not being an empty - // string. - if (Hotkeys.isMoveBackward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isCollapsed(selection)) { - Transforms.move(editor, { reverse: !isRTL }) - } else { - Transforms.collapse(editor, { - edge: isRTL ? 'end' : 'start', - }) - } - - return - } - - if (Hotkeys.isMoveForward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isCollapsed(selection)) { - Transforms.move(editor, { reverse: isRTL }) - } else { - Transforms.collapse(editor, { - edge: isRTL ? 'start' : 'end', - }) - } - - return - } - - if (Hotkeys.isMoveWordBackward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Transforms.collapse(editor, { edge: 'focus' }) - } - - Transforms.move(editor, { unit: 'word', reverse: !isRTL }) - return - } - - if (Hotkeys.isMoveWordForward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Transforms.collapse(editor, { edge: 'focus' }) - } - - Transforms.move(editor, { unit: 'word', reverse: isRTL }) - return - } + return ( + + + + ) => { // COMPAT: Certain browsers don't support the `beforeinput` event, so we - // fall back to guessing at the input intention for hotkeys. - // COMPAT: In iOS, some of these hotkeys are handled in the - if (!HAS_BEFORE_INPUT_SUPPORT) { - // We don't have a core behavior for these, but they change the - // DOM if we don't prevent them, so we have to. + // fall back to React's leaky polyfill instead just for it. It + // only works for the `insertText` input type. + if ( + !HAS_BEFORE_INPUT_SUPPORT && + !readOnly && + !isEventHandled(event, attributes.onBeforeInput) && + ReactEditor.hasSelectableTarget(editor, event.target) + ) { + event.preventDefault() + if (!ReactEditor.isComposing(editor)) { + const text = (event as any).data as string + Editor.insertText(editor, text) + } + } + }, + [attributes.onBeforeInput, editor, readOnly] + )} + onInput={useCallback( + (event: React.FormEvent) => { + if (isEventHandled(event, attributes.onInput)) { + return + } + + if (androidInputManagerRef.current) { + androidInputManagerRef.current.handleInput() + return + } + + // Flush native operations, as native events will have propogated + // and we can correctly compare DOM text values in components + // to stop rendering, so that browser functions like autocorrect + // and spellcheck work as expected. + for (const op of deferredOperations.current) { + op() + } + deferredOperations.current = [] + }, + [attributes.onInput] + )} + onBlur={useCallback( + (event: React.FocusEvent) => { + if ( + readOnly || + state.isUpdatingSelection || + !ReactEditor.hasSelectableTarget(editor, event.target) || + isEventHandled(event, attributes.onBlur) + ) { + return + } + + // COMPAT: If the current `activeElement` is still the previous + // one, this is due to the window being blurred when the tab + // itself becomes unfocused, so we want to abort early to allow to + // editor to stay focused when the tab becomes focused again. + const root = ReactEditor.findDocumentOrShadowRoot(editor) + if (state.latestElement === root.activeElement) { + return + } + + const { relatedTarget } = event + const el = ReactEditor.toDOMNode(editor, editor) + + // COMPAT: The event should be ignored if the focus is returning + // to the editor from an embedded editable element (eg. an + // element inside a void node). + if (relatedTarget === el) { + return + } + + // COMPAT: The event should be ignored if the focus is moving from + // the editor to inside a void node's spacer element. + if ( + isDOMElement(relatedTarget) && + relatedTarget.hasAttribute('data-slate-spacer') + ) { + return + } + + // COMPAT: The event should be ignored if the focus is moving to a + // non- editable section of an element that isn't a void node (eg. + // a list item of the check list example). + if ( + relatedTarget != null && + isDOMNode(relatedTarget) && + ReactEditor.hasDOMNode(editor, relatedTarget) + ) { + const node = ReactEditor.toSlateNode(editor, relatedTarget) + + if (Element.isElement(node) && !editor.isVoid(node)) { + return + } + } + + // COMPAT: Safari doesn't always remove the selection even if the content- + // editable element no longer has focus. Refer to: + // https://stackoverflow.com/questions/12353247/force-contenteditable-div-to-stop-accepting-input-after-it-loses-focus-under-web + if (IS_WEBKIT) { + const domSelection = getSelection(root) + domSelection?.removeAllRanges() + } + + IS_FOCUSED.delete(editor) + }, + [ + readOnly, + state.isUpdatingSelection, + state.latestElement, + editor, + attributes.onBlur, + ] + )} + onClick={useCallback( + (event: React.MouseEvent) => { + if ( + ReactEditor.hasTarget(editor, event.target) && + !isEventHandled(event, attributes.onClick) && + isDOMNode(event.target) + ) { + const node = ReactEditor.toSlateNode(editor, event.target) + const path = ReactEditor.findPath(editor, node) + + // At this time, the Slate document may be arbitrarily different, + // because onClick handlers can change the document before we get here. + // Therefore we must check that this path actually exists, + // and that it still refers to the same node. if ( - Hotkeys.isBold(nativeEvent) || - Hotkeys.isItalic(nativeEvent) || - Hotkeys.isTransposeCharacter(nativeEvent) + !Editor.hasPath(editor, path) || + Node.get(editor, path) !== node ) { - event.preventDefault() return } - if (Hotkeys.isSoftBreak(nativeEvent)) { - event.preventDefault() - Editor.insertSoftBreak(editor) - return - } - - if (Hotkeys.isSplitBlock(nativeEvent)) { - event.preventDefault() - Editor.insertBreak(editor) - return - } - - if (Hotkeys.isDeleteBackward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'backward' }) - } else { - Editor.deleteBackward(editor) - } - - return - } - - if (Hotkeys.isDeleteForward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'forward' }) - } else { - Editor.deleteForward(editor) - } - - return - } - - if (Hotkeys.isDeleteLineBackward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'backward' }) - } else { - Editor.deleteBackward(editor, { unit: 'line' }) - } - - return - } - - if (Hotkeys.isDeleteLineForward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'forward' }) - } else { - Editor.deleteForward(editor, { unit: 'line' }) - } - - return - } - - if (Hotkeys.isDeleteWordBackward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'backward' }) - } else { - Editor.deleteBackward(editor, { unit: 'word' }) - } - - return - } - - if (Hotkeys.isDeleteWordForward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'forward' }) - } else { - Editor.deleteForward(editor, { unit: 'word' }) - } - - return - } - } else { - if (IS_CHROME || IS_WEBKIT) { - // COMPAT: Chrome and Safari support `beforeinput` event but do not fire - // an event when deleting backwards in a selected void inline node + if (event.detail === TRIPLE_CLICK && path.length >= 1) { + let blockPath = path if ( - selection && - (Hotkeys.isDeleteBackward(nativeEvent) || - Hotkeys.isDeleteForward(nativeEvent)) && - Range.isCollapsed(selection) - ) { - const currentNode = Node.parent( - editor, - selection.anchor.path + !( + Element.isElement(node) && + Editor.isBlock(editor, node) ) + ) { + const block = Editor.above(editor, { + match: n => + Element.isElement(n) && Editor.isBlock(editor, n), + at: path, + }) - if ( - Element.isElement(currentNode) && - Editor.isVoid(editor, currentNode) && - (Editor.isInline(editor, currentNode) || - Editor.isBlock(editor, currentNode)) - ) { - event.preventDefault() - Editor.deleteBackward(editor, { unit: 'block' }) + blockPath = block?.[1] ?? path.slice(0, 1) + } - return + const range = Editor.range(editor, blockPath) + Transforms.select(editor, range) + return + } + + if (readOnly) { + return + } + + const start = Editor.start(editor, path) + const end = Editor.end(editor, path) + const startVoid = Editor.void(editor, { at: start }) + const endVoid = Editor.void(editor, { at: end }) + + if ( + startVoid && + endVoid && + Path.equals(startVoid[1], endVoid[1]) + ) { + const range = Editor.range(editor, start) + Transforms.select(editor, range) + } + } + }, + [editor, attributes.onClick, readOnly] + )} + onCompositionEnd={useCallback( + (event: React.CompositionEvent) => { + if (ReactEditor.hasSelectableTarget(editor, event.target)) { + if (ReactEditor.isComposing(editor)) { + Promise.resolve().then(() => { + setIsComposing(false) + IS_COMPOSING.set(editor, false) + }) + } + + androidInputManagerRef.current?.handleCompositionEnd(event) + + if ( + isEventHandled(event, attributes.onCompositionEnd) || + IS_ANDROID + ) { + return + } + + // COMPAT: In Chrome, `beforeinput` events for compositions + // aren't correct and never fire the "insertFromComposition" + // type that we need. So instead, insert whenever a composition + // ends since it will already have been committed to the DOM. + if ( + !IS_WEBKIT && + !IS_FIREFOX_LEGACY && + !IS_IOS && + !IS_WECHATBROWSER && + !IS_UC_MOBILE && + event.data + ) { + const placeholderMarks = + EDITOR_TO_PENDING_INSERTION_MARKS.get(editor) + EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor) + + // Ensure we insert text with the marks the user was actually seeing + if (placeholderMarks !== undefined) { + EDITOR_TO_USER_MARKS.set(editor, editor.marks) + editor.marks = placeholderMarks + } + + Editor.insertText(editor, event.data) + + const userMarks = EDITOR_TO_USER_MARKS.get(editor) + EDITOR_TO_USER_MARKS.delete(editor) + if (userMarks !== undefined) { + editor.marks = userMarks + } + } + } + }, + [attributes.onCompositionEnd, editor] + )} + onCompositionUpdate={useCallback( + (event: React.CompositionEvent) => { + if ( + ReactEditor.hasSelectableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCompositionUpdate) + ) { + if (!ReactEditor.isComposing(editor)) { + setIsComposing(true) + IS_COMPOSING.set(editor, true) + } + } + }, + [attributes.onCompositionUpdate, editor] + )} + onCompositionStart={useCallback( + (event: React.CompositionEvent) => { + if (ReactEditor.hasSelectableTarget(editor, event.target)) { + androidInputManagerRef.current?.handleCompositionStart( + event + ) + + if ( + isEventHandled(event, attributes.onCompositionStart) || + IS_ANDROID + ) { + return + } + + setIsComposing(true) + + const { selection } = editor + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor) + return + } + } + }, + [attributes.onCompositionStart, editor] + )} + onCopy={useCallback( + (event: React.ClipboardEvent) => { + if ( + ReactEditor.hasSelectableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCopy) && + !isDOMEventTargetInput(event) + ) { + event.preventDefault() + ReactEditor.setFragmentData( + editor, + event.clipboardData, + 'copy' + ) + } + }, + [attributes.onCopy, editor] + )} + onCut={useCallback( + (event: React.ClipboardEvent) => { + if ( + !readOnly && + ReactEditor.hasSelectableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCut) && + !isDOMEventTargetInput(event) + ) { + event.preventDefault() + ReactEditor.setFragmentData( + editor, + event.clipboardData, + 'cut' + ) + const { selection } = editor + + if (selection) { + if (Range.isExpanded(selection)) { + Editor.deleteFragment(editor) + } else { + const node = Node.parent(editor, selection.anchor.path) + if (Editor.isVoid(editor, node)) { + Transforms.delete(editor) } } } } - } - }, - [readOnly, editor, attributes.onKeyDown] - )} - onPaste={useCallback( - (event: React.ClipboardEvent) => { - if ( - !readOnly && - ReactEditor.hasEditableTarget(editor, event.target) && - !isEventHandled(event, attributes.onPaste) - ) { - // COMPAT: Certain browsers don't support the `beforeinput` event, so we - // fall back to React's `onPaste` here instead. - // COMPAT: Firefox, Chrome and Safari don't emit `beforeinput` events - // when "paste without formatting" is used, so fallback. (2020/02/20) - // COMPAT: Safari InputEvents generated by pasting won't include - // application/x-slate-fragment items, so use the - // ClipboardEvent here. (2023/03/15) + }, + [readOnly, editor, attributes.onCut] + )} + onDragOver={useCallback( + (event: React.DragEvent) => { if ( - !HAS_BEFORE_INPUT_SUPPORT || - isPlainTextOnlyPaste(event.nativeEvent) || - IS_WEBKIT + ReactEditor.hasTarget(editor, event.target) && + !isEventHandled(event, attributes.onDragOver) + ) { + // Only when the target is void, call `preventDefault` to signal + // that drops are allowed. Editable content is droppable by + // default, and calling `preventDefault` hides the cursor. + const node = ReactEditor.toSlateNode(editor, event.target) + + if ( + Element.isElement(node) && + Editor.isVoid(editor, node) + ) { + event.preventDefault() + } + } + }, + [attributes.onDragOver, editor] + )} + onDragStart={useCallback( + (event: React.DragEvent) => { + if ( + !readOnly && + ReactEditor.hasTarget(editor, event.target) && + !isEventHandled(event, attributes.onDragStart) + ) { + const node = ReactEditor.toSlateNode(editor, event.target) + const path = ReactEditor.findPath(editor, node) + const voidMatch = + (Element.isElement(node) && + Editor.isVoid(editor, node)) || + Editor.void(editor, { at: path, voids: true }) + + // If starting a drag on a void node, make sure it is selected + // so that it shows up in the selection's fragment. + if (voidMatch) { + const range = Editor.range(editor, path) + Transforms.select(editor, range) + } + + state.isDraggingInternally = true + + ReactEditor.setFragmentData( + editor, + event.dataTransfer, + 'drag' + ) + } + }, + [readOnly, editor, attributes.onDragStart, state] + )} + onDrop={useCallback( + (event: React.DragEvent) => { + if ( + !readOnly && + ReactEditor.hasTarget(editor, event.target) && + !isEventHandled(event, attributes.onDrop) ) { event.preventDefault() - ReactEditor.insertData(editor, event.clipboardData) + + // Keep a reference to the dragged range before updating selection + const draggedRange = editor.selection + + // Find the range where the drop happened + const range = ReactEditor.findEventRange(editor, event) + const data = event.dataTransfer + + Transforms.select(editor, range) + + if (state.isDraggingInternally) { + if ( + draggedRange && + !Range.equals(draggedRange, range) && + !Editor.void(editor, { at: range, voids: true }) + ) { + Transforms.delete(editor, { + at: draggedRange, + }) + } + } + + ReactEditor.insertData(editor, data) + + // When dragging from another source into the editor, it's possible + // that the current editor does not have focus. + if (!ReactEditor.isFocused(editor)) { + ReactEditor.focus(editor) + } } - } - }, - [readOnly, editor, attributes.onPaste] - )} - > - - - - - - ) -} + }, + [readOnly, editor, attributes.onDrop, state] + )} + onDragEnd={useCallback( + (event: React.DragEvent) => { + if ( + !readOnly && + state.isDraggingInternally && + attributes.onDragEnd && + ReactEditor.hasTarget(editor, event.target) + ) { + attributes.onDragEnd(event) + } + }, + [readOnly, state, attributes, editor] + )} + onFocus={useCallback( + (event: React.FocusEvent) => { + if ( + !readOnly && + !state.isUpdatingSelection && + ReactEditor.hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onFocus) + ) { + const el = ReactEditor.toDOMNode(editor, editor) + const root = ReactEditor.findDocumentOrShadowRoot(editor) + state.latestElement = root.activeElement + + // COMPAT: If the editor has nested editable elements, the focus + // can go to them. In Firefox, this must be prevented because it + // results in issues with keyboard navigation. (2017/03/30) + if (IS_FIREFOX && event.target !== el) { + el.focus() + return + } + + IS_FOCUSED.set(editor, true) + } + }, + [readOnly, state, editor, attributes.onFocus] + )} + onKeyDown={useCallback( + (event: React.KeyboardEvent) => { + if ( + !readOnly && + ReactEditor.hasEditableTarget(editor, event.target) + ) { + androidInputManagerRef.current?.handleKeyDown(event) + + const { nativeEvent } = event + + // COMPAT: The composition end event isn't fired reliably in all browsers, + // so we sometimes might end up stuck in a composition state even though we + // aren't composing any more. + if ( + ReactEditor.isComposing(editor) && + nativeEvent.isComposing === false + ) { + IS_COMPOSING.set(editor, false) + setIsComposing(false) + } + + if ( + isEventHandled(event, attributes.onKeyDown) || + ReactEditor.isComposing(editor) + ) { + return + } + + const { selection } = editor + const element = + editor.children[ + selection !== null ? selection.focus.path[0] : 0 + ] + const isRTL = getDirection(Node.string(element)) === 'rtl' + + // COMPAT: Since we prevent the default behavior on + // `beforeinput` events, the browser doesn't think there's ever + // any history stack to undo or redo, so we have to manage these + // hotkeys ourselves. (2019/11/06) + if (Hotkeys.isRedo(nativeEvent)) { + event.preventDefault() + const maybeHistoryEditor: any = editor + + if (typeof maybeHistoryEditor.redo === 'function') { + maybeHistoryEditor.redo() + } + + return + } + + if (Hotkeys.isUndo(nativeEvent)) { + event.preventDefault() + const maybeHistoryEditor: any = editor + + if (typeof maybeHistoryEditor.undo === 'function') { + maybeHistoryEditor.undo() + } + + return + } + + // COMPAT: Certain browsers don't handle the selection updates + // properly. In Chrome, the selection isn't properly extended. + // And in Firefox, the selection isn't properly collapsed. + // (2017/10/17) + if (Hotkeys.isMoveLineBackward(nativeEvent)) { + event.preventDefault() + Transforms.move(editor, { unit: 'line', reverse: true }) + return + } + + if (Hotkeys.isMoveLineForward(nativeEvent)) { + event.preventDefault() + Transforms.move(editor, { unit: 'line' }) + return + } + + if (Hotkeys.isExtendLineBackward(nativeEvent)) { + event.preventDefault() + Transforms.move(editor, { + unit: 'line', + edge: 'focus', + reverse: true, + }) + return + } + + if (Hotkeys.isExtendLineForward(nativeEvent)) { + event.preventDefault() + Transforms.move(editor, { unit: 'line', edge: 'focus' }) + return + } + + // COMPAT: If a void node is selected, or a zero-width text node + // adjacent to an inline is selected, we need to handle these + // hotkeys manually because browsers won't be able to skip over + // the void node with the zero-width space not being an empty + // string. + if (Hotkeys.isMoveBackward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isCollapsed(selection)) { + Transforms.move(editor, { reverse: !isRTL }) + } else { + Transforms.collapse(editor, { + edge: isRTL ? 'end' : 'start', + }) + } + + return + } + + if (Hotkeys.isMoveForward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isCollapsed(selection)) { + Transforms.move(editor, { reverse: isRTL }) + } else { + Transforms.collapse(editor, { + edge: isRTL ? 'start' : 'end', + }) + } + + return + } + + if (Hotkeys.isMoveWordBackward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Transforms.collapse(editor, { edge: 'focus' }) + } + + Transforms.move(editor, { unit: 'word', reverse: !isRTL }) + return + } + + if (Hotkeys.isMoveWordForward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Transforms.collapse(editor, { edge: 'focus' }) + } + + Transforms.move(editor, { unit: 'word', reverse: isRTL }) + return + } + + // COMPAT: Certain browsers don't support the `beforeinput` event, so we + // fall back to guessing at the input intention for hotkeys. + // COMPAT: In iOS, some of these hotkeys are handled in the + if (!HAS_BEFORE_INPUT_SUPPORT) { + // We don't have a core behavior for these, but they change the + // DOM if we don't prevent them, so we have to. + if ( + Hotkeys.isBold(nativeEvent) || + Hotkeys.isItalic(nativeEvent) || + Hotkeys.isTransposeCharacter(nativeEvent) + ) { + event.preventDefault() + return + } + + if (Hotkeys.isSoftBreak(nativeEvent)) { + event.preventDefault() + Editor.insertSoftBreak(editor) + return + } + + if (Hotkeys.isSplitBlock(nativeEvent)) { + event.preventDefault() + Editor.insertBreak(editor) + return + } + + if (Hotkeys.isDeleteBackward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { + direction: 'backward', + }) + } else { + Editor.deleteBackward(editor) + } + + return + } + + if (Hotkeys.isDeleteForward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { + direction: 'forward', + }) + } else { + Editor.deleteForward(editor) + } + + return + } + + if (Hotkeys.isDeleteLineBackward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { + direction: 'backward', + }) + } else { + Editor.deleteBackward(editor, { unit: 'line' }) + } + + return + } + + if (Hotkeys.isDeleteLineForward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { + direction: 'forward', + }) + } else { + Editor.deleteForward(editor, { unit: 'line' }) + } + + return + } + + if (Hotkeys.isDeleteWordBackward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { + direction: 'backward', + }) + } else { + Editor.deleteBackward(editor, { unit: 'word' }) + } + + return + } + + if (Hotkeys.isDeleteWordForward(nativeEvent)) { + event.preventDefault() + + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor, { + direction: 'forward', + }) + } else { + Editor.deleteForward(editor, { unit: 'word' }) + } + + return + } + } else { + if (IS_CHROME || IS_WEBKIT) { + // COMPAT: Chrome and Safari support `beforeinput` event but do not fire + // an event when deleting backwards in a selected void inline node + if ( + selection && + (Hotkeys.isDeleteBackward(nativeEvent) || + Hotkeys.isDeleteForward(nativeEvent)) && + Range.isCollapsed(selection) + ) { + const currentNode = Node.parent( + editor, + selection.anchor.path + ) + + if ( + Element.isElement(currentNode) && + Editor.isVoid(editor, currentNode) && + (Editor.isInline(editor, currentNode) || + Editor.isBlock(editor, currentNode)) + ) { + event.preventDefault() + Editor.deleteBackward(editor, { unit: 'block' }) + + return + } + } + } + } + } + }, + [readOnly, editor, attributes.onKeyDown] + )} + onPaste={useCallback( + (event: React.ClipboardEvent) => { + if ( + !readOnly && + ReactEditor.hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onPaste) + ) { + // COMPAT: Certain browsers don't support the `beforeinput` event, so we + // fall back to React's `onPaste` here instead. + // COMPAT: Firefox, Chrome and Safari don't emit `beforeinput` events + // when "paste without formatting" is used, so fallback. (2020/02/20) + // COMPAT: Safari InputEvents generated by pasting won't include + // application/x-slate-fragment items, so use the + // ClipboardEvent here. (2023/03/15) + if ( + !HAS_BEFORE_INPUT_SUPPORT || + isPlainTextOnlyPaste(event.nativeEvent) || + IS_WEBKIT + ) { + event.preventDefault() + ReactEditor.insertData(editor, event.clipboardData) + } + } + }, + [readOnly, editor, attributes.onPaste] + )} + > + + + + + + ) + } +) /** * The props that get passed to renderPlaceholder