From 4412925c9b2ae320803bab9c89ed103e956a3820 Mon Sep 17 00:00:00 2001 From: Yifei Date: Tue, 13 Aug 2024 18:07:21 +0800 Subject: [PATCH] feat: add useComposing hook --- .changeset/ten-queens-walk.md | 5 + docs/libraries/slate-react/hooks.md | 4 + .../slate-react/src/components/editable.tsx | 1364 +++++++++-------- .../slate-react/src/hooks/use-composing.ts | 15 + 4 files changed, 718 insertions(+), 670 deletions(-) create mode 100644 .changeset/ten-queens-walk.md create mode 100644 packages/slate-react/src/hooks/use-composing.ts diff --git a/.changeset/ten-queens-walk.md b/.changeset/ten-queens-walk.md new file mode 100644 index 0000000000..a521d23ced --- /dev/null +++ b/.changeset/ten-queens-walk.md @@ -0,0 +1,5 @@ +--- +'slate-react': minor +--- + +feat: Add useComposing hook" diff --git a/docs/libraries/slate-react/hooks.md b/docs/libraries/slate-react/hooks.md index 539241fced..5b896a2f91 100644 --- a/docs/libraries/slate-react/hooks.md +++ b/docs/libraries/slate-react/hooks.md @@ -8,6 +8,10 @@ React hooks for Slate editors +#### `useComposing(): boolean` + +Get the current `composing` state of the editor. It deals with `compositionstart`, `compositionupdate`, `compositionend` events. + #### `useFocused(): boolean` Get the current `focused` state of the editor. diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index 1b6f9a3254..e85bbb9152 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -71,6 +71,7 @@ import { } from '../utils/weak-maps' import { RestoreDOM } from './restore-dom/restore-dom' import { AndroidInputManager } from '../hooks/android-input-manager/android-input-manager' +import { ComposingContext } from '../hooks/use-composing' type DeferredOperation = () => void @@ -937,817 +938,840 @@ export const Editable = (props: EditableProps) => { 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. + + + + ) => { + // 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 ( - !Editor.hasPath(editor, path) || - Node.get(editor, path) !== node + !HAS_BEFORE_INPUT_SUPPORT && + !readOnly && + !isEventHandled(event, attributes.onBeforeInput) && + ReactEditor.hasSelectableTarget(editor, event.target) ) { - 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) + event.preventDefault() + if (!ReactEditor.isComposing(editor)) { + const text = (event as any).data as string + Editor.insertText(editor, text) } - - const range = Editor.range(editor, blockPath) - Transforms.select(editor, range) + } + }, + [attributes.onBeforeInput, editor, readOnly] + )} + onInput={useCallback( + (event: React.FormEvent) => { + if (isEventHandled(event, attributes.onInput)) { return } - if (readOnly) { + if (androidInputManagerRef.current) { + androidInputManagerRef.current.handleInput() 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 }) - + // 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 ( - startVoid && - endVoid && - Path.equals(startVoid[1], endVoid[1]) + readOnly || + state.isUpdatingSelection || + !ReactEditor.hasSelectableTarget(editor, event.target) || + isEventHandled(event, attributes.onBlur) ) { - const range = Editor.range(editor, start) - Transforms.select(editor, range) + return } - } - }, - [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) - }) + + // 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 } - androidInputManagerRef.current?.handleCompositionEnd(event) + 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 ( - isEventHandled(event, attributes.onCompositionEnd) || - IS_ANDROID + isDOMElement(relatedTarget) && + relatedTarget.hasAttribute('data-slate-spacer') ) { 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. + // 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 ( - !IS_WEBKIT && - !IS_FIREFOX_LEGACY && - !IS_IOS && - !IS_WECHATBROWSER && - !IS_UC_MOBILE && - event.data + relatedTarget != null && + isDOMNode(relatedTarget) && + ReactEditor.hasDOMNode(editor, relatedTarget) ) { - 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 node = ReactEditor.toSlateNode(editor, relatedTarget) - const userMarks = EDITOR_TO_USER_MARKS.get(editor) - EDITOR_TO_USER_MARKS.delete(editor) - if (userMarks !== undefined) { - editor.marks = userMarks + if (Element.isElement(node) && !editor.isVoid(node)) { + return } } - } - }, - [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) + + // 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() } - } - }, - [attributes.onCompositionUpdate, editor] - )} - onCompositionStart={useCallback( - (event: React.CompositionEvent) => { - if (ReactEditor.hasSelectableTarget(editor, event.target)) { - androidInputManagerRef.current?.handleCompositionStart(event) + IS_FOCUSED.delete(editor) + }, + [ + readOnly, + state.isUpdatingSelection, + state.latestElement, + editor, + attributes.onBlur, + ] + )} + onClick={useCallback( + (event: React.MouseEvent) => { if ( - isEventHandled(event, attributes.onCompositionStart) || - IS_ANDROID + ReactEditor.hasTarget(editor, event.target) && + !isEventHandled(event, attributes.onClick) && + isDOMNode(event.target) ) { - return - } + 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 + } - setIsComposing(true) + 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, + }) - 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) + blockPath = block?.[1] ?? path.slice(0, 1) } + + const range = Editor.range(editor, blockPath) + Transforms.select(editor, range) + return } - } - } - }, - [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 (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 ( - draggedRange && - !Range.equals(draggedRange, range) && - !Editor.void(editor, { at: range, voids: true }) + startVoid && + endVoid && + Path.equals(startVoid[1], endVoid[1]) ) { - Transforms.delete(editor, { - at: draggedRange, - }) + 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) + }) + } - ReactEditor.insertData(editor, data) + androidInputManagerRef.current?.handleCompositionEnd(event) - // 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 + if ( + isEventHandled(event, attributes.onCompositionEnd) || + IS_ANDROID + ) { + return + } - // 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 - } + // 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 + } - 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) - } + 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 ( - isEventHandled(event, attributes.onKeyDown) || - ReactEditor.isComposing(editor) + ReactEditor.hasSelectableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCompositionUpdate) ) { - return + 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 + ) - 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() + if ( + isEventHandled(event, attributes.onCompositionStart) || + IS_ANDROID + ) { + return } - return - } - - if (Hotkeys.isUndo(nativeEvent)) { - event.preventDefault() - const maybeHistoryEditor: any = editor + setIsComposing(true) - if (typeof maybeHistoryEditor.undo === 'function') { - maybeHistoryEditor.undo() + const { selection } = editor + if (selection && Range.isExpanded(selection)) { + Editor.deleteFragment(editor) + return } - - 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)) { + }, + [attributes.onCompositionStart, editor] + )} + onCopy={useCallback( + (event: React.ClipboardEvent) => { + if ( + ReactEditor.hasSelectableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCopy) && + !isDOMEventTargetInput(event) + ) { event.preventDefault() - Transforms.move(editor, { unit: 'line', reverse: true }) - return + ReactEditor.setFragmentData( + editor, + event.clipboardData, + 'copy' + ) } - - if (Hotkeys.isMoveLineForward(nativeEvent)) { + }, + [attributes.onCopy, editor] + )} + onCut={useCallback( + (event: React.ClipboardEvent) => { + if ( + !readOnly && + ReactEditor.hasSelectableTarget(editor, event.target) && + !isEventHandled(event, attributes.onCut) && + !isDOMEventTargetInput(event) + ) { event.preventDefault() - Transforms.move(editor, { unit: 'line' }) - return + 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 (Hotkeys.isExtendLineBackward(nativeEvent)) { - event.preventDefault() - Transforms.move(editor, { - unit: 'line', - edge: 'focus', - reverse: true, - }) - return + 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) + } - if (Hotkeys.isExtendLineForward(nativeEvent)) { - event.preventDefault() - Transforms.move(editor, { unit: 'line', edge: 'focus' }) - return - } + state.isDraggingInternally = true - // 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)) { + 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() - if (selection && Range.isCollapsed(selection)) { - Transforms.move(editor, { reverse: !isRTL }) - } else { - Transforms.collapse(editor, { - edge: isRTL ? 'end' : 'start', - }) - } + // Keep a reference to the dragged range before updating selection + const draggedRange = editor.selection - return - } + // Find the range where the drop happened + const range = ReactEditor.findEventRange(editor, event) + const data = event.dataTransfer - if (Hotkeys.isMoveForward(nativeEvent)) { - event.preventDefault() + Transforms.select(editor, range) - if (selection && Range.isCollapsed(selection)) { - Transforms.move(editor, { reverse: isRTL }) - } else { - Transforms.collapse(editor, { - edge: isRTL ? 'start' : 'end', - }) + if (state.isDraggingInternally) { + if ( + draggedRange && + !Range.equals(draggedRange, range) && + !Editor.void(editor, { at: range, voids: true }) + ) { + Transforms.delete(editor, { + at: draggedRange, + }) + } } - return - } - - if (Hotkeys.isMoveWordBackward(nativeEvent)) { - event.preventDefault() + ReactEditor.insertData(editor, data) - if (selection && Range.isExpanded(selection)) { - Transforms.collapse(editor, { edge: 'focus' }) + // 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) } - - Transforms.move(editor, { unit: 'word', reverse: !isRTL }) - return } - - if (Hotkeys.isMoveWordForward(nativeEvent)) { - event.preventDefault() - - if (selection && Range.isExpanded(selection)) { - Transforms.collapse(editor, { edge: 'focus' }) + }, + [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 } - Transforms.move(editor, { unit: 'word', reverse: isRTL }) - 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) - // 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. + 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 ( - Hotkeys.isBold(nativeEvent) || - Hotkeys.isItalic(nativeEvent) || - Hotkeys.isTransposeCharacter(nativeEvent) + ReactEditor.isComposing(editor) && + nativeEvent.isComposing === false ) { - event.preventDefault() - return + IS_COMPOSING.set(editor, false) + setIsComposing(false) } - if (Hotkeys.isSoftBreak(nativeEvent)) { - event.preventDefault() - Editor.insertSoftBreak(editor) + if ( + isEventHandled(event, attributes.onKeyDown) || + ReactEditor.isComposing(editor) + ) { return } - if (Hotkeys.isSplitBlock(nativeEvent)) { + 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() - Editor.insertBreak(editor) + const maybeHistoryEditor: any = editor + + if (typeof maybeHistoryEditor.redo === 'function') { + maybeHistoryEditor.redo() + } + return } - if (Hotkeys.isDeleteBackward(nativeEvent)) { + if (Hotkeys.isUndo(nativeEvent)) { event.preventDefault() + const maybeHistoryEditor: any = editor - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'backward' }) - } else { - Editor.deleteBackward(editor) + if (typeof maybeHistoryEditor.undo === 'function') { + maybeHistoryEditor.undo() } return } - if (Hotkeys.isDeleteForward(nativeEvent)) { + // 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 (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'forward' }) - } else { - Editor.deleteForward(editor) - } + 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.isDeleteLineBackward(nativeEvent)) { + if (Hotkeys.isExtendLineForward(nativeEvent)) { event.preventDefault() + Transforms.move(editor, { unit: 'line', edge: 'focus' }) + return + } - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'backward' }) + // 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 { - Editor.deleteBackward(editor, { unit: 'line' }) + Transforms.collapse(editor, { + edge: isRTL ? 'end' : 'start', + }) } return } - if (Hotkeys.isDeleteLineForward(nativeEvent)) { + if (Hotkeys.isMoveForward(nativeEvent)) { event.preventDefault() - if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'forward' }) + if (selection && Range.isCollapsed(selection)) { + Transforms.move(editor, { reverse: isRTL }) } else { - Editor.deleteForward(editor, { unit: 'line' }) + Transforms.collapse(editor, { + edge: isRTL ? 'start' : 'end', + }) } return } - if (Hotkeys.isDeleteWordBackward(nativeEvent)) { + if (Hotkeys.isMoveWordBackward(nativeEvent)) { event.preventDefault() if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'backward' }) - } else { - Editor.deleteBackward(editor, { unit: 'word' }) + Transforms.collapse(editor, { edge: 'focus' }) } + Transforms.move(editor, { unit: 'word', reverse: !isRTL }) return } - if (Hotkeys.isDeleteWordForward(nativeEvent)) { + if (Hotkeys.isMoveWordForward(nativeEvent)) { event.preventDefault() if (selection && Range.isExpanded(selection)) { - Editor.deleteFragment(editor, { direction: 'forward' }) - } else { - Editor.deleteForward(editor, { unit: 'word' }) + Transforms.collapse(editor, { edge: 'focus' }) } + Transforms.move(editor, { unit: 'word', reverse: isRTL }) 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 + + // 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 ( - selection && - (Hotkeys.isDeleteBackward(nativeEvent) || - Hotkeys.isDeleteForward(nativeEvent)) && - Range.isCollapsed(selection) + Hotkeys.isBold(nativeEvent) || + Hotkeys.isItalic(nativeEvent) || + Hotkeys.isTransposeCharacter(nativeEvent) ) { - const currentNode = Node.parent( - editor, - selection.anchor.path - ) + 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 ( - Element.isElement(currentNode) && - Editor.isVoid(editor, currentNode) && - (Editor.isInline(editor, currentNode) || - Editor.isBlock(editor, currentNode)) + selection && + (Hotkeys.isDeleteBackward(nativeEvent) || + Hotkeys.isDeleteForward(nativeEvent)) && + Range.isCollapsed(selection) ) { - event.preventDefault() - Editor.deleteBackward(editor, { unit: 'block' }) - - return + 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) + }, + [readOnly, editor, attributes.onKeyDown] + )} + onPaste={useCallback( + (event: React.ClipboardEvent) => { if ( - !HAS_BEFORE_INPUT_SUPPORT || - isPlainTextOnlyPaste(event.nativeEvent) || - IS_WEBKIT + !readOnly && + ReactEditor.hasEditableTarget(editor, event.target) && + !isEventHandled(event, attributes.onPaste) ) { - event.preventDefault() - ReactEditor.insertData(editor, event.clipboardData) + // 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] - )} - > - - - - + }, + [readOnly, editor, attributes.onPaste] + )} + > + + + + + ) } diff --git a/packages/slate-react/src/hooks/use-composing.ts b/packages/slate-react/src/hooks/use-composing.ts new file mode 100644 index 0000000000..54071216d6 --- /dev/null +++ b/packages/slate-react/src/hooks/use-composing.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react' + +/** + * A React context for sharing the `composing` state of the editor. + */ + +export const ComposingContext = createContext(false) + +/** + * Get the current `composing` state of the editor. + */ + +export const useComposing = (): boolean => { + return useContext(ComposingContext) +}