diff --git a/packages/frontend/core/src/blocksuite/ai/entries/space/setup-space.ts b/packages/frontend/core/src/blocksuite/ai/entries/space/setup-space.ts index e80a93214f..b069b0d590 100644 --- a/packages/frontend/core/src/blocksuite/ai/entries/space/setup-space.ts +++ b/packages/frontend/core/src/blocksuite/ai/entries/space/setup-space.ts @@ -1,9 +1,34 @@ -import { TextSelection } from '@blocksuite/affine/std'; +import type { RichText } from '@blocksuite/affine/rich-text'; +import { type EditorHost, TextSelection } from '@blocksuite/affine/std'; import { handleInlineAskAIAction } from '../../actions/doc-handler'; import { AIProvider } from '../../provider'; import type { AffineAIPanelWidget } from '../../widgets/ai-panel/ai-panel'; +function isSpaceEvent(event: KeyboardEvent) { + return event.key === ' ' && event.which === 32 && !event.isComposing; +} + +function insertSpace(host: EditorHost) { + const textSelection = host.selection.find(TextSelection); + if (!textSelection || !textSelection.isCollapsed()) return; + + const blockComponent = host.view.getBlock(textSelection.from.blockId); + if (!blockComponent) return; + + const richText = blockComponent.querySelector('rich-text') as RichText | null; + if (!richText) return; + + const inlineEditor = richText.inlineEditor; + inlineEditor?.insertText( + { + index: textSelection.from.index, + length: 0, + }, + ' ' + ); +} + export function setupSpaceAIEntry(panel: AffineAIPanelWidget) { // Background: The keydown event triggered by a space may originate from: // 1. Normal space insertion @@ -18,12 +43,19 @@ export function setupSpaceAIEntry(panel: AffineAIPanelWidget) { const host = panel.host; const keyboardState = ctx.get('keyboardState'); const event = keyboardState.raw; - if ( - AIProvider.actions.chat && - event.key === ' ' && - event.which === 32 && - !event.isComposing - ) { + if (AIProvider.actions.chat && isSpaceEvent(event)) { + // If the AI panel is in the input state and the input content is empty, + // insert a space back into the editor. + if (panel.state === 'input') { + const input = panel.shadowRoot?.querySelector('ai-panel-input'); + if (input?.textarea.value.trim() === '') { + event.preventDefault(); + insertSpace(host); + panel.hide(); + return; + } + } + const selection = host.selection.find(TextSelection); if (selection && selection.isCollapsed() && selection.from.index === 0) { const block = host.view.getBlock(selection.blockId); diff --git a/tests/affine-cloud-copilot/e2e/basic/guidance.spec.ts b/tests/affine-cloud-copilot/e2e/basic/guidance.spec.ts index 6bbd41bb3d..676550aec0 100644 --- a/tests/affine-cloud-copilot/e2e/basic/guidance.spec.ts +++ b/tests/affine-cloud-copilot/e2e/basic/guidance.spec.ts @@ -35,4 +35,20 @@ test.describe('AIBasic/Guidance', () => { await page.keyboard.press('Enter'); await expect(page.locator('affine-ai-panel-widget')).not.toBeVisible(); }); + + test('should hide AI panel and insert space back to editor when space is pressed on empty input', async ({ + page, + utils, + }) => { + await utils.editor.focusToEditor(page); + await page.keyboard.press('Space'); + await expect(page.locator('affine-ai-panel-widget')).toBeVisible(); + + await page.keyboard.press('Space'); + await expect(page.locator('affine-ai-panel-widget')).not.toBeVisible(); + await expect(async () => { + const content = await utils.editor.getEditorContent(page, false); + expect(content).toBe(' '); + }).toPass({ timeout: 5000 }); + }); }); diff --git a/tests/affine-cloud-copilot/e2e/utils/editor-utils.ts b/tests/affine-cloud-copilot/e2e/utils/editor-utils.ts index 04017b6b0b..7d07b3e8cc 100644 --- a/tests/affine-cloud-copilot/e2e/utils/editor-utils.ts +++ b/tests/affine-cloud-copilot/e2e/utils/editor-utils.ts @@ -21,14 +21,20 @@ export class EditorUtils { await page.keyboard.press('Enter'); } - public static async getEditorContent(page: Page) { + public static async getEditorContent(page: Page, trim = true) { let content = ''; let retry = 3; while (!content && retry > 0) { const lines = await page.$$('page-editor .inline-editor'); const contents = await Promise.all(lines.map(el => el.innerText())); content = contents - .map(c => c.replace(/[\u200B-\u200D\uFEFF]/g, '').trim()) + .map(c => { + const invisibleFiltered = c.replace(/[\u200B-\u200D\uFEFF]/g, ''); + if (trim) { + return invisibleFiltered.trim(); + } + return invisibleFiltered; + }) .filter(c => !!c) .join('\n'); if (!content) {