refactor(editor): add slash menu config extension entry (#10641)

Close [BS-2744](https://linear.app/affine-design/issue/BS-2744/slash-menu%E6%8F%92%E4%BB%B6%E5%8C%96%EF%BC%9Aaction%E6%B3%A8%E5%86%8C%E5%85%A5%E5%8F%A3)

This PR mainly focus  on providing an entry point for configuring the SlashMenu feature. Therefore, it strives to retain the original code to ensure that the modifications are simple and easy to review. Subsequent PRs will focus on moving different configurations into separate blocks.

### How to use?
Here is the type definition for the slash menu configuration.  An important change is the new field `group`, which indicates the sorting and grouping of the menu item. See the comments for details.

```ts
// types.ts
export type SlashMenuContext = {
  std: BlockStdScope;
  model: BlockModel;
};

export type SlashMenuItemBase = {
  name: string;
  description?: string;
  icon?: TemplateResult;
  /**
   * This field defines sorting and grouping of menu items like VSCode.
   * The first number indicates the group index, the second number indicates the item index in the group.
   * The group name is the string between `_` and `@`.
   * You can find an example figure in https://code.visualstudio.com/api/references/contribution-points#menu-example
   */
  group?: `${number}_${string}@${number}`;

  /**
   * The condition to show the menu item.
   */
  when?: (ctx: SlashMenuContext) => boolean;
};

export type SlashMenuActionItem = SlashMenuItemBase & {
  action: (ctx: SlashMenuContext) => void;
  tooltip?: SlashMenuTooltip;

  /**
   * The alias of the menu item for search.
   */
  searchAlias?: string[];
};

export type SlashMenuSubMenu = SlashMenuItemBase & {
  subMenu: SlashMenuItem[];
};

export type SlashMenuItem = SlashMenuActionItem | SlashMenuSubMenu;

export type SlashMenuConfig = {
  /**
   * The items in the slash menu. It can be generated dynamically with the context.
   */
  items: SlashMenuItem[] | ((ctx: SlashMenuContext) => SlashMenuItem[]);

  /**
   * Slash menu will not be triggered when the condition is true.
   */
  disableWhen?: (ctx: SlashMenuContext) => boolean;
};

// extensions.ts

/**
 * The extension to add a slash menu items or configure.
 */
export function SlashMenuConfigExtension(ext: {
  id: string;
  config: SlashMenuConfig;
}): ExtensionType {
  return {
    setup: di => {
      di.addImpl(SlashMenuConfigIdentifier(ext.id), ext.config);
    },
  };
}
```

Here is an example, `XXXSlashMenuConfig` adds a `Delete` action to the slash menu, which is assigned to the 8th group named `Actions` at position 0.
```ts
import { SlashMenuConfigExtension, type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';

const XXXSlashMenuConfig = SlashMenuConfigExtension({
  id: 'XXX',
  config: {
    items: [
      {
        name: 'Delete',
        description: 'Remove a block.',
        searchAlias: ['remove'],
        icon: DeleteIcon,
        group: '8_Actions@0',
        action: ({ std, model }) => {
          std.host.doc.deleteBlock(model);
        },
      },
    ],
  },
});
```
This commit is contained in:
L-Sun
2025-03-06 16:12:06 +00:00
parent 62d8c0c7cb
commit 750c8a44dc
15 changed files with 547 additions and 488 deletions

View File

@@ -1,12 +1,13 @@
import {
type AffineSlashMenuActionItem,
type AffineSlashMenuContext,
type AffineSlashMenuItem,
AffineSlashMenuWidget,
type AffineSlashSubMenu,
AIStarIcon,
DocModeProvider,
type SlashMenuActionItem,
type SlashMenuContext,
SlashMenuExtension,
type SlashMenuItem,
type SlashMenuSubMenu,
} from '@blocksuite/affine/blocks';
import type { BlockStdScope } from '@blocksuite/block-std';
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
@@ -18,7 +19,9 @@ import {
type AffineAIPanelWidget,
} from '../../widgets/ai-panel/ai-panel';
export function setupSlashMenuAIEntry(slashMenu: AffineSlashMenuWidget) {
export function setupSlashMenuAIEntry(std: BlockStdScope) {
const slashMenuExtension = std.get(SlashMenuExtension);
const AIItems = pageAIGroups.map(group => group.items).flat();
const iconWrapper = (icon: AIItemConfig['icon']) => {
@@ -29,7 +32,7 @@ export function setupSlashMenuAIEntry(slashMenu: AffineSlashMenuWidget) {
const showWhenWrapper =
(item?: AIItemConfig) =>
({ std }: AffineSlashMenuContext) => {
({ std }: SlashMenuContext) => {
const root = std.host.doc.root;
if (!root) return false;
const affineAIPanelWidget = std.view.getWidget(
@@ -45,19 +48,17 @@ export function setupSlashMenuAIEntry(slashMenu: AffineSlashMenuWidget) {
return item?.showWhen?.(chain, editorMode, std.host) ?? true;
};
const actionItemWrapper = (
item: AIItemConfig
): AffineSlashMenuActionItem => ({
const actionItemWrapper = (item: AIItemConfig): SlashMenuActionItem => ({
...basicItemConfig(item),
action: ({ std }: AffineSlashMenuContext) => {
action: ({ std }: SlashMenuContext) => {
item?.handler?.(std.host);
},
});
const subMenuWrapper = (item: AIItemConfig): AffineSlashSubMenu => {
const subMenuWrapper = (item: AIItemConfig): SlashMenuSubMenu => {
return {
...basicItemConfig(item),
subMenu: (item.subItem ?? []).map<AffineSlashMenuActionItem>(
subMenu: (item.subItem ?? []).map<SlashMenuActionItem>(
({ type, handler }) => ({
name: type,
action: ({ std }) => handler?.(std.host),
@@ -70,45 +71,47 @@ export function setupSlashMenuAIEntry(slashMenu: AffineSlashMenuWidget) {
return {
name: item.name,
icon: iconWrapper(item.icon),
alias: ['ai'],
showWhen: showWhenWrapper(item),
searchAlias: ['ai'],
when: showWhenWrapper(item),
};
};
const menu = slashMenu.config.items.slice();
menu.unshift({
name: 'Ask AI',
icon: AIStarIcon,
showWhen: showWhenWrapper(),
action: ({ std }) => {
const root = std.host.doc.root;
if (!root) return;
const affineAIPanelWidget = std.view.getWidget(
AFFINE_AI_PANEL_WIDGET,
root.id
) as AffineAIPanelWidget;
handleInlineAskAIAction(affineAIPanelWidget.host);
let index = 0;
const AIMenuItems: SlashMenuItem[] = [
{
name: 'Ask AI',
icon: AIStarIcon,
when: showWhenWrapper(),
action: ({ std }) => {
const root = std.host.doc.root;
if (!root) return;
const affineAIPanelWidget = std.view.getWidget(
AFFINE_AI_PANEL_WIDGET,
root.id
) as AffineAIPanelWidget;
handleInlineAskAIAction(affineAIPanelWidget.host);
},
},
});
const AIMenuItems: AffineSlashMenuItem[] = [
{ groupName: 'AFFiNE AI' },
...AIItems.filter(({ name }) =>
['Fix spelling', 'Fix grammar'].includes(name)
).map(item => ({
).map<SlashMenuActionItem>(item => ({
...actionItemWrapper(item),
name: `${item.name} from above`,
group: `1_AFFiNE AI@${index++}`,
})),
...AIItems.filter(({ name }) =>
['Summarize', 'Continue writing'].includes(name)
).map(actionItemWrapper),
).map<SlashMenuActionItem>(item => ({
...actionItemWrapper(item),
group: `1_AFFiNE AI@${index++}`,
})),
{
name: 'Action with above',
icon: iconWrapper(MoreHorizontalIcon({ width: '24px', height: '24px' })),
group: `1_AFFiNE AI@${index++}`,
subMenu: [
{ groupName: 'Action with above' },
...AIItems.filter(({ name }) =>
['Translate to', 'Change tone to'].includes(name)
).map(subMenuWrapper),
@@ -126,14 +129,8 @@ export function setupSlashMenuAIEntry(slashMenu: AffineSlashMenuWidget) {
},
];
const basicGroupEnd = menu.findIndex(
item => 'groupName' in item && item.groupName === 'List'
);
// insert ai item after basic group
menu.splice(basicGroupEnd, 0, ...AIMenuItems);
slashMenu.config = {
...AffineSlashMenuWidget.DEFAULT_CONFIG,
items: menu,
slashMenuExtension.config = {
...slashMenuExtension.config,
items: [...AIMenuItems, ...slashMenuExtension.config.items],
};
}

View File

@@ -72,7 +72,7 @@ function getAIEdgelessRootWatcher(framework: FrameworkProvider) {
}
if (component instanceof AffineSlashMenuWidget) {
setupSlashMenuAIEntry(component);
setupSlashMenuAIEntry(this.std);
}
});
}

View File

@@ -38,7 +38,7 @@ function getAIPageRootWatcher(framework: FrameworkProvider) {
}
if (component instanceof AffineSlashMenuWidget) {
setupSlashMenuAIEntry(component);
setupSlashMenuAIEntry(this.std);
}
});
}

View File

@@ -132,12 +132,12 @@ export function patchQuickSearchService(framework: FrameworkProvider) {
}
const component = payload.view;
if (component instanceof AffineSlashMenuWidget) {
component.config.items.forEach(item => {
component.configItemTransform = item => {
if (
'action' in item &&
(item.name === 'Linked Doc' || item.name === 'Link')
) {
item.action = async ({ std }) => {
item.action = ({ std }) => {
const [success, { insertedLinkType }] = std.command.exec(
insertLinkByQuickSearchCommand
);
@@ -164,7 +164,8 @@ export function patchQuickSearchService(framework: FrameworkProvider) {
.catch(console.error);
};
}
});
return item;
};
}
});
}