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

@@ -1,44 +1,28 @@
import '../../components/ask-ai-button';
import {
type AffineFormatBarWidget,
toolbarDefaultConfig,
ActionPlacement,
type ToolbarModuleConfig,
} from '@blocksuite/affine/blocks';
import { html, type TemplateResult } from 'lit';
import { html } from 'lit';
import { pageAIGroups } from '../../_common/config';
export function setupFormatBarAIEntry(formatBar: AffineFormatBarWidget) {
toolbarDefaultConfig(formatBar);
formatBar.addRawConfigItems(
[
export function toolbarAIEntryConfig(): ToolbarModuleConfig {
return {
actions: [
{
type: 'custom' as const,
render(formatBar: AffineFormatBarWidget): TemplateResult | null {
const richText = getRichText();
if (richText?.dataset.disableAskAi !== undefined) return null;
return html`
<ask-ai-toolbar-button
.host=${formatBar.host}
.actionGroups=${pageAIGroups}
></ask-ai-toolbar-button>
`;
},
placement: ActionPlacement.Start,
id: 'A.ai',
score: -1,
when: ({ flags }) => !flags.isNative(),
content: ({ host }) => html`
<ask-ai-toolbar-button
.host=${host}
.actionGroups=${pageAIGroups}
></ask-ai-toolbar-button>
`,
},
{ type: 'divider' },
],
0
);
};
}
const getRichText = () => {
const selection = getSelection();
if (!selection) return null;
if (selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const commonAncestorContainer =
range.commonAncestorContainer instanceof Element
? range.commonAncestorContainer
: range.commonAncestorContainer.parentElement;
if (!commonAncestorContainer) return null;
return commonAncestorContainer.closest('rich-text');
};

View File

@@ -1,19 +1,22 @@
import { LifeCycleWatcher } from '@blocksuite/affine/block-std';
import {
AffineFormatBarWidget,
BlockFlavourIdentifier,
LifeCycleWatcher,
} from '@blocksuite/affine/block-std';
import {
AffineSlashMenuWidget,
EdgelessElementToolbarWidget,
EdgelessRootBlockSpec,
ToolbarModuleExtension,
} from '@blocksuite/affine/blocks';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { FrameworkProvider } from '@toeverything/infra';
import { buildAIPanelConfig } from '../ai-panel';
import { toolbarAIEntryConfig } from '../entries';
import {
setupEdgelessCopilot,
setupEdgelessElementToolbarAIEntry,
} from '../entries/edgeless/index';
import { setupFormatBarAIEntry } from '../entries/format-bar/setup-format-bar';
import { setupSlashMenuAIEntry } from '../entries/slash-menu/setup-slash-menu';
import { setupSpaceAIEntry } from '../entries/space/setup-space';
import { CopilotTool } from '../tool/copilot-tool';
@@ -35,6 +38,10 @@ export function createAIEdgelessRootBlockSpec(
aiPanelWidget,
edgelessCopilotWidget,
getAIEdgelessRootWatcher(framework),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:note'),
config: toolbarAIEntryConfig(),
}),
];
}
@@ -64,10 +71,6 @@ function getAIEdgelessRootWatcher(framework: FrameworkProvider) {
setupEdgelessElementToolbarAIEntry(component);
}
if (component instanceof AffineFormatBarWidget) {
setupFormatBarAIEntry(component);
}
if (component instanceof AffineSlashMenuWidget) {
setupSlashMenuAIEntry(component);
}

View File

