fix(editor): slash menu on mobile browser (#14328)

## Summary
Fixed the slash menu not appearing when typing `/` on mobile web
browsers.

## Problem
Mobile browsers don't reliably fire keyboard events (`keyDown`) when
using virtual keyboards. This caused the slash menu trigger to fail on
mobile devices.

## Solution
- Changed from handling `keyDown` events to `beforeInput` events
- `InputEvent` is fired consistently across all platforms (mobile and
desktop)
- Added proper handling for IME composition to avoid duplicate triggers
- Uses `waitForUpdate()` to ensure the input is processed before
checking for the trigger

## Test plan
- [x] Tested on mobile Safari (iOS)
- [x] Tested on mobile Chrome (Android)
- [x] Verified desktop browsers still work correctly
- [x] Verified IME input (e.g., Chinese/Japanese) doesn't trigger false
positives

Fixes #12910 

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved slash menu input handling for better reliability and enhanced
IME (input method editor) composition support.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
passabilities.eth
2026-02-01 05:43:28 -06:00
committed by GitHub
parent b778207af9
commit 0f0bfb9f06

View File

@@ -1,8 +1,11 @@
import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { UIEventStateContext } from '@blocksuite/std';
import { TextSelection, WidgetComponent } from '@blocksuite/std';
import {
TextSelection,
type UIEventStateContext,
WidgetComponent,
} from '@blocksuite/std';
import { InlineEditor } from '@blocksuite/std/inline';
import debounce from 'lodash-es/debounce';
@@ -59,9 +62,7 @@ const showSlashMenu = debounce(
);
export class AffineSlashMenuWidget extends WidgetComponent {
private readonly _getInlineEditor = (
evt: KeyboardEvent | CompositionEvent
) => {
private readonly _getInlineEditor = (evt: CompositionEvent | InputEvent) => {
if (evt.target instanceof HTMLElement) {
const editor = (
evt.target.closest('.inline-editor') as {
@@ -152,18 +153,27 @@ export class AffineSlashMenuWidget extends WidgetComponent {
this._handleInput(inlineEditor, true);
};
private readonly _onKeyDown = (ctx: UIEventStateContext) => {
const eventState = ctx.get('keyboardState');
const event = eventState.raw;
private readonly _onBeforeInput = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event;
if (!(event instanceof InputEvent)) return;
const key = event.key;
// Skip non-character inputs and IME composition (handled by _onCompositionEnd)
if (event.data === null || event.isComposing) return;
if (event.isComposing || key !== AFFINE_SLASH_MENU_TRIGGER_KEY) return;
// Quick check: only proceed if the input contains the trigger key
if (!event.data.includes(AFFINE_SLASH_MENU_TRIGGER_KEY)) return;
const inlineEditor = this._getInlineEditor(event);
if (!inlineEditor) return;
this._handleInput(inlineEditor, false);
// Wait for the input to be processed, then handle it
// Pass true because after waitForUpdate(), the range is already synced
inlineEditor
.waitForUpdate()
.then(() => {
this._handleInput(inlineEditor, true);
})
.catch(console.error);
};
get config() {
@@ -177,8 +187,7 @@ export class AffineSlashMenuWidget extends WidgetComponent {
override connectedCallback() {
super.connectedCallback();
// this.handleEvent('beforeInput', this._onBeforeInput);
this.handleEvent('keyDown', this._onKeyDown);
this.handleEvent('beforeInput', this._onBeforeInput);
this.handleEvent('compositionEnd', this._onCompositionEnd);
}
}