From 1fa19847032a75c3d2decbfa15c8908ceba12fd7 Mon Sep 17 00:00:00 2001 From: xfy Date: Tue, 7 Jul 2026 16:12:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E7=82=B9=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E7=BC=96=E8=BE=91=E5=8F=AF=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=9D=97=E7=9A=84=E8=AF=AD=E8=A8=80=E4=B8=8E?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openRunnableModal 新增编辑模式(editPos + currentInfo): - 回填当前块的 lang + overrides(解析 info string) - 标题改「编辑」、按钮改「保存」 - 确认用 setNodeMarkup 原地更新 language 属性(保留块内代码) CodeBlockNodeView 的语言标签在 runnable 块上可点击(绿字 hover 提示), 点击触发编辑模式(getPos 传 NodeView)。update() 在 language 变化时同步 刷新 的 class,让高亮按新语言重算。 补 4 个编辑模式用例(回填/标题/保存走 setNodeMarkup/改语言写入)。 Playwright 验证:python→node 编辑后 lang 标签与 code class 同步更新。 --- .../src/__tests__/runnable-modal.test.ts | 77 +++++++++++++++++++ libs/tiptap-editor/src/code-block-view.ts | 24 +++++- libs/tiptap-editor/src/index.ts | 2 +- libs/tiptap-editor/src/slash-command.ts | 59 ++++++++++---- libs/tiptap-editor/src/style.css | 18 +++++ 5 files changed, 164 insertions(+), 16 deletions(-) diff --git a/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts b/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts index a9163a6..51ab95b 100644 --- a/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts +++ b/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts @@ -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('#runnable-lang')!; + const timeoutInput = document.querySelector('#runnable-timeout')!; + const memInput = document.querySelector('#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('.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('#runnable-lang')!; + langSelect.value = 'node'; + langSelect.dispatchEvent(new Event('change', { bubbles: true })); + document.querySelector('.tiptap-runnable-actions .insert')!.click(); + expect(markupCalls[0].language).toBe('node runnable'); + }); +}); diff --git a/libs/tiptap-editor/src/code-block-view.ts b/libs/tiptap-editor/src/code-block-view.ts index b61f1b3..0c69c8c 100644 --- a/libs/tiptap-editor/src/code-block-view.ts +++ b/libs/tiptap-editor/src/code-block-view.ts @@ -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); + // 更新 的 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 { if (!this.runBtn) return; const storage = this.editor.storage as unknown as Record; diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index 010588d..e0b2309 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -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, diff --git a/libs/tiptap-editor/src/slash-command.ts b/libs/tiptap-editor/src/slash-command.ts index f0db2d9..8ab858c 100644 --- a/libs/tiptap-editor/src/slash-command.ts +++ b/libs/tiptap-editor/src/slash-command.ts @@ -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(); } diff --git a/libs/tiptap-editor/src/style.css b/libs/tiptap-editor/src/style.css index 5b7d356..17dc68f 100644 --- a/libs/tiptap-editor/src/style.css +++ b/libs/tiptap-editor/src/style.css @@ -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;