fix(editor): prevent SwiftKey IME double input (#13590)

Close
[BS-3610](https://linear.app/affine-design/issue/BS-3610/bug-每次按空格会出现重复单词-,特定输入法,比如swiftkey)

#### PR Dependency Tree

* **PR #13591**
  * **PR #13590** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- Bug Fixes
- Android: More reliable Backspace/delete handling, preventing missed
inputs and double-deletions.
- Android: Cursor/selection is correctly restored after merging a
paragraph with the previous block.
- Android: Smoother IME composition input; captures correct composition
range.
- Deletion across lines and around embeds/empty lines is more
consistent.
- Chores
- Internal event handling updated to improve Android compatibility and
stability (no user-facing changes).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->





#### PR Dependency Tree


* **PR #13591**
  * **PR #13590** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
L-Sun
2025-09-16 17:02:54 +08:00
committed by GitHub
parent fd717af3db
commit 34a3c83d84
4 changed files with 109 additions and 43 deletions

View File

@@ -24,7 +24,7 @@ import {
getPrevContentBlock, getPrevContentBlock,
matchModels, matchModels,
} from '@blocksuite/affine-shared/utils'; } from '@blocksuite/affine-shared/utils';
import { IS_MOBILE } from '@blocksuite/global/env'; import { IS_ANDROID, IS_MOBILE } from '@blocksuite/global/env';
import { BlockSelection, type EditorHost } from '@blocksuite/std'; import { BlockSelection, type EditorHost } from '@blocksuite/std';
import type { BlockModel, Text } from '@blocksuite/store'; import type { BlockModel, Text } from '@blocksuite/store';
@@ -79,6 +79,28 @@ export function mergeWithPrev(editorHost: EditorHost, model: BlockModel) {
index: lengthBeforeJoin, index: lengthBeforeJoin,
length: 0, length: 0,
}).catch(console.error); }).catch(console.error);
// due to some IME like Microsoft Swift IME on Android will reset range after join text,
// for example:
//
// $ZERO_WIDTH_FOR_EMPTY_LINE <--- p1
// |aaa <--- p2
//
// after pressing backspace, during beforeinput event, the native range is (p1, 1) -> (p2, 0)
// and after browser and IME handle the event, the native range is (p1, 1) -> (p1, 1)
//
// a|aa <--- p1
//
// so we need to set range again after join text.
if (IS_ANDROID) {
setTimeout(() => {
asyncSetInlineRange(editorHost.std, prevBlock, {
index: lengthBeforeJoin,
length: 0,
}).catch(console.error);
});
}
return true; return true;
} }

View File

@@ -1,4 +1,5 @@
import { IS_MAC } from '@blocksuite/global/env'; import { DisposableGroup } from '@blocksuite/global/disposable';
import { IS_ANDROID, IS_MAC } from '@blocksuite/global/env';
import { import {
type UIEventHandler, type UIEventHandler,
@@ -6,7 +7,7 @@ import {
UIEventStateContext, UIEventStateContext,
} from '../base.js'; } from '../base.js';
import type { EventOptions, UIEventDispatcher } from '../dispatcher.js'; import type { EventOptions, UIEventDispatcher } from '../dispatcher.js';
import { bindKeymap } from '../keymap.js'; import { androidBindKeymapPatch, bindKeymap } from '../keymap.js';
import { KeyboardEventState } from '../state/index.js'; import { KeyboardEventState } from '../state/index.js';
import { EventScopeSourceType, EventSourceState } from '../state/source.js'; import { EventScopeSourceType, EventSourceState } from '../state/source.js';
@@ -87,15 +88,29 @@ export class KeyboardControl {
} }
bindHotkey(keymap: Record<string, UIEventHandler>, options?: EventOptions) { bindHotkey(keymap: Record<string, UIEventHandler>, options?: EventOptions) {
return this._dispatcher.add( const disposables = new DisposableGroup();
'keyDown', if (IS_ANDROID) {
ctx => { disposables.add(
if (this.composition) return false; this._dispatcher.add('beforeInput', ctx => {
const binding = bindKeymap(keymap); if (this.composition) return false;
return binding(ctx); const binding = androidBindKeymapPatch(keymap);
}, return binding(ctx);
options })
);
}
disposables.add(
this._dispatcher.add(
'keyDown',
ctx => {
if (this.composition) return false;
const binding = bindKeymap(keymap);
return binding(ctx);
},
options
)
); );
return () => disposables.dispose();
} }
listen() { listen() {

View File

@@ -103,3 +103,25 @@ export function bindKeymap(
return false; return false;
}; };
} }
// In some IME of Android like, the keypress event dose not contain
// the information about what key is pressed. See
// https://stackoverflow.com/a/68188679
// https://stackoverflow.com/a/66724830
export function androidBindKeymapPatch(
bindings: Record<string, UIEventHandler>
): UIEventHandler {
return ctx => {
const event = ctx.get('defaultState').event;
if (!(event instanceof InputEvent)) return;
if (
event.inputType === 'deleteContentBackward' &&
'Backspace' in bindings
) {
return bindings['Backspace'](ctx);
}
return false;
};
}

