1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-12 02:03:59 +02:00

Fix selection handling with slow flush in mark placeholders on android, fix auto-capitalize after placeholder (#5084)

* Fix selection handling with slow flush in mark placeholders on android, fix auto-capitalize after placeholder

* Add changeset

* Correct typos
This commit is contained in:
Eric Meier
2022-08-18 14:18:39 +02:00
committed by GitHub
parent 46d113fe1e
commit 50de780b1c
4 changed files with 186 additions and 100 deletions

View File

@@ -0,0 +1,5 @@
---
'slate-react': patch
---
Fix selection handling with slow flush in mark placeholders on android, fix auto-capitalize after placeholder

View File

@@ -222,7 +222,7 @@ export const Editable = (props: EditableProps) => {
if (range) { if (range) {
if ( if (
!ReactEditor.isComposing(editor) && !ReactEditor.isComposing(editor) &&
!androidInputManager?.hasPendingDiffs() && !androidInputManager?.hasPendingChanges() &&
!androidInputManager?.isFlushing() !androidInputManager?.isFlushing()
) { ) {
Transforms.select(editor, range) Transforms.select(editor, range)
@@ -784,11 +784,17 @@ export const Editable = (props: EditableProps) => {
// before we receive the composition end event. // before we receive the composition end event.
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
if (marks) { const { selection } = editor
EDITOR_TO_PENDING_INSERTION_MARKS.set(editor, marks) if (selection) {
} else { const { anchor } = selection
EDITOR_TO_PENDING_INSERTION_MARKS.delete(editor) 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 = ({ export const DefaultPlaceholder = ({
attributes, attributes,
children, children,
}: RenderPlaceholderProps) => <span {...attributes}>{children}</span> }: 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
<span {...attributes}>
{children}
{IS_ANDROID && <br />}
</span>
)
/** /**
* A default memoized decorate function. * A default memoized decorate function.

View File

@@ -23,7 +23,7 @@ import {
IS_COMPOSING, IS_COMPOSING,
} from '../../utils/weak-maps' } 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 // 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. // 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 hasPendingDiffs: () => boolean
hasPendingAction: () => boolean hasPendingAction: () => boolean
hasPendingChanges: () => boolean
isFlushing: () => boolean | 'action' isFlushing: () => boolean | 'action'
handleUserSelect: (range: Range | null) => void handleUserSelect: (range: Range | null) => void
@@ -62,30 +63,6 @@ export type AndroidInputManager = {
handleInput: () => void 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({ export function createAndroidInputManager({
editor, editor,
scheduleOnDOMSelectionChange, scheduleOnDOMSelectionChange,
@@ -95,8 +72,9 @@ export function createAndroidInputManager({
let compositionEndTimeoutId: ReturnType<typeof setTimeout> | null = null let compositionEndTimeoutId: ReturnType<typeof setTimeout> | null = null
let flushTimeoutId: ReturnType<typeof setTimeout> | null = null let flushTimeoutId: ReturnType<typeof setTimeout> | null = null
let actionTimeoutId: ReturnType<typeof setTimeout> | null = null let actionTimeoutId: ReturnType<typeof setTimeout> | null = null
let idCounter = 0 let idCounter = 0
let isInsertAfterMarkPlaceholder = false let insertPositionHint: StringDiff | null | false = false
const applyPendingSelection = () => { const applyPendingSelection = () => {
const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(editor) const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(editor)
@@ -121,17 +99,19 @@ export function createAndroidInputManager({
return return
} }
const target = Point.isPoint(action.at) if (action.at) {
? normalizePoint(editor, action.at) const target = Point.isPoint(action.at)
: normalizeRange(editor, action.at) ? normalizePoint(editor, action.at)
: normalizeRange(editor, action.at)
if (!target) { if (!target) {
return return
} }
const targetRange = Editor.range(editor, target) const targetRange = Editor.range(editor, target)
if (!editor.selection || !Range.equals(editor.selection, targetRange)) { if (!editor.selection || !Range.equals(editor.selection, targetRange)) {
Transforms.select(editor, target) Transforms.select(editor, target)
}
} }
action.run() action.run()
@@ -142,6 +122,7 @@ export function createAndroidInputManager({
clearTimeout(flushTimeoutId) clearTimeout(flushTimeoutId)
flushTimeoutId = null flushTimeoutId = null
} }
if (actionTimeoutId) { if (actionTimeoutId) {
clearTimeout(actionTimeoutId) clearTimeout(actionTimeoutId)
actionTimeoutId = null actionTimeoutId = null
@@ -156,6 +137,7 @@ export function createAndroidInputManager({
flushing = true flushing = true
setTimeout(() => (flushing = false)) setTimeout(() => (flushing = false))
} }
if (hasPendingAction()) { if (hasPendingAction()) {
flushing = 'action' flushing = 'action'
} }
@@ -182,8 +164,9 @@ export function createAndroidInputManager({
editor.marks = pendingMarks editor.marks = pendingMarks
} }
if (pendingMarks) { if (pendingMarks && insertPositionHint === false) {
isInsertAfterMarkPlaceholder = true insertPositionHint = null
debug('insert after mark placeholder')
} }
const range = targetRange(diff) const range = targetRange(diff)
@@ -225,6 +208,7 @@ export function createAndroidInputManager({
const selection = selectionRef?.unref() const selection = selectionRef?.unref()
if ( if (
selection && selection &&
!EDITOR_TO_PENDING_SELECTION.get(editor) &&
(!editor.selection || !Range.equals(selection, editor.selection)) (!editor.selection || !Range.equals(selection, editor.selection))
) { ) {
Transforms.select(editor, selection) Transforms.select(editor, selection)
@@ -252,6 +236,7 @@ export function createAndroidInputManager({
EDITOR_TO_USER_MARKS.delete(editor) EDITOR_TO_USER_MARKS.delete(editor)
if (userMarks !== undefined) { if (userMarks !== undefined) {
editor.marks = userMarks 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 }) debug('scheduleAction', { at, run })
EDITOR_TO_PENDING_SELECTION.delete(editor) EDITOR_TO_PENDING_SELECTION.delete(editor)
@@ -355,6 +344,14 @@ export function createAndroidInputManager({
let targetRange: Range | null = null let targetRange: Range | null = null
const data = (event as any).dataTransfer || event.data || undefined const data = (event as any).dataTransfer || event.data || undefined
if (
insertPositionHint !== false &&
type !== 'insertText' &&
type !== 'insertCompositionText'
) {
insertPositionHint = false
}
let [nativeTargetRange] = (event as any).getTargetRanges() let [nativeTargetRange] = (event as any).getTargetRanges()
if (nativeTargetRange) { if (nativeTargetRange) {
targetRange = ReactEditor.toSlateRange(editor, nativeTargetRange, { targetRange = ReactEditor.toSlateRange(editor, nativeTargetRange, {
@@ -403,8 +400,9 @@ export function createAndroidInputManager({
} }
const direction = type.endsWith('Backward') ? 'backward' : 'forward' const direction = type.endsWith('Backward') ? 'backward' : 'forward'
return scheduleAction(targetRange, () => return scheduleAction(
Editor.deleteFragment(editor, { direction }) () => Editor.deleteFragment(editor, { direction }),
{ at: targetRange }
) )
} }
@@ -412,7 +410,9 @@ export function createAndroidInputManager({
case 'deleteByComposition': case 'deleteByComposition':
case 'deleteByCut': case 'deleteByCut':
case 'deleteByDrag': { case 'deleteByDrag': {
return scheduleAction(targetRange, () => Editor.deleteFragment(editor)) return scheduleAction(() => Editor.deleteFragment(editor), {
at: targetRange,
})
} }
case 'deleteContent': case 'deleteContent':
@@ -430,7 +430,9 @@ export function createAndroidInputManager({
} }
} }
return scheduleAction(targetRange, () => Editor.deleteForward(editor)) return scheduleAction(() => Editor.deleteForward(editor), {
at: targetRange,
})
} }
case 'deleteContentBackward': { case 'deleteContentBackward': {
@@ -455,58 +457,73 @@ export function createAndroidInputManager({
}) })
} }
return scheduleAction(targetRange, () => Editor.deleteBackward(editor)) return scheduleAction(() => Editor.deleteBackward(editor), {
} at: targetRange,
case 'deleteEntireSoftLine': {
return scheduleAction(targetRange, () => {
Editor.deleteBackward(editor, { unit: 'line' })
Editor.deleteForward(editor, { unit: 'line' })
}) })
} }
case 'deleteEntireSoftLine': {
return scheduleAction(
() => {
Editor.deleteBackward(editor, { unit: 'line' })
Editor.deleteForward(editor, { unit: 'line' })
},
{ at: targetRange }
)
}
case 'deleteHardLineBackward': { case 'deleteHardLineBackward': {
return scheduleAction(targetRange, () => return scheduleAction(
Editor.deleteBackward(editor, { unit: 'block' }) () => Editor.deleteBackward(editor, { unit: 'block' }),
{ at: targetRange }
) )
} }
case 'deleteSoftLineBackward': { case 'deleteSoftLineBackward': {
return scheduleAction(targetRange, () => return scheduleAction(
Editor.deleteBackward(editor, { unit: 'line' }) () => Editor.deleteBackward(editor, { unit: 'line' }),
{ at: targetRange }
) )
} }
case 'deleteHardLineForward': { case 'deleteHardLineForward': {
return scheduleAction(targetRange, () => return scheduleAction(
Editor.deleteForward(editor, { unit: 'block' }) () => Editor.deleteForward(editor, { unit: 'block' }),
{ at: targetRange }
) )
} }
case 'deleteSoftLineForward': { case 'deleteSoftLineForward': {
return scheduleAction(targetRange, () => return scheduleAction(
Editor.deleteForward(editor, { unit: 'line' }) () => Editor.deleteForward(editor, { unit: 'line' }),
{ at: targetRange }
) )
} }
case 'deleteWordBackward': { case 'deleteWordBackward': {
return scheduleAction(targetRange, () => return scheduleAction(
Editor.deleteBackward(editor, { unit: 'word' }) () => Editor.deleteBackward(editor, { unit: 'word' }),
{ at: targetRange }
) )
} }
case 'deleteWordForward': { case 'deleteWordForward': {
return scheduleAction(targetRange, () => return scheduleAction(
Editor.deleteForward(editor, { unit: 'word' }) () => Editor.deleteForward(editor, { unit: 'word' }),
{ at: targetRange }
) )
} }
case 'insertLineBreak': { case 'insertLineBreak': {
return scheduleAction(targetRange, () => Editor.insertSoftBreak(editor)) return scheduleAction(() => Editor.insertSoftBreak(editor), {
at: targetRange,
})
} }
case 'insertParagraph': { case 'insertParagraph': {
return scheduleAction(targetRange, () => Editor.insertBreak(editor)) return scheduleAction(() => Editor.insertBreak(editor), {
at: targetRange,
})
} }
case 'insertCompositionText': case 'insertCompositionText':
case 'deleteCompositionText': case 'deleteCompositionText':
@@ -517,15 +534,15 @@ export function createAndroidInputManager({
case 'insertReplacementText': case 'insertReplacementText':
case 'insertText': { case 'insertText': {
if (data?.constructor.name === 'DataTransfer') { if (data?.constructor.name === 'DataTransfer') {
return scheduleAction(targetRange, () => return scheduleAction(() => ReactEditor.insertData(editor, data), {
ReactEditor.insertData(editor, data) at: targetRange,
) })
} }
if (typeof data === 'string' && data.includes('\n')) { if (typeof data === 'string' && data.includes('\n')) {
return scheduleAction(Range.end(targetRange), () => return scheduleAction(() => Editor.insertSoftBreak(editor), {
Editor.insertSoftBreak(editor) at: Range.end(targetRange),
) })
} }
let text = data ?? '' let text = data ?? ''
@@ -537,41 +554,80 @@ export function createAndroidInputManager({
} }
if (Path.equals(targetRange.anchor.path, targetRange.focus.path)) { 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) const [start, end] = Range.edges(targetRange)
return storeDiff(start.path, {
const diff = {
start: start.offset, start: start.offset,
end: end.offset, end: end.offset,
text, 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, () => return scheduleAction(() => Editor.insertText(editor, text), {
Editor.insertText(editor, text) at: targetRange,
) })
} }
} }
} }
const hasPendingAction = () => { const hasPendingAction = () => {
return !!EDITOR_TO_PENDING_ACTION.get(editor) || !!actionTimeoutId return !!EDITOR_TO_PENDING_ACTION.get(editor)
} }
const hasPendingDiffs = () => { const hasPendingDiffs = () => {
return !!EDITOR_TO_PENDING_DIFFS.get(editor)?.length return !!EDITOR_TO_PENDING_DIFFS.get(editor)?.length
} }
const hasPendingChanges = () => {
return hasPendingAction() || hasPendingDiffs()
}
const isFlushing = () => { const isFlushing = () => {
return flushing return flushing
} }
@@ -584,13 +640,22 @@ export function createAndroidInputManager({
flushTimeoutId = null flushTimeoutId = null
} }
const pathChanged = const { selection } = editor
range && if (!range) {
(!editor.selection || return
!Path.equals(editor.selection.anchor.path, range?.anchor.path)) }
if (pathChanged) { const pathChanged =
isInsertAfterMarkPlaceholder = false !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()) { if (pathChanged || !hasPendingDiffs()) {
@@ -643,6 +708,8 @@ export function createAndroidInputManager({
hasPendingDiffs, hasPendingDiffs,
hasPendingAction, hasPendingAction,
hasPendingChanges,
isFlushing, isFlushing,
handleUserSelect, handleUserSelect,

View File

@@ -124,8 +124,9 @@ export const withReact = <T extends Editor>(editor: T) => {
transformPendingRange(editor, pendingSelection, op) transformPendingRange(editor, pendingSelection, op)
) )
} }
const pendingAction = EDITOR_TO_PENDING_ACTION.get(editor) const pendingAction = EDITOR_TO_PENDING_ACTION.get(editor)
if (pendingAction) { if (pendingAction?.at) {
const at = Point.isPoint(pendingAction?.at) const at = Point.isPoint(pendingAction?.at)
? transformPendingPoint(editor, pendingAction.at, op) ? transformPendingPoint(editor, pendingAction.at, op)
: transformPendingRange(editor, pendingAction.at, op) : transformPendingRange(editor, pendingAction.at, op)