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:
xfy 2026-07-07 16:12:11 +08:00
parent 9c8ab791d9
commit 1fa1984703
5 changed files with 164 additions and 16 deletions

View File

@ -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');
});
});

View File

@ -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>;

View File

@ -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,

View File

@ -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 stringmarked
*
* - `setCodeBlock({ language })`
* - `editPos` + `currentInfo` lang/overrides
* `setNodeMarkup(editPos, ..., { language })` language
* CodeBlockNodeView /overrides
*
* overrides dirtydirty=false 'python runnable' JSON
* Esc / /
* + overrides//
* overrides dirtydirty=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();
}

View File

@ -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;