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.
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

View File

@@ -21,15 +21,13 @@ export const getBlockIndexCommand: Command<
const parentModel = ctx.std.store.getParent(path);
if (!parentModel) return;
const parent = ctx.std.view.getBlock(parentModel.id);
if (!parent) return;
const parentBlock = ctx.std.view.getBlock(parentModel.id);
if (!parentBlock) return;
const index = parent.childBlocks.findIndex(x => {
return x.blockId === path;
});
const blockIndex = parentBlock.childBlocks.findIndex(x => x.blockId === path);
next({
blockIndex: index,
parentBlock: parent as BlockComponent,
blockIndex,
parentBlock,
});
};

View File

@@ -78,7 +78,7 @@ export const DEFAULT_LINK_PREVIEW_ENDPOINT =
export const CANVAS_EXPORT_IGNORE_TAGS = [
'EDGELESS-TOOLBAR-WIDGET',
'AFFINE-DRAG-HANDLE-WIDGET',
'AFFINE-FORMAT-BAR-WIDGET',
'AFFINE-TOOLBAR-WIDGET',
'AFFINE-BLOCK-SELECTION',
];

View File

@@ -19,5 +19,6 @@ export * from './quick-search-service';
export * from './sidebar-service';
export * from './telemetry-service';
export * from './theme-service';
export * from './toolbar-service';
export * from './user-service';
export * from './virtual-keyboard-service';

View File

@@ -0,0 +1,56 @@
import type { TemplateResult } from 'lit';
import type { ToolbarContext } from './context';
export enum ActionPlacement {
Start = 0,
End = 1 << 1,
More = 1 << 2,
}
type ActionBase = {
id: string;
score?: number;
when?: ((cx: ToolbarContext) => boolean) | boolean;
active?: ((cx: ToolbarContext) => boolean) | boolean;
placement?: ActionPlacement;
};
export type ToolbarAction = ActionBase & {
label?: string;
icon?: TemplateResult;
tooltip?: string;
variant?: 'destructive';
disabled?: ((cx: ToolbarContext) => boolean) | boolean;
content?:
| ((cx: ToolbarContext) => TemplateResult | null)
| (TemplateResult | null);
run?: (cx: ToolbarContext) => void;
};
// Generates an action at runtime
export type ToolbarActionGenerator = ActionBase & {
generate: (cx: ToolbarContext) => Omit<ToolbarAction, 'id'> | null;
};
export type ToolbarActionGroup<
T extends ActionBase = ToolbarAction | ToolbarActionGenerator,
> = ActionBase & {
actions: T[];
content?:
| ((cx: ToolbarContext) => TemplateResult | null)
| (TemplateResult | null);
};
// Generates an action group at runtime
export type ToolbarActionGroupGenerator = ActionBase & {
generate: (cx: ToolbarContext) => Omit<ToolbarActionGroup, 'id'> | null;
};
export type ToolbarGenericAction =
| ToolbarAction
| ToolbarActionGenerator
| ToolbarActionGroup
| ToolbarActionGroupGenerator;
export type ToolbarActions<T extends ActionBase = ToolbarGenericAction> = T[];

View File

@@ -0,0 +1,9 @@
import type { Placement } from '@floating-ui/dom';
import type { ToolbarActions } from './action';
export type ToolbarModuleConfig = {
actions: ToolbarActions;
placement?: Extract<Placement, 'top' | 'top-start'>;
};

View File

