yggdrasil/libs/tiptap-editor/src/slash-command.ts
xfy 9a90514470 feat(editor): 斜杠命令支持英文别名搜索(中英文互通)
每条命令补 keywords 字段(空格分隔英文/常见词),搜索时 title/description/
keywords 任一命中即匹配。/code 现在能命中「代码块」,/link 命中「链接」。

提取 matchCommand 纯函数(原 inline filter),补 30 个用例覆盖中英文命中、
大小写不敏感、keywords 缺省回退、不命中场景。
2026-07-07 10:15:44 +08:00

634 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { type Editor, Extension, type Range } from '@tiptap/core';
import { PluginKey } from '@tiptap/pm/state';
import { Suggestion, type SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion';
interface CommandItem {
title: string;
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)
);
}
/**
* 斜杠命令扩展的选项。
*
* `onImageUpload` 由宿主注入(参见 index.ts用于把用户选择的图片文件
* 上传到服务端并返回可访问的 URL。未提供时"上传图片"命令会被隐藏,
* 只保留"图片链接"(手动填 URL
*/
export interface SlashCommandOptions {
onImageUpload?: (file: File) => Promise<string>;
/** 由 index.ts 注入:直接调 coordinator.insertUploading走占位符 + 上传)。 */
onInsertUploading?: (file: File) => void;
}
const SlashCommandPluginKey = new PluginKey('slashCommand');
/**
* 斜杠命令扩展。
*
* `onImageUpload` 通过 `addOptions` 注入,"上传图片"命令据此决定是否出现。
*/
export const SlashCommand = Extension.create<SlashCommandOptions>({
name: 'slashCommand',
addOptions() {
return {
onImageUpload: undefined,
onInsertUploading: undefined,
};
},
addProseMirrorPlugins() {
// 依据是否提供上传回调,决定可用命令集。
const uploadFn = this.options.onImageUpload;
const COMMANDS: CommandItem[] = [
{
title: '标题 1',
description: '大标题',
icon: 'H1',
keywords: 'h1 heading 标题',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run();
},
},
{
title: '标题 2',
description: '中标题',
icon: 'H2',
keywords: 'h2 heading 标题',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run();
},
},
{
title: '标题 3',
description: '小标题',
icon: 'H3',
keywords: 'h3 heading 标题',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run();
},
},
{
title: '无序列表',
description: '创建无序列表',
icon: '•',
keywords: 'bullet list ul 列表',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: '有序列表',
description: '创建有序列表',
icon: '1.',
keywords: 'ordered ol number list 列表',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: '任务列表',
description: '创建任务列表',
icon: '☑',
keywords: 'task todo checklist 列表',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: '引用',
description: '插入引用块',
icon: '❝',
keywords: 'quote blockquote 引用',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
},
},
{
title: '代码块',
description: '插入代码块',
icon: '<>',
keywords: 'code codeblock pre 代码',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
},
},
{
title: '可运行代码块',
description: '插入可被读者执行的代码块',
icon: '▶',
keywords: 'code run runnable execute 代码 运行',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
openRunnableModal(editor);
},
},
{
title: '分割线',
description: '插入水平分割线',
icon: '—',
keywords: 'hr rule divider 分割',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: '表格',
description: '插入 3×3 表格',
icon: '▦',
keywords: 'table 表格',
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
},
},
];
// 图片相关命令:上传命令仅在上传回调可用时才出现。
if (uploadFn) {
COMMANDS.push({
title: '上传图片',
description: '从本地选择并上传图片',
icon: '📤',
keywords: 'image upload 图片',
command: ({ editor, range }) => {
// 必须先删掉 /命令 文本,文件选择对话框会阻塞,关闭后 range 可能失效。
editor.chain().focus().deleteRange(range).run();
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.addEventListener('change', () => {
const file = input.files?.[0];
if (!file) return;
// 优先走 coordinator占位符 + 上传),否则退回直接上传(无占位符)
if (this.options.onInsertUploading) {
this.options.onInsertUploading(file);
} else if (uploadFn) {
uploadFn(file)
.then((url) => {
editor.chain().focus().setImage({ src: url }).run();
})
.catch((err) => {
const msg = err instanceof Error ? err.message : String(err);
console.error('[SlashCommand] Upload failed:', msg);
});
}
});
// click() 会立即触发原生文件选择器;回调在用户选择文件后异步执行。
input.click();
},
});
}
COMMANDS.push(
{
title: '图片链接',
description: '通过 URL 插入图片',
icon: '🖼',
keywords: 'image url 图片',
command: ({ editor, range }) => {
const url = window.prompt('输入图片 URL');
if (url && isValidUrl(url)) {
editor.chain().focus().deleteRange(range).setImage({ src: url }).run();
}
},
},
{
title: '链接',
description: '插入链接',
icon: '🔗',
keywords: 'link url a href 链接',
command: ({ editor, range }) => {
const url = window.prompt('输入链接 URL');
if (!url || !isValidUrl(url)) return;
// deleteRange 后光标停在 range.to先插入 URL 文本,再选中刚插入的范围设 link
// setLink 需要非空选区才生效,原顺序 setLink 在空选区无效)。
const insertFrom = range.to;
editor
.chain()
.focus()
.deleteRange(range)
.insertContent(url)
.setTextSelection({ from: insertFrom, to: insertFrom + url.length })
.setLink({ href: url })
.run();
},
},
);
return [
Suggestion<CommandItem>({
pluginKey: SlashCommandPluginKey,
editor: this.editor,
char: '/',
items: ({ query }) => {
return COMMANDS.filter((item) => matchCommand(item, query));
},
render() {
let popup: SlashPopup | null = null;
return {
onStart(props) {
popup = createPopup(props);
},
onUpdate(props) {
if (!popup) return;
popup.updateItems(props.items);
popup.updatePosition();
},
onKeyDown(props) {
if (!popup) return false;
return popup.onKeyDown(props);
},
onExit() {
if (popup) {
popup.destroy();
popup = null;
}
},
};
},
command: ({ editor, range, props: item }) => {
item.command({ editor, range });
},
}),
];
},
});
/** 斜杠命令浮层实例:供 Suggestion render 生命周期驱动。 */
interface SlashPopup {
component: HTMLElement;
updateItems(items: CommandItem[]): void;
updatePosition(): void;
onKeyDown(props: SuggestionKeyDownProps): boolean;
destroy(): void;
}
/** 校验图片/链接 URL:只允许 http(s) 和 data:image。拒绝 javascript: 等。 */
export function isValidUrl(url: string): boolean {
return /^https?:\/\//i.test(url) || /^data:image\//i.test(url);
}
function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
const component = document.createElement('div');
component.classList.add('slash-command');
const list = document.createElement('div');
list.classList.add('slash-command-list');
component.appendChild(list);
let selectedIndex = 0;
let currentItems: CommandItem[] = [];
function renderItems(items: CommandItem[]) {
currentItems = items;
list.innerHTML = '';
selectedIndex = 0;
items.forEach((item, index) => {
const el = document.createElement('div');
el.classList.add('slash-command-item');
if (index === 0) el.classList.add('is-selected');
el.innerHTML = `
<div class="slash-command-item-icon">${item.icon}</div>
<div class="slash-command-item-text">
<div class="slash-command-item-title">${item.title}</div>
<div class="slash-command-item-desc">${item.description}</div>
</div>
`;
el.addEventListener('click', () => {
props.command(item);
});
el.addEventListener('mouseenter', () => {
selectedIndex = index;
updateSelection();
});
list.appendChild(el);
});
}
function updateSelection() {
const children = list.children;
for (let i = 0; i < children.length; i++) {
if (i === selectedIndex) {
children[i].classList.add('is-selected');
} else {
children[i].classList.remove('is-selected');
}
}
children[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}
function selectItem() {
if (currentItems[selectedIndex]) {
props.command(currentItems[selectedIndex]);
}
}
function updatePosition() {
const rect = props.clientRect?.();
if (!rect) return;
component.style.left = `${rect.left}px`;
component.style.top = `${rect.bottom + 4}px`;
}
renderItems(props.items);
document.body.appendChild(component);
updatePosition();
return {
component,
updateItems(items: CommandItem[]) {
renderItems(items);
},
updatePosition,
onKeyDown({ event }: SuggestionKeyDownProps): boolean {
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length;
updateSelection();
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = (selectedIndex + 1) % currentItems.length;
updateSelection();
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
selectItem();
return true;
}
if (event.key === 'Escape') {
event.preventDefault();
return true;
}
return false;
},
destroy() {
component.remove();
},
};
}
/** buildRunnableInfo 的输入配置。 */
export interface RunnableInfoOpts {
/** 语言名python / node。 */
lang: string;
/** 超时秒数。 */
timeoutSecs: number;
/** 内存上限MB。 */
memoryMb: number;
/** 是否允许网络。 */
allowNetwork: boolean;
/** 作者是否改动过任一 overrides 字段false 则省略 JSON。 */
dirty: boolean;
}
/**
* 把弹框收集的配置转成 markdown fence 的 info string。
*
* - dirty=false → `${lang} runnable`(省略 JSON最小形态
* - dirty=true → `${lang} runnable {"timeout_secs":N,"memory_mb":M,"allow_network":B}`
*
* JSON 字段顺序固定timeout → memory → network由显式构造保证不依赖对象插入顺序
* 到达此函数时值必然合法(弹框「插入」按钮在非法值时 disabled
*/
export function buildRunnableInfo(opts: RunnableInfoOpts): string {
const prefix = `${opts.lang} runnable`;
if (!opts.dirty) return prefix;
// 显式拼字符串保证字段顺序固定timeout → memory → network不依赖对象键序。
const json = `{"timeout_secs":${opts.timeoutSecs},"memory_mb":${opts.memoryMb},"allow_network":${opts.allowNetwork}}`;
return `${prefix} ${json}`;
}
/**
* 受支持的语言(与 src/pages/admin/runner.rs SUPPORTED_LANGS 对齐)。
* 编辑器是纯 JS lib不调 server function故写死。
*/
const RUNNABLE_LANGS = ['python', 'node'] as const;
/** 模态框默认值(与后端 ResourceLimits 默认对齐:见 languages.rs。 */
const RUNNABLE_DEFAULTS = { timeoutSecs: 5, memoryMb: 256, allowNetwork: false };
/** timeout_secs 取值范围(与 CODE_RUNNER_MAX_TIMEOUT_SECS 对齐)。 */
const TIMEOUT_RANGE = { min: 1, max: 30 } as const;
/** memory_mb 取值范围(与 CODE_RUNNER_MAX_MEMORY_MB 对齐)。 */
const MEMORY_RANGE = { min: 16, max: 1024 } as const;
/**
* 打开「插入可运行代码块」模态框。
*
* 作者选语言 + 可选 overrides超时/内存/网络),确认后调用
* `editor.chain().focus().setCodeBlock({ language }).run()` 插入标准 CodeBlock
* 其 language 属性承载完整 'python runnable {...}' info stringmarked 往返保真)。
*
* 任一 overrides 字段被改动即 dirtydirty=false 插入 'python runnable'(无 JSON
* Esc / 遮罩点击 / 取消按钮 → 关闭不插入。
*/
export function openRunnableModal(editor: Editor): void {
const state = { ...RUNNABLE_DEFAULTS, lang: 'python' as string, dirty: false };
const mask = document.createElement('div');
mask.className = 'tiptap-runnable-modal-mask';
const modal = document.createElement('div');
modal.className = 'tiptap-runnable-modal';
const title = document.createElement('div');
title.className = 'tiptap-runnable-modal-title';
title.textContent = '插入可运行代码块';
modal.appendChild(title);
// 语言选择
const langRow = document.createElement('label');
langRow.className = 'tiptap-runnable-field';
langRow.textContent = '语言';
const langSelect = document.createElement('select');
langSelect.id = 'runnable-lang';
for (const l of RUNNABLE_LANGS) {
const opt = document.createElement('option');
opt.value = l;
opt.textContent = l;
langSelect.appendChild(opt);
}
langSelect.value = state.lang;
langSelect.addEventListener('change', () => {
state.lang = langSelect.value;
updatePreview();
});
langRow.appendChild(langSelect);
modal.appendChild(langRow);
// 超时
const timeoutRow = document.createElement('label');
timeoutRow.className = 'tiptap-runnable-field';
timeoutRow.textContent = '超时(秒)';
const timeoutInput = document.createElement('input');
timeoutInput.id = 'runnable-timeout';
timeoutInput.type = 'number';
timeoutInput.min = String(TIMEOUT_RANGE.min);
timeoutInput.max = String(TIMEOUT_RANGE.max);
timeoutInput.value = String(state.timeoutSecs);
timeoutInput.addEventListener('input', () => {
state.timeoutSecs = Number(timeoutInput.value) || RUNNABLE_DEFAULTS.timeoutSecs;
state.dirty = true;
updatePreview();
updateInsertEnabled();
});
timeoutRow.appendChild(timeoutInput);
modal.appendChild(timeoutRow);
// 内存
const memRow = document.createElement('label');
memRow.className = 'tiptap-runnable-field';
memRow.textContent = '内存MB';
const memInput = document.createElement('input');
memInput.id = 'runnable-memory';
memInput.type = 'number';
memInput.min = String(MEMORY_RANGE.min);
memInput.max = String(MEMORY_RANGE.max);
memInput.value = String(state.memoryMb);
memInput.addEventListener('input', () => {
state.memoryMb = Number(memInput.value) || RUNNABLE_DEFAULTS.memoryMb;
state.dirty = true;
updatePreview();
updateInsertEnabled();
});
memRow.appendChild(memInput);
modal.appendChild(memRow);
// 网络
const netRow = document.createElement('label');
netRow.className = 'tiptap-runnable-field';
const netInput = document.createElement('input');
netInput.id = 'runnable-network';
netInput.type = 'checkbox';
netInput.checked = state.allowNetwork;
netInput.addEventListener('change', () => {
state.allowNetwork = netInput.checked;
state.dirty = true;
updatePreview();
});
netRow.appendChild(netInput);
netRow.appendChild(document.createTextNode('允许网络'));
modal.appendChild(netRow);
// 预览
const preview = document.createElement('div');
preview.className = 'tiptap-runnable-preview';
modal.appendChild(preview);
// 按钮
const actions = document.createElement('div');
actions.className = 'tiptap-runnable-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'cancel';
cancelBtn.type = 'button';
cancelBtn.textContent = '取消';
cancelBtn.addEventListener('click', close);
const insertBtn = document.createElement('button');
insertBtn.className = 'insert';
insertBtn.type = 'button';
insertBtn.textContent = '插入';
insertBtn.addEventListener('click', insert);
actions.appendChild(cancelBtn);
actions.appendChild(insertBtn);
modal.appendChild(actions);
mask.appendChild(modal);
mask.addEventListener('click', (e) => {
// 仅点击遮罩本身(非卡片)时关闭
if (e.target === mask) close();
});
function updatePreview(): void {
preview.textContent = `\`\`\`${buildRunnableInfo(state)}`;
}
/** 校验数字字段:全合法才启用「插入」。 */
function updateInsertEnabled(): void {
const t = Number(timeoutInput.value);
const m = Number(memInput.value);
insertBtn.disabled = !(
t >= TIMEOUT_RANGE.min &&
t <= TIMEOUT_RANGE.max &&
m >= MEMORY_RANGE.min &&
m <= MEMORY_RANGE.max
);
}
function insert(): void {
editor
.chain()
.setCodeBlock({ language: buildRunnableInfo(state) })
.run();
close();
}
function close(): void {
document.removeEventListener('keydown', onKeydown);
mask.remove();
editor.chain().focus().run();
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') {
e.preventDefault();
close();
} else if (e.key === 'Enter' && !insertBtn.disabled) {
// Enter 在表单元素内提交(浏览器原生 number input 的 Enter 不会触发 click
// 注意:网络 checkbox 的 tagName 也是 'input'Enter 会触发提交而非切换
// checkbox 原生用 Space 切换),符合模态框 Enter=确认的惯例。
// 例外:语言 <select> 下拉展开时 Enter 用于确认选项,不应触发插入。
// HTMLSelectElement.open 在浏览器存在,但 TS lib.dom 未声明,需断言。)
if ((langSelect as HTMLSelectElement & { open: boolean }).open) return;
const tag = (document.activeElement?.tagName ?? '').toLowerCase();
if (tag === 'input' || tag === 'select') {
e.preventDefault();
insert();
}
}
}
document.addEventListener('keydown', onKeydown);
document.body.appendChild(mask);
updatePreview();
updateInsertEnabled();
langSelect.focus();
}