From 1258f47d70c7eaf1bad059fffee59f629821f3c3 Mon Sep 17 00:00:00 2001 From: yoyoyohamapi <8338436+yoyoyohamapi@users.noreply.github.com> Date: Fri, 14 Mar 2025 09:01:30 +0000 Subject: [PATCH] refactor(web): insert blew action (#10722) ### TL;DR Refactor the insert below functionality to work with page mode and edgeless mode * Page Mode - Insert content below the current selection - If nothing selected, insert content below the last block * EdgeLess Mode - If no note block is currently selected, create the content as a new note block. - Otherwise, insert content into the selected note Close BS-2760 ### What changed? - Created separate insert handlers for page and edgeless modes with context-aware behavior - Added support for inserting content when nothing is selected by targeting the last content block - Added special handling for edgeless mode to support inserting below selected note blocks - Removed the "Replace selection" action and consolidated insert functionality - Optimized the clickable area of the action button --- .../ai/_common/chat-actions-handle.ts | 204 +++++++++++------- .../ai/components/chat-action-list.ts | 72 ++++--- .../affine-cloud-copilot/e2e/copilot.spec.ts | 100 ++++++++- 3 files changed, 252 insertions(+), 124 deletions(-) diff --git a/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts b/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts index 832f133bee..26dcb8d5cc 100644 --- a/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts +++ b/packages/frontend/core/src/blocksuite/ai/_common/chat-actions-handle.ts @@ -1,9 +1,10 @@ import { ChatHistoryOrder } from '@affine/graphql'; import { - BlockSelection, + type BlockComponent, + type BlockSelection, type BlockStdScope, type EditorHost, - TextSelection, + type TextSelection, } from '@blocksuite/affine/block-std'; import { GfxControllerIdentifier } from '@blocksuite/affine/block-std/gfx'; import { EdgelessCRUDIdentifier } from '@blocksuite/affine/blocks/surface'; @@ -14,11 +15,15 @@ import { } from '@blocksuite/affine/global/gfx'; import { type DocMode, + NoteBlockModel, NoteDisplayMode, - ParagraphBlockModel, } from '@blocksuite/affine/model'; import { RefNodeSlotsProvider } from '@blocksuite/affine/rich-text'; -import { getSelectedBlocksCommand } from '@blocksuite/affine/shared/commands'; +import { + getFirstBlockCommand, + getLastBlockCommand, + getSelectedBlocksCommand, +} from '@blocksuite/affine/shared/commands'; import type { ImageSelection } from '@blocksuite/affine/shared/selection'; import { DocModeProvider, @@ -26,14 +31,12 @@ import { NotificationProvider, TelemetryProvider, } from '@blocksuite/affine/shared/services'; -import { matchModels } from '@blocksuite/affine/shared/utils'; import type { Store } from '@blocksuite/affine/store'; import { BlockIcon, InsertBleowIcon as InsertBelowIcon, LinkedPageIcon, PageIcon, - ReplaceIcon, } from '@blocksuite/icons/lit'; import type { TemplateResult } from 'lit'; @@ -41,7 +44,7 @@ import { insertFromMarkdown } from '../../utils'; import type { ChatMessage } from '../blocks'; import { AIProvider, type AIUserInfo } from '../provider'; import { reportResponse } from '../utils/action-reporter'; -import { insertBelow, replace } from '../utils/editor-actions'; +import { insertBelow } from '../utils/editor-actions'; type Selections = { text?: TextSelection; @@ -201,70 +204,63 @@ export function promptDocTitle(host: EditorHost, autofill?: string) { }); } -const REPLACE_SELECTION = { - icon: ReplaceIcon({ width: '20px', height: '20px' }), - title: 'Replace selection', - showWhen: (host: EditorHost) => { - if (host.std.store.readonly$.value) { - return false; - } - const textSelection = host.selection.find(TextSelection); - const blockSelections = host.selection.filter(BlockSelection); - if ( - (!textSelection || textSelection.from.length === 0) && - blockSelections?.length === 0 - ) { - return false; - } - return true; - }, - toast: 'Successfully replaced', - handler: async ( - host: EditorHost, - content: string, - currentSelections: Selections - ) => { - const currentTextSelection = currentSelections.text; - const currentBlockSelections = currentSelections.blocks; - const [_, data] = host.command.exec(getSelectedBlocksCommand, { +/** + * Get insert below block based on current selections + * @param host Editor host + * @param currentSelections Current selections + * @returns Selected blocks and selection state + */ +async function getInsertBelowBlock( + host: EditorHost, + currentSelections: Selections +): Promise { + const currentTextSelection = currentSelections.text; + const currentBlockSelections = currentSelections.blocks; + const currentImageSelections = currentSelections.images; + + const [_, { selectedBlocks: blocks }] = host.command.exec( + getSelectedBlocksCommand, + { currentTextSelection, currentBlockSelections, - }); - if (!data.selectedBlocks) return false; - - reportResponse('result:replace'); - - if (currentTextSelection) { - const { doc } = host; - const block = doc.getBlock(currentTextSelection.blockId); - if (matchModels(block?.model ?? null, [ParagraphBlockModel])) { - block?.model.text?.replace( - currentTextSelection.from.index, - currentTextSelection.from.length, - content - ); - return true; - } + currentImageSelections, } + ); - await replace( - host, - content, - data.selectedBlocks[0], - data.selectedBlocks.map(block => block.model), - currentTextSelection - ); - return true; - }, -}; + if (blocks && blocks.length) { + return blocks[blocks.length - 1]; + } -const INSERT_BELOW = { + return null; +} + +/** + * Base handler for inserting content below the block + * @param host Editor host + * @param content Content to insert + * @param block block + * @returns Whether insertion was successful + */ +async function insertBelowBlock( + host: EditorHost, + content: string, + block: BlockComponent | null +): Promise { + if (!block) return false; + + reportResponse('result:insert'); + await insertBelow(host, content, block); + return true; +} + +const PAGE_INSERT = { icon: InsertBelowIcon({ width: '20px', height: '20px' }), - title: 'Insert below', + title: 'Insert', showWhen: (host: EditorHost) => { if (host.std.store.readonly$.value) { return false; } + return true; }, toast: 'Successfully inserted', @@ -273,22 +269,68 @@ const INSERT_BELOW = { content: string, currentSelections: Selections ) => { - const currentTextSelection = currentSelections.text; - const currentBlockSelections = currentSelections.blocks; - const currentImageSelections = currentSelections.images; - const [_, data] = host.command.exec(getSelectedBlocksCommand, { - currentTextSelection, - currentBlockSelections, - currentImageSelections, - }); - if (!data.selectedBlocks) return false; - reportResponse('result:insert'); - await insertBelow( - host, - content, - data.selectedBlocks[data.selectedBlocks?.length - 1] - ); - return true; + const block = await getInsertBelowBlock(host, currentSelections); + + const isNothingSelected = !block; + + // In page mode, if nothing is selected, use the last content block + if (isNothingSelected) { + const [_, { firstBlock: noteBlock }] = host.command.exec( + getFirstBlockCommand, + { + flavour: 'affine:note', + } + ); + + const lastChild = noteBlock?.lastChild(); + const lastBlock = lastChild ? host.std.view.getBlock(lastChild.id) : null; + + return insertBelowBlock(host, content, lastBlock); + } + + return insertBelowBlock(host, content, block); + }, +}; + +const EDGELESS_INSERT = { + ...PAGE_INSERT, + handler: async ( + host: EditorHost, + content: string, + currentSelections: Selections + ): Promise => { + const block = await getInsertBelowBlock(host, currentSelections); + + const isNothingSelected = !block; + + // In edgeless mode, handle special cases + if (isNothingSelected) { + const gfx = host.std.get(GfxControllerIdentifier); + const selectedElements = gfx.selection.selectedElements; + const isOnlyOneNoteSelected = + selectedElements.length === 1 && + selectedElements[0] instanceof NoteBlockModel; + + if (isOnlyOneNoteSelected) { + // Insert into selected note + const [_, { lastBlock: lastBlockModel }] = host.command.exec( + getLastBlockCommand, + { + root: selectedElements[0] as NoteBlockModel, + } + ); + + const lastBlock = lastBlockModel + ? host.std.view.getBlock(lastBlockModel.id) + : null; + return insertBelowBlock(host, content, lastBlock); + } else { + // Create a new note + return !!(await ADD_TO_EDGELESS_AS_NOTE.handler(host, content)); + } + } + + return insertBelowBlock(host, content, block); }, }; @@ -397,7 +439,7 @@ const ADD_TO_EDGELESS_AS_NOTE = { return true; }, toast: 'New note created', - handler: async (host: EditorHost, content: string) => { + handler: async (host: EditorHost, content: string): Promise => { reportResponse('result:add-note'); const { doc } = host; @@ -535,16 +577,14 @@ const CREATE_AS_LINKED_DOC = { }, }; -const CommonActions: ChatAction[] = [REPLACE_SELECTION, INSERT_BELOW]; - export const PageEditorActions = [ - ...CommonActions, + PAGE_INSERT, CREATE_AS_DOC, SAVE_CHAT_TO_BLOCK_ACTION, ]; export const EdgelessEditorActions = [ - ...CommonActions, + EDGELESS_INSERT, ADD_TO_EDGELESS_AS_NOTE, SAVE_CHAT_TO_BLOCK_ACTION, ]; diff --git a/packages/frontend/core/src/blocksuite/ai/components/chat-action-list.ts b/packages/frontend/core/src/blocksuite/ai/components/chat-action-list.ts index 6370ddf539..0fc977a4b6 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/chat-action-list.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/chat-action-list.ts @@ -115,43 +115,45 @@ export class ChatActionList extends LitElement { actions.filter(action => action.showWhen(host)), action => action.title, action => { - return html`
+ return html`
{ + if ( + action.title === 'Insert below' && + this._selectionValue.length === 1 && + this._selectionValue[0].type === 'database' + ) { + const element = this.host.view.getBlock( + this._selectionValue[0].blockId + ); + if (!element) return; + await insertBelow(host, content, element); + return; + } + const currentSelections = { + text: this._currentTextSelection, + blocks: this._currentBlockSelections, + images: this._currentImageSelections, + }; + const sessionId = await this.getSessionId(); + const success = await action.handler( + host, + content, + currentSelections, + sessionId, + messageId + ); + if (success) { + this.host.std.getOptional(NotificationProvider)?.notify({ + title: action.toast, + accent: 'success', + onClose: function (): void {}, + }); + } + }} + > ${action.icon}
{ - if ( - action.title === 'Insert below' && - this._selectionValue.length === 1 && - this._selectionValue[0].type === 'database' - ) { - const element = this.host.view.getBlock( - this._selectionValue[0].blockId - ); - if (!element) return; - await insertBelow(host, content, element); - return; - } - const currentSelections = { - text: this._currentTextSelection, - blocks: this._currentBlockSelections, - images: this._currentImageSelections, - }; - const sessionId = await this.getSessionId(); - const success = await action.handler( - host, - content, - currentSelections, - sessionId, - messageId - ); - if (success) { - this.host.std.getOptional(NotificationProvider)?.notify({ - title: action.toast, - accent: 'success', - onClose: function (): void {}, - }); - } - }} data-testid="action-${action.title .toLowerCase() .replaceAll(' ', '-')}" diff --git a/tests/affine-cloud-copilot/e2e/copilot.spec.ts b/tests/affine-cloud-copilot/e2e/copilot.spec.ts index 17fda66701..f17b7ace6e 100644 --- a/tests/affine-cloud-copilot/e2e/copilot.spec.ts +++ b/tests/affine-cloud-copilot/e2e/copilot.spec.ts @@ -4,7 +4,7 @@ import { loginUser, loginUserDirectly, } from '@affine-test/kit/utils/cloud'; -import { getPageMode } from '@affine-test/kit/utils/editor'; +import { focusDocTitle, getPageMode } from '@affine-test/kit/utils/editor'; import { openHomePage, setCoreUrl } from '@affine-test/kit/utils/load-page'; import { clickNewPageButton, @@ -258,20 +258,106 @@ test.describe('chat panel', () => { expect((await collectChat(page))[1].content).not.toBe(content); }); - test('can be insert below', async ({ page }) => { + test('can be inserted below the current selected block', async ({ page }) => { await page.reload(); await clickSideBarAllPageButton(page); await page.waitForTimeout(200); await createLocalWorkspace({ name: 'test' }, page); await clickNewPageButton(page); + + // create tow blocks + await focusToEditor(page); + await page.keyboard.type('hello'); + await page.keyboard.press('Enter'); + await page.keyboard.type('world'); + + // focus to hello + await page.waitForSelector('affine-paragraph'); + const paragraphs = await page.$$('affine-paragraph'); + await paragraphs[0].click(); + await makeChat(page, 'hello'); const content = (await collectChat(page))[1].content; - await focusToEditor(page); - // insert below - await page.getByTestId('action-insert-below').click(); - await page.waitForSelector('affine-toolbar-widget editor-toolbar'); + await paragraphs[0].click(); + + await page.getByTestId('action-insert').click(); + const editorContent = await getEditorContent(page); - expect(editorContent).toBe(content); + expect(editorContent).toBe(`hello\n${content}\nworld`); + }); + + test('can be inserted below the last block if nothing selected', async ({ + page, + }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + + // create tow blocks + await focusToEditor(page); + await page.keyboard.type('hello'); + await page.keyboard.press('Enter'); + await page.keyboard.type('world'); + + // focus to hello + await page.waitForSelector('affine-paragraph'); + + await makeChat(page, 'hello'); + const content = (await collectChat(page))[1].content; + + await focusDocTitle(page); + + await page.getByTestId('action-insert').click(); + + const editorContent = await getEditorContent(page); + expect(editorContent).toBe(`hello\nworld\n${content}`); + }); + + test('can be inserted as a new note if no note selected in edgeless mode', async ({ + page, + }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await switchToEdgelessMode(page); + + // delete default note + await (await page.waitForSelector('affine-edgeless-note')).click(); + page.keyboard.press('Delete'); + + // insert as a new note + await makeChat(page, 'hello'); + const content = (await collectChat(page))[1].content; + await page.getByTestId('action-insert').click(); + + const edgelessNode = await page.waitForSelector('affine-edgeless-note'); + expect(await edgelessNode.innerText()).toBe(content); + }); + + test('can be inserted into selected note in edgeless mode', async ({ + page, + }) => { + await page.reload(); + await clickSideBarAllPageButton(page); + await page.waitForTimeout(200); + await createLocalWorkspace({ name: 'test' }, page); + await clickNewPageButton(page); + await switchToEdgelessMode(page); + + // select + const note = await page.waitForSelector('affine-edgeless-note'); + await note.click(); + + // insert as a new note + await makeChat(page, 'hello'); + const content = (await collectChat(page))[1].content; + await page.getByTestId('action-insert').click(); + + expect(await note.innerText()).toContain(content); }); test('can be add to edgeless as node', async ({ page }) => {