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

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

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

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

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

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

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

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

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

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

export type SlashMenuItem = SlashMenuActionItem | SlashMenuSubMenu;

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

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

// extensions.ts

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

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

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

View File

@@ -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<SlashMenuConfig, 'items'> & {
items: SlashMenuStaticItem[];
};
export type SlashMenuItem = SlashMenuStaticItem | SlashMenuItemGenerator;
export type SlashMenuStaticItem =
| SlashMenuGroupDivider
| SlashMenuActionItem
| SlashSubMenu;
export type SlashMenuGroupDivider = {
groupName: string;
showWhen?: (ctx: SlashMenuContext) => boolean;
};
export type SlashMenuActionItem = {
name: string;
description?: string;
icon?: TemplateResult;
tooltip?: SlashMenuTooltip;
alias?: string[];
showWhen?: (ctx: SlashMenuContext) => boolean;
action: (ctx: SlashMenuContext) => void | Promise<void>;
customTemplate?: TemplateResult<1>;
};
export type SlashSubMenu = {
name: string;
description?: string;
icon?: TemplateResult;
alias?: string[];
showWhen?: (ctx: SlashMenuContext) => boolean;
subMenu: SlashMenuStaticItem[];
};
export type SlashMenuItemGenerator = (
ctx: SlashMenuContext
) => (SlashMenuGroupDivider | SlashMenuActionItem | SlashSubMenu)[];
export type SlashMenuContext = {
std: BlockStdScope;
model: BlockModel;
};
// 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<SlashMenuActionItem>(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<SlashMenuActionItem>(createConversionItem),
.map(config => createConversionItem(config, `0_Basic@${index++}`)),
...textConversionConfigs
.filter(i => i.type && ['divider', 'quote'].includes(i.type))
.map<SlashMenuActionItem>(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<SlashMenuActionItem>(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<SlashMenuActionItem>(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<SlashMenuActionItem>(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);
},

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<SlashMenuConfig>(
`${AFFINE_SLASH_MENU_WIDGET}-config`
);
export function SlashMenuConfigExtension({
id,
config,
}: {
id: string;
config: SlashMenuConfig;
}): ExtensionType {
return {
setup: di => {
di.addImpl(SlashMenuConfigIdentifier(id), config);
},
};
}

View File

@@ -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';

View File

@@ -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<typeof queue>(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}
<inner-slash-menu
.context=${this._innerSlashMenuContext}
.menu=${this._queryState === 'off'
? this.config.items
: this._filteredItems}
.onClickItem=${this._handleClickItem}
.menu=${this._queryState === 'off' ? this.items : this._filteredItems}
.mainMenuStyle=${slashMenuStyles}
.abortController=${this.abortController}
>
@@ -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) {
</icon-button>`;
};
private readonly _renderGroupItem = (item: SlashMenuGroupDivider) => {
return html`<div class="slash-menu-group-name">${item.groupName}</div>`;
private readonly _renderGroup = (
groupName: string,
items: SlashMenuItem[]
) => {
return html`<div class="slash-menu-group">
${when(
!this.context.searching,
() => html`<div class="slash-menu-group-name">${groupName}</div>`
)}
${items.map(this._renderItem)}
</div>`;
};
private readonly _renderItem = (item: SlashMenuStaticItem) => {
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`<div
class="slash-menu"
style=${style}
data-testid=${`sub-menu-${this.depth}`}
>
${this.menu.map(this._renderItem)}
${Object.entries(groups).map(([groupName, items]) =>
this._renderGroup(groupName, items)
)}
</div>`;
}
override willUpdate(changedProperties: PropertyValues<this>) {
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<typeof styleMap>[0] | null = null;
@property({ attribute: false })
accessor menu!: SlashMenuStaticItem[];
accessor menu!: SlashMenuItem[];
}

View File

@@ -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<string, SlashMenuTooltip> = {
Text: {
figure: TextTooltip,

View File

@@ -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;
};

View File

@@ -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<SlashMenuStaticItem, SlashMenuGroupDivider> {
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<SlashMenuItem['group']>) {
return [
parseInt(group.split('_')[0]),
group.split('_')[1].split('@')[0],
parseInt(group.split('@')[1]),
] as const;
}
function itemCompareFn(a: SlashMenuItem, b: SlashMenuItem) {
if (a.group === undefined && b.group === undefined) return 0;
if (a.group === undefined) return -1;
if (b.group === undefined) return 1;
const [aGroupIndex, aGroupName, aItemIndex] = parseGroup(a.group);
const [bGroupIndex, bGroupName, bItemIndex] = parseGroup(b.group);
if (isNaN(aGroupIndex)) return -1;
if (isNaN(bGroupIndex)) return 1;
if (aGroupIndex < bGroupIndex) return -1;
if (aGroupIndex > bGroupIndex) return 1;
if (aGroupName !== bGroupName) return aGroupName.localeCompare(bGroupName);
if (isNaN(aItemIndex)) return -1;
if (isNaN(bItemIndex)) return 1;
return aItemIndex - bItemIndex;
}
export function buildSlashMenuItems(
items: SlashMenuItem[],
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<SlashMenuActionItem | SlashSubMenu | undefined>(firstItem);
return firstItem ?? null;
export function mergeSlashMenuConfigs(
configs: Map<string, SlashMenuConfig>
): 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;

View File

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