feat(editor): 斜杠命令支持英文别名搜索(中英文互通)

每条命令补 keywords 字段(空格分隔英文/常见词),搜索时 title/description/
keywords 任一命中即匹配。/code 现在能命中「代码块」,/link 命中「链接」。

提取 matchCommand 纯函数(原 inline filter),补 30 个用例覆盖中英文命中、
大小写不敏感、keywords 缺省回退、不命中场景。
This commit is contained in:
xfy 2026-07-07 10:15:44 +08:00
parent d8036c6e70
commit 9a90514470
2 changed files with 117 additions and 6 deletions

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { isValidUrl } from '../slash-command';
import { isValidUrl, matchCommand } from '../slash-command';
/**
* isValidUrl
@ -54,3 +54,82 @@ describe('isValidUrl', () => {
});
});
});
/**
* matchCommand
*
* title/description keywords
*/
describe('matchCommand', () => {
/** 构造最小命令项(只含匹配需要的字段)。 */
const mk = (title: string, description: string, keywords?: string) =>
({ title, description, keywords, icon: '', command: () => {} }) as Parameters<
typeof matchCommand
>[0];
describe('中文标题命中', () => {
it.each([
['代码块', '插入代码块', 'code codeblock pre 代码', '代码', 'title 含中文'],
['标题 1', '大标题', 'h1 heading 标题', '标题', '标题'],
['链接', '插入链接', 'link url a href 链接', '链接', '链接'],
])('%s 搜 %s', (title, desc, keywords, query) => {
expect(matchCommand(mk(title, desc, keywords), query)).toBe(true);
});
});
describe('英文 keywords 别名命中(核心:中英文互通)', () => {
it.each([
['代码块', '插入代码块', 'code codeblock pre 代码', 'code', '/code 命中「代码块」'],
['代码块', '插入代码块', 'code codeblock pre 代码', 'pre', '/pre 命中'],
[
'可运行代码块',
'插入可被读者执行的代码块',
'code run runnable execute 代码 运行',
'run',
'/run 命中',
],
['标题 1', '大标题', 'h1 heading 标题', 'h1', '/h1 命中'],
['标题 1', '大标题', 'h1 heading 标题', 'heading', '/heading 命中'],
['无序列表', '创建无序列表', 'bullet list ul 列表', 'bullet', '/bullet 命中'],
['无序列表', '创建无序列表', 'bullet list ul 列表', 'ul', '/ul 命中'],
['任务列表', '创建任务列表', 'task todo checklist 列表', 'todo', '/todo 命中'],
['引用', '插入引用块', 'quote blockquote 引用', 'quote', '/quote 命中'],
['分割线', '插入水平分割线', 'hr rule divider 分割', 'hr', '/hr 命中'],
['表格', '插入 3×3 表格', 'table 表格', 'table', '/table 命中'],
['链接', '插入链接', 'link url a href 链接', 'link', '/link 命中'],
['链接', '插入链接', 'link url a href 链接', 'href', '/href 命中'],
])('「%s」搜 %s (%s)', (title, desc, keywords, query) => {
expect(matchCommand(mk(title, desc, keywords ?? undefined), query)).toBe(true);
});
});
describe('不命中', () => {
it.each<[string, string, string | undefined, string, string]>([
['代码块', '插入代码块', 'code codeblock 代码', 'image', '无关词'],
['链接', '插入链接', 'link url', 'code', '跨命令误命中'],
['', '', undefined, 'anything', '空命令'],
])('「%s」搜 %s (%s)', (title, desc, keywords, query) => {
expect(matchCommand(mk(title, desc, keywords), query)).toBe(false);
});
});
describe('大小写不敏感', () => {
it.each([
['CODE', '大写 query'],
['Code', '混合大小写'],
['code', '小写'],
])('搜 %s 都命中「代码块」(%s)', (query) => {
expect(matchCommand(mk('代码块', '插入代码块', 'code codeblock'), query)).toBe(true);
});
});
describe('无 keywords 字段时仍按 title/description 匹配', () => {
it('keywords 缺省,搜 title 中文仍命中', () => {
expect(matchCommand(mk('引用', '插入引用块'), '引用')).toBe(true);
});
it('keywords 缺省,搜英文不命中(回归:不会因缺字段报错)', () => {
expect(matchCommand(mk('引用', '插入引用块'), 'quote')).toBe(false);
});
});
});

