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
This commit is contained in:
yoyoyohamapi
2025-03-14 09:01:30 +00:00
parent 1546b76337
commit 1258f47d70
3 changed files with 252 additions and 124 deletions

View File

@@ -1,9 +1,10 @@
import { ChatHistoryOrder } from '@affine/graphql'; import { ChatHistoryOrder } from '@affine/graphql';
import { import {
BlockSelection, type BlockComponent,
type BlockSelection,
type BlockStdScope, type BlockStdScope,
type EditorHost, type EditorHost,
TextSelection, type TextSelection,
} from '@blocksuite/affine/block-std'; } from '@blocksuite/affine/block-std';
import { GfxControllerIdentifier } from '@blocksuite/affine/block-std/gfx'; import { GfxControllerIdentifier } from '@blocksuite/affine/block-std/gfx';
import { EdgelessCRUDIdentifier } from '@blocksuite/affine/blocks/surface'; import { EdgelessCRUDIdentifier } from '@blocksuite/affine/blocks/surface';
@@ -14,11 +15,15 @@ import {
} from '@blocksuite/affine/global/gfx'; } from '@blocksuite/affine/global/gfx';
import { import {
type DocMode, type DocMode,
NoteBlockModel,
NoteDisplayMode, NoteDisplayMode,
ParagraphBlockModel,
} from '@blocksuite/affine/model'; } from '@blocksuite/affine/model';
import { RefNodeSlotsProvider } from '@blocksuite/affine/rich-text'; 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 type { ImageSelection } from '@blocksuite/affine/shared/selection';
import { import {
DocModeProvider, DocModeProvider,
@@ -26,14 +31,12 @@ import {
NotificationProvider, NotificationProvider,
TelemetryProvider, TelemetryProvider,
} from '@blocksuite/affine/shared/services'; } from '@blocksuite/affine/shared/services';
import { matchModels } from '@blocksuite/affine/shared/utils';
import type { Store } from '@blocksuite/affine/store'; import type { Store } from '@blocksuite/affine/store';
import { import {
BlockIcon, BlockIcon,
InsertBleowIcon as InsertBelowIcon, InsertBleowIcon as InsertBelowIcon,
LinkedPageIcon, LinkedPageIcon,
PageIcon, PageIcon,
ReplaceIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import type { TemplateResult } from 'lit'; import type { TemplateResult } from 'lit';
@@ -41,7 +44,7 @@ import { insertFromMarkdown } from '../../utils';
import type { ChatMessage } from '../blocks'; import type { ChatMessage } from '../blocks';
import { AIProvider, type AIUserInfo } from '../provider'; import { AIProvider, type AIUserInfo } from '../provider';
import { reportResponse } from '../utils/action-reporter'; import { reportResponse } from '../utils/action-reporter';
import { insertBelow, replace } from '../utils/editor-actions'; import { insertBelow } from '../utils/editor-actions';
type Selections = { type Selections = {
text?: TextSelection; text?: TextSelection;
@@ -201,70 +204,63 @@ export function promptDocTitle(host: EditorHost, autofill?: string) {
}); });
} }
const REPLACE_SELECTION = { /**
icon: ReplaceIcon({ width: '20px', height: '20px' }), * Get insert below block based on current selections
title: 'Replace selection', * @param host Editor host
showWhen: (host: EditorHost) => { * @param currentSelections Current selections
if (host.std.store.readonly$.value) { * @returns Selected blocks and selection state
return false; */
} async function getInsertBelowBlock(
const textSelection = host.selection.find(TextSelection); host: EditorHost,
const blockSelections = host.selection.filter(BlockSelection); currentSelections: Selections
if ( ): Promise<BlockComponent | null> {
(!textSelection || textSelection.from.length === 0) && const currentTextSelection = currentSelections.text;
blockSelections?.length === 0 const currentBlockSelections = currentSelections.blocks;
) { const currentImageSelections = currentSelections.images;
return false;
} const [_, { selectedBlocks: blocks }] = host.command.exec(
return true; getSelectedBlocksCommand,
}, {
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, {
currentTextSelection, currentTextSelection,
currentBlockSelections, currentBlockSelections,
}); currentImageSelections,
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;
}
} }
);
await replace( if (blocks && blocks.length) {
host, return blocks[blocks.length - 1];
content, }
data.selectedBlocks[0],
data.selectedBlocks.map(block => block.model),
currentTextSelection
);
return true;
},
};
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<boolean> {
if (!block) return false;
reportResponse('result:insert');
await insertBelow(host, content, block);
return true;
}
const PAGE_INSERT = {
icon: InsertBelowIcon({ width: '20px', height: '20px' }), icon: InsertBelowIcon({ width: '20px', height: '20px' }),
title: 'Insert below', title: 'Insert',
showWhen: (host: EditorHost) => { showWhen: (host: EditorHost) => {
if (host.std.store.readonly$.value) { if (host.std.store.readonly$.value) {
return false; return false;
} }
return true; return true;
}, },
toast: 'Successfully inserted', toast: 'Successfully inserted',
@@ -273,22 +269,68 @@ const INSERT_BELOW = {
content: string, content: string,
currentSelections: Selections currentSelections: Selections
) => { ) => {
const currentTextSelection = currentSelections.text; const block = await getInsertBelowBlock(host, currentSelections);
const currentBlockSelections = currentSelections.blocks;
const currentImageSelections = currentSelections.images; const isNothingSelected = !block;
const [_, data] = host.command.exec(getSelectedBlocksCommand, {
currentTextSelection, // In page mode, if nothing is selected, use the last content block
currentBlockSelections, if (isNothingSelected) {
currentImageSelections, const [_, { firstBlock: noteBlock }] = host.command.exec(
}); getFirstBlockCommand,
if (!data.selectedBlocks) return false; {
reportResponse('result:insert'); flavour: 'affine:note',
await insertBelow( }
host, );
content,
data.selectedBlocks[data.selectedBlocks?.length - 1] const lastChild = noteBlock?.lastChild();
); const lastBlock = lastChild ? host.std.view.getBlock(lastChild.id) : null;
return true;
return insertBelowBlock(host, content, lastBlock);
}
return insertBelowBlock(host, content, block);
},
};
const EDGELESS_INSERT = {
...PAGE_INSERT,
handler: async (
host: EditorHost,
content: string,
currentSelections: Selections
): Promise<boolean> => {
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; return true;
}, },
toast: 'New note created', toast: 'New note created',
handler: async (host: EditorHost, content: string) => { handler: async (host: EditorHost, content: string): Promise<boolean> => {
reportResponse('result:add-note'); reportResponse('result:add-note');
const { doc } = host; const { doc } = host;
@@ -535,16 +577,14 @@ const CREATE_AS_LINKED_DOC = {
}, },
}; };
const CommonActions: ChatAction[] = [REPLACE_SELECTION, INSERT_BELOW];
export const PageEditorActions = [ export const PageEditorActions = [
...CommonActions, PAGE_INSERT,
CREATE_AS_DOC, CREATE_AS_DOC,
SAVE_CHAT_TO_BLOCK_ACTION, SAVE_CHAT_TO_BLOCK_ACTION,
]; ];
export const EdgelessEditorActions = [ export const EdgelessEditorActions = [
...CommonActions, EDGELESS_INSERT,
ADD_TO_EDGELESS_AS_NOTE, ADD_TO_EDGELESS_AS_NOTE,
SAVE_CHAT_TO_BLOCK_ACTION, SAVE_CHAT_TO_BLOCK_ACTION,
]; ];

