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