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`](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/blocksuite/affine/shared/src/services/toolbar-service/config.ts).

1.  Add toolbar configuration file to a block type, such as bookmark block: [`config.ts`](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/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](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/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](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/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.
This commit is contained in:
fundon
2025-03-06 06:46:03 +00:00
parent 06e4bd9aed
commit ec9bd1f383
147 changed files with 6389 additions and 5156 deletions
@@ -0,0 +1,276 @@
import { toast } from '@blocksuite/affine-components/toast';
import { EmbedLinkedDocModel } from '@blocksuite/affine-model';
import {
ActionPlacement,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import {
getBlockProps,
referenceToNode,
} from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
} from '@blocksuite/icons/lit';
import { Slice } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block';
const trackBaseProps = {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'card view',
};
export const builtinToolbarConfig = {
actions: [
{
id: 'a.doc-title',
content(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
if (!component) return null;
const model = component.model;
if (!model.title) return null;
const originalTitle =
ctx.workspace.getDoc(model.pageId)?.meta?.title || 'Untitled';
return html`<affine-linked-doc-title
.title=${originalTitle}
.open=${(event: MouseEvent) => component.open({ event })}
></affine-linked-doc-title>`;
},
},
{
id: 'b.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
component?.covertToInline();
// Clears
ctx.select('note');
ctx.reset();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
},
{
id: 'card',
label: 'Card view',
disabled: true,
},
{
id: 'embed',
label: 'Embed view',
disabled(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
if (!component) return true;
if (component.closest('affine-embed-synced-doc-block')) return true;
const model = component.model;
// same doc
if (model.pageId === ctx.store.id) return true;
// linking to block
if (referenceToNode(model)) return true;
return false;
},
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
component?.convertToEmbed();
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedLinkedDocModel
);
if (!model) return null;
const actions = this.actions.map(action => ({ ...action }));
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedViewSelector', {
...trackBaseProps,
control: 'switch view',
});
};
return html`${keyed(
model,
html`<affine-view-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.viewType$=${signal(actions[1].label)}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedLinkedDocModel
);
if (!model) return null;
const actions = this.actions.map(action => ({
...action,
run: ({ store }) => {
store.updateBlock(model, { style: action.id });
ctx.track('SelectedCardStyle', {
...trackBaseProps,
control: 'select card style',
type: action.id,
});
},
})) satisfies ToolbarAction[];
const toggle = (e: CustomEvent<boolean>) => {
const opened = e.detail;
if (!opened) return;
ctx.track('OpenedCardStyleSelector', {
...trackBaseProps,
control: 'switch card style',
});
};
return html`${keyed(
model,
html`<affine-card-style-dropdown-menu
.actions=${actions}
.context=${ctx}
.toggle=${toggle}
.style$=${model.style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
component?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
},
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
const slice = Slice.fromModels(ctx.store, [model]);
ctx.clipboard
.copySlice(slice)
.then(() => toast(ctx.host, 'Copied to clipboard'))
.catch(console.error);
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
const { flavour, parent } = model;
const props = getBlockProps(model);
const index = parent?.children.indexOf(model);
ctx.store.addBlock(flavour, props, parent, index);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelBy(BlockSelection);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
@@ -109,7 +109,7 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
};
private readonly _selectBlock = () => {
const selectionManager = this.host.selection;
const selectionManager = this.std.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
@@ -129,15 +129,10 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
convertToEmbed = () => {
if (this._referenceToNode) return;
const { doc, caption } = this.model;
const { doc, caption, parent } = this.model;
const index = parent?.children.indexOf(this.model);
const parent = doc.getParent(this.model);
if (!parent) {
return;
}
const index = parent.children.indexOf(this.model);
doc.addBlock(
const blockId = doc.addBlock(
'affine:embed-synced-doc',
{
caption,
@@ -147,8 +142,11 @@ export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent<EmbedLinke
index
);
this.std.selection.setGroup('note', []);
doc.deleteBlock(this.model);
this.std.selection.setGroup('note', [
this.std.selection.create(BlockSelection, { blockId }),
]);
};
covertToInline = () => {
@@ -1,14 +1,26 @@
import { BlockViewExtension } from '@blocksuite/block-std';
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockServiceIdentifier,
BlockViewExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension.js';
import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension';
import { builtinToolbarConfig } from './configs/toolbar';
const flavour = EmbedLinkedDocBlockSchema.model.flavour;
export const EmbedLinkedDocBlockSpec: ExtensionType[] = [
BlockViewExtension('affine:embed-linked-doc', model => {
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-embed-edgeless-linked-doc-block`
: literal`affine-embed-linked-doc-block`;
}),
EmbedLinkedDocBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockServiceIdentifier(flavour),
config: builtinToolbarConfig,
}),
].flat();