mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-26 10:45:57 +08:00
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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user