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; + }; } }); }