mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-22 20:41:50 +08:00
fix(android): stabilize IME composition and deletion handling (#15370)
## Summary Fix Android editor IME corruption around composition, autocorrect replay, delete, Enter, and old WebView delete behavior. ## What changed - Add Android WebView InputConnection wrapper for editor IME handling. - Route Android delete events through BlockSuite editor input. - Guard against keyboard autocorrect/composition replay after delete or space. - Stabilize delete fallback on older Android/WebView versions. - Gate IME diagnostic logs behind debug builds. - Add Android IME fix notes and regression coverage. ## Validation - Manual Android testing passed. - `git diff --check upstream/canary...HEAD` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved Android text editing for Backspace, Delete, Enter, composing text, and keyboard events. * Prevented input methods from targeting the wrong editor area. * Improved caret-based text handling, focus synchronization, and composing-session cleanup. * **Platform Improvements** * Added a dedicated Android input bridge for smoother IME interactions and fallback keyboard behavior. * Improved editor actions, input recovery, and trusted-page validation for Android communication. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { bindKeymap } from '../event/keymap.js';
|
||||
import { UIEventState, UIEventStateContext } from '../event/base.js';
|
||||
import { androidBindKeymapPatch, bindKeymap } from '../event/keymap.js';
|
||||
|
||||
const createKeyboardEvent = (options: {
|
||||
key: string;
|
||||
@@ -117,3 +118,86 @@ describe('bindKeymap', () => {
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('androidBindKeymapPatch', () => {
|
||||
const beforeInputCtx = (inputType: string) => {
|
||||
const event = new InputEvent('beforeinput', {
|
||||
inputType,
|
||||
cancelable: true,
|
||||
});
|
||||
return { ctx: UIEventStateContext.from(new UIEventState(event)), event };
|
||||
};
|
||||
|
||||
test('routes deleteContentBackward to the Backspace binding', () => {
|
||||
const backspace = vi.fn(() => true);
|
||||
const handler = androidBindKeymapPatch({ Backspace: backspace });
|
||||
const { ctx } = beforeInputCtx('deleteContentBackward');
|
||||
|
||||
expect(handler(ctx)).toBe(true);
|
||||
expect(backspace).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('routes insertParagraph to the Enter binding', () => {
|
||||
const enter = vi.fn((ctx: UIEventStateContext) => {
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
return true;
|
||||
});
|
||||
const handler = androidBindKeymapPatch({ Enter: enter });
|
||||
const { ctx, event } = beforeInputCtx('insertParagraph');
|
||||
const preventDefault = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
expect(handler(ctx)).toBe(true);
|
||||
expect(enter).toHaveBeenCalledOnce();
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(ctx.get('keyboardState').raw.key).toBe('Enter');
|
||||
expect(ctx.get('keyboardState').composing).toBe(false);
|
||||
});
|
||||
|
||||
test('propagates preventDefault when the binding returns false', () => {
|
||||
const backspace = vi.fn((ctx: UIEventStateContext) => {
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
return false;
|
||||
});
|
||||
const handler = androidBindKeymapPatch({ Backspace: backspace });
|
||||
const { ctx, event } = beforeInputCtx('deleteContentBackward');
|
||||
|
||||
expect(handler(ctx)).toBe(false);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
test('does nothing for insertParagraph without an Enter binding', () => {
|
||||
const handler = androidBindKeymapPatch({ Backspace: vi.fn(() => true) });
|
||||
const { ctx } = beforeInputCtx('insertParagraph');
|
||||
|
||||
expect(handler(ctx)).toBe(false);
|
||||
expect(ctx.has('keyboardState')).toBe(false);
|
||||
});
|
||||
|
||||
test('ignores non-input events', () => {
|
||||
const enter = vi.fn(() => true);
|
||||
const backspace = vi.fn(() => true);
|
||||
const ctx = UIEventStateContext.from(
|
||||
new UIEventState(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||
);
|
||||
|
||||
expect(
|
||||
androidBindKeymapPatch({ Enter: enter, Backspace: backspace })(ctx)
|
||||
).toBeUndefined();
|
||||
expect(enter).not.toHaveBeenCalled();
|
||||
expect(backspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ignores unrelated input types', () => {
|
||||
const enter = vi.fn(() => true);
|
||||
const backspace = vi.fn(() => true);
|
||||
const handler = androidBindKeymapPatch({
|
||||
Enter: enter,
|
||||
Backspace: backspace,
|
||||
});
|
||||
const { ctx } = beforeInputCtx('insertText');
|
||||
|
||||
expect(handler(ctx)).toBe(false);
|
||||
expect(enter).not.toHaveBeenCalled();
|
||||
expect(backspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { base, keyName } from 'w3c-keyname';
|
||||
|
||||
import type { UIEventHandler } from './base.js';
|
||||
import { KeyboardEventState } from './state/index.js';
|
||||
|
||||
function normalizeKeyName(name: string) {
|
||||
const parts = name.split(/-(?!$)/);
|
||||
@@ -127,13 +128,38 @@ export function androidBindKeymapPatch(
|
||||
const event = ctx.get('defaultState').event;
|
||||
if (!(event instanceof InputEvent)) return;
|
||||
|
||||
if (
|
||||
event.inputType === 'deleteContentBackward' &&
|
||||
'Backspace' in bindings
|
||||
) {
|
||||
return bindings['Backspace'](ctx);
|
||||
const bindingName =
|
||||
event.inputType === 'deleteContentBackward'
|
||||
? 'Backspace'
|
||||
: event.inputType === 'deleteContentForward'
|
||||
? 'Delete'
|
||||
: event.inputType === 'insertParagraph'
|
||||
? 'Enter'
|
||||
: undefined;
|
||||
if (!bindingName || !(bindingName in bindings)) return false;
|
||||
|
||||
if (!ctx.has('keyboardState')) {
|
||||
const keyboardEvent = new KeyboardEvent('keydown', {
|
||||
key: bindingName,
|
||||
code: bindingName,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(keyboardEvent, 'isComposing', {
|
||||
configurable: true,
|
||||
value: event.isComposing,
|
||||
});
|
||||
ctx.add(
|
||||
new KeyboardEventState({
|
||||
event: keyboardEvent,
|
||||
composing: event.isComposing,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
const handled = bindings[bindingName](ctx);
|
||||
if (handled || ctx.get('keyboardState').raw.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,11 +13,58 @@ import { isMaybeInlineRangeEqual } from '../utils/inline-range.js';
|
||||
import { transformInput } from '../utils/transform-input.js';
|
||||
import type { BeforeinputHookCtx, CompositionEndHookCtx } from './hook.js';
|
||||
|
||||
type AndroidIMEInputType = 'deleteContentBackward' | 'deleteContentForward';
|
||||
|
||||
type AndroidIMEInputDetail = {
|
||||
inputType?: AndroidIMEInputType;
|
||||
handled?: boolean;
|
||||
};
|
||||
|
||||
type AndroidIMEBridge = {
|
||||
getProtocolVersion?: () => number;
|
||||
finishComposingSession?: () => void;
|
||||
finishDeleteSession?: () => void;
|
||||
setEditorFocused?: (focused: boolean) => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
'affine-android-ime-input': CustomEvent<AndroidIMEInputDetail>;
|
||||
}
|
||||
}
|
||||
|
||||
export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
private _compositionInlineRange: InlineRange | null = null;
|
||||
|
||||
private _isComposing = false;
|
||||
|
||||
private readonly _androidIMEBridge = () => {
|
||||
const bridge = (
|
||||
globalThis as typeof globalThis & {
|
||||
AffineAndroidIME?: AndroidIMEBridge;
|
||||
}
|
||||
).AffineAndroidIME;
|
||||
return bridge?.getProtocolVersion?.() === 1 ? bridge : undefined;
|
||||
};
|
||||
|
||||
private readonly _finishAndroidComposingSession = (isDelete: boolean) => {
|
||||
if (!IS_ANDROID) return;
|
||||
|
||||
window.setTimeout(() => {
|
||||
const bridge = this._androidIMEBridge();
|
||||
if (isDelete) {
|
||||
bridge?.finishDeleteSession?.();
|
||||
} else {
|
||||
bridge?.finishComposingSession?.();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
private readonly _setAndroidEditorFocused = (focused: boolean) => {
|
||||
if (!IS_ANDROID) return;
|
||||
this._androidIMEBridge()?.setEditorFocused?.(focused);
|
||||
};
|
||||
|
||||
private readonly _getClosestInlineRoot = (node: Node): Element | null => {
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
return el?.closest(`[${INLINE_ROOT_ATTR}]`) ?? null;
|
||||
@@ -203,6 +250,97 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
);
|
||||
|
||||
this.editor.slots.inputting.next(event.data ?? '');
|
||||
|
||||
if (
|
||||
IS_ANDROID &&
|
||||
(ctx.raw.inputType === 'deleteContentBackward' ||
|
||||
ctx.raw.inputType === 'deleteContentForward' ||
|
||||
ctx.raw.inputType === 'insertParagraph' ||
|
||||
ctx.raw.inputType === 'insertLineBreak' ||
|
||||
(ctx.raw.inputType === 'insertText' &&
|
||||
(ctx.data === ' ' || ctx.data === '\n')))
|
||||
) {
|
||||
this._finishAndroidComposingSession(
|
||||
ctx.raw.inputType === 'deleteContentBackward' ||
|
||||
ctx.raw.inputType === 'deleteContentForward'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _onAndroidIMEInput = async (
|
||||
event: CustomEvent<AndroidIMEInputDetail>
|
||||
) => {
|
||||
if (!IS_ANDROID) return;
|
||||
|
||||
const inputType = event.detail?.inputType;
|
||||
if (
|
||||
inputType !== 'deleteContentBackward' &&
|
||||
inputType !== 'deleteContentForward'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = this.editor.rangeService.getNativeRange();
|
||||
if (!range || !this._isRangeCompletelyInRoot(range)) return;
|
||||
|
||||
event.detail.handled = true;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.editor.isReadonly) return;
|
||||
|
||||
let inlineRange = this.editor.toInlineRange(range);
|
||||
if (!inlineRange) {
|
||||
this.editor.rerenderWholeEditor();
|
||||
await this.editor.waitForUpdate();
|
||||
const newRange = this.editor.rangeService.getNativeRange();
|
||||
inlineRange = newRange ? this.editor.toInlineRange(newRange) : null;
|
||||
if (!inlineRange) return;
|
||||
}
|
||||
|
||||
if (inlineRange.length === 0) {
|
||||
if (inputType === 'deleteContentBackward') {
|
||||
if (inlineRange.index === 0) return;
|
||||
inlineRange = {
|
||||
index: inlineRange.index - 1,
|
||||
length: 1,
|
||||
};
|
||||
} else {
|
||||
if (inlineRange.index >= this.editor.yTextLength) return;
|
||||
inlineRange = {
|
||||
index: inlineRange.index,
|
||||
length: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this._isComposing = false;
|
||||
this._compositionInlineRange = null;
|
||||
|
||||
const raw = new InputEvent('beforeinput', {
|
||||
inputType,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
const ctx: BeforeinputHookCtx<TextAttributes> = {
|
||||
inlineEditor: this.editor,
|
||||
raw,
|
||||
inlineRange,
|
||||
data: null,
|
||||
attributes: {} as TextAttributes,
|
||||
};
|
||||
this.editor.hooks.beforeinput?.(ctx);
|
||||
|
||||
transformInput<TextAttributes>(
|
||||
ctx.raw.inputType,
|
||||
ctx.data,
|
||||
ctx.attributes,
|
||||
ctx.inlineRange,
|
||||
this.editor as never
|
||||
);
|
||||
this.editor.slots.inputting.next('');
|
||||
this._finishAndroidComposingSession(true);
|
||||
};
|
||||
|
||||
private readonly _onClick = (event: MouseEvent) => {
|
||||
@@ -265,6 +403,7 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
}
|
||||
|
||||
this.editor.slots.inputting.next(event.data ?? '');
|
||||
this._finishAndroidComposingSession(false);
|
||||
};
|
||||
|
||||
private readonly _onCompositionStart = (event: CompositionEvent) => {
|
||||
@@ -432,6 +571,30 @@ export class EventService<TextAttributes extends BaseTextAttributes> {
|
||||
this.editor.disposables.addFromEvent(eventSource, 'beforeinput', e => {
|
||||
this._onBeforeInput(e).catch(console.error);
|
||||
});
|
||||
this.editor.disposables.addFromEvent(
|
||||
eventSource,
|
||||
'affine-android-ime-input',
|
||||
e => {
|
||||
this._onAndroidIMEInput(e).catch(console.error);
|
||||
}
|
||||
);
|
||||
this.editor.disposables.addFromEvent(eventSource, 'focusin', () => {
|
||||
this._setAndroidEditorFocused(true);
|
||||
});
|
||||
this.editor.disposables.addFromEvent(
|
||||
eventSource,
|
||||
'focusout',
|
||||
(event: FocusEvent) => {
|
||||
const relatedTarget = event.relatedTarget;
|
||||
if (
|
||||
relatedTarget instanceof Node &&
|
||||
this.editor.rootElement?.contains(relatedTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._setAndroidEditorFocused(false);
|
||||
}
|
||||
);
|
||||
this.editor.disposables.addFromEvent(
|
||||
eventSource,
|
||||
'compositionstart',
|
||||
|
||||
Reference in New Issue
Block a user