Files
AFFiNE-Mirror/blocksuite/affine/widgets/slash-menu/src/widget.ts
T
passabilities.eth 0f0bfb9f06 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 -->
2026-02-01 19:43:28 +08:00

194 lines
5.9 KiB
TypeScript

import { getInlineEditorByModel } from '@blocksuite/affine-rich-text';
import type { AffineInlineEditor } from '@blocksuite/affine-shared/types';
import { DisposableGroup } from '@blocksuite/global/disposable';
import {
TextSelection,
type UIEventStateContext,
WidgetComponent,
} from '@blocksuite/std';
import { InlineEditor } from '@blocksuite/std/inline';
import debounce from 'lodash-es/debounce';
import { AFFINE_SLASH_MENU_TRIGGER_KEY } from './consts';
import { SlashMenuExtension } from './extensions';
import { SlashMenu } from './slash-menu-popover';
import type { SlashMenuConfig, SlashMenuContext, SlashMenuItem } from './types';
import { buildSlashMenuItems } from './utils';
let globalAbortController = new AbortController();
function closeSlashMenu() {
globalAbortController.abort();
}
const showSlashMenu = debounce(
({
context,
config,
container = document.body,
abortController = new AbortController(),
configItemTransform,
}: {
context: SlashMenuContext;
config: SlashMenuConfig;
container?: HTMLElement;
abortController?: AbortController;
configItemTransform: (item: SlashMenuItem) => SlashMenuItem;
}) => {
globalAbortController = abortController;
const disposables = new DisposableGroup();
abortController.signal.addEventListener('abort', () =>
disposables.dispose()
);
const inlineEditor = getInlineEditorByModel(context.std, context.model);
if (!inlineEditor) return;
const slashMenu = new SlashMenu(inlineEditor, abortController);
disposables.add(() => slashMenu.remove());
slashMenu.context = context;
slashMenu.items = buildSlashMenuItems(
typeof config.items === 'function' ? config.items(context) : config.items,
context,
configItemTransform
);
// FIXME(Flrande): It is not a best practice,
// but merely a temporary measure for reusing previous components.
// Mount
container.append(slashMenu);
return slashMenu;
},
100,
{ leading: true }
);
export class AffineSlashMenuWidget extends WidgetComponent {
private readonly _getInlineEditor = (evt: CompositionEvent | InputEvent) => {
if (evt.target instanceof HTMLElement) {
const editor = (
evt.target.closest('.inline-editor') as {
inlineEditor?: AffineInlineEditor;
}
)?.inlineEditor;
if (editor instanceof InlineEditor) {
return editor;
}
}
const textSelection = this.host.selection.find(TextSelection);
if (!textSelection) return;
const model = this.host.store.getBlock(textSelection.blockId)?.model;
if (!model) return;
return getInlineEditorByModel(this.std, model);
};
private readonly _handleInput = (
inlineEditor: InlineEditor,
isCompositionEnd: boolean
) => {
const inlineRangeApplyCallback = (callback: () => void) => {
// the inline ranged updated in compositionEnd event before this event callback
if (isCompositionEnd) {
callback();
} else {
const subscription = inlineEditor.slots.inlineRangeSync.subscribe(
() => {
subscription.unsubscribe();
callback();
}
);
}
};
if (this.block?.model.flavour !== 'affine:page') {
console.error('SlashMenuWidget should be used in RootBlock');
return;
}
inlineRangeApplyCallback(() => {
const textSelection = this.host.selection.find(TextSelection);
if (!textSelection) return;
const block = this.host.view.getBlock(textSelection.blockId);
if (!block) return;
const model = block.model;
if (this.config.disableWhen?.({ model, std: this.std })) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const textPoint = inlineEditor.getTextPoint(inlineRange.index);
if (!textPoint) return;
const [leafStart, offsetStart] = textPoint;
const text = leafStart.textContent
? leafStart.textContent.slice(0, offsetStart)
: '';
if (!text.endsWith(AFFINE_SLASH_MENU_TRIGGER_KEY)) return;
closeSlashMenu();
showSlashMenu({
context: {
model,
std: this.std,
},
config: this.config,
configItemTransform: this.configItemTransform,
});
});
};
private readonly _onCompositionEnd = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event as CompositionEvent;
if (event.data !== AFFINE_SLASH_MENU_TRIGGER_KEY) return;
const inlineEditor = this._getInlineEditor(event);
if (!inlineEditor) return;
this._handleInput(inlineEditor, true);
};
private readonly _onBeforeInput = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event;
if (!(event instanceof InputEvent)) return;
// Skip non-character inputs and IME composition (handled by _onCompositionEnd)
if (event.data === null || event.isComposing) 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;
// 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() {
return this.std.get(SlashMenuExtension).config;
}
// TODO(@L-Sun): Remove this when moving each config item to corresponding blocks
// This is a temporary way for patching the slash menu config
configItemTransform: (item: SlashMenuItem) => SlashMenuItem = item => item;
override connectedCallback() {
super.connectedCallback();
this.handleEvent('beforeInput', this._onBeforeInput);
this.handleEvent('compositionEnd', this._onCompositionEnd);
}
}