@@ -1,14 +1,17 @@
import { LifeCycleWatcher } from '@blocksuite/affine/block-std';
import {
AffineFormatBarWidget,
BlockFlavourIdentifier,
LifeCycleWatcher,
} from '@blocksuite/affine/block-std';
import {
AffineSlashMenuWidget,
PageRootBlockSpec,
ToolbarModuleExtension,
} from '@blocksuite/affine/blocks';
import type { ExtensionType } from '@blocksuite/affine/store';
import type { FrameworkProvider } from '@toeverything/infra';
import { buildAIPanelConfig } from '../ai-panel';
import { setupFormatBarAIEntry } from '../entries/format-bar/setup-format-bar';
import { toolbarAIEntryConfig } from '../entries';
import { setupSlashMenuAIEntry } from '../entries/slash-menu/setup-slash-menu';
import { setupSpaceAIEntry } from '../entries/space/setup-space';
import {
@@ -34,10 +37,6 @@ function getAIPageRootWatcher(framework: FrameworkProvider) {
setupSpaceAIEntry(component);
}
if (component instanceof AffineFormatBarWidget) {
setupFormatBarAIEntry(component);
}
if (component instanceof AffineSlashMenuWidget) {
setupSlashMenuAIEntry(component);
}
@@ -50,5 +49,13 @@ function getAIPageRootWatcher(framework: FrameworkProvider) {
export function createAIPageRootBlockSpec(
framework: FrameworkProvider
): ExtensionType[] {
return [...PageRootBlockSpec, aiPanelWidget, getAIPageRootWatcher(framework)];
return [
...PageRootBlockSpec,
aiPanelWidget,
getAIPageRootWatcher(framework),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:note'),
config: toolbarAIEntryConfig(),
}),
];
}

View File