View File

@ -7,6 +7,28 @@ interface CommandItem {
description: string;
icon: string;
command: (props: { editor: Editor; range: Range }) => void;
/**
*
* keywords='code codeblock' /code /
* title/description
*/
keywords?: string;
}
/**
*
*
* title / description / keywords keywords
* /`/code` `/代码`
* 便
*/
export function matchCommand(item: CommandItem, query: string): boolean {
const q = query.toLowerCase();
return (
item.title.toLowerCase().includes(q) ||
item.description.toLowerCase().includes(q) ||
(item.keywords?.toLowerCase().includes(q) ?? false)
);
}
/**
@ -47,6 +69,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '标题 1',
description: '大标题',
icon: 'H1',
keywords: 'h1 heading 标题',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run();
},
@ -55,6 +78,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '标题 2',
description: '中标题',
icon: 'H2',
keywords: 'h2 heading 标题',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run();
},
@ -63,6 +87,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '标题 3',
description: '小标题',
icon: 'H3',
keywords: 'h3 heading 标题',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run();
},
@ -71,6 +96,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '无序列表',
description: '创建无序列表',
icon: '•',
keywords: 'bullet list ul 列表',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
@ -79,6 +105,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '有序列表',
description: '创建有序列表',
icon: '1.',
keywords: 'ordered ol number list 列表',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
@ -87,6 +114,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '任务列表',
description: '创建任务列表',
icon: '☑',
keywords: 'task todo checklist 列表',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
@ -95,6 +123,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '引用',
description: '插入引用块',
icon: '❝',
keywords: 'quote blockquote 引用',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
},
@ -103,6 +132,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '代码块',
description: '插入代码块',
icon: '<>',
keywords: 'code codeblock pre 代码',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
@ -111,6 +141,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '可运行代码块',
description: '插入可被读者执行的代码块',
icon: '▶',
keywords: 'code run runnable execute 代码 运行',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
openRunnableModal(editor);
@ -120,6 +151,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '分割线',
description: '插入水平分割线',
icon: '—',
keywords: 'hr rule divider 分割',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
@ -128,6 +160,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '表格',
description: '插入 3×3 表格',
icon: '▦',
keywords: 'table 表格',
command: ({ editor, range }) => {
editor
.chain()
@ -145,6 +178,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '上传图片',
description: '从本地选择并上传图片',
icon: '📤',
keywords: 'image upload 图片',
command: ({ editor, range }) => {
// 必须先删掉 /命令 文本,文件选择对话框会阻塞,关闭后 range 可能失效。
editor.chain().focus().deleteRange(range).run();
@ -179,6 +213,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '图片链接',
description: '通过 URL 插入图片',
icon: '🖼',
keywords: 'image url 图片',
command: ({ editor, range }) => {
const url = window.prompt('输入图片 URL');
if (url && isValidUrl(url)) {
@ -190,6 +225,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
title: '链接',
description: '插入链接',
icon: '🔗',
keywords: 'link url a href 链接',
command: ({ editor, range }) => {
const url = window.prompt('输入链接 URL');
if (!url || !isValidUrl(url)) return;
@ -214,11 +250,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
editor: this.editor,
char: '/',
items: ({ query }) => {
return COMMANDS.filter(
(item) =>
item.title.toLowerCase().includes(query.toLowerCase()) ||
item.description.toLowerCase().includes(query.toLowerCase()),
);
return COMMANDS.filter((item) => matchCommand(item, query));
},
render() {
let popup: SlashPopup | null = null;