mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 19:46:32 +08:00
0f0bfb9f06
## 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 -->
194 lines
5.9 KiB
TypeScript
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);
|
|
}
|
|
}
|