@@ -4,7 +4,6 @@ import {
} from '@blocksuite/affine/block-std';
import { GfxControllerIdentifier } from '@blocksuite/affine/block-std/gfx';
import {
AFFINE_FORMAT_BAR_WIDGET,
AFFINE_VIEWPORT_OVERLAY_WIDGET,
type AffineViewportOverlayWidget,
DocModeProvider,
@@ -12,6 +11,8 @@ import {
NotificationProvider,
stopPropagation,
ThemeProvider,
ToolbarFlag,
ToolbarRegistryIdentifier,
} from '@blocksuite/affine/blocks';
import type { BaseSelection } from '@blocksuite/affine/store';
import {
@@ -519,27 +520,24 @@ export class AffineAIPanelWidget extends WidgetComponent {
protected override willUpdate(changed: PropertyValues): void {
const prevState = changed.get('state');
if (prevState) {
if (prevState === 'hidden') {
const shouldBeHidden = prevState === 'hidden';
if (shouldBeHidden) {
this._selection = this.host.selection.value;
} else {
this.restoreSelection();
}
// tell format bar to show or hide
const rootBlockId = this.host.doc.root?.id;
const formatBar = rootBlockId
? this.host.view.getWidget(AFFINE_FORMAT_BAR_WIDGET, rootBlockId)
: null;
if (formatBar) {
formatBar.requestUpdate();
}
// tell toolbar to show or hide
this.std
.get(ToolbarRegistryIdentifier)
.flags.toggle(ToolbarFlag.Hiding, shouldBeHidden);
}
if (this.state !== 'hidden') {
this.viewportOverlayWidget?.lock();
} else {
if (this.state === 'hidden') {
this.viewportOverlayWidget?.unlock();
} else {
this.viewportOverlayWidget?.lock();
}
this.dataset.state = this.state;

View File

@@ -1,3 +1,4 @@
import { WorkspaceServerService } from '@affine/core/modules/cloud';
import { EditorSettingService } from '@affine/core/modules/editor-setting';
import {
DatabaseConfigExtension,
@@ -10,12 +11,18 @@ import type { FrameworkProvider } from '@toeverything/infra';
import { createDatabaseOptionsConfig } from './database';
import { createLinkedWidgetConfig } from './linked';
import { createToolbarMoreMenuConfig } from './toolbar';
import {
createCustomToolbarExtension,
createToolbarMoreMenuConfig,
} from './toolbar';
export function getEditorConfigExtension(
framework: FrameworkProvider
): ExtensionType[] {
const editorSettingService = framework.get(EditorSettingService);
const workspaceServerService = framework.get(WorkspaceServerService);
const baseUrl = workspaceServerService.server?.baseUrl ?? location.origin;
return [
EditorSettingExtension(editorSettingService.editorSetting.settingSignal),
DatabaseConfigExtension(createDatabaseOptionsConfig(framework)),
@@ -23,5 +30,7 @@ export function getEditorConfigExtension(
linkedWidget: createLinkedWidgetConfig(framework),
}),
ToolbarMoreMenuConfigExtension(createToolbarMoreMenuConfig(framework)),
];
createCustomToolbarExtension(baseUrl),
].flat();
}

View File

@@ -8,13 +8,66 @@ import { EditorService } from '@affine/core/modules/editor';
import { copyLinkToBlockStdScopeClipboard } from '@affine/core/utils/clipboard';
import { I18n } from '@affine/i18n';
import { track } from '@affine/track';
import type {
import {
BlockFlavourIdentifier,
BlockSelection,
TextSelection,
} from '@blocksuite/affine/block-std';
import {
GfxBlockElementModel,
GfxPrimitiveElementModel,
} from '@blocksuite/affine/block-std/gfx';
import type { MenuContext, MenuItemGroup } from '@blocksuite/affine/blocks';
import { LinkIcon } from '@blocksuite/icons/lit';
import {
ActionPlacement,
AffineReference,
BookmarkBlockComponent,
BookmarkBlockModel,
EmbedFigmaBlockComponent,
EmbedGithubBlockComponent,
EmbedLinkedDocBlockComponent,
EmbedLinkedDocModel,
EmbedLoomBlockComponent,
EmbedSyncedDocBlockComponent,
EmbedSyncedDocModel,
EmbedYoutubeBlockComponent,
GenerateDocUrlProvider,
getDocContentWithMaxLength,
getSelectedModelsCommand,
ImageSelection,
isPeekable,
matchModels,
type MenuContext,
type MenuItemGroup,
notifyLinkedDocClearedAliases,
notifyLinkedDocSwitchedToCard,
type OpenDocMode,
peek,
toast,
toggleEmbedCardEditModal,
toggleReferencePopup,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarContext,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine/blocks';
import type { ExtensionType } from '@blocksuite/affine/store';
import {
ArrowDownSmallIcon,
CenterPeekIcon,
CopyAsImgaeIcon,
CopyIcon,
EditIcon,
ExpandFullIcon,
LinkIcon,
OpenInNewIcon,
SplitViewIcon,
} from '@blocksuite/icons/lit';
import type { FrameworkProvider } from '@toeverything/infra';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { keyed } from 'lit/directives/keyed.js';
import { repeat } from 'lit/directives/repeat.js';
import { createCopyAsPngMenuItem } from './copy-as-image';
@@ -136,3 +189,673 @@ function createCopyLinkToBlockMenuItem(
},
};
}
function createToolbarMoreMenuConfigV2(baseUrl?: string) {
return {
actions: [
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy-as-image',
label: 'Copy as Image',
icon: CopyAsImgaeIcon(),
when: ({ isEdgelessMode, gfx }) =>
isEdgelessMode && gfx.selection.selectedElements.length > 0,
},
{
id: 'copy-link-to-block',
label: 'Copy link to block',
icon: LinkIcon(),
when: ({ isPageMode, selection, gfx }) => {
const items = selection
.getGroup('note')
.filter(item =>
[TextSelection, BlockSelection, ImageSelection].some(t =>
item.is(t)
)
);
const hasNoteSelection = items.length > 0;
if (isPageMode) {
const item = items[0];
if (item && item.is(TextSelection)) {
return (
!item.isCollapsed() &&
Boolean(item.from.length + (item.to?.length ?? 0))
);
}
return hasNoteSelection;
}
// Linking blocks in notes is currently not supported under edgeless.
if (hasNoteSelection) return false;
// Linking single block/element in edgeless mode.
return gfx.selection.selectedElements.length === 1;
},
run({ isPageMode, std, store, gfx, workspace, editorMode }) {
const pageId = store.doc.id;
const mode = editorMode;
const workspaceId = workspace.id;
const options: UseSharingUrl = { workspaceId, pageId, mode };
let type = '';
if (isPageMode) {
const [ok, { selectedModels = [] }] = std.command.exec(
getSelectedModelsCommand
);
if (!ok || !selectedModels.length) return;
options.blockIds = selectedModels.map(model => model.id);
type = selectedModels[0].flavour;
} else {
const firstElement = gfx.selection.firstElement;
if (!firstElement) return;
const ids = [firstElement.id];
if (firstElement instanceof GfxPrimitiveElementModel) {
type = firstElement.type;
options.elementIds = ids;
} else if (firstElement instanceof GfxBlockElementModel) {
type = firstElement.flavour;
options.blockIds = ids;
}
}
if (!type) return;
const str = generateUrl({
...options,
baseUrl: baseUrl ?? location.origin,
});
if (!str) return;
copyLinkToBlockStdScopeClipboard(str, std.clipboard)
.then(ok => {
if (!ok) return;
notify.success({ title: I18n['Copied link to clipboard']() });
})
.catch(console.error);
track.doc.editor.toolbar.copyBlockToLink({ type });
},
},
],
},
],
} as const satisfies ToolbarModuleConfig;
}
function createExternalLinkableToolbarConfig(
kclass:
| typeof BookmarkBlockComponent
| typeof EmbedFigmaBlockComponent
| typeof EmbedGithubBlockComponent
| typeof EmbedLoomBlockComponent
| typeof EmbedYoutubeBlockComponent
) {
return {
actions: [
{
id: 'a.preview.after.copy-link-and-edit',
actions: [
{
id: 'copy-link',
tooltip: 'Copy link',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentBlockComponentBy(
BlockSelection,
kclass
)?.model;
if (!model) return;
const { url } = model;
navigator.clipboard.writeText(url).catch(console.error);
toast(ctx.host, 'Copied link to clipboard');
ctx.track('CopiedLink', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: matchModels(model, [BookmarkBlockModel])
? 'bookmark'
: 'link',
type: 'card view',
control: 'copy link',
});
},
},
{
id: 'edit',
tooltip: 'Edit',
icon: EditIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
kclass
);
if (!component) return;
ctx.hide();
const model = component.model;
const abortController = new AbortController();
abortController.signal.onabort = () => ctx.show();
toggleEmbedCardEditModal(
ctx.host,
model,
'card',
undefined,
undefined,
(_std, _component, props) => {
ctx.store.updateBlock(model, props);
component.requestUpdate();
},
abortController
);
ctx.track('OpenedAliasPopup', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: matchModels(model, [BookmarkBlockModel])
? 'bookmark'
: 'link',
type: 'card view',
control: 'edit',
});
},
},
],
},
],
} as const satisfies ToolbarModuleConfig;
}
const openDocActions = [
{
id: 'open-in-active-view',
label: I18n['com.affine.peek-view-controls.open-doc'](),
icon: ExpandFullIcon(),
},
{
id: 'open-in-new-view',
label: I18n['com.affine.peek-view-controls.open-doc-in-split-view'](),
icon: SplitViewIcon(),
when: () => BUILD_CONFIG.isElectron,
},
{
id: 'open-in-new-tab',
label: I18n['com.affine.peek-view-controls.open-doc-in-new-tab'](),
icon: OpenInNewIcon(),
},
{
id: 'open-in-center-peek',
label: I18n['com.affine.peek-view-controls.open-doc-in-center-peek'](),
icon: CenterPeekIcon(),
},
] as const satisfies ToolbarAction[];
function createOpenDocActionGroup(
klass:
| typeof EmbedLinkedDocBlockComponent
| typeof EmbedSyncedDocBlockComponent
) {
return {
placement: ActionPlacement.Start,
id: 'A.open-doc',
actions: openDocActions,
content(ctx) {
const component = ctx.getCurrentBlockComponentBy(BlockSelection, klass);
if (!component) return null;
const actions = this.actions
.map<ToolbarAction>(action => {
const shouldOpenInCenterPeek = action.id === 'open-in-center-peek';
const shouldOpenInActiveView = action.id === 'open-in-active-view';
const allowed =
typeof action.when === 'function'
? action.when(ctx)
: (action.when ?? true);
return {
...action,
disabled: shouldOpenInActiveView
? component.model.pageId === ctx.store.id
: false,
when:
allowed &&
(shouldOpenInCenterPeek ? isPeekable(component) : true),
run: shouldOpenInCenterPeek
? (_ctx: ToolbarContext) => peek(component)
: (_ctx: ToolbarContext) =>
component.open({
openMode: action.id as OpenDocMode,
}),
};
})
.filter(action => {
if (typeof action.when === 'function') return action.when(ctx);
return action.when ?? true;
});
return html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button aria-label="Open doc" .tooltip=${'Open doc'}>
${OpenInNewIcon()} ${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<div data-size="small" data-orientation="vertical">
${repeat(
actions,
action => action.id,
({ label, icon, run, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${ifDefined(
typeof disabled === 'function' ? disabled(ctx) : disabled
)}
@click=${() => run?.(ctx)}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`;
},
} satisfies ToolbarActionGroup<ToolbarAction>;
}
const embedLinkedDocToolbarConfig = {
actions: [
createOpenDocActionGroup(EmbedLinkedDocBlockComponent),
{
id: 'a.doc-title.after.copy-link-and-edit',
actions: [
{
id: 'copy-link',
tooltip: 'Copy link',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedLinkedDocModel
);
if (!model) return;
const { pageId, params } = model;
const url = ctx.std
.getOptional(GenerateDocUrlProvider)
?.generateDocUrl(pageId, params);
if (!url) return;
navigator.clipboard.writeText(url).catch(console.error);
toast(ctx.host, 'Copied link to clipboard');
ctx.track('CopiedLink', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'card view',
control: 'copy link',
});
},
},
{
id: 'edit',
tooltip: 'Edit',
icon: EditIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedLinkedDocBlockComponent
);
if (!component) return;
ctx.hide();
const model = component.model;
const doc = ctx.workspace.getDoc(model.pageId);
const abortController = new AbortController();
abortController.signal.onabort = () => ctx.show();
toggleEmbedCardEditModal(
ctx.host,
component.model,
'card',
doc
? {
title: doc.meta?.title,
description: getDocContentWithMaxLength(doc),
}
: undefined,
std => {
component.refreshData();
notifyLinkedDocClearedAliases(std);
},
(_std, _component, props) => {
ctx.store.updateBlock(model, props);
component.requestUpdate();
},
abortController
);
ctx.track('OpenedAliasPopup', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'embed view',
control: 'edit',
});
},
},
],
},
],
} as const satisfies ToolbarModuleConfig;
const embedSyncedDocToolbarConfig = {
actions: [
createOpenDocActionGroup(EmbedSyncedDocBlockComponent),
{
placement: ActionPlacement.Start,
id: 'B.copy-link-and-edit',
actions: [
{
id: 'copy-link',
tooltip: 'Copy link',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentModelByType(
BlockSelection,
EmbedSyncedDocModel
);
if (!model) return;
const { pageId, params } = model;
const url = ctx.std
.getOptional(GenerateDocUrlProvider)
?.generateDocUrl(pageId, params);
if (!url) return;
navigator.clipboard.writeText(url).catch(console.error);
toast(ctx.host, 'Copied link to clipboard');
ctx.track('CopiedLink', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'embed view',
control: 'copy link',
});
},
},
{
id: 'edit',
tooltip: 'Edit',
icon: EditIcon(),
run(ctx) {
const component = ctx.getCurrentBlockComponentBy(
BlockSelection,
EmbedSyncedDocBlockComponent
);
if (!component) return;
ctx.hide();
const model = component.model;
const doc = ctx.workspace.getDoc(model.pageId);
const abortController = new AbortController();
abortController.signal.onabort = () => ctx.show();
toggleEmbedCardEditModal(
ctx.host,
model,
'embed',
doc ? { title: doc.meta?.title } : undefined,
undefined,
(std, _component, props) => {
component.convertToCard(props);
notifyLinkedDocSwitchedToCard(std);
},
abortController
);
ctx.track('OpenedAliasPopup', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'embed view',
control: 'edit',
});
},
},
],
},
],
} as const satisfies ToolbarModuleConfig;
const inlineReferenceToolbarConfig = {
actions: [
{
placement: ActionPlacement.Start,
id: 'A.open-doc',
actions: openDocActions,
content(ctx) {
const registry = ctx.toolbarRegistry;
const target = registry.message$.peek()?.element;
if (!(target instanceof AffineReference)) return null;
const actions = this.actions
.map<ToolbarAction>(action => {
const shouldOpenInCenterPeek = action.id === 'open-in-center-peek';
const shouldOpenInActiveView = action.id === 'open-in-active-view';
const allowed =
typeof action.when === 'function'
? action.when(ctx)
: (action.when ?? true);
return {
...action,
disabled: shouldOpenInActiveView
? target.referenceInfo.pageId === ctx.store.id
: false,
when:
allowed && (shouldOpenInCenterPeek ? isPeekable(target) : true),
run: shouldOpenInCenterPeek
? (_ctx: ToolbarContext) => peek(target)
: (_ctx: ToolbarContext) =>
target.open({
openMode: action.id as OpenDocMode,
}),
};
})
.filter(action => {
if (typeof action.when === 'function') return action.when(ctx);
return action.when ?? true;
});
return html`${keyed(
target,
html`
<editor-menu-button
.contentPadding="${'8px'}"
.button=${html`
<editor-icon-button
aria-label="Open doc"
.tooltip=${'Open doc'}
>
${OpenInNewIcon()} ${ArrowDownSmallIcon()}
</editor-icon-button>
`}
>
<div data-size="small" data-orientation="vertical">
${repeat(
actions,
action => action.id,
({ label, icon, run, disabled }) => html`
<editor-menu-action
aria-label=${ifDefined(label)}
?disabled=${ifDefined(
typeof disabled === 'function'
? disabled(ctx)
: disabled
)}
@click=${() => run?.(ctx)}
>
${icon}<span class="label">${label}</span>
</editor-menu-action>
`
)}
</div>
</editor-menu-button>
`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'b.copy-link-and-edit',
actions: [
{
id: 'copy-link',
tooltip: 'Copy link',
icon: CopyIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return;
const { pageId, params } = target.referenceInfo;
const url = ctx.std
.getOptional(GenerateDocUrlProvider)
?.generateDocUrl(pageId, params);
if (!url) return;
// Clears
ctx.reset();
navigator.clipboard.writeText(url).catch(console.error);
toast(ctx.host, 'Copied link to clipboard');
ctx.track('CopiedLink', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'inline view',
control: 'copy link',
});
},
},
{
id: 'edit',
tooltip: 'Edit',
icon: EditIcon(),
run(ctx) {
const target = ctx.message$.peek()?.element;
if (!(target instanceof AffineReference)) return;
// Clears
ctx.reset();
const { inlineEditor, selfInlineRange, docTitle, referenceInfo } =
target;
if (!inlineEditor || !selfInlineRange) return;
const abortController = new AbortController();
const popover = toggleReferencePopup(
ctx.std,
docTitle,
referenceInfo,
inlineEditor,
selfInlineRange,
abortController
);
abortController.signal.onabort = () => popover.remove();
ctx.track('OpenedAliasPopup', {
segment: 'doc',
page: 'doc editor',
module: 'toolbar',
category: 'linked doc',
type: 'inline view',
control: 'edit',
});
},
},
],
},
],
} as const satisfies ToolbarModuleConfig;
export const createCustomToolbarExtension = (
baseUrl: string
): ExtensionType[] => {
return [
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:*'),
config: createToolbarMoreMenuConfigV2(baseUrl),
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:bookmark'),
config: createExternalLinkableToolbarConfig(BookmarkBlockComponent),
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:embed-figma'),
config: createExternalLinkableToolbarConfig(EmbedFigmaBlockComponent),
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:embed-github'),
config: createExternalLinkableToolbarConfig(EmbedGithubBlockComponent),
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:embed-loom'),
config: createExternalLinkableToolbarConfig(EmbedLoomBlockComponent),
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:embed-youtube'),
config: createExternalLinkableToolbarConfig(EmbedYoutubeBlockComponent),
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:embed-linked-doc'),
config: embedLinkedDocToolbarConfig,
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:embed-synced-doc'),
config: embedSyncedDocToolbarConfig,
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:reference'),
config: inlineReferenceToolbarConfig,
}),
];
};

View File

@@ -13,14 +13,13 @@ import type {
import {
codeToolbarWidget,
DocModeProvider,
embedCardToolbarWidget,
FeatureFlagService,
formatBarWidget,
imageToolbarWidget,
ParagraphBlockService,
ReferenceNodeConfigIdentifier,
slashMenuWidget,
surfaceRefToolbarWidget,
toolbarWidget,
VirtualKeyboardProvider as BSVirtualKeyboardProvider,
} from '@blocksuite/affine/blocks';
import type {
@@ -167,11 +166,10 @@ export function enableMobileExtension(
specBuilder: SpecBuilder,
framework: FrameworkProvider
): void {
specBuilder.omit(formatBarWidget);
specBuilder.omit(embedCardToolbarWidget);
specBuilder.omit(slashMenuWidget);
specBuilder.omit(codeToolbarWidget);
specBuilder.omit(imageToolbarWidget);
specBuilder.omit(surfaceRefToolbarWidget);
specBuilder.omit(toolbarWidget);
specBuilder.extend([MobileSpecsPatches, KeyboardToolbarExtension(framework)]);
}