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,374 @@
import {
convertToDatabase,
DATABASE_CONVERT_WHITE_LIST,
} from '@blocksuite/affine-block-database';
import {
convertSelectedBlocksToLinkedDoc,
getTitleFromSelectedModels,
notifyDocCreated,
promptDocTitle,
} from '@blocksuite/affine-block-embed';
import {
deleteTextCommand,
formatBlockCommand,
formatNativeCommand,
formatTextCommand,
isFormatSupported,
textConversionConfigs,
textFormatConfigs,
} from '@blocksuite/affine-components/rich-text';
import { toast } from '@blocksuite/affine-components/toast';
import {
copySelectedModelsCommand,
deleteSelectedModelsCommand,
draftSelectedModelsCommand,
duplicateSelectedModelsCommand,
getBlockSelectionsCommand,
getImageSelectionsCommand,
getSelectedBlocksCommand,
getSelectedModelsCommand,
getTextSelectionCommand,
} from '@blocksuite/affine-shared/commands';
import type {
ToolbarAction,
ToolbarActionGenerator,
ToolbarActionGroup,
ToolbarModuleConfig,
} from '@blocksuite/affine-shared/services';
import { ActionPlacement } from '@blocksuite/affine-shared/services';
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { type BlockComponent, BlockSelection } from '@blocksuite/block-std';
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
import {
ArrowDownSmallIcon,
CopyIcon,
DatabaseTableViewIcon,
DeleteIcon,
DuplicateIcon,
LinkedPageIcon,
} from '@blocksuite/icons/lit';
import { toDraftModel } from '@blocksuite/store';
import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { updateBlockType } from '../commands';
const conversionsActionGroup = {
id: 'a.conversions',
when: ({ chain }) => isFormatSupported(chain).run()[0],
generate({ chain }) {
const [ok, { selectedModels = [] }] = chain
.tryAll(chain => [
chain.pipe(getTextSelectionCommand),
chain.pipe(getBlockSelectionsCommand),
])
.pipe(getSelectedModelsCommand, { types: ['text', 'block'] })
.run();
// only support model with text
// TODO(@fundon): displays only in a single paragraph, `length === 1`.
const allowed = ok && selectedModels.filter(model => model.text).length > 0;
if (!allowed) return null;
const model = selectedModels[0];
const conversion =
textConversionConfigs.find(
({ flavour, type }) =>
flavour === model.flavour &&
(type ? 'type' in model && type === model.type : true)
) ?? textConversionConfigs[0];
const update = (flavour: string, type?: string) => {
chain
.pipe(updateBlockType, {
flavour,
...(type && { props: { type } }),
})
.run();
};
return {
content: html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Conversions"
.tooltip="${'Turn Into'}"
>
${conversion.icon} ${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<div data-size="large" data-orientation="vertical">
${repeat(
textConversionConfigs.filter(c => c.flavour !== 'affine:divider'),
item => item.name,
({ flavour, type, name, icon }) => html`
<editor-menu-action
aria-label=${name}
?data-selected=${conversion.name === name}
@click=${() => update(flavour, type)}
>
${icon}<span class="label">${name}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`,
};
},
} as const satisfies ToolbarActionGenerator;
const inlineTextActionGroup = {
id: 'b.inline-text',
when: ({ chain }) => isFormatSupported(chain).run()[0],
actions: textFormatConfigs.map(
({ id, name, action, activeWhen, icon }, score) => {
return {
id,
icon,
score,
tooltip: name,
run: ({ host }) => action(host),
active: ({ host }) => activeWhen(host),
};
}
),
} as const satisfies ToolbarActionGroup;
const highlightActionGroup = {
id: 'c.highlight',
when: ({ chain }) => isFormatSupported(chain).run()[0],
content({ chain }) {
const updateHighlight = (styles: AffineTextAttributes) => {
const payload = { styles };
chain
.try(chain => [
chain.pipe(getTextSelectionCommand).pipe(formatTextCommand, payload),
chain
.pipe(getBlockSelectionsCommand)
.pipe(formatBlockCommand, payload),
chain.pipe(formatNativeCommand, payload),
])
.run();
};
return html`
<affine-highlight-dropdown-menu
.updateHighlight=${updateHighlight}
></affine-highlight-dropdown-menu>
`;
},
} as const satisfies ToolbarAction;
export const turnIntoDatabase = {
id: 'd.convert-to-database',
tooltip: 'Create Table',
icon: DatabaseTableViewIcon(),
when({ chain }) {
const middleware = (count = 0) => {
return (ctx: { selectedBlocks: BlockComponent[] }, next: () => void) => {
const { selectedBlocks } = ctx;
if (!selectedBlocks || selectedBlocks.length === count) return;
const allowed = selectedBlocks.every(block =>
DATABASE_CONVERT_WHITE_LIST.includes(block.flavour)
);
if (!allowed) return;
next();
};
};
let [ok] = chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
})
.pipe(middleware(1))
.run();
if (ok) return true;
[ok] = chain
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedBlocksCommand, {
types: ['block', 'image'],
})
.pipe(middleware(0))
.run();
return ok;
},
run({ host }) {
convertToDatabase(host, tableViewMeta.type);
},
} as const satisfies ToolbarAction;
export const turnIntoLinkedDoc = {
id: 'e.convert-to-linked-doc',
tooltip: 'Create Linked Doc',
icon: LinkedPageIcon(),
when({ chain }) {
const [ok, { selectedModels }] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'flat',
})
.run();
return ok && Boolean(selectedModels?.length);
},
run({ chain, store, selection, std, track }) {
const [ok, { draftedModels, selectedModels }] = chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'text'],
mode: 'flat',
})
.pipe(draftSelectedModelsCommand)
.run();
if (!ok || !draftedModels || !selectedModels?.length) return;
selection.clear();
const autofill = getTitleFromSelectedModels(
selectedModels.map(toDraftModel)
);
promptDocTitle(std, autofill)
.then(async title => {
if (title === null) return;
await convertSelectedBlocksToLinkedDoc(
std,
store,
draftedModels,
title
);
notifyDocCreated(std, store);
track('DocCreated', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
control: 'create linked doc',
type: 'embed-linked-doc',
});
track('LinkedDocCreated', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
control: 'create linked doc',
type: 'embed-linked-doc',
});
})
.catch(console.error);
},
} as const satisfies ToolbarAction;
export const builtinToolbarConfig = {
actions: [
conversionsActionGroup,
inlineTextActionGroup,
highlightActionGroup,
turnIntoDatabase,
turnIntoLinkedDoc,
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run({ chain, host }) {
const [ok] = chain
.pipe(getSelectedModelsCommand)
.pipe(draftSelectedModelsCommand)
.pipe(copySelectedModelsCommand)
.run();
if (!ok) return;
toast(host, 'Copied to clipboard');
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run({ chain, store, selection }) {
store.captureSync();
const [ok, { selectedBlocks = [] }] = chain
.pipe(getTextSelectionCommand)
.pipe(getSelectedBlocksCommand, {
types: ['text'],
mode: 'highest',
})
.run();
// If text selection exists, convert to block selection
if (ok && selectedBlocks.length) {
selection.setGroup(
'note',
selectedBlocks.map(block =>
selection.create(BlockSelection, {
blockId: block.model.id,
})
)
);
}
chain
.pipe(getSelectedModelsCommand, {
types: ['block', 'image'],
mode: 'highest',
})
.pipe(draftSelectedModelsCommand)
.pipe(duplicateSelectedModelsCommand)
.run();
},
},
],
when(ctx) {
return !ctx.flags.isNative();
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
actions: [
{
id: 'delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run({ chain }) {
// removes text
const [ok] = chain
.pipe(getTextSelectionCommand)
.pipe(deleteTextCommand)
.run();
if (ok) return;
// removes blocks
chain
.tryAll(chain => [
chain.pipe(getBlockSelectionsCommand),
chain.pipe(getImageSelectionsCommand),
])
.pipe(getSelectedModelsCommand)
.pipe(deleteSelectedModelsCommand)
.run();
},
},
],
when(ctx) {
return !ctx.flags.isNative();
},
},
],
} as const satisfies ToolbarModuleConfig;
+22 -5
View File
@@ -1,4 +1,10 @@
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
import { NoteBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import {
BlockFlavourIdentifier,
BlockViewExtension,
FlavourExtension,
} from '@blocksuite/block-std';
import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
@@ -6,18 +12,29 @@ import {
DocNoteBlockAdapterExtensions,
EdgelessNoteBlockAdapterExtensions,
} from './adapters/index.js';
import { builtinToolbarConfig } from './configs/toolbar.js';
import { NoteBlockService } from './note-service.js';
const flavour = NoteBlockSchema.model.flavour;
export const NoteBlockSpec: ExtensionType[] = [
FlavourExtension('affine:note'),
FlavourExtension(flavour),
NoteBlockService,
BlockViewExtension('affine:note', literal`affine-note`),
BlockViewExtension(flavour, literal`affine-note`),
DocNoteBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
].flat();
export const EdgelessNoteBlockSpec: ExtensionType[] = [
FlavourExtension('affine:note'),
FlavourExtension(flavour),
NoteBlockService,
BlockViewExtension('affine:note', literal`affine-edgeless-note`),
BlockViewExtension(flavour, literal`affine-edgeless-note`),
EdgelessNoteBlockAdapterExtensions,
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
].flat();