@@ -0,0 +1,172 @@
import {
type BlockComponent,
BlockSelection,
type BlockStdScope,
} from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { nextTick } from '@blocksuite/global/utils';
import type {
BaseSelection,
Block,
SelectionConstructor,
} from '@blocksuite/store';
import { matchModels } from '../../utils';
import { DocModeProvider } from '../doc-mode-service';
import { TelemetryProvider, type TelemetryService } from '../telemetry-service';
import { ThemeProvider } from '../theme-service';
import { ToolbarRegistryIdentifier } from './registry';
abstract class ToolbarContextBase {
constructor(readonly std: BlockStdScope) {}
get command() {
return this.std.command;
}
get chain() {
return this.command.chain();
}
get doc() {
return this.store.doc;
}
get workspace() {
return this.std.workspace;
}
get host() {
return this.std.host;
}
get clipboard() {
return this.std.clipboard;
}
get selection() {
return this.std.selection;
}
get store() {
return this.std.store;
}
get view() {
return this.std.view;
}
get activated() {
if (this.readonly) return false;
if (this.flags.accept()) return true;
if (this.host.event.active) return true;
// Selects `embed-synced-doc-block`
return this.host.contains(document.activeElement);
}
get readonly() {
return this.store.readonly;
}
get docModeProvider() {
return this.std.get(DocModeProvider);
}
get editorMode() {
return this.docModeProvider.getEditorMode() ?? 'page';
}
get isPageMode() {
return this.editorMode === 'page';
}
get isEdgelessMode() {
return this.editorMode === 'edgeless';
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get themeProvider() {
return this.std.get(ThemeProvider);
}
get theme() {
return this.themeProvider.theme;
}
get toolbarRegistry() {
return this.std.get(ToolbarRegistryIdentifier);
}
get flags() {
return this.toolbarRegistry.flags;
}
get message$() {
return this.toolbarRegistry.message$;
}
getCurrentBlockBy<T extends SelectionConstructor>(type?: T): Block | null {
const selection = this.selection.find(type ?? BlockSelection);
return (selection && this.store.getBlock(selection.blockId)) ?? null;
}
getCurrentModelBy<T extends SelectionConstructor>(type: T) {
return this.getCurrentBlockBy<T>(type)?.model ?? null;
}
getCurrentModelByType<
T extends SelectionConstructor,
M extends Parameters<typeof matchModels>[1][number],
>(type: T, klass: M) {
const model = this.getCurrentModelBy(type);
return matchModels(model, [klass]) ? model : null;
}
getCurrentBlockComponentBy<
T extends SelectionConstructor,
K extends abstract new (...args: any) => any,
>(type: T, klass: K): InstanceType<K> | null {
const block = this.getCurrentBlockBy<T>(type);
const component = block && this.view.getBlock(block.id);
return this.blockComponentIs(component, klass) ? component : null;
}
blockComponentIs<K extends abstract new (...args: any) => any>(
component: BlockComponent | null,
...classes: K[]
): component is InstanceType<K> {
return classes.some(k => component instanceof k);
}
select(group: string, selections: BaseSelection[] = []) {
nextTick()
.then(() => this.selection.setGroup(group, selections))
.catch(console.error);
}
show() {
this.flags.show();
}
hide() {
this.flags.hide();
}
reset() {
this.flags.reset();
this.message$.value = null;
}
get telemetryProvider() {
return this.std.getOptional(TelemetryProvider);
}
track = (...args: Parameters<TelemetryService['track']>) => {
this.telemetryProvider?.track(...args);
};
}
export class ToolbarContext extends ToolbarContextBase {}

View File

@@ -0,0 +1,80 @@
import { batch, signal } from '@preact/signals-core';
export enum Flag {
None = 0b0,
Surface = 0b1,
Block = 0b10,
Text = 0b100,
Native = 0b1000,
// Hovering something, e.g. inline links
Hovering = 0b10000,
// Dragging something or opening modal, e.g. drag handle, drag resources from outside, bookmark rename modal
Hiding = 0b100000,
// When the editor is inactive and the toolbar is hidden, we still want to accept the message
Accepting = 0b1000000,
}
export class Flags {
value$ = signal(Flag.None);
get value() {
return this.value$.peek();
}
toggle(flag: Flag, activated: boolean) {
if (activated) {
this.value$.value |= flag;
return;
}
this.value$.value &= ~flag;
}
check(flag: Flag, value = this.value) {
return (flag & value) === flag;
}
contains(flag: number, value = this.value) {
return (flag & value) !== Flag.None;
}
refresh(flag: Flag) {
batch(() => {
this.toggle(flag, false);
this.toggle(flag, true);
});
}
reset() {
this.value$.value = Flag.None;
}
hide() {
batch(() => {
this.toggle(Flag.Accepting, true);
this.toggle(Flag.Hiding, true);
});
}
show() {
batch(() => {
this.toggle(Flag.Hiding, false);
this.toggle(Flag.Accepting, false);
});
}
isText() {
return this.check(Flag.Text);
}
isBlock() {
return this.check(Flag.Block);
}
isNative() {
return this.check(Flag.Native);
}
accept() {
return this.check(Flag.Accepting);
}
}

View File

@@ -0,0 +1,6 @@
export * from './action';
export * from './config';
export * from './context';
export { Flag as ToolbarFlag } from './flags';
export * from './module';
export * from './registry';

View File

@@ -0,0 +1,9 @@
import type { BlockFlavourIdentifier } from '@blocksuite/block-std';
import type { ToolbarModuleConfig } from './config';
export type ToolbarModule = {
readonly id: ReturnType<typeof BlockFlavourIdentifier>;
readonly config: ToolbarModuleConfig;
};

View File

@@ -0,0 +1,44 @@
import { type BlockStdScope, StdIdentifier } from '@blocksuite/block-std';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { Extension, type ExtensionType } from '@blocksuite/store';
import { signal } from '@preact/signals-core';
import { Flags } from './flags';
import type { ToolbarModule } from './module';
export const ToolbarModuleIdentifier = createIdentifier<ToolbarModule>(
'AffineToolbarModuleIdentifier'
);
export const ToolbarRegistryIdentifier =
createIdentifier<ToolbarRegistryExtension>('AffineToolbarRegistryIdentifier');
export function ToolbarModuleExtension(module: ToolbarModule): ExtensionType {
return {
setup: di => {
di.addImpl(ToolbarModuleIdentifier(module.id.variant), module);
},
};
}
export class ToolbarRegistryExtension extends Extension {
message$ = signal<{
flavour: string;
element: Element;
setFloating: (element?: Element) => void;
} | null>(null);
flags = new Flags();
constructor(readonly std: BlockStdScope) {
super();
}
get modules() {
return this.std.provider.getAll(ToolbarModuleIdentifier);
}
static override setup(di: Container) {
di.addImpl(ToolbarRegistryIdentifier, this, [StdIdentifier]);
}
}

View File

@@ -55,11 +55,13 @@ export function createButtonPopper(
crossAxis,
rootBoundary,
ignoreShift,
offsetHeight,
}: {
mainAxis?: number;
crossAxis?: number;
rootBoundary?: Rect | (() => Rect | undefined);
ignoreShift?: boolean;
offsetHeight?: number;
} = {}
) {
let display: Display = 'hidden';
@@ -87,9 +89,10 @@ export function createButtonPopper(
size({
...overflowOptions,
apply({ availableHeight }) {
popperElement.style.maxHeight = originMaxHeight
? `min(${originMaxHeight}, ${availableHeight}px)`
: `${availableHeight}px`;
popperElement.style.maxHeight =
originMaxHeight && originMaxHeight !== 'none'
? `min(${originMaxHeight}, ${availableHeight}px)`
: `${availableHeight - (offsetHeight ?? 0)}px`;
},
}),
],