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 }; 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', () => { describe('openRunnableModal', () => {
it('创建后 body 含模态框', () => { it('创建后 body 含模态框', () => {
const { editor } = mockEditor(); const { editor } = mockEditor();
@ -128,3 +160,48 @@ describe('openRunnableModal', () => {
expect(document.querySelector('.tiptap-runnable-modal')).toBeNull(); 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 { Node as PMNode } from '@tiptap/pm/model';
import type { ViewMutationRecord } from '@tiptap/pm/view'; import type { ViewMutationRecord } from '@tiptap/pm/view';
import { extractLang, extractOverridesJson } from './highlight'; import { extractLang, extractOverridesJson } from './highlight';
import { openRunnableModal } from './slash-command';
/** editor.storage 的 key宿主index.ts在此注入 onRunCode 回调。 */ /** editor.storage 的 key宿主index.ts在此注入 onRunCode 回调。 */
export const ON_RUN_CODE_STORAGE_KEY = '__onRunCode'; export const ON_RUN_CODE_STORAGE_KEY = '__onRunCode';
@ -46,9 +47,12 @@ export class CodeBlockNodeView {
private code: HTMLElement; private code: HTMLElement;
private resultArea: HTMLDivElement | null = null; 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.node = opts.node;
this.editor = opts.editor; this.editor = opts.editor;
this.getPos = opts.getPos;
this.container = document.createElement('div'); this.container = document.createElement('div');
this.container.classList.add('tiptap-codeblock'); this.container.classList.add('tiptap-codeblock');
@ -61,6 +65,12 @@ export class CodeBlockNodeView {
this.langBadge = document.createElement('span'); this.langBadge = document.createElement('span');
this.langBadge.classList.add('tiptap-codeblock-lang'); this.langBadge.classList.add('tiptap-codeblock-lang');
this.langBadge.textContent = extractLang((this.node.attrs.language as string) ?? ''); 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); this.toolbar.appendChild(this.langBadge);
// 运行按钮(仅 runnable 块) // 运行按钮(仅 runnable 块)
@ -104,6 +114,10 @@ export class CodeBlockNodeView {
this.node = node; this.node = node;
if (oldLang !== newLang) { if (oldLang !== newLang) {
this.langBadge.textContent = extractLang(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 状态变化时重建按钮(简化:不细粒度增删,整体重建工具栏按钮区) // runnable 状态变化时重建按钮(简化:不细粒度增删,整体重建工具栏按钮区)
this.refreshRunButton(); this.refreshRunButton();
} }
@ -162,6 +176,14 @@ export class CodeBlockNodeView {
} }
/** 点击运行:调 editor.storage.__onRunCode结果填入结果区。 */ /** 点击运行:调 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> { private async runCode(): Promise<void> {
if (!this.runBtn) return; if (!this.runBtn) return;
const storage = this.editor.storage as unknown as Record<string, unknown>; const storage = this.editor.storage as unknown as Record<string, unknown>;

View File

@ -114,7 +114,7 @@ class TiptapEditorInstance {
Markdown, Markdown,
CodeBlockLowlight.configure({ lowlight }).extend({ CodeBlockLowlight.configure({ lowlight }).extend({
addNodeView() { addNodeView() {
return ({ node, editor }) => new CodeBlockNodeView({ node, editor }); return ({ node, editor, getPos }) => new CodeBlockNodeView({ node, editor, getPos });
}, },
}), }),
CodeBlockBackspaceFix, CodeBlockBackspaceFix,

View File

@ -1,6 +1,7 @@
import { type Editor, Extension, type Range } from '@tiptap/core'; import { type Editor, Extension, type Range } from '@tiptap/core';
import { PluginKey } from '@tiptap/pm/state'; import { PluginKey } from '@tiptap/pm/state';
import { Suggestion, type SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion'; import { Suggestion, type SuggestionKeyDownProps, type SuggestionProps } from '@tiptap/suggestion';
import { extractLang, extractOverridesJson } from './highlight';
interface CommandItem { interface CommandItem {
title: string; title: string;
@ -475,17 +476,40 @@ const TIMEOUT_RANGE = { min: 1, max: 30 } as const;
const MEMORY_RANGE = { min: 16, max: 1024 } as const; const MEMORY_RANGE = { min: 16, max: 1024 } as const;
/** /**
* *
* *
* + overrides// *
* `editor.chain().focus().setCodeBlock({ language }).run()` CodeBlock * - `setCodeBlock({ language })`
* language 'python runnable {...}' info stringmarked * - `editPos` + `currentInfo` lang/overrides
* `setNodeMarkup(editPos, ..., { language })` language
* CodeBlockNodeView /overrides
* *
* overrides dirtydirty=false 'python runnable' JSON * + overrides//
* Esc / / * overrides dirtydirty=false 'python runnable' JSON
* Esc / /
*/ */
export function openRunnableModal(editor: Editor): void { export function openRunnableModal(editor: Editor, editPos?: number, currentInfo?: string): void {
const state = { ...RUNNABLE_DEFAULTS, lang: 'python' as string, dirty: false }; 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'); const mask = document.createElement('div');
mask.className = 'tiptap-runnable-modal-mask'; mask.className = 'tiptap-runnable-modal-mask';
@ -495,7 +519,7 @@ export function openRunnableModal(editor: Editor): void {
const title = document.createElement('div'); const title = document.createElement('div');
title.className = 'tiptap-runnable-modal-title'; title.className = 'tiptap-runnable-modal-title';
title.textContent = '插入可运行代码块'; title.textContent = isEdit ? '编辑可运行代码块' : '插入可运行代码块';
modal.appendChild(title); modal.appendChild(title);
// 语言选择 // 语言选择
@ -588,7 +612,7 @@ export function openRunnableModal(editor: Editor): void {
const insertBtn = document.createElement('button'); const insertBtn = document.createElement('button');
insertBtn.className = 'insert'; insertBtn.className = 'insert';
insertBtn.type = 'button'; insertBtn.type = 'button';
insertBtn.textContent = '插入'; insertBtn.textContent = isEdit ? '保存' : '插入';
insertBtn.addEventListener('click', insert); insertBtn.addEventListener('click', insert);
actions.appendChild(cancelBtn); actions.appendChild(cancelBtn);
actions.appendChild(insertBtn); actions.appendChild(insertBtn);
@ -617,10 +641,17 @@ export function openRunnableModal(editor: Editor): void {
} }
function insert(): void { function insert(): void {
editor const language = buildRunnableInfo(state);
.chain() if (isEdit && editPos !== undefined) {
.setCodeBlock({ language: buildRunnableInfo(state) }) // 编辑模式:原地更新块的 language 属性(保留块内代码内容)。
.run(); // 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(); close();
} }

View File

@ -982,6 +982,24 @@
text-transform: lowercase; 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 { .tiptap-codeblock-run {
border: none; border: none;
border-radius: 4px; border-radius: 4px;