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 };
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 string(marked 往返保真)。
|
* - 编辑模式(传 `editPos` + `currentInfo`):回填当前块的 lang/overrides,确认后
|
||||||
|
* `setNodeMarkup(editPos, ..., { language })` 原地更新该块的 language 属性。
|
||||||
|
* 供 CodeBlockNodeView 的语言标签点击触发(创建后修改语言/overrides)。
|
||||||
*
|
*
|
||||||
* 任一 overrides 字段被改动即 dirty;dirty=false 插入 'python runnable'(无 JSON)。
|
* 作者选语言 + 可选 overrides(超时/内存/网络)。
|
||||||
* Esc / 遮罩点击 / 取消按钮 → 关闭不插入。
|
* 任一 overrides 字段被改动即 dirty;dirty=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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user