diff --git a/.changeset/friendly-oranges-remain.md b/.changeset/friendly-oranges-remain.md new file mode 100644 index 000000000..67eb2218b --- /dev/null +++ b/.changeset/friendly-oranges-remain.md @@ -0,0 +1,5 @@ +--- +'slate-react': patch +--- + +Fix selection handling with slow flush in mark placeholders on android, fix auto-capitalize after placeholder diff --git a/packages/slate-react/src/components/editable.tsx b/packages/slate-react/src/components/editable.tsx index 517f6863f..4efade8d8 100644 --- a/packages/slate-react/src/components/editable.tsx +++ b/packages/slate-react/src/components/editable.tsx @@ -222,7 +222,7 @@ export const Editable = (props: EditableProps) => { if (range) { if ( !ReactEditor.isComposing(editor) && - !androidInputManager?.hasPendingDiffs() && + !androidInputManager?.hasPendingChanges() && !androidInputManager?.isFlushing() ) { Transforms.select(editor, range) @@ -784,11 +784,17 @@ export const Editable = (props: EditableProps) => { // before we receive the composition end event. useEffect(() => { setTimeout(() => { - if (marks) { - EDITOR_TO_PENDING_INSERTION_MARKS.set(editor, marks) - } else { - EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor) + const { selection } = editor + if (selection) { + const { anchor } = selection + const { text, ...rest } = Node.leaf(editor, anchor.path) + if (!Text.equals(rest as Text, marks as Text, { loose: true })) { + EDITOR_TO_PENDING_INSERTION_MARKS.set(editor, marks) + return + } } + + EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor) }) }) @@ -1616,7 +1622,14 @@ export type RenderPlaceholderProps = { export const DefaultPlaceholder = ({ attributes, children, -}: RenderPlaceholderProps) => {children} +}: RenderPlaceholderProps) => ( + // COMPAT: Artificially add a line-break to the end on the placeholder element + // to prevent Android IMEs to pick up its content in autocorrect and to auto-capitalize the first letter + + {children} + {IS_ANDROID &&
} +
+) /** * A default memoized decorate function. diff --git a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts index 4b6d46209..8dc58579f 100644 --- a/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts +++ b/packages/slate-react/src/hooks/android-input-manager/android-input-manager.ts @@ -23,7 +23,7 @@ import { IS_COMPOSING, } from '../../utils/weak-maps' -export type Action = { at: Point | Range; run: () => void } +export type Action = { at?: Point | Range; run: () => void } // https://github.com/facebook/draft-js/blob/main/src/component/handlers/composition/DraftEditorCompositionHandler.js#L41 // When using keyboard English association function, conpositionEnd triggered too fast, resulting in after `insertText` still maintain association state. @@ -48,6 +48,7 @@ export type AndroidInputManager = { hasPendingDiffs: () => boolean hasPendingAction: () => boolean + hasPendingChanges: () => boolean isFlushing: () => boolean | 'action' handleUserSelect: (range: Range | null) => void @@ -62,30 +63,6 @@ export type AndroidInputManager = { handleInput: () => void } -export function forceSwiftKeyUpdate(editor: ReactEditor) { - const { document } = ReactEditor.getWindow(editor) - debug('force ime update') - - const div = document.createElement('div') - div.setAttribute('contenteditable', 'true') - div.setAttribute('display', 'none') - div.setAttribute('position', 'absolute') - div.setAttribute('top', '0') - div.setAttribute('left', '0') - div.textContent = ' ' - - document.body.appendChild(div) - const range = document.createRange() - range.selectNodeContents(div) - const selection = window.getSelection() - - selection?.removeAllRanges() - selection?.addRange(range) - div.parentElement?.removeChild(div) - - ReactEditor.focus(editor) -} - export function createAndroidInputManager({ editor, scheduleOnDOMSelectionChange, @@ -95,8 +72,9 @@ export function createAndroidInputManager({ let compositionEndTimeoutId: ReturnType | null = null let flushTimeoutId: ReturnType | null = null let actionTimeoutId: ReturnType | null = null + let idCounter = 0 - let isInsertAfterMarkPlaceholder = false + let insertPositionHint: StringDiff | null | false = false const applyPendingSelection = () => { const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(editor) @@ -121,17 +99,19 @@ export function createAndroidInputManager({ return } - const target = Point.isPoint(action.at) - ? normalizePoint(editor, action.at) - : normalizeRange(editor, action.at) + if (action.at) { + const target = Point.isPoint(action.at) + ? normalizePoint(editor, action.at) + : normalizeRange(editor, action.at) - if (!target) { - return - } + if (!target) { + return + } - const targetRange = Editor.range(editor, target) - if (!editor.selection || !Range.equals(editor.selection, targetRange)) { - Transforms.select(editor, target) + const targetRange = Editor.range(editor, target) + if (!editor.selection || !Range.equals(editor.selection, targetRange)) { + Transforms.select(editor, target) + } } action.run() @@ -142,6 +122,7 @@ export function createAndroidInputManager({ clearTimeout(flushTimeoutId) flushTimeoutId = null } + if (actionTimeoutId) { clearTimeout(actionTimeoutId) actionTimeoutId = null @@ -156,6 +137,7 @@ export function createAndroidInputManager({ flushing = true setTimeout(() => (flushing = false)) } + if (hasPendingAction()) { flushing = 'action' } @@ -182,8 +164,9 @@ export function createAndroidInputManager({ editor.marks = pendingMarks } - if (pendingMarks) { - isInsertAfterMarkPlaceholder = true + if (pendingMarks && insertPositionHint === false) { + insertPositionHint = null + debug('insert after mark placeholder') } const range = targetRange(diff) @@ -225,6 +208,7 @@ export function createAndroidInputManager({ const selection = selectionRef?.unref() if ( selection && + !EDITOR_TO_PENDING_SELECTION.get(editor) && (!editor.selection || !Range.equals(selection, editor.selection)) ) { Transforms.select(editor, selection) @@ -252,6 +236,7 @@ export function createAndroidInputManager({ EDITOR_TO_USER_MARKS.delete(editor) if (userMarks !== undefined) { editor.marks = userMarks + editor.onChange() } } @@ -326,7 +311,11 @@ export function createAndroidInputManager({ } } - const scheduleAction = (at: Point | Range, run: () => void): void => { + const scheduleAction = ( + run: () => void, + { at }: { at?: Point | Range } = {} + ): void => { + insertPositionHint = false debug('scheduleAction', { at, run }) EDITOR_TO_PENDING_SELECTION.delete(editor) @@ -355,6 +344,14 @@ export function createAndroidInputManager({ let targetRange: Range | null = null const data = (event as any).dataTransfer || event.data || undefined + if ( + insertPositionHint !== false && + type !== 'insertText' && + type !== 'insertCompositionText' + ) { + insertPositionHint = false + } + let [nativeTargetRange] = (event as any).getTargetRanges() if (nativeTargetRange) { targetRange = ReactEditor.toSlateRange(editor, nativeTargetRange, { @@ -403,8 +400,9 @@ export function createAndroidInputManager({ } const direction = type.endsWith('Backward') ? 'backward' : 'forward' - return scheduleAction(targetRange, () => - Editor.deleteFragment(editor, { direction }) + return scheduleAction( + () => Editor.deleteFragment(editor, { direction }), + { at: targetRange } ) } @@ -412,7 +410,9 @@ export function createAndroidInputManager({ case 'deleteByComposition': case 'deleteByCut': case 'deleteByDrag': { - return scheduleAction(targetRange, () => Editor.deleteFragment(editor)) + return scheduleAction(() => Editor.deleteFragment(editor), { + at: targetRange, + }) } case 'deleteContent': @@ -430,7 +430,9 @@ export function createAndroidInputManager({ } } - return scheduleAction(targetRange, () => Editor.deleteForward(editor)) + return scheduleAction(() => Editor.deleteForward(editor), { + at: targetRange, + }) } case 'deleteContentBackward': { @@ -455,58 +457,73 @@ export function createAndroidInputManager({ }) } - return scheduleAction(targetRange, () => Editor.deleteBackward(editor)) - } - - case 'deleteEntireSoftLine': { - return scheduleAction(targetRange, () => { - Editor.deleteBackward(editor, { unit: 'line' }) - Editor.deleteForward(editor, { unit: 'line' }) + return scheduleAction(() => Editor.deleteBackward(editor), { + at: targetRange, }) } + case 'deleteEntireSoftLine': { + return scheduleAction( + () => { + Editor.deleteBackward(editor, { unit: 'line' }) + Editor.deleteForward(editor, { unit: 'line' }) + }, + { at: targetRange } + ) + } + case 'deleteHardLineBackward': { - return scheduleAction(targetRange, () => - Editor.deleteBackward(editor, { unit: 'block' }) + return scheduleAction( + () => Editor.deleteBackward(editor, { unit: 'block' }), + { at: targetRange } ) } case 'deleteSoftLineBackward': { - return scheduleAction(targetRange, () => - Editor.deleteBackward(editor, { unit: 'line' }) + return scheduleAction( + () => Editor.deleteBackward(editor, { unit: 'line' }), + { at: targetRange } ) } case 'deleteHardLineForward': { - return scheduleAction(targetRange, () => - Editor.deleteForward(editor, { unit: 'block' }) + return scheduleAction( + () => Editor.deleteForward(editor, { unit: 'block' }), + { at: targetRange } ) } case 'deleteSoftLineForward': { - return scheduleAction(targetRange, () => - Editor.deleteForward(editor, { unit: 'line' }) + return scheduleAction( + () => Editor.deleteForward(editor, { unit: 'line' }), + { at: targetRange } ) } case 'deleteWordBackward': { - return scheduleAction(targetRange, () => - Editor.deleteBackward(editor, { unit: 'word' }) + return scheduleAction( + () => Editor.deleteBackward(editor, { unit: 'word' }), + { at: targetRange } ) } case 'deleteWordForward': { - return scheduleAction(targetRange, () => - Editor.deleteForward(editor, { unit: 'word' }) + return scheduleAction( + () => Editor.deleteForward(editor, { unit: 'word' }), + { at: targetRange } ) } case 'insertLineBreak': { - return scheduleAction(targetRange, () => Editor.insertSoftBreak(editor)) + return scheduleAction(() => Editor.insertSoftBreak(editor), { + at: targetRange, + }) } case 'insertParagraph': { - return scheduleAction(targetRange, () => Editor.insertBreak(editor)) + return scheduleAction(() => Editor.insertBreak(editor), { + at: targetRange, + }) } case 'insertCompositionText': case 'deleteCompositionText': @@ -517,15 +534,15 @@ export function createAndroidInputManager({ case 'insertReplacementText': case 'insertText': { if (data?.constructor.name === 'DataTransfer') { - return scheduleAction(targetRange, () => - ReactEditor.insertData(editor, data) - ) + return scheduleAction(() => ReactEditor.insertData(editor, data), { + at: targetRange, + }) } if (typeof data === 'string' && data.includes('\n')) { - return scheduleAction(Range.end(targetRange), () => - Editor.insertSoftBreak(editor) - ) + return scheduleAction(() => Editor.insertSoftBreak(editor), { + at: Range.end(targetRange), + }) } let text = data ?? '' @@ -537,41 +554,80 @@ export function createAndroidInputManager({ } if (Path.equals(targetRange.anchor.path, targetRange.focus.path)) { - // COMPAT: Swiftkey has a weird bug where the target range of the 2nd word - // inserted after a mark placeholder is inserted with a anchor offset off by 1. - // So writing 'some text' will result in 'some ttext'. If we force a IME update - // after inserting the first word, swiftkey will insert with the correct offset - if (text.endsWith(' ') && isInsertAfterMarkPlaceholder) { - isInsertAfterMarkPlaceholder = false - forceSwiftKeyUpdate(editor) - return scheduleAction(targetRange, () => - Editor.insertText(editor, text) - ) - } - const [start, end] = Range.edges(targetRange) - return storeDiff(start.path, { + + const diff = { start: start.offset, end: end.offset, text, - }) + } + + // COMPAT: Swiftkey has a weird bug where the target range of the 2nd word + // inserted after a mark placeholder is inserted with an anchor offset off by 1. + // So writing 'some text' will result in 'some ttext'. Luckily all 'normal' insert + // text events are fired with the correct target ranges, only the final 'insertComposition' + // isn't, so we can adjust the target range start offset if we are confident this is the + // swiftkey insert causing the issue. + if (text && insertPositionHint && type === 'insertCompositionText') { + const hintPosition = + insertPositionHint.start + insertPositionHint.text.search(/\S|$/) + const diffPosition = diff.start + diff.text.search(/\S|$/) + + if ( + diffPosition === hintPosition + 1 && + diff.end === + insertPositionHint.start + insertPositionHint.text.length + ) { + debug('adjusting swiftKey insert position using hint') + diff.start -= 1 + insertPositionHint = null + scheduleFlush() + } else { + insertPositionHint = false + } + } else if (type === 'insertText') { + if (insertPositionHint === null) { + insertPositionHint = diff + } else if ( + insertPositionHint && + Range.isCollapsed(targetRange) && + insertPositionHint.end + insertPositionHint.text.length === + start.offset + ) { + insertPositionHint = { + ...insertPositionHint, + text: insertPositionHint.text + text, + } + } else { + insertPositionHint = false + } + } else { + insertPositionHint = false + } + + storeDiff(start.path, diff) + return } - return scheduleAction(targetRange, () => - Editor.insertText(editor, text) - ) + return scheduleAction(() => Editor.insertText(editor, text), { + at: targetRange, + }) } } } const hasPendingAction = () => { - return !!EDITOR_TO_PENDING_ACTION.get(editor) || !!actionTimeoutId + return !!EDITOR_TO_PENDING_ACTION.get(editor) } const hasPendingDiffs = () => { return !!EDITOR_TO_PENDING_DIFFS.get(editor)?.length } + const hasPendingChanges = () => { + return hasPendingAction() || hasPendingDiffs() + } + const isFlushing = () => { return flushing } @@ -584,13 +640,22 @@ export function createAndroidInputManager({ flushTimeoutId = null } - const pathChanged = - range && - (!editor.selection || - !Path.equals(editor.selection.anchor.path, range?.anchor.path)) + const { selection } = editor + if (!range) { + return + } - if (pathChanged) { - isInsertAfterMarkPlaceholder = false + const pathChanged = + !selection || !Path.equals(selection.anchor.path, range.anchor.path) + const parentPathChanged = + !selection || + !Path.equals( + selection.anchor.path.slice(0, -1), + range.anchor.path.slice(0, -1) + ) + + if ((pathChanged && insertPositionHint) || parentPathChanged) { + insertPositionHint = false } if (pathChanged || !hasPendingDiffs()) { @@ -643,6 +708,8 @@ export function createAndroidInputManager({ hasPendingDiffs, hasPendingAction, + hasPendingChanges, + isFlushing, handleUserSelect, diff --git a/packages/slate-react/src/plugin/with-react.ts b/packages/slate-react/src/plugin/with-react.ts index a40f5d012..1127f0112 100644 --- a/packages/slate-react/src/plugin/with-react.ts +++ b/packages/slate-react/src/plugin/with-react.ts @@ -124,8 +124,9 @@ export const withReact = (editor: T) => { transformPendingRange(editor, pendingSelection, op) ) } + const pendingAction = EDITOR_TO_PENDING_ACTION.get(editor) - if (pendingAction) { + if (pendingAction?.at) { const at = Point.isPoint(pendingAction?.at) ? transformPendingPoint(editor, pendingAction.at, op) : transformPendingRange(editor, pendingAction.at, op)