mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-22 12:32:00 +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:
@@ -19,6 +19,7 @@ import {
|
||||
getScrollContainer,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { IS_ANDROID } from '@blocksuite/global/env';
|
||||
import { Point } from '@blocksuite/global/gfx';
|
||||
import type { PointerEventState } from '@blocksuite/std';
|
||||
import { BlockComponent, BlockSelection, TextSelection } from '@blocksuite/std';
|
||||
@@ -413,7 +414,10 @@ export class PageRootBlockComponent extends BlockComponent<RootBlockModel> {
|
||||
return !(isNote && displayOnEdgeless);
|
||||
});
|
||||
|
||||
this.contentEditable = String(!this.store.readonly$.value);
|
||||
// Android IMEs can target this outer editable root instead of a block's
|
||||
// inline editor, leaving composition text in the DOM without committing it
|
||||
// to the document model. Keep only the block editors editable on Android.
|
||||
this.contentEditable = String(!this.store.readonly$.value && !IS_ANDROID);
|
||||
|
||||
return html`
|
||||
<div class="affine-page-root-block-container">${children} ${widgets}</div>
|
||||
|
||||
@@ -57,12 +57,14 @@ export function getPrefixText(inlineEditor: InlineEditor) {
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange || inlineRange.length > 0) return '';
|
||||
|
||||
const nearestLineBreakIndex = inlineEditor.yTextString
|
||||
.slice(0, inlineRange.index)
|
||||
.lastIndexOf('\n');
|
||||
const prefixText = inlineEditor.yTextString.slice(
|
||||
nearestLineBreakIndex + 1,
|
||||
inlineRange.index
|
||||
);
|
||||
return prefixText;
|
||||
const maxMarkdownPrefixLength = 512;
|
||||
const prefixStart = Math.max(0, inlineRange.index - maxMarkdownPrefixLength);
|
||||
const yTextString = inlineEditor.yTextString;
|
||||
const prefixWindow = yTextString.slice(prefixStart, inlineRange.index);
|
||||
const nearestLineBreakIndex = prefixWindow.lastIndexOf('\n');
|
||||
if (nearestLineBreakIndex === -1 && prefixStart > 0) return '';
|
||||
|
||||
return nearestLineBreakIndex === -1
|
||||
? prefixWindow
|
||||
: prefixWindow.slice(nearestLineBreakIndex + 1);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import com.getcapacitor.CapacitorWebView
|
||||
|
||||
class AffineEditorWebView(
|
||||
context: Context,
|
||||
attrs: AttributeSet,
|
||||
) : CapacitorWebView(context, attrs) {
|
||||
private val imeState = AndroidImeState()
|
||||
private var imeBridgeInstalled = false
|
||||
@Volatile
|
||||
private var isTrustedPage = false
|
||||
|
||||
private val imeBridge = AffineImeBridge(
|
||||
isTrustedPage = { this.isTrustedPage },
|
||||
clearComposingState = { imeState.nextClearRequestGeneration() },
|
||||
requestRestartInput = ::requestRestartInput,
|
||||
onEditorFocusedChanged = { focused -> imeState.editorFocused = focused },
|
||||
)
|
||||
|
||||
fun updateAndroidIMEBridge(url: String?, expectedOrigin: String?) {
|
||||
val shouldInstallBridge = isTrustedAffineOrigin(url, expectedOrigin)
|
||||
if (shouldInstallBridge == imeBridgeInstalled) {
|
||||
isTrustedPage = shouldInstallBridge
|
||||
return
|
||||
}
|
||||
|
||||
if (shouldInstallBridge) {
|
||||
addJavascriptInterface(imeBridge, AFFINE_IME_BRIDGE_NAME)
|
||||
imeBridgeInstalled = true
|
||||
isTrustedPage = true
|
||||
} else {
|
||||
isTrustedPage = false
|
||||
imeBridgeInstalled = false
|
||||
removeJavascriptInterface(AFFINE_IME_BRIDGE_NAME)
|
||||
imeState.editorFocused = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
|
||||
val connection = super.onCreateInputConnection(outAttrs) ?: return null
|
||||
return AffineInputConnection(
|
||||
connection,
|
||||
imeState,
|
||||
dispatchDeleteBackward = {
|
||||
dispatchAndroidEditorInput(this, AndroidImeInputType.BACKWARD_DELETE)
|
||||
},
|
||||
dispatchDeleteForward = {
|
||||
dispatchAndroidEditorInput(this, AndroidImeInputType.FORWARD_DELETE)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestRestartInput(delayMs: Long) {
|
||||
val restartGeneration = imeState.nextRestartGeneration()
|
||||
if (delayMs <= 0L) {
|
||||
post { restartInput() }
|
||||
return
|
||||
}
|
||||
|
||||
postDelayed(
|
||||
{
|
||||
if (restartGeneration != imeState.restartInputGeneration) return@postDelayed
|
||||
restartInput()
|
||||
},
|
||||
delayMs,
|
||||
)
|
||||
}
|
||||
|
||||
private fun restartInput() {
|
||||
val inputMethodManager =
|
||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
||||
inputMethodManager?.restartInput(this)
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.net.Uri
|
||||
import android.webkit.JavascriptInterface
|
||||
|
||||
internal const val AFFINE_IME_BRIDGE_NAME = "AffineAndroidIME"
|
||||
internal const val AFFINE_IME_BRIDGE_PROTOCOL_VERSION = 1
|
||||
internal const val DELETE_RESTART_INPUT_DEBOUNCE_MS = 120L
|
||||
|
||||
internal fun normalizeAffineOrigin(url: String?): String? {
|
||||
val uri = Uri.parse(url ?: return null)
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
val host = uri.host?.lowercase() ?: return null
|
||||
val isSupportedOrigin =
|
||||
(scheme == "https" && host == "localhost") ||
|
||||
(BuildConfig.DEBUG &&
|
||||
scheme == "http" &&
|
||||
host in setOf("localhost", "127.0.0.1", "10.0.2.2"))
|
||||
if (!isSupportedOrigin) return null
|
||||
|
||||
val port = when {
|
||||
uri.port == -1 -> ""
|
||||
scheme == "https" && uri.port == 443 -> ""
|
||||
scheme == "http" && uri.port == 80 -> ""
|
||||
else -> ":${uri.port}"
|
||||
}
|
||||
return "$scheme://$host$port"
|
||||
}
|
||||
|
||||
internal fun isTrustedAffineOrigin(url: String?, expectedOrigin: String?): Boolean {
|
||||
return expectedOrigin != null && normalizeAffineOrigin(url) == expectedOrigin
|
||||
}
|
||||
|
||||
internal class AffineImeBridge(
|
||||
private val isTrustedPage: () -> Boolean,
|
||||
private val clearComposingState: () -> Unit,
|
||||
private val requestRestartInput: (Long) -> Unit,
|
||||
private val onEditorFocusedChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
@JavascriptInterface
|
||||
fun getProtocolVersion(): Int {
|
||||
return if (isTrustedPage()) AFFINE_IME_BRIDGE_PROTOCOL_VERSION else 0
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun finishComposingSession() {
|
||||
if (!isTrustedPage()) return
|
||||
clearComposingState()
|
||||
requestRestartInput(0L)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun finishDeleteSession() {
|
||||
if (!isTrustedPage()) return
|
||||
requestRestartInput(DELETE_RESTART_INPUT_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setEditorFocused(focused: Boolean) {
|
||||
if (isTrustedPage()) {
|
||||
onEditorFocusedChanged(focused)
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.webkit.WebView
|
||||
|
||||
internal enum class AndroidImeInputType(
|
||||
val value: String,
|
||||
val key: String,
|
||||
val keyCode: Int,
|
||||
) {
|
||||
BACKWARD_DELETE("deleteContentBackward", "Backspace", 8),
|
||||
FORWARD_DELETE("deleteContentForward", "Delete", 46),
|
||||
}
|
||||
|
||||
internal fun dispatchAndroidEditorInput(
|
||||
webView: WebView,
|
||||
inputType: AndroidImeInputType,
|
||||
) {
|
||||
webView.post {
|
||||
webView.evaluateJavascript(
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const selection = document.getSelection();
|
||||
let target = selection?.anchorNode ?? document.activeElement ?? document.body;
|
||||
if (target && target.nodeType === Node.TEXT_NODE) {
|
||||
target = target.parentElement;
|
||||
}
|
||||
if (!(target instanceof EventTarget)) {
|
||||
target = document.activeElement ?? document.body;
|
||||
}
|
||||
const detail = {
|
||||
inputType: '${inputType.value}',
|
||||
handled: false,
|
||||
};
|
||||
const event = new CustomEvent('affine-android-ime-input', {
|
||||
detail,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
const dispatched = target.dispatchEvent(event);
|
||||
const handled = detail.handled || !dispatched;
|
||||
let fallbackKey = null;
|
||||
if (!handled) {
|
||||
fallbackKey = '${inputType.key}';
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: '${inputType.key}',
|
||||
code: '${inputType.key}',
|
||||
keyCode: ${inputType.keyCode},
|
||||
which: ${inputType.keyCode},
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
return {
|
||||
inputType: '${inputType.value}',
|
||||
handled,
|
||||
fallbackKey,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AffineIME] dispatch editor input failed', error);
|
||||
return { error: String(error) };
|
||||
}
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.view.KeyEvent
|
||||
import android.view.inputmethod.ExtractedTextRequest
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.view.inputmethod.InputConnectionWrapper
|
||||
|
||||
internal class AffineInputConnection(
|
||||
target: InputConnection,
|
||||
private val state: AndroidImeState,
|
||||
private val dispatchDeleteBackward: () -> Unit,
|
||||
private val dispatchDeleteForward: () -> Unit,
|
||||
) : InputConnectionWrapper(target, true) {
|
||||
private var handledClearRequestGeneration = state.clearRequestGeneration
|
||||
private var composingText = ""
|
||||
private var isComposingTextActive = false
|
||||
private var isConsumingDeleteKeyEvent = false
|
||||
|
||||
private val replay = ImeReplayController(
|
||||
deleteBefore = { length -> super.deleteSurroundingText(length, 0) },
|
||||
recordDeleteIntent = { recordDeleteIntent() },
|
||||
hasRecentDeleteIntent = { currentTime -> hasRecentDeleteIntent(currentTime) },
|
||||
)
|
||||
|
||||
override fun setComposingRegion(start: Int, end: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
val regionText = getTextForRegion(start, end)
|
||||
|
||||
val nextComposingText = replay.updateComposingRegion(
|
||||
regionText,
|
||||
composingText,
|
||||
isComposingTextActive,
|
||||
)
|
||||
if (nextComposingText != null) {
|
||||
composingText = nextComposingText
|
||||
isComposingTextActive = nextComposingText.isNotEmpty()
|
||||
}
|
||||
|
||||
// Keep the native composing region untouched so IME autocorrect replay stays in the
|
||||
// explicit replay state machine instead of being applied twice by the platform.
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setComposingText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
val nextText = text?.toString() ?: ""
|
||||
|
||||
if (replay.shouldAdoptExternalRegionForReplacement(nextText)) {
|
||||
replay.clearDroppingReplay()
|
||||
}
|
||||
|
||||
if (replay.shouldDropExternalReplay(nextText)) {
|
||||
replay.markDroppingReplay()
|
||||
replay.deleteForShrinkingExternalReplay(nextText.length)
|
||||
return true
|
||||
}
|
||||
|
||||
val adoptedText = replay.adoptExternalRegionAsComposingTextIfNeeded(nextText)
|
||||
if (adoptedText != null) {
|
||||
composingText = adoptedText
|
||||
isComposingTextActive = adoptedText.isNotEmpty()
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
return applyComposingText(nextText)
|
||||
}
|
||||
|
||||
override fun commitText(text: CharSequence?, newCursorPosition: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
val committedText = text?.toString() ?: ""
|
||||
|
||||
if (
|
||||
replay.shouldDeleteExternalRegionOnEmptyCommit(
|
||||
committedText,
|
||||
isComposingTextActive,
|
||||
)
|
||||
) {
|
||||
replay.deleteRemainingExternalReplayText()
|
||||
replay.clearExternalRegion()
|
||||
return true
|
||||
}
|
||||
|
||||
if (replay.isDroppingReplay) {
|
||||
if (
|
||||
committedText.isEmpty() ||
|
||||
committedText == replay.currentExternalRegionText
|
||||
) {
|
||||
if (committedText.isEmpty()) {
|
||||
replay.deleteRemainingExternalReplayTextAfterShrink()
|
||||
}
|
||||
return true
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
}
|
||||
|
||||
if (isComposingTextActive && committedText.isNotEmpty()) {
|
||||
if (isWordBoundaryCommit(committedText)) {
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
return super.commitText(text, newCursorPosition)
|
||||
}
|
||||
|
||||
val result = applyComposingText(committedText)
|
||||
resetComposingText()
|
||||
return result
|
||||
}
|
||||
|
||||
if (committedText.isNotEmpty()) {
|
||||
replay.clearExternalRegion()
|
||||
}
|
||||
|
||||
return super.commitText(text, newCursorPosition)
|
||||
}
|
||||
|
||||
override fun finishComposingText(): Boolean {
|
||||
consumeClearRequest()
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
return super.finishComposingText()
|
||||
}
|
||||
|
||||
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
recordDeleteIntent(beforeLength, afterLength)
|
||||
if (replay.shouldDropNativeDeleteAfterSyntheticExternalDelete(beforeLength, afterLength)) {
|
||||
replay.clearExternalRegion()
|
||||
return true
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
if (isComposingTextActive && beforeLength > 0) {
|
||||
trimComposingTail(beforeLength, codePoints = false)
|
||||
}
|
||||
return super.deleteSurroundingText(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
override fun deleteSurroundingTextInCodePoints(
|
||||
beforeLength: Int,
|
||||
afterLength: Int,
|
||||
): Boolean {
|
||||
consumeClearRequest()
|
||||
recordDeleteIntent(beforeLength, afterLength)
|
||||
if (replay.shouldDropNativeDeleteAfterSyntheticExternalDelete(beforeLength, afterLength)) {
|
||||
replay.clearExternalRegion()
|
||||
return true
|
||||
}
|
||||
replay.clearExternalRegion()
|
||||
if (isComposingTextActive && beforeLength > 0) {
|
||||
trimComposingTail(beforeLength, codePoints = true)
|
||||
}
|
||||
return super.deleteSurroundingTextInCodePoints(beforeLength, afterLength)
|
||||
}
|
||||
|
||||
override fun setSelection(start: Int, end: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
replay.clearExternalRegion()
|
||||
resetComposingText()
|
||||
return super.setSelection(start, end)
|
||||
}
|
||||
|
||||
override fun performEditorAction(editorAction: Int): Boolean {
|
||||
consumeClearRequest()
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
return super.performEditorAction(editorAction)
|
||||
}
|
||||
|
||||
override fun sendKeyEvent(event: KeyEvent): Boolean {
|
||||
val isDeleteActionDown =
|
||||
event.action == KeyEvent.ACTION_DOWN &&
|
||||
(event.keyCode == KeyEvent.KEYCODE_DEL ||
|
||||
event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL)
|
||||
consumeClearRequest(skipNativeFinish = isDeleteActionDown)
|
||||
|
||||
if (event.keyCode == KeyEvent.KEYCODE_DEL) {
|
||||
if (!state.editorFocused && !isConsumingDeleteKeyEvent) {
|
||||
return super.sendKeyEvent(event)
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
recordDeleteIntent()
|
||||
dispatchDeleteBackward()
|
||||
isConsumingDeleteKeyEvent = true
|
||||
return true
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_UP && isConsumingDeleteKeyEvent) {
|
||||
isConsumingDeleteKeyEvent = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
|
||||
if (!state.editorFocused && !isConsumingDeleteKeyEvent) {
|
||||
return super.sendKeyEvent(event)
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
recordDeleteIntent()
|
||||
dispatchDeleteForward()
|
||||
isConsumingDeleteKeyEvent = true
|
||||
return true
|
||||
}
|
||||
if (event.action == KeyEvent.ACTION_UP && isConsumingDeleteKeyEvent) {
|
||||
isConsumingDeleteKeyEvent = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.action == KeyEvent.ACTION_DOWN &&
|
||||
(event.keyCode == KeyEvent.KEYCODE_SPACE ||
|
||||
event.keyCode == KeyEvent.KEYCODE_ENTER)
|
||||
) {
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
}
|
||||
return super.sendKeyEvent(event)
|
||||
}
|
||||
|
||||
private fun applyComposingText(nextText: String): Boolean {
|
||||
val previousText = composingText
|
||||
val prefixLength = commonPrefixLength(previousText, nextText)
|
||||
val deleteCount = previousText.length - prefixLength
|
||||
val insertText = nextText.substring(prefixLength)
|
||||
|
||||
if (deleteCount > 0) {
|
||||
super.deleteSurroundingText(deleteCount, 0)
|
||||
}
|
||||
if (insertText.isNotEmpty()) {
|
||||
super.commitText(insertText, 1)
|
||||
}
|
||||
|
||||
composingText = nextText
|
||||
isComposingTextActive = nextText.isNotEmpty()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun trimComposingTail(beforeLength: Int, codePoints: Boolean) {
|
||||
val length =
|
||||
if (codePoints) {
|
||||
beforeLength.coerceAtMost(composingText.codePointCount(0, composingText.length))
|
||||
} else {
|
||||
beforeLength.coerceAtMost(composingText.length)
|
||||
}
|
||||
composingText =
|
||||
if (codePoints) {
|
||||
val end = composingText.offsetByCodePoints(composingText.length, -length)
|
||||
composingText.substring(0, end)
|
||||
} else {
|
||||
composingText.dropLast(length)
|
||||
}
|
||||
if (composingText.isEmpty()) {
|
||||
resetComposingText()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetComposingText() {
|
||||
composingText = ""
|
||||
isComposingTextActive = false
|
||||
}
|
||||
|
||||
private fun consumeClearRequest(skipNativeFinish: Boolean = false) {
|
||||
val clearRequestGeneration = state.clearRequestGeneration
|
||||
if (
|
||||
clearRequestGeneration == 0L ||
|
||||
clearRequestGeneration == handledClearRequestGeneration
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
handledClearRequestGeneration = clearRequestGeneration
|
||||
resetComposingText()
|
||||
replay.clearExternalRegion()
|
||||
if (skipNativeFinish) {
|
||||
return
|
||||
}
|
||||
super.finishComposingText()
|
||||
}
|
||||
|
||||
private fun getTextForRegion(start: Int, end: Int): String {
|
||||
if (start < 0 || end <= start) return ""
|
||||
|
||||
val extractedText =
|
||||
try {
|
||||
getExtractedText(ExtractedTextRequest(), 0)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return ""
|
||||
val text = extractedText.text?.toString().orEmpty()
|
||||
val localStart = start - extractedText.startOffset
|
||||
val localEnd = end - extractedText.startOffset
|
||||
if (localStart < 0 || localEnd > text.length) return ""
|
||||
|
||||
return text.substring(localStart, localEnd)
|
||||
}
|
||||
|
||||
private fun recordDeleteIntent(beforeLength: Int, afterLength: Int) {
|
||||
if (beforeLength <= 0 || afterLength != 0) return
|
||||
recordDeleteIntent()
|
||||
}
|
||||
|
||||
private fun recordDeleteIntent() {
|
||||
state.lastDeleteIntentAtMs = android.os.SystemClock.uptimeMillis()
|
||||
}
|
||||
|
||||
private fun hasRecentDeleteIntent(currentTime: Long): Boolean {
|
||||
return currentTime - state.lastDeleteIntentAtMs <= IME_REPLAY_DELETE_WINDOW_MS
|
||||
}
|
||||
|
||||
private fun isWordBoundaryCommit(text: String): Boolean {
|
||||
return text == " " || text == "\n"
|
||||
}
|
||||
|
||||
private fun commonPrefixLength(left: String, right: String): Int {
|
||||
val maxLength = minOf(left.length, right.length)
|
||||
for (index in 0 until maxLength) {
|
||||
if (left[index] != right[index]) {
|
||||
return snapToCodePointBoundary(left, index)
|
||||
}
|
||||
}
|
||||
return snapToCodePointBoundary(left, maxLength)
|
||||
}
|
||||
|
||||
private fun snapToCodePointBoundary(text: String, index: Int): Int {
|
||||
return if (
|
||||
index > 0 &&
|
||||
index < text.length &&
|
||||
Character.isHighSurrogate(text[index - 1]) &&
|
||||
Character.isLowSurrogate(text[index])
|
||||
) {
|
||||
index - 1
|
||||
} else {
|
||||
index
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import com.getcapacitor.Bridge
|
||||
import com.getcapacitor.BridgeWebViewClient
|
||||
|
||||
internal class AffineWebViewClient(
|
||||
bridge: Bridge,
|
||||
private val trustedOrigin: String?,
|
||||
) : BridgeWebViewClient(bridge) {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView,
|
||||
request: WebResourceRequest,
|
||||
): Boolean {
|
||||
val shouldOverride = super.shouldOverrideUrlLoading(view, request)
|
||||
if (!shouldOverride && request.isForMainFrame) {
|
||||
(view as? AffineEditorWebView)?.updateAndroidIMEBridge(
|
||||
request.url.toString(),
|
||||
trustedOrigin,
|
||||
)
|
||||
}
|
||||
return shouldOverride
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package app.affine.pro
|
||||
|
||||
internal class AndroidImeState {
|
||||
@Volatile
|
||||
var clearRequestGeneration: Long = 0L
|
||||
|
||||
@Volatile
|
||||
var editorFocused: Boolean = false
|
||||
|
||||
@Volatile
|
||||
var lastDeleteIntentAtMs: Long = 0L
|
||||
|
||||
@Volatile
|
||||
var restartInputGeneration: Int = 0
|
||||
|
||||
@Synchronized
|
||||
fun nextRestartGeneration(): Int {
|
||||
restartInputGeneration += 1
|
||||
return restartInputGeneration
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun nextClearRequestGeneration(): Long {
|
||||
clearRequestGeneration += 1
|
||||
return clearRequestGeneration
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package app.affine.pro
|
||||
|
||||
import android.os.SystemClock
|
||||
|
||||
internal const val IME_REPLAY_DELETE_WINDOW_MS = 500L
|
||||
|
||||
internal class ImeReplayController(
|
||||
private val deleteBefore: (Int) -> Unit,
|
||||
private val recordDeleteIntent: () -> Unit,
|
||||
private val hasRecentDeleteIntent: (Long) -> Boolean,
|
||||
private val now: () -> Long = SystemClock::uptimeMillis,
|
||||
) {
|
||||
private var externalRegionText = ""
|
||||
private var externalRegionAtMs = 0L
|
||||
private var isDroppingExternalReplay = false
|
||||
private var lastExternalReplayTextLength = -1
|
||||
private var lastExternalReplayTextAtMs = 0L
|
||||
private var lastExternalReplayDeletedLength = 0
|
||||
private var syntheticExternalDeleteAtMs = 0L
|
||||
|
||||
val currentExternalRegionText: String
|
||||
get() = externalRegionText
|
||||
|
||||
val isDroppingReplay: Boolean
|
||||
get() = isDroppingExternalReplay
|
||||
|
||||
fun updateComposingRegion(
|
||||
regionText: String,
|
||||
composingText: String,
|
||||
isComposingTextActive: Boolean,
|
||||
): String? {
|
||||
if (!isComposingTextActive) {
|
||||
externalRegionText = regionText
|
||||
externalRegionAtMs = now()
|
||||
isDroppingExternalReplay = false
|
||||
lastExternalReplayTextLength = regionText.length
|
||||
lastExternalReplayTextAtMs = externalRegionAtMs
|
||||
lastExternalReplayDeletedLength = 0
|
||||
return null
|
||||
}
|
||||
|
||||
if (regionText == composingText) return null
|
||||
|
||||
clearExternalRegion()
|
||||
return regionText
|
||||
}
|
||||
|
||||
fun shouldAdoptExternalRegionForReplacement(nextText: String): Boolean {
|
||||
if (nextText.isEmpty() || externalRegionText.isEmpty()) return false
|
||||
if (externalRegionText.startsWith(nextText)) return false
|
||||
|
||||
val commonPrefixLength = commonPrefixLength(externalRegionText, nextText)
|
||||
val minPrefixLength = minOf(
|
||||
MIN_REPLACEMENT_COMMON_PREFIX_LENGTH,
|
||||
externalRegionText.length,
|
||||
nextText.length,
|
||||
)
|
||||
|
||||
return commonPrefixLength >= minPrefixLength &&
|
||||
nextText.length >= externalRegionText.length
|
||||
}
|
||||
|
||||
fun shouldDropExternalReplay(nextText: String): Boolean {
|
||||
if (nextText.isEmpty()) return false
|
||||
if (isDroppingExternalReplay) {
|
||||
return !shouldAdoptExternalRegionForReplacement(nextText)
|
||||
}
|
||||
if (externalRegionText.isEmpty()) return false
|
||||
|
||||
val currentTime = now()
|
||||
if (
|
||||
currentTime - externalRegionAtMs > EXTERNAL_REGION_REPLAY_WINDOW_MS &&
|
||||
!shouldAdoptExternalRegionForReplacement(nextText)
|
||||
) {
|
||||
clearExternalRegion()
|
||||
return false
|
||||
}
|
||||
|
||||
val isSameRegionReplay =
|
||||
externalRegionText == nextText && hasRecentDeleteIntent(currentTime)
|
||||
val isDeleteShrinkReplay =
|
||||
externalRegionText.startsWith(nextText) && hasRecentDeleteIntent(currentTime)
|
||||
val isLikelyPassiveReplay = nextText.length > 1 || externalRegionText.length > 1
|
||||
|
||||
return (isSameRegionReplay || isDeleteShrinkReplay) && isLikelyPassiveReplay
|
||||
}
|
||||
|
||||
fun markDroppingReplay() {
|
||||
isDroppingExternalReplay = true
|
||||
}
|
||||
|
||||
fun clearDroppingReplay() {
|
||||
isDroppingExternalReplay = false
|
||||
}
|
||||
|
||||
fun adoptExternalRegionAsComposingTextIfNeeded(nextText: String): String? {
|
||||
if (externalRegionText.isEmpty()) return null
|
||||
|
||||
val currentTime = now()
|
||||
if (
|
||||
currentTime - externalRegionAtMs > EXTERNAL_REGION_REPLAY_WINDOW_MS &&
|
||||
!shouldAdoptExternalRegionForReplacement(nextText)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return externalRegionText
|
||||
}
|
||||
|
||||
fun clearExternalRegion() {
|
||||
externalRegionText = ""
|
||||
externalRegionAtMs = 0L
|
||||
isDroppingExternalReplay = false
|
||||
lastExternalReplayTextLength = -1
|
||||
lastExternalReplayTextAtMs = 0L
|
||||
lastExternalReplayDeletedLength = 0
|
||||
}
|
||||
|
||||
fun deleteForShrinkingExternalReplay(nextTextLength: Int) {
|
||||
val currentTime = now()
|
||||
val deleteCount =
|
||||
if (
|
||||
lastExternalReplayTextLength > 0 &&
|
||||
nextTextLength < lastExternalReplayTextLength &&
|
||||
currentTime - lastExternalReplayTextAtMs <= IME_REPLAY_DELETE_WINDOW_MS
|
||||
) {
|
||||
lastExternalReplayTextLength - nextTextLength
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
if (deleteCount > 0) {
|
||||
recordDeleteIntent()
|
||||
deleteBefore(deleteCount)
|
||||
lastExternalReplayDeletedLength += deleteCount
|
||||
syntheticExternalDeleteAtMs = now()
|
||||
}
|
||||
|
||||
lastExternalReplayTextLength = nextTextLength
|
||||
lastExternalReplayTextAtMs = currentTime
|
||||
}
|
||||
|
||||
fun deleteRemainingExternalReplayText() {
|
||||
val currentTime = now()
|
||||
if (
|
||||
lastExternalReplayTextLength <= 0 ||
|
||||
currentTime - lastExternalReplayTextAtMs > IME_REPLAY_DELETE_WINDOW_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
recordDeleteIntent()
|
||||
deleteBefore(lastExternalReplayTextLength)
|
||||
syntheticExternalDeleteAtMs = now()
|
||||
lastExternalReplayTextLength = 0
|
||||
lastExternalReplayTextAtMs = currentTime
|
||||
}
|
||||
|
||||
fun deleteRemainingExternalReplayTextAfterShrink() {
|
||||
if (lastExternalReplayDeletedLength <= 0) return
|
||||
deleteRemainingExternalReplayText()
|
||||
}
|
||||
|
||||
fun shouldDropNativeDeleteAfterSyntheticExternalDelete(
|
||||
beforeLength: Int,
|
||||
afterLength: Int,
|
||||
): Boolean {
|
||||
val currentTime = now()
|
||||
return beforeLength > 0 &&
|
||||
afterLength == 0 &&
|
||||
currentTime - syntheticExternalDeleteAtMs <=
|
||||
SYNTHETIC_EXTERNAL_DELETE_SUPPRESS_WINDOW_MS
|
||||
}
|
||||
|
||||
fun shouldDeleteExternalRegionOnEmptyCommit(
|
||||
committedText: String,
|
||||
isComposingTextActive: Boolean,
|
||||
): Boolean {
|
||||
if (committedText.isNotEmpty()) return false
|
||||
if (isComposingTextActive || externalRegionText.isEmpty()) return false
|
||||
if (lastExternalReplayTextLength <= 0) return false
|
||||
if (lastExternalReplayDeletedLength <= 0) return false
|
||||
|
||||
val currentTime = now()
|
||||
return currentTime - externalRegionAtMs <= IME_REPLAY_DELETE_WINDOW_MS &&
|
||||
hasRecentDeleteIntent(currentTime)
|
||||
}
|
||||
|
||||
private fun commonPrefixLength(left: String, right: String): Int {
|
||||
val maxLength = minOf(left.length, right.length)
|
||||
for (index in 0 until maxLength) {
|
||||
if (left[index] != right[index]) {
|
||||
return snapToCodePointBoundary(left, index)
|
||||
}
|
||||
}
|
||||
return snapToCodePointBoundary(left, maxLength)
|
||||
}
|
||||
|
||||
private fun snapToCodePointBoundary(text: String, index: Int): Int {
|
||||
return if (
|
||||
index > 0 &&
|
||||
index < text.length &&
|
||||
Character.isHighSurrogate(text[index - 1]) &&
|
||||
Character.isLowSurrogate(text[index])
|
||||
) {
|
||||
index - 1
|
||||
} else {
|
||||
index
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EXTERNAL_REGION_REPLAY_WINDOW_MS = 500L
|
||||
const val SYNTHETIC_EXTERNAL_DELETE_SUPPRESS_WINDOW_MS = 120L
|
||||
const val MIN_REPLACEMENT_COMMON_PREFIX_LENGTH = 2
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -30,6 +31,7 @@ import app.affine.pro.service.WebService
|
||||
import app.affine.pro.utils.px2dp
|
||||
import app.affine.pro.utils.dp2px
|
||||
import com.getcapacitor.BridgeActivity
|
||||
import com.getcapacitor.WebViewListener
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -99,10 +101,26 @@ class MainActivity : BridgeActivity(), AIButtonPlugin.Callback, AFFiNEThemePlugi
|
||||
|
||||
override fun load() {
|
||||
super.load()
|
||||
configureAndroidIMEBridge()
|
||||
AuthInitializer.initialize(bridge)
|
||||
configureEditorWebView()
|
||||
}
|
||||
|
||||
private fun configureAndroidIMEBridge() {
|
||||
val trustedOrigin = normalizeAffineOrigin(bridge.localUrl)
|
||||
bridge.setWebViewClient(AffineWebViewClient(bridge, trustedOrigin))
|
||||
bridge.addWebViewListener(object : WebViewListener() {
|
||||
override fun onPageCommitVisible(view: WebView?, url: String?) {
|
||||
(view as? AffineEditorWebView)?.updateAndroidIMEBridge(url, trustedOrigin)
|
||||
}
|
||||
})
|
||||
|
||||
(bridge.webView as? AffineEditorWebView)?.updateAndroidIMEBridge(
|
||||
bridge.webView.url ?: bridge.localUrl,
|
||||
trustedOrigin,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<!-- Derived from @capacitor/android 8.4.2; keep this override aligned with the Capacitor layout. -->
|
||||
<app.affine.pro.AffineEditorWebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
Reference in New Issue
Block a user