feat(editor): 新增 openRunnableModal 模态框配置可运行代码块
原生 DOM 模态框(遮罩+卡片),语言 select + 超时/内存/网络 3 项 overrides
+ 实时预览。确认插入 setCodeBlock({language: 'python runnable {...}'}),
Esc/遮罩/取消关闭不插入。
This commit is contained in:
parent
84b3da9db6
commit
5929ab68b4
122
libs/tiptap-editor/src/__tests__/runnable-modal.test.ts
Normal file
122
libs/tiptap-editor/src/__tests__/runnable-modal.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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 string(marked 往返保真)。
|
||||
*
|
||||
* 任一 overrides 字段被改动即 dirty;dirty=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();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user