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:
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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(' ', '-')}"
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user