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)