feat(editor): 点语言标签编辑可运行代码块的语言与配置
openRunnableModal 新增编辑模式(editPos + currentInfo): - 回填当前块的 lang + overrides(解析 info string) - 标题改「编辑」、按钮改「保存」 - 确认用 setNodeMarkup 原地更新 language 属性(保留块内代码) CodeBlockNodeView 的语言标签在 runnable 块上可点击(绿字 hover 提示), 点击触发编辑模式(getPos 传 NodeView)。update() 在 language 变化时同步 刷新 <code> 的 class,让高亮按新语言重算。 补 4 个编辑模式用例(回填/标题/保存走 setNodeMarkup/改语言写入)。 Playwright 验证:python→node 编辑后 lang 标签与 code class 同步更新。
This commit is contained in:
parent
9c8ab791d9
commit
1fa1984703
@ -28,6 +28,38 @@ function mockEditor() {
|
||||
return { editor: { chain: vi.fn(() => chain) } as unknown as Editor, calls };
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造支持编辑模式的 mock editor:额外记录 view.dispatch 调用(编辑模式用 setNodeMarkup)。
|
||||
*/
|
||||
function mockEditorWithView() {
|
||||
const calls: { language: string }[] = [];
|
||||
const markupCalls: { pos: number | undefined; language: string }[] = [];
|
||||
const chain = {
|
||||
focus: vi.fn(() => chain),
|
||||
setCodeBlock: vi.fn((attrs: { language: string }) => {
|
||||
calls.push(attrs);
|
||||
return chain;
|
||||
}),
|
||||
run: vi.fn(),
|
||||
};
|
||||
// tr.setNodeMarkup 记录调用,view.dispatch 触发记录
|
||||
const tr = {
|
||||
setNodeMarkup: vi.fn((pos: number, _type: unknown, attrs: { language: string }) => {
|
||||
markupCalls.push({ pos, language: attrs.language });
|
||||
return tr;
|
||||
}),
|
||||
};
|
||||
const view = {
|
||||
dispatch: vi.fn(() => {}),
|
||||
};
|
||||
const editor = {
|
||||
chain: vi.fn(() => chain),
|
||||
view,
|
||||
state: { tr },
|
||||
};
|
||||
return { editor: editor as unknown as Editor, calls, markupCalls };
|
||||
}
|
||||
|
||||
describe('openRunnableModal', () => {
|
||||
it('创建后 body 含模态框', () => {
|
||||
const { editor } = mockEditor();
|
||||
@ -128,3 +160,48 @@ describe('openRunnableModal', () => {
|
||||
expect(document.querySelector('.tiptap-runnable-modal')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 编辑模式测试:openRunnableModal(editor, editPos, currentInfo) 回填 + 原地更新。
|
||||
*/
|
||||
describe('openRunnableModal 编辑模式', () => {
|
||||
it('回填当前语言(python runnable + overrides)', () => {
|
||||
const { editor } = mockEditorWithView();
|
||||
openRunnableModal(editor, 0, 'python runnable {"timeout_secs":10,"memory_mb":512}');
|
||||
const langSelect = document.querySelector<HTMLSelectElement>('#runnable-lang')!;
|
||||
const timeoutInput = document.querySelector<HTMLInputElement>('#runnable-timeout')!;
|
||||
const memInput = document.querySelector<HTMLInputElement>('#runnable-memory')!;
|
||||
expect(langSelect.value).toBe('python');
|
||||
expect(timeoutInput.value).toBe('10');
|
||||
expect(memInput.value).toBe('512');
|
||||
});
|
||||
|
||||
it('标题为「编辑」、按钮为「保存」', () => {
|
||||
const { editor } = mockEditorWithView();
|
||||
openRunnableModal(editor, 0, 'node runnable');
|
||||
expect(document.querySelector('.tiptap-runnable-modal-title')?.textContent).toBe(
|
||||
'编辑可运行代码块',
|
||||
);
|
||||
expect(document.querySelector('.tiptap-runnable-actions .insert')?.textContent).toBe('保存');
|
||||
});
|
||||
|
||||
it('保存时用 setNodeMarkup 原地更新(非 setCodeBlock 新建)', () => {
|
||||
const { editor, calls, markupCalls } = mockEditorWithView();
|
||||
openRunnableModal(editor, 5, 'python runnable');
|
||||
document.querySelector<HTMLButtonElement>('.tiptap-runnable-actions .insert')!.click();
|
||||
expect(calls).toHaveLength(0); // 编辑模式不走 setCodeBlock
|
||||
expect(markupCalls).toHaveLength(1);
|
||||
expect(markupCalls[0].pos).toBe(5);
|
||||
expect(markupCalls[0].language).toBe('python runnable');
|
||||
});
|
||||
|
||||
it('改语言后保存,新语言写入', () => {
|
||||
const { editor, markupCalls } = mockEditorWithView();
|
||||
openRunnableModal(editor, 5, 'python runnable');
|
||||
const langSelect = document.querySelector<HTMLSelectElement>('#runnable-lang')!;
|
||||
langSelect.value = 'node';
|
||||
langSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
document.querySelector<HTMLButtonElement>('.tiptap-runnable-actions .insert')!.click();
|
||||
expect(markupCalls[0].language).toBe('node runnable');
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@ import type { Editor } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import type { ViewMutationRecord } from '@tiptap/pm/view';
|
||||
import { extractLang, extractOverridesJson } from './highlight';
|
||||
import { openRunnableModal } from './slash-command';
|
||||
|
||||
/** editor.storage 的 key,宿主(index.ts)在此注入 onRunCode 回调。 */
|
||||
export const ON_RUN_CODE_STORAGE_KEY = '__onRunCode';
|
||||
@ -46,9 +47,12 @@ export class CodeBlockNodeView {
|
||||
private code: HTMLElement;
|
||||
private resultArea: HTMLDivElement | null = null;
|
||||
|
||||
constructor(opts: { node: PMNode; editor: Editor }) {
|
||||
private getPos: (() => number | undefined) | undefined;
|
||||
|
||||
constructor(opts: { node: PMNode; editor: Editor; getPos?: () => number | undefined }) {
|
||||
this.node = opts.node;
|
||||
this.editor = opts.editor;
|
||||
this.getPos = opts.getPos;
|
||||
|
||||
this.container = document.createElement('div');
|
||||
this.container.classList.add('tiptap-codeblock');
|
||||
@ -61,6 +65,12 @@ export class CodeBlockNodeView {
|
||||
this.langBadge = document.createElement('span');
|
||||
this.langBadge.classList.add('tiptap-codeblock-lang');
|
||||
this.langBadge.textContent = extractLang((this.node.attrs.language as string) ?? '');
|
||||
// runnable 块的语言标签可点击,触发编辑模态框(改语言/overrides)
|
||||
if (isRunnable(this.node)) {
|
||||
this.langBadge.classList.add('tiptap-codeblock-lang-editable');
|
||||
this.langBadge.title = '点击修改语言与运行配置';
|
||||
this.langBadge.addEventListener('click', () => this.openEditModal());
|
||||
}
|
||||
this.toolbar.appendChild(this.langBadge);
|
||||
|
||||
// 运行按钮(仅 runnable 块)
|
||||
@ -104,6 +114,10 @@ export class CodeBlockNodeView {
|
||||
this.node = node;
|
||||
if (oldLang !== newLang) {
|
||||
this.langBadge.textContent = extractLang(newLang);
|
||||
// 更新 <code> 的 language class(低亮按新语言重算)
|
||||
this.code.className = '';
|
||||
const langClass = extractLang(newLang);
|
||||
if (langClass) this.code.classList.add(`language-${langClass}`);
|
||||
// runnable 状态变化时重建按钮(简化:不细粒度增删,整体重建工具栏按钮区)
|
||||
this.refreshRunButton();
|
||||
}
|
||||
@ -162,6 +176,14 @@ export class CodeBlockNodeView {
|
||||
}
|
||||
|
||||
/** 点击运行:调 editor.storage.__onRunCode,结果填入结果区。 */
|
||||
/** 点击语言标签:打开编辑模态框,修改当前 runnable 块的语言/overrides。 */
|
||||
private openEditModal(): void {
|
||||
const pos = this.getPos?.();
|
||||
const currentInfo = (this.node.attrs.language as string) ?? '';
|
||||
if (pos === undefined) return;
|
||||
openRunnableModal(this.editor, pos, currentInfo);
|
||||
}
|
||||
|
||||
private async runCode(): Promise<void> {
|
||||
if (!this.runBtn) return;
|
||||
const storage = this.editor.storage as unknown as Record<string, unknown>;
|
||||
|
||||
@ -114,7 +114,7 @@ class TiptapEditorInstance {
|
||||
Markdown,
|
||||
CodeBlockLowlight.configure({ lowlight }).extend({
|
||||
addNodeView() {
|
||||
return ({ node, editor }) => new CodeBlockNodeView({ node, editor });
|
||||
return ({ node, editor, getPos }) => new CodeBlockNodeView({ node, editor, getPos });
|
||||
},
|
||||
}),
|
||||
CodeBlockBackspaceFix,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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;
|
||||
@ -475,17 +476,40 @@ const TIMEOUT_RANGE = { min: 1, max: 30 } as const;
|
||||
const MEMORY_RANGE = { min: 16, max: 1024 } as const;
|
||||
|
||||
/**
|
||||
* 打开「插入可运行代码块」模态框。
|
||||
* 打开「可运行代码块」配置模态框。
|
||||
*
|
||||
* 作者选语言 + 可选 overrides(超时/内存/网络),确认后调用
|
||||
* `editor.chain().focus().setCodeBlock({ language }).run()` 插入标准 CodeBlock,
|
||||
* 其 language 属性承载完整 'python runnable {...}' info string(marked 往返保真)。
|
||||
* 两种模式:
|
||||
* - 插入模式(默认):确认后 `setCodeBlock({ language })` 插入新块。
|
||||
* - 编辑模式(传 `editPos` + `currentInfo`):回填当前块的 lang/overrides,确认后
|
||||
* `setNodeMarkup(editPos, ..., { language })` 原地更新该块的 language 属性。
|
||||
* 供 CodeBlockNodeView 的语言标签点击触发(创建后修改语言/overrides)。
|
||||
*
|
||||
* 任一 overrides 字段被改动即 dirty;dirty=false 插入 'python runnable'(无 JSON)。
|
||||
* Esc / 遮罩点击 / 取消按钮 → 关闭不插入。
|
||||
* 作者选语言 + 可选 overrides(超时/内存/网络)。
|
||||
* 任一 overrides 字段被改动即 dirty;dirty=false 用 'python runnable'(无 JSON)。
|
||||
* Esc / 遮罩点击 / 取消按钮 → 关闭不改动。
|
||||
*/
|
||||
export function openRunnableModal(editor: Editor): void {
|
||||
const state = { ...RUNNABLE_DEFAULTS, lang: 'python' as string, dirty: false };
|
||||
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';
|
||||
@ -495,7 +519,7 @@ export function openRunnableModal(editor: Editor): void {
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'tiptap-runnable-modal-title';
|
||||
title.textContent = '插入可运行代码块';
|
||||
title.textContent = isEdit ? '编辑可运行代码块' : '插入可运行代码块';
|
||||
modal.appendChild(title);
|
||||
|
||||
// 语言选择
|
||||
@ -588,7 +612,7 @@ export function openRunnableModal(editor: Editor): void {
|
||||
const insertBtn = document.createElement('button');
|
||||
insertBtn.className = 'insert';
|
||||
insertBtn.type = 'button';
|
||||
insertBtn.textContent = '插入';
|
||||
insertBtn.textContent = isEdit ? '保存' : '插入';
|
||||
insertBtn.addEventListener('click', insert);
|
||||
actions.appendChild(cancelBtn);
|
||||
actions.appendChild(insertBtn);
|
||||
@ -617,10 +641,17 @@ export function openRunnableModal(editor: Editor): void {
|
||||
}
|
||||
|
||||
function insert(): void {
|
||||
editor
|
||||
.chain()
|
||||
.setCodeBlock({ language: buildRunnableInfo(state) })
|
||||
.run();
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@ -982,6 +982,24 @@
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
/* runnable 块的语言标签可点击(触发编辑模态框) */
|
||||
.tiptap-codeblock-lang-editable {
|
||||
cursor: pointer;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.tiptap-codeblock-lang-editable:hover {
|
||||
/* Latte surface0 */
|
||||
background: #ccd0da;
|
||||
/* Catppuccin green,暗示可改 */
|
||||
color: #40a02b;
|
||||
}
|
||||
.dark .tiptap-codeblock-lang-editable:hover {
|
||||
/* Mocha surface0 / green */
|
||||
background: #313244;
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
.tiptap-codeblock-run {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user