feat(editor): 新增 openRunnableModal 模态框配置可运行代码块

原生 DOM 模态框(遮罩+卡片),语言 select + 超时/内存/网络 3 项 overrides
+ 实时预览。确认插入 setCodeBlock({language: 'python runnable {...}'}),
Esc/遮罩/取消关闭不插入。
This commit is contained in:
xfy 2026-07-06 13:47:31 +08:00
parent 84b3da9db6
commit 5929ab68b4
2 changed files with 307 additions and 0 deletions

View File

@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Editor } from '@tiptap/core';
import { openRunnableModal } from '../slash-command';
/**
* openRunnableModal (happy-dom)
*
* 验证:DOM /
* editor mock, setCodeBlock language
*/
// happy-dom 提供 document.body;每个用例后清理。
afterEach(() => {
document.body.innerHTML = '';
});
/** 构造 mock editor:记录 chain() 调用链上的 setCodeBlock 参数。 */
function mockEditor() {
const calls: { language: string }[] = [];
const chain = {
focus: vi.fn(() => chain),
setCodeBlock: vi.fn((attrs: { language: string }) => {
calls.push(attrs);
return chain;
}),
run: vi.fn(),
};
return { editor: { chain: vi.fn(() => chain) } as unknown as Editor, calls };
}
describe('openRunnableModal', () => {
it('创建后 body 含模态框', () => {
const { editor } = mockEditor();
openRunnableModal(editor);
expect(document.querySelector('.tiptap-runnable-modal')).not.toBeNull();
expect(document.querySelector('.tiptap-runnable-modal-mask')).not.toBeNull();
});
it('改 timeout 后预览更新', () => {
const { editor } = mockEditor();
openRunnableModal(editor);
const timeoutInput = document.querySelector<HTMLInputElement>('#runnable-timeout')!;
timeoutInput.value = '10';
timeoutInput.dispatchEvent(new Event('input', { bubbles: true }));
const preview = document.querySelector('.tiptap-runnable-preview')!;
expect(preview.textContent).toContain('"timeout_secs":10');
});
it('点「插入」调用 setCodeBlock 并销毁弹框', () => {
const { editor, calls } = mockEditor();
openRunnableModal(editor);
// 改 timeout 让 dirty=true
const timeoutInput = document.querySelector<HTMLInputElement>('#runnable-timeout')!;
timeoutInput.value = '10';
timeoutInput.dispatchEvent(new Event('input', { bubbles: true }));
// 点插入
const insertBtn = document.querySelector<HTMLButtonElement>('.tiptap-runnable-actions .insert')!;
insertBtn.click();
expect(calls).toHaveLength(1);
expect(calls[0].language).toBe('python runnable {"timeout_secs":10,"memory_mb":256,"allow_network":false}');
expect(document.querySelector('.tiptap-runnable-modal')).toBeNull();
});
it('全默认(不动 overrides)插入 → info string 无 JSON', () => {
const { editor, calls } = mockEditor();
openRunnableModal(editor);
document.querySelector<HTMLButtonElement>('.tiptap-runnable-actions .insert')!.click();
expect(calls[0].language).toBe('python runnable');
});
it('超时输入非法值(0)时「插入」按钮 disabled', () => {
const { editor, calls } = mockEditor();
openRunnableModal(editor);
const timeoutInput = document.querySelector<HTMLInputElement>('#runnable-timeout')!;
timeoutInput.value = '0';
timeoutInput.dispatchEvent(new Event('input', { bubbles: true }));
const insertBtn = document.querySelector<HTMLButtonElement>('.tiptap-runnable-actions .insert')!;
expect(insertBtn.disabled).toBe(true);
// 且点插入(即使强制)不应触发——实际 disabled 按钮不接收 click,这里只验状态
insertBtn.click();
expect(calls).toHaveLength(0);
});
it('超时恢复合法值后「插入」按钮重新启用', () => {
const { editor } = mockEditor();
openRunnableModal(editor);
const timeoutInput = document.querySelector<HTMLInputElement>('#runnable-timeout')!;
const insertBtn = document.querySelector<HTMLButtonElement>('.tiptap-runnable-actions .insert')!;
timeoutInput.value = '0';
timeoutInput.dispatchEvent(new Event('input', { bubbles: true }));
expect(insertBtn.disabled).toBe(true);
timeoutInput.value = '10';
timeoutInput.dispatchEvent(new Event('input', { bubbles: true }));
expect(insertBtn.disabled).toBe(false);
});
it('点「取消」不调用 setCodeBlock 且销毁弹框', () => {
const { editor, calls } = mockEditor();
openRunnableModal(editor);
document.querySelector<HTMLButtonElement>('.tiptap-runnable-actions .cancel')!.click();
expect(calls).toHaveLength(0);
expect(document.querySelector('.tiptap-runnable-modal')).toBeNull();
});
it('Esc 关闭弹框(不插入)', () => {
const { editor, calls } = mockEditor();
openRunnableModal(editor);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(calls).toHaveLength(0);
expect(document.querySelector('.tiptap-runnable-modal')).toBeNull();
});
it('点击遮罩关闭弹框(不插入)', () => {
const { editor, calls } = mockEditor();
openRunnableModal(editor);
const mask = document.querySelector('.tiptap-runnable-modal-mask')!;
// 点击遮罩本身(非卡片):target = currentTarget
mask.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(calls).toHaveLength(0);
expect(document.querySelector('.tiptap-runnable-modal')).toBeNull();
});
});

View File

@ -394,3 +394,188 @@ export function buildRunnableInfo(opts: RunnableInfoOpts): string {
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=确认的惯例。
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();
}