View File

@@ -115,43 +115,45 @@ export class ChatActionList extends LitElement {
actions.filter(action => action.showWhen(host)), actions.filter(action => action.showWhen(host)),
action => action.title, action => action.title,
action => { action => {
return html`<div class="action"> return html`<div
class="action"
@click=${async () => {
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} ${action.icon}
<div <div
@click=${async () => {
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 data-testid="action-${action.title
.toLowerCase() .toLowerCase()
.replaceAll(' ', '-')}" .replaceAll(' ', '-')}"

View File

@@ -4,7 +4,7 @@ import {
loginUser, loginUser,
loginUserDirectly, loginUserDirectly,
} from '@affine-test/kit/utils/cloud'; } 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 { openHomePage, setCoreUrl } from '@affine-test/kit/utils/load-page';
import { import {
clickNewPageButton, clickNewPageButton,
@@ -258,20 +258,106 @@ test.describe('chat panel', () => {
expect((await collectChat(page))[1].content).not.toBe(content); 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 page.reload();
await clickSideBarAllPageButton(page); await clickSideBarAllPageButton(page);
await page.waitForTimeout(200); await page.waitForTimeout(200);
await createLocalWorkspace({ name: 'test' }, page); await createLocalWorkspace({ name: 'test' }, page);
await clickNewPageButton(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'); await makeChat(page, 'hello');
const content = (await collectChat(page))[1].content; const content = (await collectChat(page))[1].content;
await focusToEditor(page); await paragraphs[0].click();
// insert below
await page.getByTestId('action-insert-below').click(); await page.getByTestId('action-insert').click();
await page.waitForSelector('affine-toolbar-widget editor-toolbar');
const editorContent = await getEditorContent(page); 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 }) => { test('can be add to edgeless as node', async ({ page }) => {