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
+198 -212
View File
@@ -55,9 +55,9 @@ import {
} from '@blocksuite/affine-shared/services'; } from '@blocksuite/affine-shared/services';
import { import {
createDefaultDoc, createDefaultDoc,
findAncestorModel,
openFileOrFiles, openFileOrFiles,
} from '@blocksuite/affine-shared/utils'; } from '@blocksuite/affine-shared/utils';
import type { BlockStdScope } from '@blocksuite/block-std';
import { viewPresets } from '@blocksuite/data-view/view-presets'; import { viewPresets } from '@blocksuite/data-view/view-presets';
import { import {
DualLinkIcon, DualLinkIcon,
@@ -70,11 +70,10 @@ import {
TeXIcon, TeXIcon,
} from '@blocksuite/icons/lit'; } from '@blocksuite/icons/lit';
import type { DeltaInsert } from '@blocksuite/inline'; import type { DeltaInsert } from '@blocksuite/inline';
import type { BlockModel } from '@blocksuite/store';
import { Slice, Text } from '@blocksuite/store'; import { Slice, Text } from '@blocksuite/store';
import type { TemplateResult } from 'lit';
import { type SlashMenuTooltip, slashMenuToolTips } from './tooltips'; import { slashMenuToolTips } from './tooltips';
import type { SlashMenuActionItem, SlashMenuConfig } from './types';
import { import {
createConversionItem, createConversionItem,
createTextFormatItem, createTextFormatItem,
@@ -84,102 +83,55 @@ import {
tryRemoveEmptyLine, tryRemoveEmptyLine,
} from './utils'; } from './utils';
export type SlashMenuConfig = { // TODO(@L-Sun): This counter temporarily added variables for refactoring.
triggerKeys: string[]; let index = 0;
ignoreBlockTypes: string[];
ignoreSelector: string;
items: SlashMenuItem[];
maxHeight: number;
tooltipTimeout: number;
};
export type SlashMenuStaticConfig = Omit<SlashMenuConfig, 'items'> & {
items: SlashMenuStaticItem[];
};
export type SlashMenuItem = SlashMenuStaticItem | SlashMenuItemGenerator;
export type SlashMenuStaticItem =
| SlashMenuGroupDivider
| SlashMenuActionItem
| SlashSubMenu;
export type SlashMenuGroupDivider = {
groupName: string;
showWhen?: (ctx: SlashMenuContext) => boolean;
};
export type SlashMenuActionItem = {
name: string;
description?: string;
icon?: TemplateResult;
tooltip?: SlashMenuTooltip;
alias?: string[];
showWhen?: (ctx: SlashMenuContext) => boolean;
action: (ctx: SlashMenuContext) => void | Promise<void>;
customTemplate?: TemplateResult<1>;
};
export type SlashSubMenu = {
name: string;
description?: string;
icon?: TemplateResult;
alias?: string[];
showWhen?: (ctx: SlashMenuContext) => boolean;
subMenu: SlashMenuStaticItem[];
};
export type SlashMenuItemGenerator = (
ctx: SlashMenuContext
) => (SlashMenuGroupDivider | SlashMenuActionItem | SlashSubMenu)[];
export type SlashMenuContext = {
std: BlockStdScope;
model: BlockModel;
};
export const defaultSlashMenuConfig: SlashMenuConfig = { export const defaultSlashMenuConfig: SlashMenuConfig = {
triggerKeys: ['/'], disableWhen: ({ model }) => {
ignoreBlockTypes: ['affine:code'], return (
ignoreSelector: 'affine-callout', ['affine:database', 'affine:code'].includes(model.flavour) ||
maxHeight: 344, !!findAncestorModel(
tooltipTimeout: 800, model,
ancestor => ancestor.flavour === 'affine:callout'
)
);
},
items: [ items: [
// --------------------------------------------------------- // ---------------------------------------------------------
{ groupName: 'Basic' },
...textConversionConfigs ...textConversionConfigs
.filter(i => i.type && ['h1', 'h2', 'h3', 'text'].includes(i.type)) .filter(i => i.type && ['h1', 'h2', 'h3', 'text'].includes(i.type))
.map(createConversionItem), .map(config => createConversionItem(config, `0_Basic@${index++}`)),
{ {
name: 'Other Headings', name: 'Other Headings',
icon: HeadingIcon, icon: HeadingIcon,
subMenu: [ group: `0_Basic@${index++}`,
{ groupName: 'Headings' }, subMenu: textConversionConfigs
...textConversionConfigs .filter(i => i.type && ['h4', 'h5', 'h6'].includes(i.type))
.filter(i => i.type && ['h4', 'h5', 'h6'].includes(i.type)) .map(config => createConversionItem(config)),
.map<SlashMenuActionItem>(createConversionItem),
],
}, },
...textConversionConfigs ...textConversionConfigs
.filter(i => i.flavour === 'affine:code') .filter(i => i.flavour === 'affine:code')
.map<SlashMenuActionItem>(createConversionItem), .map(config => createConversionItem(config, `0_Basic@${index++}`)),
...textConversionConfigs ...textConversionConfigs
.filter(i => i.type && ['divider', 'quote'].includes(i.type)) .filter(i => i.type && ['divider', 'quote'].includes(i.type))
.map<SlashMenuActionItem>(config => ({ .map(
...createConversionItem(config), config =>
showWhen: ({ model }) => ({
model.doc.schema.flavourSchemaMap.has(config.flavour) && ...createConversionItem(config, `0_Basic@${index++}`),
!insideEdgelessText(model), when: ({ model }) =>
})), model.doc.schema.flavourSchemaMap.has(config.flavour) &&
!insideEdgelessText(model),
}) satisfies SlashMenuActionItem
),
{ {
name: 'Callout', name: 'Callout',
description: 'Let your words stand out.', description: 'Let your words stand out.',
icon: FontIcon(), icon: FontIcon(),
alias: ['callout'], searchAlias: ['callout'],
showWhen: ({ model }) => { group: `0_Basic@${index++}`,
when: ({ model }) => {
return model.doc.get(FeatureFlagService).getFlag('enable_callout'); return model.doc.get(FeatureFlagService).getFlag('enable_callout');
}, },
action: ({ model, std }) => { action: ({ model, std }) => {
@@ -207,9 +159,10 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
{ {
name: 'Inline equation', name: 'Inline equation',
group: `0_Basic@${index++}`,
description: 'Create a equation block.', description: 'Create a equation block.',
icon: TeXIcon(), icon: TeXIcon(),
alias: ['inlineMath, inlineEquation', 'inlineLatex'], searchAlias: ['inlineMath, inlineEquation', 'inlineLatex'],
action: ({ std }) => { action: ({ std }) => {
std.command std.command
.chain() .chain()
@@ -220,29 +173,30 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
}, },
// --------------------------------------------------------- // ---------------------------------------------------------
{ groupName: 'List' }, // { groupName: 'List' },
...textConversionConfigs ...textConversionConfigs
.filter(i => i.flavour === 'affine:list') .filter(i => i.flavour === 'affine:list')
.map(createConversionItem), .map(config => createConversionItem(config, `1_List@${index++}`)),
// --------------------------------------------------------- // ---------------------------------------------------------
{ groupName: 'Style' }, // { groupName: 'Style' },
...textFormatConfigs ...textFormatConfigs
.filter(i => !['Code', 'Link'].includes(i.name)) .filter(i => !['Code', 'Link'].includes(i.name))
.map<SlashMenuActionItem>(createTextFormatItem), .map(config => createTextFormatItem(config, `2_Style@${index++}`)),
// --------------------------------------------------------- // ---------------------------------------------------------
{ // {
groupName: 'Page', // groupName: 'Page',
showWhen: ({ model }) => // when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'), // model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'),
}, // },
{ {
name: 'New Doc', name: 'New Doc',
description: 'Start a new document.', description: 'Start a new document.',
icon: NewDocIcon, icon: NewDocIcon,
tooltip: slashMenuToolTips['New Doc'], tooltip: slashMenuToolTips['New Doc'],
showWhen: ({ model }) => group: `3_Page@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'), model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'),
action: ({ std, model }) => { action: ({ std, model }) => {
const newDoc = createDefaultDoc(std.host.doc.workspace); const newDoc = createDefaultDoc(std.host.doc.workspace);
@@ -259,8 +213,9 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Link to another document.', description: 'Link to another document.',
icon: LinkedDocIcon, icon: LinkedDocIcon,
tooltip: slashMenuToolTips['Linked Doc'], tooltip: slashMenuToolTips['Linked Doc'],
alias: ['dual link'], searchAlias: ['dual link'],
showWhen: ({ std, model }) => { group: `3_Page@${index++}`,
when: ({ std, model }) => {
const root = model.doc.root; const root = model.doc.root;
if (!root) return false; if (!root) return false;
const linkedDocWidget = std.view.getWidget( const linkedDocWidget = std.view.getWidget(
@@ -296,13 +251,14 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
}, },
// --------------------------------------------------------- // ---------------------------------------------------------
{ groupName: 'Content & Media' }, // { groupName: 'Content & Media' },
{ {
name: 'Table', name: 'Table',
description: 'Create a simple table.', description: 'Create a simple table.',
icon: TableIcon(), icon: TableIcon(),
tooltip: slashMenuToolTips['Table View'], tooltip: slashMenuToolTips['Table View'],
showWhen: ({ model }) => !insideEdgelessText(model), group: `4_Content & Media@${index++}`,
when: ({ model }) => !insideEdgelessText(model),
action: ({ std }) => { action: ({ std }) => {
std.command std.command
.chain() .chain()
@@ -327,16 +283,17 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Insert an image.', description: 'Insert an image.',
icon: ImageIcon(), icon: ImageIcon(),
tooltip: slashMenuToolTips['Image'], tooltip: slashMenuToolTips['Image'],
showWhen: ({ model }) => group: `4_Content & Media@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:image'), model.doc.schema.flavourSchemaMap.has('affine:image'),
action: async ({ std }) => { action: ({ std }) => {
const [success, ctx] = std.command const [success, ctx] = std.command
.chain() .chain()
.pipe(getSelectedModelsCommand) .pipe(getSelectedModelsCommand)
.pipe(insertImagesCommand, { removeEmptyLine: true }) .pipe(insertImagesCommand, { removeEmptyLine: true })
.run(); .run();
if (success) await ctx.insertedImageIds; if (success) ctx.insertedImageIds.catch(console.error);
}, },
}, },
{ {
@@ -344,22 +301,26 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Add a bookmark for reference.', description: 'Add a bookmark for reference.',
icon: LinkIcon, icon: LinkIcon,
tooltip: slashMenuToolTips['Link'], tooltip: slashMenuToolTips['Link'],
showWhen: ({ model }) => group: `4_Content & Media@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:bookmark'), model.doc.schema.flavourSchemaMap.has('affine:bookmark'),
action: async ({ std, model }) => { action: ({ std, model }) => {
const { host } = std; const { host } = std;
const parentModel = host.doc.getParent(model); const parentModel = host.doc.getParent(model);
if (!parentModel) { if (!parentModel) {
return; return;
} }
const index = parentModel.children.indexOf(model) + 1; const index = parentModel.children.indexOf(model) + 1;
await toggleEmbedCardCreateModal( toggleEmbedCardCreateModal(
host, host,
'Links', 'Links',
'The added link will be displayed as a card view.', 'The added link will be displayed as a card view.',
{ mode: 'page', parentModel, index } { mode: 'page', parentModel, index }
); )
tryRemoveEmptyLine(model); .then(() => {
tryRemoveEmptyLine(model);
})
.catch(console.error);
}, },
}, },
{ {
@@ -367,17 +328,23 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Attach a file to document.', description: 'Attach a file to document.',
icon: FileIcon, icon: FileIcon,
tooltip: slashMenuToolTips['Attachment'], tooltip: slashMenuToolTips['Attachment'],
alias: ['file'], searchAlias: ['file'],
showWhen: ({ model }) => group: `4_Content & Media@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:attachment'), model.doc.schema.flavourSchemaMap.has('affine:attachment'),
action: async ({ std, model }) => { action: ({ std, model }) => {
const file = await openFileOrFiles(); (async () => {
if (!file) return; const file = await openFileOrFiles();
if (!file) return;
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize; const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
await addSiblingAttachmentBlocks(
await addSiblingAttachmentBlocks(std.host, [file], maxFileSize, model); std.host,
tryRemoveEmptyLine(model); [file],
maxFileSize,
model
);
tryRemoveEmptyLine(model);
})().catch(console.error);
}, },
}, },
{ {
@@ -385,23 +352,26 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Upload a PDF to document.', description: 'Upload a PDF to document.',
icon: ExportToPdfIcon({ width: '20', height: '20' }), icon: ExportToPdfIcon({ width: '20', height: '20' }),
tooltip: slashMenuToolTips['PDF'], tooltip: slashMenuToolTips['PDF'],
showWhen: ({ model }) => group: `4_Content & Media@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:attachment'), model.doc.schema.flavourSchemaMap.has('affine:attachment'),
action: async ({ std, model }) => { action: ({ std, model }) => {
const file = await openFileOrFiles(); (async () => {
if (!file) return; const file = await openFileOrFiles();
if (!file) return;
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize; const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
await addSiblingAttachmentBlocks( await addSiblingAttachmentBlocks(
std.host, std.host,
[file], [file],
maxFileSize, maxFileSize,
model, model,
'after', 'after',
true true
); );
tryRemoveEmptyLine(model); tryRemoveEmptyLine(model);
})().catch(console.error);
}, },
}, },
{ {
@@ -409,22 +379,25 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Embed a YouTube video.', description: 'Embed a YouTube video.',
icon: YoutubeIcon, icon: YoutubeIcon,
tooltip: slashMenuToolTips['YouTube'], tooltip: slashMenuToolTips['YouTube'],
showWhen: ({ model }) => group: `4_Content & Media@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-youtube'), model.doc.schema.flavourSchemaMap.has('affine:embed-youtube'),
action: async ({ std, model }) => { action: ({ std, model }) => {
const { host } = std; (async () => {
const parentModel = host.doc.getParent(model); const { host } = std;
if (!parentModel) { const parentModel = host.doc.getParent(model);
return; if (!parentModel) {
} return;
const index = parentModel.children.indexOf(model) + 1; }
await toggleEmbedCardCreateModal( const index = parentModel.children.indexOf(model) + 1;
host, await toggleEmbedCardCreateModal(
'YouTube', host,
'The added YouTube video link will be displayed as an embed view.', 'YouTube',
{ mode: 'page', parentModel, index } 'The added YouTube video link will be displayed as an embed view.',
); { mode: 'page', parentModel, index }
tryRemoveEmptyLine(model); );
tryRemoveEmptyLine(model);
})().catch(console.error);
}, },
}, },
{ {
@@ -432,22 +405,25 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Link to a GitHub repository.', description: 'Link to a GitHub repository.',
icon: GithubIcon, icon: GithubIcon,
tooltip: slashMenuToolTips['Github'], tooltip: slashMenuToolTips['Github'],
showWhen: ({ model }) => group: `4_Content & Media@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-github'), model.doc.schema.flavourSchemaMap.has('affine:embed-github'),
action: async ({ std, model }) => { action: ({ std, model }) => {
const { host } = std; (async () => {
const parentModel = host.doc.getParent(model); const { host } = std;
if (!parentModel) { const parentModel = host.doc.getParent(model);
return; if (!parentModel) {
} return;
const index = parentModel.children.indexOf(model) + 1; }
await toggleEmbedCardCreateModal( const index = parentModel.children.indexOf(model) + 1;
host, await toggleEmbedCardCreateModal(
'GitHub', host,
'The added GitHub issue or pull request link will be displayed as a card view.', 'GitHub',
{ mode: 'page', parentModel, index } 'The added GitHub issue or pull request link will be displayed as a card view.',
); { mode: 'page', parentModel, index }
tryRemoveEmptyLine(model); );
tryRemoveEmptyLine(model);
})().catch(console.error);
}, },
}, },
// TODO: X Twitter // TODO: X Twitter
@@ -457,44 +433,50 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Embed a Figma document.', description: 'Embed a Figma document.',
icon: FigmaIcon, icon: FigmaIcon,
tooltip: slashMenuToolTips['Figma'], tooltip: slashMenuToolTips['Figma'],
showWhen: ({ model }) => group: `4_Content & Media@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-figma'), model.doc.schema.flavourSchemaMap.has('affine:embed-figma'),
action: async ({ std, model }) => { action: ({ std, model }) => {
const { host } = std; (async () => {
const parentModel = host.doc.getParent(model); const { host } = std;
if (!parentModel) { const parentModel = host.doc.getParent(model);
return; if (!parentModel) {
} return;
const index = parentModel.children.indexOf(model) + 1; }
await toggleEmbedCardCreateModal( const index = parentModel.children.indexOf(model) + 1;
host, await toggleEmbedCardCreateModal(
'Figma', host,
'The added Figma link will be displayed as an embed view.', 'Figma',
{ mode: 'page', parentModel, index } 'The added Figma link will be displayed as an embed view.',
); { mode: 'page', parentModel, index }
tryRemoveEmptyLine(model); );
tryRemoveEmptyLine(model);
})().catch(console.error);
}, },
}, },
{ {
name: 'Loom', name: 'Loom',
icon: LoomIcon, icon: LoomIcon,
showWhen: ({ model }) => group: `4_Content & Media@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-loom'), model.doc.schema.flavourSchemaMap.has('affine:embed-loom'),
action: async ({ std, model }) => { action: ({ std, model }) => {
const { host } = std; (async () => {
const parentModel = host.doc.getParent(model); const { host } = std;
if (!parentModel) { const parentModel = host.doc.getParent(model);
return; if (!parentModel) {
} return;
const index = parentModel.children.indexOf(model) + 1; }
await toggleEmbedCardCreateModal( const index = parentModel.children.indexOf(model) + 1;
host, await toggleEmbedCardCreateModal(
'Loom', host,
'The added Loom video link will be displayed as an embed view.', 'Loom',
{ mode: 'page', parentModel, index } 'The added Loom video link will be displayed as an embed view.',
); { mode: 'page', parentModel, index }
tryRemoveEmptyLine(model); );
tryRemoveEmptyLine(model);
})().catch(console.error);
}, },
}, },
@@ -502,7 +484,8 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
name: 'Equation', name: 'Equation',
description: 'Create a equation block.', description: 'Create a equation block.',
icon: TeXIcon(), icon: TeXIcon(),
alias: ['mathBlock, equationBlock', 'latexBlock'], searchAlias: ['mathBlock, equationBlock', 'latexBlock'],
group: `4_Content & Media@${index++}`,
action: ({ std }) => { action: ({ std }) => {
std.command std.command
.chain() .chain()
@@ -518,7 +501,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
// TODO(@L-Sun): Linear // TODO(@L-Sun): Linear
// --------------------------------------------------------- // ---------------------------------------------------------
({ model, std }) => { ({ std, model }) => {
const { host } = std; const { host } = std;
const surfaceModel = getSurfaceBlock(host.doc); const surfaceModel = getSurfaceBlock(host.doc);
@@ -534,6 +517,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
const frameItems = frameModels.map<SlashMenuActionItem>(frameModel => ({ const frameItems = frameModels.map<SlashMenuActionItem>(frameModel => ({
name: 'Frame: ' + frameModel.title, name: 'Frame: ' + frameModel.title,
icon: FrameIcon(), icon: FrameIcon(),
group: `5_Document Group & Frame@${index++}`,
action: ({ std }) => { action: ({ std }) => {
std.command std.command
.chain() .chain()
@@ -548,10 +532,11 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
})); }));
const groupElements = surfaceModel.getElementsByType('group'); const groupElements = surfaceModel.getElementsByType('group');
const groupItems = groupElements.map(group => ({ const groupItems = groupElements.map<SlashMenuActionItem>(group => ({
name: 'Group: ' + group.title.toString(), name: 'Group: ' + group.title.toString(),
icon: GroupingIcon(), icon: GroupingIcon(),
action: () => { group: `5_Document Group & Frame@${index++}`,
action: ({ std }) => {
std.command std.command
.chain() .chain()
.pipe(getSelectedModelsCommand) .pipe(getSelectedModelsCommand)
@@ -564,21 +549,10 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
}, },
})); }));
const items = [...frameItems, ...groupItems]; return [...frameItems, ...groupItems];
if (items.length !== 0) {
return [
{
groupName: 'Document Group & Frame',
},
...items,
];
} else {
return [];
}
}, },
// --------------------------------------------------------- // ---------------------------------------------------------
{ groupName: 'Date' },
() => { () => {
const now = new Date(); const now = new Date();
const tomorrow = new Date(); const tomorrow = new Date();
@@ -593,6 +567,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
icon: TodayIcon, icon: TodayIcon,
tooltip: slashMenuToolTips['Today'], tooltip: slashMenuToolTips['Today'],
description: formatDate(now), description: formatDate(now),
group: `6_Date@${index++}`,
action: ({ std, model }) => { action: ({ std, model }) => {
insertContent(std.host, model, formatDate(now)); insertContent(std.host, model, formatDate(now));
}, },
@@ -602,6 +577,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
icon: TomorrowIcon, icon: TomorrowIcon,
tooltip: slashMenuToolTips['Tomorrow'], tooltip: slashMenuToolTips['Tomorrow'],
description: formatDate(tomorrow), description: formatDate(tomorrow),
group: `6_Date@${index++}`,
action: ({ std, model }) => { action: ({ std, model }) => {
const tomorrow = new Date(); const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
@@ -613,6 +589,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
icon: YesterdayIcon, icon: YesterdayIcon,
tooltip: slashMenuToolTips['Yesterday'], tooltip: slashMenuToolTips['Yesterday'],
description: formatDate(yesterday), description: formatDate(yesterday),
group: `6_Date@${index++}`,
action: ({ std, model }) => { action: ({ std, model }) => {
const yesterday = new Date(); const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1); yesterday.setDate(yesterday.getDate() - 1);
@@ -624,6 +601,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
icon: NowIcon, icon: NowIcon,
tooltip: slashMenuToolTips['Now'], tooltip: slashMenuToolTips['Now'],
description: formatTime(now), description: formatTime(now),
group: `6_Date@${index++}`,
action: ({ std, model }) => { action: ({ std, model }) => {
insertContent(std.host, model, formatTime(now)); insertContent(std.host, model, formatTime(now));
}, },
@@ -632,14 +610,15 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
}, },
// --------------------------------------------------------- // ---------------------------------------------------------
{ groupName: 'Database' }, // { groupName: 'Database' },
{ {
name: 'Table View', name: 'Table View',
description: 'Display items in a table format.', description: 'Display items in a table format.',
alias: ['database'], searchAlias: ['database'],
icon: DatabaseTableViewIcon20, icon: DatabaseTableViewIcon20,
tooltip: slashMenuToolTips['Table View'], tooltip: slashMenuToolTips['Table View'],
showWhen: ({ model }) => group: `7_Database@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:database') && model.doc.schema.flavourSchemaMap.has('affine:database') &&
!insideEdgelessText(model), !insideEdgelessText(model),
action: ({ std }) => { action: ({ std }) => {
@@ -664,10 +643,11 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
}, },
{ {
name: 'Todo', name: 'Todo',
alias: ['todo view'], searchAlias: ['todo view'],
icon: DatabaseTableViewIcon20, icon: DatabaseTableViewIcon20,
tooltip: slashMenuToolTips['Todo'], tooltip: slashMenuToolTips['Todo'],
showWhen: ({ model }) => group: `7_Database@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:database') && model.doc.schema.flavourSchemaMap.has('affine:database') &&
!insideEdgelessText(model) && !insideEdgelessText(model) &&
!!model.doc.get(FeatureFlagService).getFlag('enable_block_query'), !!model.doc.get(FeatureFlagService).getFlag('enable_block_query'),
@@ -699,10 +679,11 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
{ {
name: 'Kanban View', name: 'Kanban View',
description: 'Visualize data in a dashboard.', description: 'Visualize data in a dashboard.',
alias: ['database'], searchAlias: ['database'],
icon: DatabaseKanbanViewIcon20, icon: DatabaseKanbanViewIcon20,
tooltip: slashMenuToolTips['Kanban View'], tooltip: slashMenuToolTips['Kanban View'],
showWhen: ({ model }) => group: `7_Database@${index++}`,
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:database') && model.doc.schema.flavourSchemaMap.has('affine:database') &&
!insideEdgelessText(model), !insideEdgelessText(model),
action: ({ std }) => { action: ({ std }) => {
@@ -727,12 +708,13 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
}, },
// --------------------------------------------------------- // ---------------------------------------------------------
{ groupName: 'Actions' }, // { groupName: 'Actions' },
{ {
name: 'Move Up', name: 'Move Up',
description: 'Shift this line up.', description: 'Shift this line up.',
icon: ArrowUpBigIcon, icon: ArrowUpBigIcon,
tooltip: slashMenuToolTips['Move Up'], tooltip: slashMenuToolTips['Move Up'],
group: `8_Actions@${index++}`,
action: ({ std, model }) => { action: ({ std, model }) => {
const { host } = std; const { host } = std;
const previousSiblingModel = host.doc.getPrev(model); const previousSiblingModel = host.doc.getPrev(model);
@@ -749,6 +731,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Shift this line down.', description: 'Shift this line down.',
icon: ArrowDownBigIcon, icon: ArrowDownBigIcon,
tooltip: slashMenuToolTips['Move Down'], tooltip: slashMenuToolTips['Move Down'],
group: `8_Actions@${index++}`,
action: ({ std, model }) => { action: ({ std, model }) => {
const { host } = std; const { host } = std;
const nextSiblingModel = host.doc.getNext(model); const nextSiblingModel = host.doc.getNext(model);
@@ -765,6 +748,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Copy this line to clipboard.', description: 'Copy this line to clipboard.',
icon: CopyIcon, icon: CopyIcon,
tooltip: slashMenuToolTips['Copy'], tooltip: slashMenuToolTips['Copy'],
group: `8_Actions@${index++}`,
action: ({ std, model }) => { action: ({ std, model }) => {
const slice = Slice.fromModels(std.store, [model]); const slice = Slice.fromModels(std.store, [model]);
@@ -783,6 +767,7 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
description: 'Create a duplicate of this line.', description: 'Create a duplicate of this line.',
icon: DualLinkIcon(), icon: DualLinkIcon(),
tooltip: slashMenuToolTips['Copy'], tooltip: slashMenuToolTips['Copy'],
group: `8_Actions@${index++}`,
action: ({ std, model }) => { action: ({ std, model }) => {
if (!model.text || !(model.text instanceof Text)) { if (!model.text || !(model.text instanceof Text)) {
console.error("Can't duplicate a block without text"); console.error("Can't duplicate a block without text");
@@ -818,9 +803,10 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
{ {
name: 'Delete', name: 'Delete',
description: 'Remove this line permanently.', description: 'Remove this line permanently.',
alias: ['remove'], searchAlias: ['remove'],
icon: DeleteIcon, icon: DeleteIcon,
tooltip: slashMenuToolTips['Delete'], tooltip: slashMenuToolTips['Delete'],
group: `8_Actions@${index++}`,
action: ({ std, model }) => { action: ({ std, model }) => {
std.host.doc.deleteBlock(model); std.host.doc.deleteBlock(model);
}, },
@@ -0,0 +1,5 @@
export const AFFINE_SLASH_MENU_WIDGET = 'affine-slash-menu-widget';
export const AFFINE_SLASH_MENU_TRIGGER_KEY = '/';
export const AFFINE_SLASH_MENU_TOOLTIP_TIMEOUT = 800;
// TODO(@L-Sun): Move this to styles.ts
export const AFFINE_SLASH_MENU_MAX_HEIGHT = 334;
@@ -1,5 +1,6 @@
import { AFFINE_SLASH_MENU_WIDGET } from './consts';
import { InnerSlashMenu, SlashMenu } from './slash-menu-popover'; import { InnerSlashMenu, SlashMenu } from './slash-menu-popover';
import { AFFINE_SLASH_MENU_WIDGET, AffineSlashMenuWidget } from './widget'; import { AffineSlashMenuWidget } from './widget';
export function effects() { export function effects() {
customElements.define(AFFINE_SLASH_MENU_WIDGET, AffineSlashMenuWidget); customElements.define(AFFINE_SLASH_MENU_WIDGET, AffineSlashMenuWidget);
@@ -1,11 +1,20 @@
import { WidgetViewExtension } from '@blocksuite/block-std'; import {
import { type Container } from '@blocksuite/global/di'; type BlockStdScope,
import { Extension } from '@blocksuite/store'; StdIdentifier,
WidgetViewExtension,
} from '@blocksuite/block-std';
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { Extension, type ExtensionType } from '@blocksuite/store';
import { literal, unsafeStatic } from 'lit/static-html.js'; import { literal, unsafeStatic } from 'lit/static-html.js';
import { AFFINE_SLASH_MENU_WIDGET } from './widget'; import { defaultSlashMenuConfig } from './config';
import { AFFINE_SLASH_MENU_WIDGET } from './consts';
import type { SlashMenuConfig } from './types';
import { mergeSlashMenuConfigs } from './utils';
export class SlashMenuExtension extends Extension { export class SlashMenuExtension extends Extension {
config: SlashMenuConfig;
static override setup(di: Container) { static override setup(di: Container) {
WidgetViewExtension( WidgetViewExtension(
'affine:page', 'affine:page',
@@ -13,6 +22,37 @@ export class SlashMenuExtension extends Extension {
literal`${unsafeStatic(AFFINE_SLASH_MENU_WIDGET)}` literal`${unsafeStatic(AFFINE_SLASH_MENU_WIDGET)}`
).setup(di); ).setup(di);
di.add(this); di.add(this, [StdIdentifier]);
// TODO(@L-Sun): remove this after moving all configs to corresponding extensions
SlashMenuConfigExtension({
id: 'default',
config: defaultSlashMenuConfig,
}).setup(di);
}
constructor(readonly std: BlockStdScope) {
super();
this.config = mergeSlashMenuConfigs(
this.std.provider.getAll(SlashMenuConfigIdentifier)
);
} }
} }
const SlashMenuConfigIdentifier = createIdentifier<SlashMenuConfig>(
`${AFFINE_SLASH_MENU_WIDGET}-config`
);
export function SlashMenuConfigExtension({
id,
config,
}: {
id: string;
config: SlashMenuConfig;
}): ExtensionType {
return {
setup: di => {
di.addImpl(SlashMenuConfigIdentifier(id), config);
},
};
}
@@ -1,3 +1,5 @@
export { AFFINE_SLASH_MENU_WIDGET } from './consts';
// TODO(@L-Sun): narrow the scope of the exported symbols // TODO(@L-Sun): narrow the scope of the exported symbols
export * from './extensions'; export * from './extensions';
export * from './types';
export * from './widget'; export * from './widget';
@@ -20,30 +20,32 @@ import { html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js'; import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js'; import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import groupBy from 'lodash-es/groupBy';
import throttle from 'lodash-es/throttle'; import throttle from 'lodash-es/throttle';
import {
AFFINE_SLASH_MENU_MAX_HEIGHT,
AFFINE_SLASH_MENU_TOOLTIP_TIMEOUT,
AFFINE_SLASH_MENU_TRIGGER_KEY,
} from './consts.js';
import { slashItemToolTipStyle, styles } from './styles.js';
import type { import type {
SlashMenuActionItem, SlashMenuActionItem,
SlashMenuContext, SlashMenuContext,
SlashMenuGroupDivider,
SlashMenuItem, SlashMenuItem,
SlashMenuStaticConfig, SlashMenuSubMenu,
SlashMenuStaticItem, } from './types.js';
SlashSubMenu,
} from './config.js';
import { slashItemToolTipStyle, styles } from './styles.js';
import { import {
getFirstNotDividerItem,
isActionItem, isActionItem,
isGroupDivider,
isSubMenuItem, isSubMenuItem,
notGroupDivider, parseGroup,
slashItemClassName, slashItemClassName,
} from './utils.js'; } from './utils.js';
type InnerSlashMenuContext = SlashMenuContext & { type InnerSlashMenuContext = SlashMenuContext & {
tooltipTimeout: number;
onClickItem: (item: SlashMenuActionItem) => void; onClickItem: (item: SlashMenuActionItem) => void;
searching: boolean;
}; };
export class SlashMenu extends WithDisposable(LitElement) { export class SlashMenu extends WithDisposable(LitElement) {
@@ -56,19 +58,19 @@ export class SlashMenu extends WithDisposable(LitElement) {
cleanSpecifiedTail( cleanSpecifiedTail(
this.host, this.host,
this.context.model, this.context.model,
this.triggerKey + (this._query || '') AFFINE_SLASH_MENU_TRIGGER_KEY + (this._query || '')
); );
this.inlineEditor this.inlineEditor
.waitForUpdate() .waitForUpdate()
.then(() => { .then(() => {
item.action(this.context)?.catch(console.error); item.action(this.context);
this.abortController.abort(); this.abortController.abort();
}) })
.catch(console.error); .catch(console.error);
}; };
private readonly _initItemPathMap = () => { private readonly _initItemPathMap = () => {
const traverse = (item: SlashMenuStaticItem, path: number[]) => { const traverse = (item: SlashMenuItem, path: number[]) => {
this._itemPathMap.set(item, [...path]); this._itemPathMap.set(item, [...path]);
if (isSubMenuItem(item)) { if (isSubMenuItem(item)) {
item.subMenu.forEach((subItem, index) => item.subMenu.forEach((subItem, index) =>
@@ -77,7 +79,7 @@ export class SlashMenu extends WithDisposable(LitElement) {
} }
}; };
this.config.items.forEach((item, index) => traverse(item, [index])); this.items.forEach((item, index) => traverse(item, [index]));
}; };
private _innerSlashMenuContext!: InnerSlashMenuContext; private _innerSlashMenuContext!: InnerSlashMenuContext;
@@ -98,12 +100,13 @@ export class SlashMenu extends WithDisposable(LitElement) {
const searchStr = query.toLowerCase(); const searchStr = query.toLowerCase();
if (searchStr === '' || searchStr.endsWith(' ')) { if (searchStr === '' || searchStr.endsWith(' ')) {
this._queryState = searchStr === '' ? 'off' : 'no_result'; this._queryState = searchStr === '' ? 'off' : 'no_result';
this._innerSlashMenuContext.searching = false;
return; return;
} }
// Layer order traversal // Layer order traversal
let depth = 0; let depth = 0;
let queue = this.config.items.filter(notGroupDivider); let queue = this.items;
while (queue.length !== 0) { while (queue.length !== 0) {
// remove the sub menu item from the previous layer result // remove the sub menu item from the previous layer result
this._filteredItems = this._filteredItems.filter( this._filteredItems = this._filteredItems.filter(
@@ -111,8 +114,8 @@ export class SlashMenu extends WithDisposable(LitElement) {
); );
this._filteredItems = this._filteredItems.concat( this._filteredItems = this._filteredItems.concat(
queue.filter(({ name, alias = [] }) => queue.filter(({ name, searchAlias = [] }) =>
[name, ...alias].some(str => isFuzzyMatch(str, searchStr)) [name, ...searchAlias].some(str => isFuzzyMatch(str, searchStr))
) )
); );
@@ -122,7 +125,7 @@ export class SlashMenu extends WithDisposable(LitElement) {
queue = queue queue = queue
.map<typeof queue>(item => { .map<typeof queue>(item => {
if (isSubMenuItem(item)) { if (isSubMenuItem(item)) {
return item.subMenu.filter(notGroupDivider); return item.subMenu;
} else { } else {
return []; return [];
} }
@@ -132,7 +135,7 @@ export class SlashMenu extends WithDisposable(LitElement) {
depth++; depth++;
} }
this._filteredItems = this._filteredItems.sort((a, b) => { this._filteredItems.sort((a, b) => {
return -( return -(
substringMatchScore(a.name, searchStr) - substringMatchScore(a.name, searchStr) -
substringMatchScore(b.name, searchStr) substringMatchScore(b.name, searchStr)
@@ -140,6 +143,7 @@ export class SlashMenu extends WithDisposable(LitElement) {
}); });
this._queryState = this._filteredItems.length === 0 ? 'no_result' : 'on'; this._queryState = this._filteredItems.length === 0 ? 'no_result' : 'on';
this._innerSlashMenuContext.searching = true;
}; };
private get _query() { private get _query() {
@@ -163,7 +167,7 @@ export class SlashMenu extends WithDisposable(LitElement) {
this._innerSlashMenuContext = { this._innerSlashMenuContext = {
...this.context, ...this.context,
onClickItem: this._handleClickItem, onClickItem: this._handleClickItem,
tooltipTimeout: this.config.tooltipTimeout, searching: false,
}; };
this._initItemPathMap(); this._initItemPathMap();
@@ -194,7 +198,7 @@ export class SlashMenu extends WithDisposable(LitElement) {
signal: this.abortController.signal, signal: this.abortController.signal,
interceptor: (event, next) => { interceptor: (event, next) => {
const { key, isComposing, code } = event; const { key, isComposing, code } = event;
if (key === this.triggerKey) { if (key === AFFINE_SLASH_MENU_TRIGGER_KEY) {
// Can not stopPropagation here, // Can not stopPropagation here,
// otherwise the rich text will not be able to trigger a new the slash menu // otherwise the rich text will not be able to trigger a new the slash menu
return; return;
@@ -268,7 +272,7 @@ export class SlashMenu extends WithDisposable(LitElement) {
const slashMenuStyles = this._position const slashMenuStyles = this._position
? { ? {
transform: `translate(${this._position.x}, ${this._position.y})`, transform: `translate(${this._position.x}, ${this._position.y})`,
maxHeight: `${Math.min(this._position.height, this.config.maxHeight)}px`, maxHeight: `${Math.min(this._position.height, AFFINE_SLASH_MENU_MAX_HEIGHT)}px`,
} }
: { : {
visibility: 'hidden', visibility: 'hidden',
@@ -282,10 +286,7 @@ export class SlashMenu extends WithDisposable(LitElement) {
: nothing} : nothing}
<inner-slash-menu <inner-slash-menu
.context=${this._innerSlashMenuContext} .context=${this._innerSlashMenuContext}
.menu=${this._queryState === 'off' .menu=${this._queryState === 'off' ? this.items : this._filteredItems}
? this.config.items
: this._filteredItems}
.onClickItem=${this._handleClickItem}
.mainMenuStyle=${slashMenuStyles} .mainMenuStyle=${slashMenuStyles}
.abortController=${this.abortController} .abortController=${this.abortController}
> >
@@ -293,7 +294,8 @@ export class SlashMenu extends WithDisposable(LitElement) {
} }
@state() @state()
private accessor _filteredItems: (SlashMenuActionItem | SlashSubMenu)[] = []; private accessor _filteredItems: (SlashMenuActionItem | SlashMenuSubMenu)[] =
[];
@state() @state()
private accessor _position: { private accessor _position: {
@@ -303,13 +305,10 @@ export class SlashMenu extends WithDisposable(LitElement) {
} | null = null; } | null = null;
@property({ attribute: false }) @property({ attribute: false })
accessor config!: SlashMenuStaticConfig; accessor items!: SlashMenuItem[];
@property({ attribute: false }) @property({ attribute: false })
accessor context!: SlashMenuContext; accessor context!: SlashMenuContext;
@property({ attribute: false })
accessor triggerKey!: string;
} }
export class InnerSlashMenu extends WithDisposable(LitElement) { export class InnerSlashMenu extends WithDisposable(LitElement) {
@@ -321,9 +320,9 @@ export class InnerSlashMenu extends WithDisposable(LitElement) {
this._currentSubMenu = null; this._currentSubMenu = null;
}; };
private _currentSubMenu: SlashSubMenu | null = null; private _currentSubMenu: SlashMenuSubMenu | null = null;
private readonly _openSubMenu = (item: SlashSubMenu) => { private readonly _openSubMenu = (item: SlashMenuSubMenu) => {
if (item === this._currentSubMenu) return; if (item === this._currentSubMenu) return;
const itemElement = this.shadowRoot?.querySelector( const itemElement = this.shadowRoot?.querySelector(
@@ -366,7 +365,7 @@ export class InnerSlashMenu extends WithDisposable(LitElement) {
}; };
private readonly _renderActionItem = (item: SlashMenuActionItem) => { private readonly _renderActionItem = (item: SlashMenuActionItem) => {
const { name, icon, description, tooltip, customTemplate } = item; const { name, icon, description, tooltip } = item;
const hover = item === this._activeItem; const hover = item === this._activeItem;
@@ -374,7 +373,7 @@ export class InnerSlashMenu extends WithDisposable(LitElement) {
class="slash-menu-item ${slashItemClassName(item)}" class="slash-menu-item ${slashItemClassName(item)}"
width="100%" width="100%"
height="44px" height="44px"
text=${customTemplate ?? name} text=${name}
subText=${ifDefined(description)} subText=${ifDefined(description)}
data-testid="${name}" data-testid="${name}"
hover=${hover} hover=${hover}
@@ -391,7 +390,7 @@ export class InnerSlashMenu extends WithDisposable(LitElement) {
.offset=${22} .offset=${22}
.tooltipStyle=${slashItemToolTipStyle} .tooltipStyle=${slashItemToolTipStyle}
.hoverOptions=${{ .hoverOptions=${{
enterDelay: this.context.tooltipTimeout, enterDelay: AFFINE_SLASH_MENU_TOOLTIP_TIMEOUT,
allowMultiple: false, allowMultiple: false,
}} }}
> >
@@ -401,22 +400,26 @@ export class InnerSlashMenu extends WithDisposable(LitElement) {
</icon-button>`; </icon-button>`;
}; };
private readonly _renderGroupItem = (item: SlashMenuGroupDivider) => { private readonly _renderGroup = (
return html`<div class="slash-menu-group-name">${item.groupName}</div>`; groupName: string,
items: SlashMenuItem[]
) => {
return html`<div class="slash-menu-group">
${when(
!this.context.searching,
() => html`<div class="slash-menu-group-name">${groupName}</div>`
)}
${items.map(this._renderItem)}
</div>`;
}; };
private readonly _renderItem = (item: SlashMenuStaticItem) => { private readonly _renderItem = (item: SlashMenuItem) => {
if (isGroupDivider(item)) return this._renderGroupItem(item); if (isActionItem(item)) return this._renderActionItem(item);
else if (isActionItem(item)) return this._renderActionItem(item); if (isSubMenuItem(item)) return this._renderSubMenuItem(item);
else if (isSubMenuItem(item)) return this._renderSubMenuItem(item); return nothing;
else {
console.error('Unknown item type for slash menu');
console.error(item);
return nothing;
}
}; };
private readonly _renderSubMenuItem = (item: SlashSubMenu) => { private readonly _renderSubMenuItem = (item: SlashMenuSubMenu) => {
const { name, icon, description } = item; const { name, icon, description } = item;
const hover = item === this._activeItem; const hover = item === this._activeItem;
@@ -449,15 +452,13 @@ export class InnerSlashMenu extends WithDisposable(LitElement) {
private _subMenuAbortController: AbortController | null = null; private _subMenuAbortController: AbortController | null = null;
private _scrollToItem(item: SlashMenuStaticItem) { private _scrollToItem(item: SlashMenuItem) {
const shadowRoot = this.shadowRoot; const shadowRoot = this.shadowRoot;
if (!shadowRoot) { if (!shadowRoot) {
return; return;
} }
const text = isGroupDivider(item) ? item.groupName : item.name; const ele = shadowRoot.querySelector(`icon-button[text="${item.name}"]`);
const ele = shadowRoot.querySelector(`icon-button[text="${text}"]`);
if (!ele) { if (!ele) {
return; return;
} }
@@ -521,11 +522,9 @@ export class InnerSlashMenu extends WithDisposable(LitElement) {
} }
if (moveStep !== 0) { if (moveStep !== 0) {
let itemIndex = this.menu.indexOf(this._activeItem); const activeItemIndex = this.menu.indexOf(this._activeItem);
do { const itemIndex =
itemIndex = (activeItemIndex + moveStep + this.menu.length) % this.menu.length;
(itemIndex + moveStep + this.menu.length) % this.menu.length;
} while (isGroupDivider(this.menu[itemIndex]));
this._activeItem = this.menu[itemIndex] as typeof this._activeItem; this._activeItem = this.menu[itemIndex] as typeof this._activeItem;
this._scrollToItem(this._activeItem); this._scrollToItem(this._activeItem);
@@ -577,24 +576,24 @@ export class InnerSlashMenu extends WithDisposable(LitElement) {
const style = styleMap(this.mainMenuStyle ?? { position: 'relative' }); const style = styleMap(this.mainMenuStyle ?? { position: 'relative' });
const groups = groupBy(this.menu, ({ group }) =>
group && !this.context.searching ? parseGroup(group)[1] : ''
);
return html`<div return html`<div
class="slash-menu" class="slash-menu"
style=${style} style=${style}
data-testid=${`sub-menu-${this.depth}`} data-testid=${`sub-menu-${this.depth}`}
> >
${this.menu.map(this._renderItem)} ${Object.entries(groups).map(([groupName, items]) =>
this._renderGroup(groupName, items)
)}
</div>`; </div>`;
} }
override willUpdate(changedProperties: PropertyValues<this>) { override willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has('menu') && this.menu.length !== 0) { if (changedProperties.has('menu') && this.menu.length !== 0) {
const firstItem = getFirstNotDividerItem(this.menu); this._activeItem = this.menu[0];
if (!firstItem) {
console.error('No item found in slash menu');
return;
}
this._activeItem = firstItem;
// this case happen on query updated // this case happen on query updated
this._subMenuAbortController?.abort(); this._subMenuAbortController?.abort();
@@ -602,7 +601,7 @@ export class InnerSlashMenu extends WithDisposable(LitElement) {
} }
@state() @state()
private accessor _activeItem!: SlashMenuActionItem | SlashSubMenu; private accessor _activeItem!: SlashMenuActionItem | SlashMenuSubMenu;
@property({ attribute: false }) @property({ attribute: false })
accessor abortController!: AbortController; accessor abortController!: AbortController;
@@ -617,5 +616,5 @@ export class InnerSlashMenu extends WithDisposable(LitElement) {
accessor mainMenuStyle: Parameters<typeof styleMap>[0] | null = null; accessor mainMenuStyle: Parameters<typeof styleMap>[0] | null = null;
@property({ attribute: false }) @property({ attribute: false })
accessor menu!: SlashMenuStaticItem[]; accessor menu!: SlashMenuItem[];
} }
@@ -1,5 +1,4 @@
import type { TemplateResult } from 'lit'; import type { SlashMenuTooltip } from '../types';
import { AttachmentTooltip } from './attachment'; import { AttachmentTooltip } from './attachment';
import { BoldTextTooltip } from './bold-text'; import { BoldTextTooltip } from './bold-text';
import { BulletedListTooltip } from './bulleted-list'; import { BulletedListTooltip } from './bulleted-list';
@@ -40,11 +39,6 @@ import { UnderlineTooltip } from './underline';
import { YesterdayTooltip } from './yesterday'; import { YesterdayTooltip } from './yesterday';
import { YoutubeVideoTooltip } from './youtube-video'; import { YoutubeVideoTooltip } from './youtube-video';
export type SlashMenuTooltip = {
figure: TemplateResult;
caption: string;
};
export const slashMenuToolTips: Record<string, SlashMenuTooltip> = { export const slashMenuToolTips: Record<string, SlashMenuTooltip> = {
Text: { Text: {
figure: TextTooltip, figure: TextTooltip,
@@ -0,0 +1,60 @@
import type { BlockStdScope } from '@blocksuite/block-std';
import type { BlockModel } from '@blocksuite/store';
import type { TemplateResult } from 'lit';
export type SlashMenuContext = {
std: BlockStdScope;
model: BlockModel;
};
export type SlashMenuTooltip = {
figure: TemplateResult;
caption: string;
};
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}`;
// TODO(@L-Sun): move this field to SlashMenuActionItem when refactoring search
/**
* The alias of the menu item for search.
*/
searchAlias?: string[];
/**
* The condition to show the menu item.
*/
when?: (ctx: SlashMenuContext) => boolean;
};
export type SlashMenuActionItem = SlashMenuItemBase & {
action: (ctx: SlashMenuContext) => void;
tooltip?: SlashMenuTooltip;
};
export type SlashMenuSubMenu = SlashMenuItemBase & {
subMenu: SlashMenuItem[];
};
export type SlashMenuItem = SlashMenuActionItem | SlashMenuSubMenu;
export type SlashMenuConfig = {
// TODO(@L-Sun): change this type to SlashMenuItem[] and (ctx: SlashMenuContext) => SlashMenuItem[]
/**
* 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;
};
@@ -6,67 +6,72 @@ import {
} from '@blocksuite/affine-components/rich-text'; } from '@blocksuite/affine-components/rich-text';
import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils'; import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std'; import { BlockSelection } from '@blocksuite/block-std';
import { assertType } from '@blocksuite/global/utils';
import type { BlockModel } from '@blocksuite/store'; import type { BlockModel } from '@blocksuite/store';
import { slashMenuToolTips } from './tooltips/index.js';
import type { import type {
SlashMenuActionItem, SlashMenuActionItem,
SlashMenuConfig,
SlashMenuContext, SlashMenuContext,
SlashMenuGroupDivider,
SlashMenuItem, SlashMenuItem,
SlashMenuItemGenerator, SlashMenuSubMenu,
SlashMenuStaticItem, } from './types';
SlashSubMenu,
} from './config.js';
import { slashMenuToolTips } from './tooltips/index.js';
export function isGroupDivider( export function isActionItem(item: SlashMenuItem): item is SlashMenuActionItem {
item: SlashMenuStaticItem
): item is SlashMenuGroupDivider {
return 'groupName' in item;
}
export function notGroupDivider(
item: SlashMenuStaticItem
): item is Exclude<SlashMenuStaticItem, SlashMenuGroupDivider> {
return !isGroupDivider(item);
}
export function isActionItem(
item: SlashMenuStaticItem
): item is SlashMenuActionItem {
return 'action' in item; return 'action' in item;
} }
export function isSubMenuItem(item: SlashMenuStaticItem): item is SlashSubMenu { export function isSubMenuItem(item: SlashMenuItem): item is SlashMenuSubMenu {
return 'subMenu' in item; return 'subMenu' in item;
} }
export function isMenuItemGenerator( export function slashItemClassName({ name }: SlashMenuItem) {
item: SlashMenuItem
): item is SlashMenuItemGenerator {
return typeof item === 'function';
}
export function slashItemClassName(item: SlashMenuStaticItem) {
const name = isGroupDivider(item) ? item.groupName : item.name;
return name.split(' ').join('-').toLocaleLowerCase(); return name.split(' ').join('-').toLocaleLowerCase();
} }
export function filterEnabledSlashMenuItems( export function parseGroup(group: NonNullable<SlashMenuItem['group']>) {
return [
parseInt(group.split('_')[0]),
group.split('_')[1].split('@')[0],
parseInt(group.split('@')[1]),
] as const;
}
function itemCompareFn(a: SlashMenuItem, b: SlashMenuItem) {
if (a.group === undefined && b.group === undefined) return 0;
if (a.group === undefined) return -1;
if (b.group === undefined) return 1;
const [aGroupIndex, aGroupName, aItemIndex] = parseGroup(a.group);
const [bGroupIndex, bGroupName, bItemIndex] = parseGroup(b.group);
if (isNaN(aGroupIndex)) return -1;
if (isNaN(bGroupIndex)) return 1;
if (aGroupIndex < bGroupIndex) return -1;
if (aGroupIndex > bGroupIndex) return 1;
if (aGroupName !== bGroupName) return aGroupName.localeCompare(bGroupName);
if (isNaN(aItemIndex)) return -1;
if (isNaN(bItemIndex)) return 1;
return aItemIndex - bItemIndex;
}
export function buildSlashMenuItems(
items: SlashMenuItem[], items: SlashMenuItem[],
context: SlashMenuContext context: SlashMenuContext,
): SlashMenuStaticItem[] { transform?: (item: SlashMenuItem) => SlashMenuItem
): SlashMenuItem[] {
if (transform) items = items.map(transform);
const result = items const result = items
.map(item => (isMenuItemGenerator(item) ? item(context) : item)) .filter(item => (item.when ? item.when(context) : true))
.flat() .sort(itemCompareFn)
.filter(item => (item.showWhen ? item.showWhen(context) : true))
.map(item => { .map(item => {
if (isSubMenuItem(item)) { if (isSubMenuItem(item)) {
return { return {
...item, ...item,
subMenu: filterEnabledSlashMenuItems(item.subMenu, context), subMenu: buildSlashMenuItems(item.subMenu, context),
}; };
} else { } else {
return { ...item }; return { ...item };
@@ -75,14 +80,20 @@ export function filterEnabledSlashMenuItems(
return result; return result;
} }
export function getFirstNotDividerItem( export function mergeSlashMenuConfigs(
items: SlashMenuStaticItem[] configs: Map<string, SlashMenuConfig>
): SlashMenuActionItem | SlashSubMenu | null { ): SlashMenuConfig {
const firstItem = items.find(item => !isGroupDivider(item)); return {
assertType<SlashMenuActionItem | SlashSubMenu | undefined>(firstItem); items: Array.from(configs.values().flatMap(config => config.items)),
return firstItem ?? null; disableWhen: ctx =>
configs
.values()
.map(config => config.disableWhen?.(ctx) ?? false)
.some(Boolean),
};
} }
// TODO(@L-Sun): remove edgeless text check
export function insideEdgelessText(model: BlockModel) { export function insideEdgelessText(model: BlockModel) {
return isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text'); return isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text');
} }
@@ -94,15 +105,17 @@ export function tryRemoveEmptyLine(model: BlockModel) {
} }
export function createConversionItem( export function createConversionItem(
config: TextConversionConfig config: TextConversionConfig,
group?: SlashMenuItem['group']
): SlashMenuActionItem { ): SlashMenuActionItem {
const { name, description, icon, flavour, type } = config; const { name, description, icon, flavour, type } = config;
return { return {
name, name,
group,
description, description,
icon, icon,
tooltip: slashMenuToolTips[name], tooltip: slashMenuToolTips[name],
showWhen: ({ model }) => model.doc.schema.flavourSchemaMap.has(flavour), when: ({ model }) => model.doc.schema.flavourSchemaMap.has(flavour),
action: ({ std }) => { action: ({ std }) => {
std.command.exec(updateBlockType, { std.command.exec(updateBlockType, {
flavour, flavour,
@@ -113,12 +126,14 @@ export function createConversionItem(
} }
export function createTextFormatItem( export function createTextFormatItem(
config: TextFormatConfig config: TextFormatConfig,
group?: SlashMenuItem['group']
): SlashMenuActionItem { ): SlashMenuActionItem {
const { name, icon, id, action } = config; const { name, icon, id, action } = config;
return { return {
name, name,
icon, icon,
group,
tooltip: slashMenuToolTips[name], tooltip: slashMenuToolTips[name],
action: ({ std, model }) => { action: ({ std, model }) => {
const { host } = std; const { host } = std;
@@ -8,26 +8,11 @@ import { DisposableGroup } from '@blocksuite/global/slot';
import { InlineEditor } from '@blocksuite/inline'; import { InlineEditor } from '@blocksuite/inline';
import debounce from 'lodash-es/debounce'; import debounce from 'lodash-es/debounce';
import { import { AFFINE_SLASH_MENU_TRIGGER_KEY } from './consts';
defaultSlashMenuConfig, import { SlashMenuExtension } from './extensions';
type SlashMenuActionItem,
type SlashMenuContext,
type SlashMenuGroupDivider,
type SlashMenuItem,
type SlashMenuItemGenerator,
type SlashMenuStaticConfig,
type SlashSubMenu,
} from './config';
import { SlashMenu } from './slash-menu-popover'; import { SlashMenu } from './slash-menu-popover';
import { filterEnabledSlashMenuItems } from './utils'; import type { SlashMenuConfig, SlashMenuContext, SlashMenuItem } from './types';
import { buildSlashMenuItems } from './utils';
export type AffineSlashMenuContext = SlashMenuContext;
export type AffineSlashMenuItem = SlashMenuItem;
export type AffineSlashMenuActionItem = SlashMenuActionItem;
export type AffineSlashMenuItemGenerator = SlashMenuItemGenerator;
export type AffineSlashSubMenu = SlashSubMenu;
export type AffineSlashMenuGroupDivider = SlashMenuGroupDivider;
let globalAbortController = new AbortController(); let globalAbortController = new AbortController();
function closeSlashMenu() { function closeSlashMenu() {
@@ -37,16 +22,16 @@ function closeSlashMenu() {
const showSlashMenu = debounce( const showSlashMenu = debounce(
({ ({
context, context,
config,
container = document.body, container = document.body,
abortController = new AbortController(), abortController = new AbortController(),
config, configItemTransform,
triggerKey,
}: { }: {
context: SlashMenuContext; context: SlashMenuContext;
config: SlashMenuConfig;
container?: HTMLElement; container?: HTMLElement;
abortController?: AbortController; abortController?: AbortController;
config: SlashMenuStaticConfig; configItemTransform: (item: SlashMenuItem) => SlashMenuItem;
triggerKey: string;
}) => { }) => {
globalAbortController = abortController; globalAbortController = abortController;
const disposables = new DisposableGroup(); const disposables = new DisposableGroup();
@@ -62,8 +47,18 @@ const showSlashMenu = debounce(
const slashMenu = new SlashMenu(inlineEditor, abortController); const slashMenu = new SlashMenu(inlineEditor, abortController);
disposables.add(() => slashMenu.remove()); disposables.add(() => slashMenu.remove());
slashMenu.context = context; slashMenu.context = context;
slashMenu.config = config; slashMenu.items = buildSlashMenuItems(
slashMenu.triggerKey = triggerKey; config.items
.map(item => {
if (typeof item === 'function') {
return item(context);
}
return item;
})
.flat(),
context,
configItemTransform
);
// FIXME(Flrande): It is not a best practice, // FIXME(Flrande): It is not a best practice,
// but merely a temporary measure for reusing previous components. // but merely a temporary measure for reusing previous components.
@@ -75,11 +70,7 @@ const showSlashMenu = debounce(
{ leading: true } { leading: true }
); );
export const AFFINE_SLASH_MENU_WIDGET = 'affine-slash-menu-widget';
export class AffineSlashMenuWidget extends WidgetComponent { export class AffineSlashMenuWidget extends WidgetComponent {
static DEFAULT_CONFIG = defaultSlashMenuConfig;
private readonly _getInlineEditor = ( private readonly _getInlineEditor = (
evt: KeyboardEvent | CompositionEvent evt: KeyboardEvent | CompositionEvent
) => { ) => {
@@ -126,9 +117,7 @@ export class AffineSlashMenuWidget extends WidgetComponent {
if (!block) return; if (!block) return;
const model = block.model; const model = block.model;
if (block.closest(this.config.ignoreSelector)) return; if (this.config.disableWhen?.({ model, std: this.std })) return;
if (this.config.ignoreBlockTypes.includes(block.flavour)) return;
const inlineRange = inlineEditor.getInlineRange(); const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return; if (!inlineRange) return;
@@ -142,18 +131,7 @@ export class AffineSlashMenuWidget extends WidgetComponent {
? leafStart.textContent.slice(0, offsetStart) ? leafStart.textContent.slice(0, offsetStart)
: ''; : '';
const matchedKey = this.config.triggerKeys.find(triggerKey => if (!text.endsWith(AFFINE_SLASH_MENU_TRIGGER_KEY)) return;
text.endsWith(triggerKey)
);
if (!matchedKey) return;
const config: SlashMenuStaticConfig = {
...this.config,
items: filterEnabledSlashMenuItems(this.config.items, {
model,
std: this.std,
}),
};
closeSlashMenu(); closeSlashMenu();
showSlashMenu({ showSlashMenu({
@@ -161,8 +139,8 @@ export class AffineSlashMenuWidget extends WidgetComponent {
model, model,
std: this.std, std: this.std,
}, },
triggerKey: matchedKey, config: this.config,
config, configItemTransform: this.configItemTransform,
}); });
}); });
}; };
@@ -170,12 +148,7 @@ export class AffineSlashMenuWidget extends WidgetComponent {
private readonly _onCompositionEnd = (ctx: UIEventStateContext) => { private readonly _onCompositionEnd = (ctx: UIEventStateContext) => {
const event = ctx.get('defaultState').event as CompositionEvent; const event = ctx.get('defaultState').event as CompositionEvent;
if ( if (event.data !== AFFINE_SLASH_MENU_TRIGGER_KEY) return;
!this.config.triggerKeys.some(triggerKey =>
triggerKey.includes(event.data)
)
)
return;
const inlineEditor = this._getInlineEditor(event); const inlineEditor = this._getInlineEditor(event);
if (!inlineEditor) return; if (!inlineEditor) return;
@@ -189,16 +162,7 @@ export class AffineSlashMenuWidget extends WidgetComponent {
const key = event.key; const key = event.key;
// check event is not composing if (event.isComposing || key !== AFFINE_SLASH_MENU_TRIGGER_KEY) return;
if (
key === undefined || // in mac os, the key may be undefined
key === 'Process' ||
event.isComposing
)
return;
if (!this.config.triggerKeys.some(triggerKey => triggerKey.includes(key)))
return;
const inlineEditor = this._getInlineEditor(event); const inlineEditor = this._getInlineEditor(event);
if (!inlineEditor) return; if (!inlineEditor) return;
@@ -206,16 +170,18 @@ export class AffineSlashMenuWidget extends WidgetComponent {
this._handleInput(inlineEditor, false); this._handleInput(inlineEditor, false);
}; };
config = AffineSlashMenuWidget.DEFAULT_CONFIG; get config() {
return this.std.get(SlashMenuExtension).config;
}
// TODO(@L-Sun): Remove this when moving each config item to corresponding blocks
// This is a temporary way for patching the slash menu config
configItemTransform: (item: SlashMenuItem) => SlashMenuItem = item => item;
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
if (this.config.triggerKeys.some(key => key.length === 0)) { // this.handleEvent('beforeInput', this._onBeforeInput);
console.error('Trigger key of slash menu should not be empty string');
return;
}
this.handleEvent('keyDown', this._onKeyDown); this.handleEvent('keyDown', this._onKeyDown);
this.handleEvent('compositionEnd', this._onCompositionEnd); this.handleEvent('compositionEnd', this._onCompositionEnd);
} }
+24 -31
View File
@@ -1,3 +1,4 @@
import type { SlashMenuActionItem } from '@blocksuite/blocks';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import { addNote, switchEditorMode } from './utils/actions/edgeless.js'; import { addNote, switchEditorMode } from './utils/actions/edgeless.js';
@@ -387,6 +388,7 @@ test.describe('slash menu should show and hide correctly', () => {
const slashItems = slashMenu.locator('icon-button'); const slashItems = slashMenu.locator('icon-button');
await type(page, '/'); await type(page, '/');
await slashMenu.waitFor({ state: 'visible' });
await pressArrowDown(page, 4); await pressArrowDown(page, 4);
await expect(slashItems.nth(4)).toHaveAttribute('hover', 'true'); await expect(slashItems.nth(4)).toHaveAttribute('hover', 'true');
await expect(slashItems.nth(4).locator('.text')).toHaveText([ await expect(slashItems.nth(4).locator('.text')).toHaveText([
@@ -760,6 +762,7 @@ test('should insert database', async ({ page }) => {
expect(await defaultRows.count()).toBe(3); expect(await defaultRows.count()).toBe(3);
}); });
// TODO(@L-Sun): Refactor this test after refactoring the slash menu
test.describe('slash menu with customize menu', () => { test.describe('slash menu with customize menu', () => {
test('can remove specified menus', async ({ page }) => { test('can remove specified menus', async ({ page }) => {
await enterPlaygroundRoom(page); await enterPlaygroundRoom(page);
@@ -777,15 +780,17 @@ test.describe('slash menu with customize menu', () => {
const SlashMenuWidget = window.$blocksuite.blocks.AffineSlashMenuWidget; const SlashMenuWidget = window.$blocksuite.blocks.AffineSlashMenuWidget;
class CustomSlashMenu extends SlashMenuWidget { class CustomSlashMenu extends SlashMenuWidget {
override config = { override get config() {
...SlashMenuWidget.DEFAULT_CONFIG, return {
items: [ items: super.config.items
{ groupName: 'custom-group' },
...SlashMenuWidget.DEFAULT_CONFIG.items
.filter(item => 'action' in item) .filter(item => 'action' in item)
.slice(0, 5), .slice(0, 5)
], .map<SlashMenuActionItem>((item, index) => ({
}; ...item,
group: `0_custom-group@${index++}`,
})),
};
}
} }
// Fix `Illegal constructor` error // Fix `Illegal constructor` error
// see https://stackoverflow.com/questions/41521812/illegal-constructor-with-ecmascript-6 // see https://stackoverflow.com/questions/41521812/illegal-constructor-with-ecmascript-6
@@ -836,29 +841,17 @@ test.describe('slash menu with customize menu', () => {
const SlashMenuWidget = window.$blocksuite.blocks.AffineSlashMenuWidget; const SlashMenuWidget = window.$blocksuite.blocks.AffineSlashMenuWidget;
class CustomSlashMenu extends SlashMenuWidget { class CustomSlashMenu extends SlashMenuWidget {
override config = { override get config() {
...SlashMenuWidget.DEFAULT_CONFIG, return {
items: [ items: [
{ groupName: 'Custom Menu' }, {
{ name: 'Custom Menu Item',
name: 'Custom Menu Item', group: '0_custom-group@0',
// oxlint-disable-next-line @typescript-eslint/no-explicit-any action: () => {},
icon: '' as any, } satisfies SlashMenuActionItem,
action: () => { ],
// do nothing };
}, }
},
{
name: 'Custom Menu Item',
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
icon: '' as any,
action: () => {
// do nothing
},
showWhen: () => false,
},
],
};
} }
// Fix `Illegal constructor` error // Fix `Illegal constructor` error
// see https://stackoverflow.com/questions/41521812/illegal-constructor-with-ecmascript-6 // see https://stackoverflow.com/questions/41521812/illegal-constructor-with-ecmascript-6
@@ -1,12 +1,13 @@
import { import {
type AffineSlashMenuActionItem,
type AffineSlashMenuContext,
type AffineSlashMenuItem,
AffineSlashMenuWidget,
type AffineSlashSubMenu,
AIStarIcon, AIStarIcon,
DocModeProvider, DocModeProvider,
type SlashMenuActionItem,
type SlashMenuContext,
SlashMenuExtension,
type SlashMenuItem,
type SlashMenuSubMenu,
} from '@blocksuite/affine/blocks'; } from '@blocksuite/affine/blocks';
import type { BlockStdScope } from '@blocksuite/block-std';
import { MoreHorizontalIcon } from '@blocksuite/icons/lit'; import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
import { html } from 'lit'; import { html } from 'lit';
@@ -18,7 +19,9 @@ import {
type AffineAIPanelWidget, type AffineAIPanelWidget,
} from '../../widgets/ai-panel/ai-panel'; } 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 AIItems = pageAIGroups.map(group => group.items).flat();
const iconWrapper = (icon: AIItemConfig['icon']) => { const iconWrapper = (icon: AIItemConfig['icon']) => {
@@ -29,7 +32,7 @@ export function setupSlashMenuAIEntry(slashMenu: AffineSlashMenuWidget) {
const showWhenWrapper = const showWhenWrapper =
(item?: AIItemConfig) => (item?: AIItemConfig) =>
({ std }: AffineSlashMenuContext) => { ({ std }: SlashMenuContext) => {
const root = std.host.doc.root; const root = std.host.doc.root;
if (!root) return false; if (!root) return false;
const affineAIPanelWidget = std.view.getWidget( const affineAIPanelWidget = std.view.getWidget(
@@ -45,19 +48,17 @@ export function setupSlashMenuAIEntry(slashMenu: AffineSlashMenuWidget) {
return item?.showWhen?.(chain, editorMode, std.host) ?? true; return item?.showWhen?.(chain, editorMode, std.host) ?? true;
}; };
const actionItemWrapper = ( const actionItemWrapper = (item: AIItemConfig): SlashMenuActionItem => ({
item: AIItemConfig
): AffineSlashMenuActionItem => ({
...basicItemConfig(item), ...basicItemConfig(item),
action: ({ std }: AffineSlashMenuContext) => { action: ({ std }: SlashMenuContext) => {
item?.handler?.(std.host); item?.handler?.(std.host);
}, },
}); });
const subMenuWrapper = (item: AIItemConfig): AffineSlashSubMenu => { const subMenuWrapper = (item: AIItemConfig): SlashMenuSubMenu => {
return { return {
...basicItemConfig(item), ...basicItemConfig(item),
subMenu: (item.subItem ?? []).map<AffineSlashMenuActionItem>( subMenu: (item.subItem ?? []).map<SlashMenuActionItem>(
({ type, handler }) => ({ ({ type, handler }) => ({
name: type, name: type,
action: ({ std }) => handler?.(std.host), action: ({ std }) => handler?.(std.host),
@@ -70,45 +71,47 @@ export function setupSlashMenuAIEntry(slashMenu: AffineSlashMenuWidget) {
return { return {
name: item.name, name: item.name,
icon: iconWrapper(item.icon), icon: iconWrapper(item.icon),
alias: ['ai'], searchAlias: ['ai'],
showWhen: showWhenWrapper(item), when: showWhenWrapper(item),
}; };
}; };
const menu = slashMenu.config.items.slice(); let index = 0;
menu.unshift({ const AIMenuItems: SlashMenuItem[] = [
name: 'Ask AI', {
icon: AIStarIcon, name: 'Ask AI',
showWhen: showWhenWrapper(), icon: AIStarIcon,
action: ({ std }) => { when: showWhenWrapper(),
const root = std.host.doc.root; action: ({ std }) => {
if (!root) return; const root = std.host.doc.root;
const affineAIPanelWidget = std.view.getWidget( if (!root) return;
AFFINE_AI_PANEL_WIDGET, const affineAIPanelWidget = std.view.getWidget(
root.id AFFINE_AI_PANEL_WIDGET,
) as AffineAIPanelWidget; root.id
handleInlineAskAIAction(affineAIPanelWidget.host); ) as AffineAIPanelWidget;
handleInlineAskAIAction(affineAIPanelWidget.host);
},
}, },
});
const AIMenuItems: AffineSlashMenuItem[] = [
{ groupName: 'AFFiNE AI' },
...AIItems.filter(({ name }) => ...AIItems.filter(({ name }) =>
['Fix spelling', 'Fix grammar'].includes(name) ['Fix spelling', 'Fix grammar'].includes(name)
).map(item => ({ ).map<SlashMenuActionItem>(item => ({
...actionItemWrapper(item), ...actionItemWrapper(item),
name: `${item.name} from above`, name: `${item.name} from above`,
group: `1_AFFiNE AI@${index++}`,
})), })),
...AIItems.filter(({ name }) => ...AIItems.filter(({ name }) =>
['Summarize', 'Continue writing'].includes(name) ['Summarize', 'Continue writing'].includes(name)
).map(actionItemWrapper), ).map<SlashMenuActionItem>(item => ({
...actionItemWrapper(item),
group: `1_AFFiNE AI@${index++}`,
})),
{ {
name: 'Action with above', name: 'Action with above',
icon: iconWrapper(MoreHorizontalIcon({ width: '24px', height: '24px' })), icon: iconWrapper(MoreHorizontalIcon({ width: '24px', height: '24px' })),
group: `1_AFFiNE AI@${index++}`,
subMenu: [ subMenu: [
{ groupName: 'Action with above' },
...AIItems.filter(({ name }) => ...AIItems.filter(({ name }) =>
['Translate to', 'Change tone to'].includes(name) ['Translate to', 'Change tone to'].includes(name)
).map(subMenuWrapper), ).map(subMenuWrapper),
@@ -126,14 +129,8 @@ export function setupSlashMenuAIEntry(slashMenu: AffineSlashMenuWidget) {
}, },
]; ];
const basicGroupEnd = menu.findIndex( slashMenuExtension.config = {
item => 'groupName' in item && item.groupName === 'List' ...slashMenuExtension.config,
); items: [...AIMenuItems, ...slashMenuExtension.config.items],
// insert ai item after basic group
menu.splice(basicGroupEnd, 0, ...AIMenuItems);
slashMenu.config = {
...AffineSlashMenuWidget.DEFAULT_CONFIG,
items: menu,
}; };
} }
@@ -72,7 +72,7 @@ function getAIEdgelessRootWatcher(framework: FrameworkProvider) {
} }
if (component instanceof AffineSlashMenuWidget) { if (component instanceof AffineSlashMenuWidget) {
setupSlashMenuAIEntry(component); setupSlashMenuAIEntry(this.std);
} }
}); });
} }
@@ -38,7 +38,7 @@ function getAIPageRootWatcher(framework: FrameworkProvider) {
} }
if (component instanceof AffineSlashMenuWidget) { if (component instanceof AffineSlashMenuWidget) {
setupSlashMenuAIEntry(component); setupSlashMenuAIEntry(this.std);
} }
}); });
} }
@@ -132,12 +132,12 @@ export function patchQuickSearchService(framework: FrameworkProvider) {
} }
const component = payload.view; const component = payload.view;
if (component instanceof AffineSlashMenuWidget) { if (component instanceof AffineSlashMenuWidget) {
component.config.items.forEach(item => { component.configItemTransform = item => {
if ( if (
'action' in item && 'action' in item &&
(item.name === 'Linked Doc' || item.name === 'Link') (item.name === 'Linked Doc' || item.name === 'Link')
) { ) {
item.action = async ({ std }) => { item.action = ({ std }) => {
const [success, { insertedLinkType }] = std.command.exec( const [success, { insertedLinkType }] = std.command.exec(
insertLinkByQuickSearchCommand insertLinkByQuickSearchCommand
); );
@@ -164,7 +164,8 @@ export function patchQuickSearchService(framework: FrameworkProvider) {
.catch(console.error); .catch(console.error);
}; };
} }
}); return item;
};
} }
}); });
} }