Files
AFFiNE-Mirror/blocksuite/tests-legacy/e2e/attachment.spec.ts
fundon ec9bd1f383 feat(editor): add toolbar registry extension (#9572)
### What's Changed!

#### Added
Manage various types of toolbars uniformly in one place.

* `affine-toolbar-widget`
* `ToolbarRegistryExtension`

The toolbar currently supports and handles several scenarios:

1.  Select blocks: `BlockSelection`
2. Select text: `TextSelection` or `NativeSelection`
3. Hover a link: `affine-link` and `affine-reference`

#### Removed
Remove redundant toolbar implementations.

* `attachment` toolbar
* `bookmark` toolbar
* `embed` toolbar
* `formatting` toolbar
* `affine-link` toolbar
* `affine-reference` toolbar

### How to migrate?

Here is an example that can help us migrate some unrefactored toolbars:

Check out the more detailed types of [`ToolbarModuleConfig`](c178debf2d/blocksuite/affine/shared/src/services/toolbar-service/config.ts).

1.  Add toolbar configuration file to a block type, such as bookmark block: [`config.ts`](c178debf2d/blocksuite/affine/block-bookmark/src/configs/toolbar.ts)

```ts
export const builtinToolbarConfig = {
  actions: [
    {
      id: 'a.preview',
      content(ctx) {
        const model = ctx.getCurrentModelBy(BlockSelection, BookmarkBlockModel);
        if (!model) return null;

        const { url } = model;

        return html`<affine-link-preview .url=${url}></affine-link-preview>`;
      },
    },
    {
      id: 'b.conversions',
      actions: [
        {
          id: 'inline',
          label: 'Inline view',
          run(ctx) {
          },
        },
        {
          id: 'card',
          label: 'Card view',
          disabled: true,
        },
        {
          id: 'embed',
          label: 'Embed view',
          disabled(ctx) {
          },
          run(ctx) {
          },
        },
      ],
      content(ctx) {
      },
    } satisfies ToolbarActionGroup<ToolbarAction>,
    {
      id: 'c.style',
      actions: [
        {
          id: 'horizontal',
          label: 'Large horizontal style',
        },
        {
          id: 'list',
          label: 'Small horizontal style',
        },
      ],
      content(ctx) {
      },
    } satisfies ToolbarActionGroup<ToolbarAction>,
    {
      id: 'd.caption',
      tooltip: 'Caption',
      icon: CaptionIcon(),
      run(ctx) {
      },
    },
    {
      placement: ActionPlacement.More,
      id: 'a.clipboard',
      actions: [
        {
          id: 'copy',
          label: 'Copy',
          icon: CopyIcon(),
          run(ctx) {
          },
        },
        {
          id: 'duplicate',
          label: 'Duplicate',
          icon: DuplicateIcon(),
          run(ctx) {
          },
        },
      ],
    },
    {
      placement: ActionPlacement.More,
      id: 'b.refresh',
      label: 'Reload',
      icon: ResetIcon(),
      run(ctx) {
      },
    },
    {
      placement: ActionPlacement.More,
      id: 'c.delete',
      label: 'Delete',
      icon: DeleteIcon(),
      variant: 'destructive',
      run(ctx) {
      },
    },
  ],
} as const satisfies ToolbarModuleConfig;
```

2. Add configuration extension to a block spec: [bookmark's spec](c178debf2d/blocksuite/affine/block-bookmark/src/bookmark-spec.ts)

```ts
const flavour = BookmarkBlockSchema.model.flavour;

export const BookmarkBlockSpec: ExtensionType[] = [
  ...,
  ToolbarModuleExtension({
    id: BlockFlavourIdentifier(flavour),
    config: builtinToolbarConfig,
  }),
].flat();
```

3. If the bock type already has a toolbar configuration built in, we can customize it in the following ways:

Check out the [editor's config](c178debf2d/packages/frontend/core/src/blocksuite/extensions/editor-config/index.ts (L51C4-L54C8)) file.

```ts
// Defines a toolbar configuration for the bookmark block type
const customBookmarkToolbarConfig = {
  actions: [
    ...
  ]
} as const satisfies ToolbarModuleConfig;

// Adds it into the editor's config
 ToolbarModuleExtension({
    id: BlockFlavourIdentifier('custom:affine:bookmark'),
    config: customBookmarkToolbarConfig,
 }),
```

4. If we want to extend the global:

```ts
// Defines a toolbar configuration
const customWildcardToolbarConfig = {
  actions: [
    ...
  ]
} as const satisfies ToolbarModuleConfig;

// Adds it into the editor's config
 ToolbarModuleExtension({
    id: BlockFlavourIdentifier('custom:affine:*'),
    config: customWildcardToolbarConfig,
 }),
```

Currently, only most toolbars in page mode have been refactored. Next is edgeless mode.
2025-03-06 06:46:03 +00:00

464 lines
13 KiB
TypeScript

import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { sleep } from '@blocksuite/global/utils';
import { expect, type Page } from '@playwright/test';
import { dragBlockToPoint, popImageMoreMenu } from './utils/actions/drag.js';
import { switchEditorMode } from './utils/actions/edgeless.js';
import {
pressArrowDown,
pressArrowUp,
pressBackspace,
pressEnter,
pressEscape,
pressShiftTab,
pressTab,
redoByKeyboard,
SHORT_KEY,
type,
undoByKeyboard,
} from './utils/actions/keyboard.js';
import {
captureHistory,
enterPlaygroundRoom,
focusRichText,
getPageSnapshot,
initEmptyEdgelessState,
initEmptyParagraphState,
resetHistory,
waitNextFrame,
} from './utils/actions/misc.js';
import {
assertBlockChildrenIds,
assertBlockCount,
assertBlockFlavour,
assertBlockSelections,
assertKeyboardWorkInInput,
assertParentBlockFlavour,
assertRichImage,
assertRichTextInlineRange,
} from './utils/asserts.js';
import { test } from './utils/playwright.js';
const FILE_NAME = 'test-card-1.png';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FILE_PATH = path.resolve(
__dirname,
`../../playground/public/${FILE_NAME}`
);
function getAttachment(page: Page) {
const attachment = page.locator('affine-attachment');
const loading = attachment.locator('.affine-attachment-card.loading');
const toolbar = page.locator('affine-toolbar-widget editor-toolbar');
const switchViewButton = toolbar.getByRole('button', { name: 'Switch view' });
const renameBtn = toolbar.getByRole('button', { name: 'Rename' });
const renameInput = page.locator('.affine-attachment-rename-container input');
const insertAttachment = async () => {
await page.evaluate(() => {
// Force fallback to input[type=file] in tests
// See https://github.com/microsoft/playwright/issues/8850
window.showOpenFilePicker = undefined;
});
const slashMenu = page.locator(`.slash-menu`);
await waitNextFrame(page);
await type(page, '/');
await resetHistory(page);
await expect(slashMenu).toBeVisible();
await type(page, 'file', 100);
await expect(slashMenu).toBeVisible();
const fileChooser = page.waitForEvent('filechooser');
await pressEnter(page);
await sleep(100);
await (await fileChooser).setFiles(FILE_PATH);
// Try to break the undo redo test
await captureHistory(page);
await expect(attachment).toBeVisible();
};
const getName = () =>
attachment.locator('.affine-attachment-content-title-text').innerText();
return {
// locators
attachment,
toolbar,
switchViewButton,
renameBtn,
renameInput,
// actions
insertAttachment,
/**
* Wait for the attachment upload to finish
*/
waitLoading: () => loading.waitFor({ state: 'hidden' }),
getName,
getSize: () =>
attachment.locator('.affine-attachment-content-info').innerText(),
turnToEmbed: async () => {
await expect(switchViewButton).toBeVisible();
await switchViewButton.click();
await page.getByRole('button', { name: 'Embed view' }).click();
await assertRichImage(page, 1);
},
rename: async (newName: string) => {
await attachment.click();
await expect(toolbar).toBeVisible();
await renameBtn.click();
await page.keyboard.press(`${SHORT_KEY}+a`, { delay: 50 });
await pressBackspace(page);
await type(page, newName);
await pressEnter(page);
expect(await getName()).toContain(newName);
},
// external
turnImageToCard: async () => {
const { turnIntoCardButton } = await popImageMoreMenu(page);
await turnIntoCardButton.click();
await expect(attachment).toBeVisible();
},
};
}
test('can insert attachment from slash menu', async ({ page }, testInfo) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
const { insertAttachment, waitLoading, getName, getSize } =
getAttachment(page);
await focusRichText(page);
await insertAttachment();
// Wait for the attachment to be uploaded
await waitLoading();
expect(await getName()).toBe(FILE_NAME);
expect(await getSize()).toBe('45.8 kB');
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}.json`
);
});
test('should undo/redo works for attachment', async ({ page }, testInfo) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
const { insertAttachment, waitLoading } = getAttachment(page);
await focusRichText(page);
await insertAttachment();
// Wait for the attachment to be uploaded
await waitLoading();
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}_1.json`
);
await undoByKeyboard(page);
await waitNextFrame(page);
// The loading/error state should not be restored after undo
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}_2.json`
);
await redoByKeyboard(page);
await waitNextFrame(page);
// The loading/error state should not be restored after undo
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}_3.json`
);
});
test('should rename attachment works', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/toeverything/blocksuite/issues/4534',
});
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
const {
attachment,
renameBtn,
renameInput,
insertAttachment,
waitLoading,
getName,
rename,
} = getAttachment(page);
await focusRichText(page);
await insertAttachment();
// Wait for the attachment to be uploaded
await waitLoading();
expect(await getName()).toBe(FILE_NAME);
await attachment.click();
await expect(renameBtn).toBeVisible();
await renameBtn.click();
await assertKeyboardWorkInInput(page, renameInput);
await pressEscape(page);
await expect(renameInput).not.toBeVisible();
await rename('new-name');
expect(await getName()).toBe('new-name.png');
await rename('');
expect(await getName()).toBe('.png');
await rename('abc');
expect(await getName()).toBe('abc');
});
test('should turn attachment to image works', async ({ page }, testInfo) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
const {
attachment,
insertAttachment,
waitLoading,
turnToEmbed,
turnImageToCard,
} = getAttachment(page);
await focusRichText(page);
await insertAttachment();
// Wait for the attachment to be uploaded
await waitLoading();
await attachment.click();
await turnToEmbed();
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}_1.json`
);
await turnImageToCard();
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}_2.json`
);
});
test('should attachment can be deleted', async ({ page }, testInfo) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
const { attachment, insertAttachment, waitLoading } = getAttachment(page);
await focusRichText(page);
await insertAttachment();
// Wait for the attachment to be uploaded
await waitLoading();
await attachment.click();
await pressBackspace(page);
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}.json`
);
});
test(`support dragging attachment block directly`, async ({
page,
}, testInfo) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
const { insertAttachment, waitLoading, getName, getSize } =
getAttachment(page);
await focusRichText(page);
await insertAttachment();
// Wait for the attachment to be uploaded
await waitLoading();
expect(await getName()).toBe(FILE_NAME);
expect(await getSize()).toBe('45.8 kB');
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}_1.json`
);
const attachmentBlock = page.locator('affine-attachment');
const rect = await attachmentBlock.boundingBox();
if (!rect) {
throw new Error('image not found');
}
// add new paragraph blocks
await page.mouse.click(rect.x + 20, rect.y + rect.height + 20);
await focusRichText(page);
await type(page, '111');
await page.waitForTimeout(200);
await pressEnter(page);
await type(page, '222');
await page.waitForTimeout(200);
await pressEnter(page);
await type(page, '333');
await page.waitForTimeout(200);
await page.waitForTimeout(200);
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}_2.json`
);
// drag bookmark block
await page.mouse.move(rect.x + 20, rect.y + 20);
await page.mouse.down();
await page.mouse.move(rect.x + 40, rect.y + rect.height + 80, { steps: 20 });
await page.mouse.up();
const rects = page.locator('affine-block-selection').locator('visible=true');
await expect(rects).toHaveCount(1);
expect(await getPageSnapshot(page, true)).toMatchSnapshot(
`${testInfo.title}_3.json`
);
});
test('press backspace after bookmark block can select bookmark block', async ({
page,
}) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
const { insertAttachment, waitLoading } = getAttachment(page);
await focusRichText(page);
await pressEnter(page);
await pressArrowUp(page);
await insertAttachment();
// Wait for the attachment to be uploaded
await waitLoading();
await focusRichText(page);
await assertBlockCount(page, 'paragraph', 1);
await assertRichTextInlineRange(page, 0, 0);
await pressBackspace(page);
await assertBlockSelections(page, ['4']);
await assertBlockCount(page, 'paragraph', 0);
});
test('cancel file picker with input element resolves', async ({ page }) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
const { attachment } = getAttachment(page);
await focusRichText(page);
await pressEnter(page);
await pressArrowUp(page);
await page.evaluate(() => {
// Force fallback to input[type=file]
window.showOpenFilePicker = undefined;
});
const slashMenu = page.locator(`.slash-menu`);
await waitNextFrame(page);
await type(page, '/file', 100);
await expect(slashMenu).toBeVisible();
const fileChooser = page.waitForEvent('filechooser');
await pressEnter(page);
const inputFile = page.locator("input[type='file']");
await expect(inputFile).toHaveCount(1);
// This does not trigger `cancel` event and,
// therefore, the test isn't representative.
// Waiting for https://github.com/microsoft/playwright/issues/27524
await (await fileChooser).setFiles([]);
await expect(attachment).toHaveCount(0);
await expect(inputFile).toHaveCount(0);
});
test('indent attachment block to paragraph', async ({ page }) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
const { insertAttachment, waitLoading } = getAttachment(page);
await focusRichText(page);
await pressEnter(page);
await insertAttachment();
// Wait for the attachment to be uploaded
await waitLoading();
await assertBlockChildrenIds(page, '1', ['2', '4']);
await assertBlockFlavour(page, '1', 'affine:note');
await assertBlockFlavour(page, '2', 'affine:paragraph');
await assertBlockFlavour(page, '4', 'affine:attachment');
await focusRichText(page);
await pressArrowDown(page);
await assertBlockSelections(page, ['4']);
await pressTab(page);
await assertBlockChildrenIds(page, '1', ['2']);
await assertBlockChildrenIds(page, '2', ['4']);
await pressShiftTab(page);
await assertBlockChildrenIds(page, '1', ['2', '4']);
});
test('indent attachment block to list', async ({ page }) => {
await enterPlaygroundRoom(page);
await initEmptyParagraphState(page);
const { insertAttachment, waitLoading } = getAttachment(page);
await focusRichText(page);
await type(page, '- a');
await pressEnter(page);
await insertAttachment();
// Wait for the attachment to be uploaded
await waitLoading();
await assertBlockChildrenIds(page, '1', ['3', '5']);
await assertBlockFlavour(page, '1', 'affine:note');
await assertBlockFlavour(page, '3', 'affine:list');
await assertBlockFlavour(page, '5', 'affine:attachment');
await focusRichText(page);
await pressArrowDown(page);
await assertBlockSelections(page, ['5']);
await pressTab(page);
await assertBlockChildrenIds(page, '1', ['3']);
await assertBlockChildrenIds(page, '3', ['5']);
await pressShiftTab(page);
await assertBlockChildrenIds(page, '1', ['3', '5']);
});
test('attachment can be dragged from note to surface top level block', async ({
page,
}) => {
await enterPlaygroundRoom(page);
await initEmptyEdgelessState(page);
const { insertAttachment, waitLoading } = getAttachment(page);
await focusRichText(page);
await insertAttachment();
// Wait for the attachment to be uploaded
await waitLoading();
await switchEditorMode(page);
await page.mouse.dblclick(450, 450);
await dragBlockToPoint(page, '4', { x: 200, y: 200 });
await waitNextFrame(page);
await assertParentBlockFlavour(page, '4', 'affine:surface');
});