yggdrasil/libs/tiptap-editor/src/slash-command.ts
xfy 82315853a2 fix(tiptap): slash 命令创建可运行代码块时模态框被立即关闭
斜杠菜单中按 Enter 选择「可运行代码块」时,openRunnableModal 在
同一个 keydown 事件处理中同步调用。模态框的 document keydown 监听
立即注册后,该 Enter 事件继续冒泡到 document 触发模态框的 Enter
处理——insert() → close(),模态框在同一 tick 内创建又销毁,用户
看不到配置弹框。

将 document.addEventListener("keydown", ...) 延迟到 setTimeout(0)
注册,确保触发事件完成传播后才开始监听。
2026-07-07 17:51:06 +08:00

693 lines
24 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';
import { extractLang, extractOverridesJson } from './highlight';
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 }) => {
// 防御性重算 rangeSuggestion 的 range 在某些输入路径下会过时
//(停在首次输入 '/' 的位置,不随后续字符更新),导致 deleteRange 只删 '/'
// 命令文本(如 'code')残留进新节点。基于当前 selection 重算:
// 从光标往前在同一段落内找触发字符 '/',删到光标。
const { from } = editor.state.selection;
const $from = editor.state.doc.resolve(from);
const text = $from.parent.textBetween(0, $from.parentOffset, '', '');
const slashIdx = text.lastIndexOf('/');
const effectiveRange =
slashIdx >= 0 ? { from: $from.start() + slashIdx, to: from } : range;
item.command({ editor, range: effectiveRange });
},
}),
];
},
});
/** 斜杠命令浮层实例:供 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);
}
export 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;
// 空状态:显示提示,不渲染列表项。
if (items.length === 0) {
const empty = document.createElement('div');
empty.classList.add('slash-command-empty');
empty.textContent = '无匹配命令';
list.appendChild(empty);
return;
}
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 {
// 空列表时不拦截键盘:避免 % 0 产生 NaN也避免吞掉 Enter让用户正常输入
// Escape 仍拦截(关闭浮层)。
if (event.key === 'Escape') {
event.preventDefault();
return true;
}
if (currentItems.length === 0) {
return false;
}
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;
}
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;
/**
* 打开「可运行代码块」配置模态框。
*
* 两种模式:
* - 插入模式(默认):确认后 `setCodeBlock({ language })` 插入新块。
* - 编辑模式(传 `editPos` + `currentInfo`):回填当前块的 lang/overrides确认后
* `setNodeMarkup(editPos, ..., { language })` 原地更新该块的 language 属性。
* 供 CodeBlockNodeView 的语言标签点击触发(创建后修改语言/overrides
*
* 作者选语言 + 可选 overrides超时/内存/网络)。
* 任一 overrides 字段被改动即 dirtydirty=false 用 'python runnable'(无 JSON
* Esc / 遮罩点击 / 取消按钮 → 关闭不改动。
*/
export function openRunnableModal(editor: Editor, editPos?: number, currentInfo?: string): void {
const isEdit = editPos !== undefined;
// 编辑模式:从 currentInfo 回填 lang + overrides插入模式用默认值
const initialLang = isEdit ? extractLang(currentInfo ?? '') : 'python';
const initialOverrides = isEdit ? extractOverridesJson(currentInfo ?? '') : '';
const parsedOverrides = initialOverrides
? (() => {
try {
return JSON.parse(initialOverrides);
} catch {
return null;
}
})()
: null;
const state = {
lang: (RUNNABLE_LANGS as readonly string[]).includes(initialLang) ? initialLang : 'python',
timeoutSecs: parsedOverrides?.timeout_secs ?? RUNNABLE_DEFAULTS.timeoutSecs,
memoryMb: parsedOverrides?.memory_mb ?? RUNNABLE_DEFAULTS.memoryMb,
allowNetwork: parsedOverrides?.allow_network ?? RUNNABLE_DEFAULTS.allowNetwork,
// 编辑模式下若有 overrides 即 dirty保留现有 overrides 除非作者改动)
dirty: isEdit && parsedOverrides !== null,
};
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 = isEdit ? '编辑可运行代码块' : '插入可运行代码块';
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 = isEdit ? '保存' : '插入';
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 {
const language = buildRunnableInfo(state);
if (isEdit && editPos !== undefined) {
// 编辑模式:原地更新块的 language 属性(保留块内代码内容)。
// Tiptap chain 无 setNodeMarkup用原生 tr。
const tr = editor.state.tr;
tr.setNodeMarkup(editPos, undefined, { language });
editor.view.dispatch(tr);
} else {
// 插入模式:新建 codeBlock
editor.chain().setCodeBlock({ language }).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.body.appendChild(mask);
// 延迟注册 keydown 监听openRunnableModal 可能在 slash 命令的 Enter keydown 事件处理中
// 同步调用。若立即注册,该 Enter 事件继续冒泡到 document 时会被模态框的 onKeydown 捕获,
// 触发 insert() → close()——模态框在同一 tick 内被创建又被销毁,用户看不到。
// setTimeout(0) 推迟到下一个宏任务,确保触发事件已完成传播。
setTimeout(() => document.addEventListener('keydown', onKeydown), 0);
updatePreview();
updateInsertEnabled();
langSelect.focus();
}