View File

@@ -1,3 +1,4 @@
import { IS_ANDROID } from '@blocksuite/global/env';
import type { BaseTextAttributes } from '@blocksuite/store'; import type { BaseTextAttributes } from '@blocksuite/store';
import type { InlineEditor } from '../inline-editor.js'; import type { InlineEditor } from '../inline-editor.js';
@@ -41,11 +42,10 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
} }
}; };
private readonly _onBeforeInput = (event: InputEvent) => { private readonly _onBeforeInput = async (event: InputEvent) => {
const range = this.editor.rangeService.getNativeRange(); const range = this.editor.rangeService.getNativeRange();
if ( if (
this.editor.isReadonly || this.editor.isReadonly ||
this._isComposing ||
!range || !range ||
!this._isRangeCompletelyInRoot(range) !this._isRangeCompletelyInRoot(range)
) )
@@ -54,33 +54,29 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
let inlineRange = this.editor.toInlineRange(range); let inlineRange = this.editor.toInlineRange(range);
if (!inlineRange) return; if (!inlineRange) return;
if (this._isComposing) {
if (IS_ANDROID && event.inputType === 'insertCompositionText') {
this._compositionInlineRange = inlineRange;
}
return;
}
let ifHandleTargetRange = true; let ifHandleTargetRange = true;
if (event.inputType.startsWith('delete')) { if (
if ( event.inputType.startsWith('delete') &&
isInEmbedGap(range.commonAncestorContainer) && (isInEmbedGap(range.commonAncestorContainer) ||
inlineRange.length === 0 &&
inlineRange.index > 0
) {
inlineRange = {
index: inlineRange.index - 1,
length: 1,
};
ifHandleTargetRange = false;
} else if (
isInEmptyLine(range.commonAncestorContainer) &&
inlineRange.length === 0 &&
inlineRange.index > 0
// eslint-disable-next-line sonarjs/no-duplicated-branches
) {
// do not use target range when deleting across lines
// https://github.com/toeverything/blocksuite/issues/5381 // https://github.com/toeverything/blocksuite/issues/5381
inlineRange = { isInEmptyLine(range.commonAncestorContainer)) &&
index: inlineRange.index - 1, inlineRange.length === 0 &&
length: 1, inlineRange.index > 0
}; ) {
ifHandleTargetRange = false; // do not use target range when deleting across lines
} inlineRange = {
index: inlineRange.index - 1,
length: 1,
};
ifHandleTargetRange = false;
} }
if (ifHandleTargetRange) { if (ifHandleTargetRange) {
@@ -97,11 +93,24 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
} }
} }
} }
if (!inlineRange) return; if (!inlineRange) return;
event.preventDefault(); event.preventDefault();
if (IS_ANDROID) {
this.editor.rerenderWholeEditor();
await this.editor.waitForUpdate();
if (
event.inputType === 'deleteContentBackward' &&
!(inlineRange.index === 0 && inlineRange.length === 0)
) {
// when press backspace at offset 1, double characters will be removed.
// because we mock backspace key event `androidBindKeymapPatch` in blocksuite/framework/std/src/event/keymap.ts
// so we need to stop the event propagation to prevent the double characters removal.
event.stopPropagation();
}
}
const ctx: BeforeinputHookCtx<TextAttributes> = { const ctx: BeforeinputHookCtx<TextAttributes> = {
inlineEditor: this.editor, inlineEditor: this.editor,
raw: event, raw: event,
@@ -346,11 +355,9 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
return; return;
} }
this.editor.disposables.addFromEvent( this.editor.disposables.addFromEvent(eventSource, 'beforeinput', e => {
eventSource, this._onBeforeInput(e).catch(console.error);
'beforeinput', });
this._onBeforeInput
);
this.editor.disposables.addFromEvent( this.editor.disposables.addFromEvent(
eventSource, eventSource,
'compositionstart', 'compositionstart',