From 84b3da9db66b5979fa78ae87208c6bec8d4ae2a5 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 13:39:46 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat(editor):=20=E6=96=B0=E5=A2=9E=20buil?= =?UTF-8?q?dRunnableInfo=20=E6=9E=84=E9=80=A0=20runnable=20fence=20info=20?= =?UTF-8?q?string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯函数,把语言+overrides 配置转成 'python runnable {...}' 形态。 dirty=false 省略 JSON,dirty=true 写 timeout/memory/network 三项, 字段顺序固定。 --- .../src/__tests__/runnable-info.test.ts | 48 +++++++++++++++++++ libs/tiptap-editor/src/slash-command.ts | 31 ++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 libs/tiptap-editor/src/__tests__/runnable-info.test.ts diff --git a/libs/tiptap-editor/src/__tests__/runnable-info.test.ts b/libs/tiptap-editor/src/__tests__/runnable-info.test.ts new file mode 100644 index 0000000..b49f704 --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/runnable-info.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { buildRunnableInfo } from '../slash-command'; + +/** + * buildRunnableInfo 测试:把弹框收集的配置转成 fence info string。 + * + * dirty=false(全默认) → 'python runnable'(省略 JSON) + * dirty=true → 'python runnable {"timeout_secs":N,"memory_mb":M,"allow_network":B}' + * JSON 字段顺序固定:timeout → memory → network + */ + +describe('buildRunnableInfo', () => { + const base = { + lang: 'python', + timeoutSecs: 5, + memoryMb: 256, + allowNetwork: false, + } as const; + + describe('dirty=false(全默认,省略 JSON)', () => { + it('python → "python runnable"', () => { + expect(buildRunnableInfo({ ...base, dirty: false })).toBe('python runnable'); + }); + it('node → "node runnable"', () => { + expect(buildRunnableInfo({ ...base, lang: 'node', dirty: false })).toBe('node runnable'); + }); + }); + + describe('dirty=true(写 JSON)', () => { + it('改了 timeout,JSON 含全部 3 项', () => { + expect(buildRunnableInfo({ ...base, timeoutSecs: 10, dirty: true })).toBe( + 'python runnable {"timeout_secs":10,"memory_mb":256,"allow_network":false}', + ); + }); + it('node + 改了 allow_network', () => { + expect(buildRunnableInfo({ ...base, lang: 'node', allowNetwork: true, dirty: true })).toBe( + 'node runnable {"timeout_secs":5,"memory_mb":256,"allow_network":true}', + ); + }); + it('JSON 字段顺序固定(timeout→memory→network)', () => { + const out = buildRunnableInfo({ ...base, dirty: true }); + const jsonStart = out.indexOf('{'); + const jsonEnd = out.lastIndexOf('}') + 1; + const keys = Object.keys(JSON.parse(out.slice(jsonStart, jsonEnd))); + expect(keys).toEqual(['timeout_secs', 'memory_mb', 'allow_network']); + }); + }); +}); diff --git a/libs/tiptap-editor/src/slash-command.ts b/libs/tiptap-editor/src/slash-command.ts index 0afee58..8e82018 100644 --- a/libs/tiptap-editor/src/slash-command.ts +++ b/libs/tiptap-editor/src/slash-command.ts @@ -363,3 +363,34 @@ function createPopup(props: SuggestionProps): SlashPopup { }, }; } + +/** buildRunnableInfo 的输入配置。 */ +export interface RunnableInfoOpts { + /** 语言名(python / node)。 */ + lang: string; + /** 超时秒数。 */ + timeoutSecs: number; + /** 内存上限(MB)。 */ + memoryMb: number; + /** 是否允许网络。 */ + allowNetwork: boolean; + /** 作者是否改动过任一 overrides 字段;false 则省略 JSON。 */ + dirty: boolean; +} + +/** + * 把弹框收集的配置转成 markdown fence 的 info string。 + * + * - dirty=false → `${lang} runnable`(省略 JSON,最小形态) + * - dirty=true → `${lang} runnable {"timeout_secs":N,"memory_mb":M,"allow_network":B}` + * + * JSON 字段顺序固定(timeout → memory → network),由显式构造保证(不依赖对象插入顺序)。 + * 到达此函数时值必然合法(弹框「插入」按钮在非法值时 disabled)。 + */ +export function buildRunnableInfo(opts: RunnableInfoOpts): string { + const prefix = `${opts.lang} runnable`; + if (!opts.dirty) return prefix; + // 显式拼字符串,保证字段顺序固定(timeout → memory → network),不依赖对象键序。 + const json = `{"timeout_secs":${opts.timeoutSecs},"memory_mb":${opts.memoryMb},"allow_network":${opts.allowNetwork}}`; + return `${prefix} ${json}`; +} From 5929ab68b46055b7aa3e123a1cb701c97c42c7c6 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 13:47:31 +0800 Subject: [PATCH 02/16] =?UTF-8?q?feat(editor):=20=E6=96=B0=E5=A2=9E=20open?= =?UTF-8?q?RunnableModal=20=E6=A8=A1=E6=80=81=E6=A1=86=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8F=AF=E8=BF=90=E8=A1=8C=E4=BB=A3=E7=A0=81=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原生 DOM 模态框(遮罩+卡片),语言 select + 超时/内存/网络 3 项 overrides + 实时预览。确认插入 setCodeBlock({language: 'python runnable {...}'}), Esc/遮罩/取消关闭不插入。 --- .../src/__tests__/runnable-modal.test.ts | 122 ++++++++++++ libs/tiptap-editor/src/slash-command.ts | 185 ++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 libs/tiptap-editor/src/__tests__/runnable-modal.test.ts diff --git a/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts b/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts new file mode 100644 index 0000000..adcfe10 --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '@tiptap/core'; +import { openRunnableModal } from '../slash-command'; + +/** + * openRunnableModal 测试(happy-dom)。 + * + * 验证:DOM 创建/销毁、预览实时更新、插入回调、关闭路径。 + * editor 用 mock,只校验 setCodeBlock 被以正确 language 调用。 + */ + +// happy-dom 提供 document.body;每个用例后清理。 +afterEach(() => { + document.body.innerHTML = ''; +}); + +/** 构造 mock editor:记录 chain() 调用链上的 setCodeBlock 参数。 */ +function mockEditor() { + const calls: { language: string }[] = []; + const chain = { + focus: vi.fn(() => chain), + setCodeBlock: vi.fn((attrs: { language: string }) => { + calls.push(attrs); + return chain; + }), + run: vi.fn(), + }; + return { editor: { chain: vi.fn(() => chain) } as unknown as Editor, calls }; +} + +describe('openRunnableModal', () => { + it('创建后 body 含模态框', () => { + const { editor } = mockEditor(); + openRunnableModal(editor); + expect(document.querySelector('.tiptap-runnable-modal')).not.toBeNull(); + expect(document.querySelector('.tiptap-runnable-modal-mask')).not.toBeNull(); + }); + + it('改 timeout 后预览更新', () => { + const { editor } = mockEditor(); + openRunnableModal(editor); + const timeoutInput = document.querySelector('#runnable-timeout')!; + timeoutInput.value = '10'; + timeoutInput.dispatchEvent(new Event('input', { bubbles: true })); + const preview = document.querySelector('.tiptap-runnable-preview')!; + expect(preview.textContent).toContain('"timeout_secs":10'); + }); + + it('点「插入」调用 setCodeBlock 并销毁弹框', () => { + const { editor, calls } = mockEditor(); + openRunnableModal(editor); + // 改 timeout 让 dirty=true + const timeoutInput = document.querySelector('#runnable-timeout')!; + timeoutInput.value = '10'; + timeoutInput.dispatchEvent(new Event('input', { bubbles: true })); + // 点插入 + const insertBtn = document.querySelector('.tiptap-runnable-actions .insert')!; + insertBtn.click(); + expect(calls).toHaveLength(1); + expect(calls[0].language).toBe('python runnable {"timeout_secs":10,"memory_mb":256,"allow_network":false}'); + expect(document.querySelector('.tiptap-runnable-modal')).toBeNull(); + }); + + it('全默认(不动 overrides)插入 → info string 无 JSON', () => { + const { editor, calls } = mockEditor(); + openRunnableModal(editor); + document.querySelector('.tiptap-runnable-actions .insert')!.click(); + expect(calls[0].language).toBe('python runnable'); + }); + + it('超时输入非法值(0)时「插入」按钮 disabled', () => { + const { editor, calls } = mockEditor(); + openRunnableModal(editor); + const timeoutInput = document.querySelector('#runnable-timeout')!; + timeoutInput.value = '0'; + timeoutInput.dispatchEvent(new Event('input', { bubbles: true })); + const insertBtn = document.querySelector('.tiptap-runnable-actions .insert')!; + expect(insertBtn.disabled).toBe(true); + // 且点插入(即使强制)不应触发——实际 disabled 按钮不接收 click,这里只验状态 + insertBtn.click(); + expect(calls).toHaveLength(0); + }); + + it('超时恢复合法值后「插入」按钮重新启用', () => { + const { editor } = mockEditor(); + openRunnableModal(editor); + const timeoutInput = document.querySelector('#runnable-timeout')!; + const insertBtn = document.querySelector('.tiptap-runnable-actions .insert')!; + timeoutInput.value = '0'; + timeoutInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(insertBtn.disabled).toBe(true); + timeoutInput.value = '10'; + timeoutInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(insertBtn.disabled).toBe(false); + }); + + it('点「取消」不调用 setCodeBlock 且销毁弹框', () => { + const { editor, calls } = mockEditor(); + openRunnableModal(editor); + document.querySelector('.tiptap-runnable-actions .cancel')!.click(); + expect(calls).toHaveLength(0); + expect(document.querySelector('.tiptap-runnable-modal')).toBeNull(); + }); + + it('Esc 关闭弹框(不插入)', () => { + const { editor, calls } = mockEditor(); + openRunnableModal(editor); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(calls).toHaveLength(0); + expect(document.querySelector('.tiptap-runnable-modal')).toBeNull(); + }); + + it('点击遮罩关闭弹框(不插入)', () => { + const { editor, calls } = mockEditor(); + openRunnableModal(editor); + const mask = document.querySelector('.tiptap-runnable-modal-mask')!; + // 点击遮罩本身(非卡片):target = currentTarget + mask.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(calls).toHaveLength(0); + expect(document.querySelector('.tiptap-runnable-modal')).toBeNull(); + }); +}); diff --git a/libs/tiptap-editor/src/slash-command.ts b/libs/tiptap-editor/src/slash-command.ts index 8e82018..5e6864d 100644 --- a/libs/tiptap-editor/src/slash-command.ts +++ b/libs/tiptap-editor/src/slash-command.ts @@ -394,3 +394,188 @@ export function buildRunnableInfo(opts: RunnableInfoOpts): string { const json = `{"timeout_secs":${opts.timeoutSecs},"memory_mb":${opts.memoryMb},"allow_network":${opts.allowNetwork}}`; return `${prefix} ${json}`; } + +/** + * 受支持的语言(与 src/pages/admin/runner.rs SUPPORTED_LANGS 对齐)。 + * 编辑器是纯 JS lib,不调 server function,故写死。 + */ +const RUNNABLE_LANGS = ['python', 'node'] as const; + +/** 模态框默认值(与后端 ResourceLimits 默认对齐:见 languages.rs)。 */ +const RUNNABLE_DEFAULTS = { timeoutSecs: 5, memoryMb: 256, allowNetwork: false }; + +/** timeout_secs 取值范围(与 CODE_RUNNER_MAX_TIMEOUT_SECS 对齐)。 */ +const TIMEOUT_RANGE = { min: 1, max: 30 } as const; +/** memory_mb 取值范围(与 CODE_RUNNER_MAX_MEMORY_MB 对齐)。 */ +const MEMORY_RANGE = { min: 16, max: 1024 } as const; + +/** + * 打开「插入可运行代码块」模态框。 + * + * 作者选语言 + 可选 overrides(超时/内存/网络),确认后调用 + * `editor.chain().focus().setCodeBlock({ language }).run()` 插入标准 CodeBlock, + * 其 language 属性承载完整 'python runnable {...}' info string(marked 往返保真)。 + * + * 任一 overrides 字段被改动即 dirty;dirty=false 插入 'python runnable'(无 JSON)。 + * Esc / 遮罩点击 / 取消按钮 → 关闭不插入。 + */ +export function openRunnableModal(editor: Editor): void { + const state = { ...RUNNABLE_DEFAULTS, lang: 'python' as string, dirty: false }; + + const mask = document.createElement('div'); + mask.className = 'tiptap-runnable-modal-mask'; + + const modal = document.createElement('div'); + modal.className = 'tiptap-runnable-modal'; + + const title = document.createElement('div'); + title.className = 'tiptap-runnable-modal-title'; + title.textContent = '插入可运行代码块'; + modal.appendChild(title); + + // 语言选择 + const langRow = document.createElement('label'); + langRow.className = 'tiptap-runnable-field'; + langRow.textContent = '语言'; + const langSelect = document.createElement('select'); + langSelect.id = 'runnable-lang'; + for (const l of RUNNABLE_LANGS) { + const opt = document.createElement('option'); + opt.value = l; + opt.textContent = l; + langSelect.appendChild(opt); + } + langSelect.value = state.lang; + langSelect.addEventListener('change', () => { + state.lang = langSelect.value; + updatePreview(); + }); + langRow.appendChild(langSelect); + modal.appendChild(langRow); + + // 超时 + const timeoutRow = document.createElement('label'); + timeoutRow.className = 'tiptap-runnable-field'; + timeoutRow.textContent = '超时(秒)'; + const timeoutInput = document.createElement('input'); + timeoutInput.id = 'runnable-timeout'; + timeoutInput.type = 'number'; + timeoutInput.min = String(TIMEOUT_RANGE.min); + timeoutInput.max = String(TIMEOUT_RANGE.max); + timeoutInput.value = String(state.timeoutSecs); + timeoutInput.addEventListener('input', () => { + state.timeoutSecs = Number(timeoutInput.value) || RUNNABLE_DEFAULTS.timeoutSecs; + state.dirty = true; + updatePreview(); + updateInsertEnabled(); + }); + timeoutRow.appendChild(timeoutInput); + modal.appendChild(timeoutRow); + + // 内存 + const memRow = document.createElement('label'); + memRow.className = 'tiptap-runnable-field'; + memRow.textContent = '内存(MB)'; + const memInput = document.createElement('input'); + memInput.id = 'runnable-memory'; + memInput.type = 'number'; + memInput.min = String(MEMORY_RANGE.min); + memInput.max = String(MEMORY_RANGE.max); + memInput.value = String(state.memoryMb); + memInput.addEventListener('input', () => { + state.memoryMb = Number(memInput.value) || RUNNABLE_DEFAULTS.memoryMb; + state.dirty = true; + updatePreview(); + updateInsertEnabled(); + }); + memRow.appendChild(memInput); + modal.appendChild(memRow); + + // 网络 + const netRow = document.createElement('label'); + netRow.className = 'tiptap-runnable-field'; + const netInput = document.createElement('input'); + netInput.id = 'runnable-network'; + netInput.type = 'checkbox'; + netInput.checked = state.allowNetwork; + netInput.addEventListener('change', () => { + state.allowNetwork = netInput.checked; + state.dirty = true; + updatePreview(); + }); + netRow.appendChild(netInput); + netRow.appendChild(document.createTextNode('允许网络')); + modal.appendChild(netRow); + + // 预览 + const preview = document.createElement('div'); + preview.className = 'tiptap-runnable-preview'; + modal.appendChild(preview); + + // 按钮 + const actions = document.createElement('div'); + actions.className = 'tiptap-runnable-actions'; + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'cancel'; + cancelBtn.type = 'button'; + cancelBtn.textContent = '取消'; + cancelBtn.addEventListener('click', close); + const insertBtn = document.createElement('button'); + insertBtn.className = 'insert'; + insertBtn.type = 'button'; + insertBtn.textContent = '插入'; + insertBtn.addEventListener('click', insert); + actions.appendChild(cancelBtn); + actions.appendChild(insertBtn); + modal.appendChild(actions); + + mask.appendChild(modal); + mask.addEventListener('click', (e) => { + // 仅点击遮罩本身(非卡片)时关闭 + if (e.target === mask) close(); + }); + + function updatePreview(): void { + preview.textContent = `\`\`\`${buildRunnableInfo(state)}`; + } + + /** 校验数字字段:全合法才启用「插入」。 */ + function updateInsertEnabled(): void { + const t = Number(timeoutInput.value); + const m = Number(memInput.value); + insertBtn.disabled = !(t >= TIMEOUT_RANGE.min && t <= TIMEOUT_RANGE.max && m >= MEMORY_RANGE.min && m <= MEMORY_RANGE.max); + } + + function insert(): void { + editor.chain().setCodeBlock({ language: buildRunnableInfo(state) }).run(); + close(); + } + + function close(): void { + document.removeEventListener('keydown', onKeydown); + mask.remove(); + editor.chain().focus().run(); + } + + function onKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.preventDefault(); + close(); + } else if (e.key === 'Enter' && !insertBtn.disabled) { + // Enter 在表单元素内提交(浏览器原生 number input 的 Enter 不会触发 click)。 + // 注意:网络 checkbox 的 tagName 也是 'input',Enter 会触发提交而非切换 + // (checkbox 原生用 Space 切换),符合模态框 Enter=确认的惯例。 + const tag = (document.activeElement?.tagName ?? '').toLowerCase(); + if (tag === 'input' || tag === 'select') { + e.preventDefault(); + insert(); + } + } + } + + document.addEventListener('keydown', onKeydown); + document.body.appendChild(mask); + updatePreview(); + updateInsertEnabled(); + langSelect.focus(); +} From d6fc98d253d1ad6fd6f0193fd5cf90b6bd28784a Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 14:02:12 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat(editor):=20=E6=96=9C=E6=9D=A0?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=96=B0=E5=A2=9E=E3=80=8C=E5=8F=AF=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=BB=A3=E7=A0=81=E5=9D=97=E3=80=8D=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 触发 openRunnableModal,配置后插入标准 CodeBlock(language 承载 'python runnable {...}' info string)。 --- libs/tiptap-editor/src/slash-command.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/libs/tiptap-editor/src/slash-command.ts b/libs/tiptap-editor/src/slash-command.ts index 5e6864d..28caee0 100644 --- a/libs/tiptap-editor/src/slash-command.ts +++ b/libs/tiptap-editor/src/slash-command.ts @@ -107,6 +107,15 @@ export const SlashCommand = Extension.create({ editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); }, }, + { + title: '可运行代码块', + description: '插入可被读者执行的代码块', + icon: '▶', + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).run(); + openRunnableModal(editor); + }, + }, { title: '分割线', description: '插入水平分割线', From 01ceef269471c6bbf9beadca3c8f56fca5f32047 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 14:04:52 +0800 Subject: [PATCH 04/16] =?UTF-8?q?feat(editor):=20=E5=8F=AF=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E4=BB=A3=E7=A0=81=E5=9D=97=E6=A8=A1=E6=80=81=E6=A1=86?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F=20+=20runnable=20=E5=9D=97=E7=BB=BF=E8=89=B2?= =?UTF-8?q?=E8=BE=B9=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模态框复用 Catppuccin Latte/Mocha 配色;编辑器内含 runnable 标记的 代码块加左侧绿色边框(与 Code Runner 运行按钮主色一致)。 --- libs/tiptap-editor/src/style.css | 168 +++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/libs/tiptap-editor/src/style.css b/libs/tiptap-editor/src/style.css index 63045bf..ea18bb9 100644 --- a/libs/tiptap-editor/src/style.css +++ b/libs/tiptap-editor/src/style.css @@ -652,3 +652,171 @@ /* Mocha overlay0 */ color: #6c7086; } + +/* ========== Runnable Code Block Modal ========== */ +.tiptap-runnable-modal-mask { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); +} + +.tiptap-runnable-modal { + /* Latte mantle —— 复用 .slash-command 配色 */ + background: #e6e9ef; + border: 1px solid #bcc0cc; + border-radius: 8px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); + padding: 16px; + min-width: 320px; + max-width: 90vw; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +.tiptap-runnable-modal-title { + font-size: 15px; + font-weight: 600; + /* Latte text */ + color: #4c4f69; + margin-bottom: 12px; +} + +.tiptap-runnable-field { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 13px; + /* Latte subtext0 */ + color: #6c6f85; +} + +.tiptap-runnable-field input[type='number'], +.tiptap-runnable-field select { + flex: 1; + padding: 4px 8px; + border: 1px solid #bcc0cc; + border-radius: 4px; + background: #fff; + /* Latte text */ + color: #4c4f69; + font-size: 13px; + font-family: inherit; + outline: none; +} + +.tiptap-runnable-field input[type='checkbox'] { + accent-color: #40a02b; +} + +.tiptap-runnable-preview { + margin: 12px 0; + padding: 8px; + /* Latte surface0 */ + background: #ccd0da; + border-radius: 4px; + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace; + font-size: 12px; + /* Latte subtext1 */ + color: #5c5f77; + word-break: break-all; +} + +.tiptap-runnable-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.tiptap-runnable-actions button { + padding: 6px 16px; + border: none; + border-radius: 4px; + font-size: 13px; + cursor: pointer; +} + +.tiptap-runnable-actions .cancel { + /* Latte surface0 */ + background: #ccd0da; + /* Latte text */ + color: #4c4f69; +} + +.tiptap-runnable-actions .insert { + /* Catppuccin green —— 与 Code Runner 运行按钮主色一致 */ + background: #40a02b; + color: #fff; +} + +.tiptap-runnable-actions .insert:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* 编辑器内 runnable 代码块视觉提示:左侧绿色边框 */ +.tiptap-editor pre:has(code[class*='runnable']) { + border-left: 3px solid #40a02b; + padding-left: 12px; +} + +/* ========== Dark Theme: Runnable Modal (Mocha) ========== */ +.dark .tiptap-runnable-modal { + /* Mocha mantle */ + background: #181825; + border-color: #45475a; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); +} + +.dark .tiptap-runnable-modal-title { + /* Mocha text */ + color: #cdd6f4; +} + +.dark .tiptap-runnable-field { + /* Mocha subtext0 */ + color: #a6adc8; +} + +.dark .tiptap-runnable-field input[type='number'], +.dark .tiptap-runnable-field select { + border-color: #45475a; + /* Mocha base */ + background: #1e1e2e; + /* Mocha text */ + color: #cdd6f4; +} + +.dark .tiptap-runnable-field input[type='checkbox'] { + /* Mocha green */ + accent-color: #a6e3a1; +} + +.dark .tiptap-runnable-preview { + /* Mocha surface0 */ + background: #313244; + /* Mocha subtext1 */ + color: #bac2de; +} + +.dark .tiptap-runnable-actions .cancel { + /* Mocha surface0 */ + background: #313244; + /* Mocha text */ + color: #cdd6f4; +} + +.dark .tiptap-runnable-actions .insert { + /* Mocha green */ + background: #a6e3a1; + /* Mocha base */ + color: #1e1e2e; +} + +.dark .tiptap-editor pre:has(code[class*='runnable']) { + border-left-color: #a6e3a1; +} From 9b4c0ea36eb689b36c4f6716dbfcf6cc7bc0159a Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 14:08:06 +0800 Subject: [PATCH 05/16] =?UTF-8?q?style(editor):=20biome=20=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=96=20runnable=20=E6=A8=A1=E6=80=81=E6=A1=86?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit biome check --write 修正 import 排序 + 格式化 openRunnableModal 链式调用 (TDD 阶段子代理未跑 make fix,此处补齐以过 make lint)。 --- .../src/__tests__/runnable-modal.test.ts | 18 +++++++++++++----- libs/tiptap-editor/src/slash-command.ts | 12 ++++++++++-- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts b/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts index adcfe10..a9163a6 100644 --- a/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts +++ b/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Editor } from '@tiptap/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { openRunnableModal } from '../slash-command'; /** @@ -54,10 +54,14 @@ describe('openRunnableModal', () => { timeoutInput.value = '10'; timeoutInput.dispatchEvent(new Event('input', { bubbles: true })); // 点插入 - const insertBtn = document.querySelector('.tiptap-runnable-actions .insert')!; + const insertBtn = document.querySelector( + '.tiptap-runnable-actions .insert', + )!; insertBtn.click(); expect(calls).toHaveLength(1); - expect(calls[0].language).toBe('python runnable {"timeout_secs":10,"memory_mb":256,"allow_network":false}'); + expect(calls[0].language).toBe( + 'python runnable {"timeout_secs":10,"memory_mb":256,"allow_network":false}', + ); expect(document.querySelector('.tiptap-runnable-modal')).toBeNull(); }); @@ -74,7 +78,9 @@ describe('openRunnableModal', () => { const timeoutInput = document.querySelector('#runnable-timeout')!; timeoutInput.value = '0'; timeoutInput.dispatchEvent(new Event('input', { bubbles: true })); - const insertBtn = document.querySelector('.tiptap-runnable-actions .insert')!; + const insertBtn = document.querySelector( + '.tiptap-runnable-actions .insert', + )!; expect(insertBtn.disabled).toBe(true); // 且点插入(即使强制)不应触发——实际 disabled 按钮不接收 click,这里只验状态 insertBtn.click(); @@ -85,7 +91,9 @@ describe('openRunnableModal', () => { const { editor } = mockEditor(); openRunnableModal(editor); const timeoutInput = document.querySelector('#runnable-timeout')!; - const insertBtn = document.querySelector('.tiptap-runnable-actions .insert')!; + const insertBtn = document.querySelector( + '.tiptap-runnable-actions .insert', + )!; timeoutInput.value = '0'; timeoutInput.dispatchEvent(new Event('input', { bubbles: true })); expect(insertBtn.disabled).toBe(true); diff --git a/libs/tiptap-editor/src/slash-command.ts b/libs/tiptap-editor/src/slash-command.ts index 28caee0..139650d 100644 --- a/libs/tiptap-editor/src/slash-command.ts +++ b/libs/tiptap-editor/src/slash-command.ts @@ -552,11 +552,19 @@ export function openRunnableModal(editor: Editor): void { function updateInsertEnabled(): void { const t = Number(timeoutInput.value); const m = Number(memInput.value); - insertBtn.disabled = !(t >= TIMEOUT_RANGE.min && t <= TIMEOUT_RANGE.max && m >= MEMORY_RANGE.min && m <= MEMORY_RANGE.max); + insertBtn.disabled = !( + t >= TIMEOUT_RANGE.min && + t <= TIMEOUT_RANGE.max && + m >= MEMORY_RANGE.min && + m <= MEMORY_RANGE.max + ); } function insert(): void { - editor.chain().setCodeBlock({ language: buildRunnableInfo(state) }).run(); + editor + .chain() + .setCodeBlock({ language: buildRunnableInfo(state) }) + .run(); close(); } From 0b4346ecd4c6677f18f468ccd4191ff0f2f68fc0 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 14:15:25 +0800 Subject: [PATCH 06/16] =?UTF-8?q?fix(editor):=20=E8=AF=AD=E8=A8=80?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E5=B1=95=E5=BC=80=E6=97=B6=20Enter=20?= =?UTF-8?q?=E4=B8=8D=E8=AF=AF=E8=A7=A6=E5=8F=91=E6=8F=92=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 下拉展开时 Enter 用于确认选项,不应触发插入。 + // (HTMLSelectElement.open 在浏览器存在,但 TS lib.dom 未声明,需断言。) + if ((langSelect as HTMLSelectElement & { open: boolean }).open) return; const tag = (document.activeElement?.tagName ?? '').toLowerCase(); if (tag === 'input' || tag === 'select') { e.preventDefault(); From e79e0d9ee2f308634f7c17a53224d43ba54acf38 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 15:40:38 +0800 Subject: [PATCH 07/16] =?UTF-8?q?feat(editor):=20=E6=96=B0=E5=A2=9E=20lowl?= =?UTF-8?q?ight=20=E5=AE=9E=E4=BE=8B=E4=B8=8E=20extractLang=20=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=E6=8F=90=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit common 集(~35 种语言)注册到 lowlight;extractLang 从完整 info string (如 'python runnable {...}')提取首 token,使 runnable 块按正确语言高亮。 --- libs/pnpm-lock.yaml | 63 +++++++++++++++++++ libs/tiptap-editor/package.json | 4 +- .../src/__tests__/highlight.test.ts | 50 +++++++++++++++ libs/tiptap-editor/src/highlight.ts | 29 +++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 libs/tiptap-editor/src/__tests__/highlight.test.ts create mode 100644 libs/tiptap-editor/src/highlight.ts diff --git a/libs/pnpm-lock.yaml b/libs/pnpm-lock.yaml index 7111867..6f04c19 100644 --- a/libs/pnpm-lock.yaml +++ b/libs/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: '@tiptap/core': specifier: ^3.27.1 version: 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/extension-code-block-lowlight': + specifier: ^3.27.1 + version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(highlight.js@11.11.1)(lowlight@3.3.0) '@tiptap/extension-file-handler': specifier: ^3.27.1 version: 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/extension-text-style@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1)))(@tiptap/pm@3.27.1) @@ -94,6 +97,9 @@ importers: '@tiptap/suggestion': specifier: ^3.27.1 version: 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) + lowlight: + specifier: ^3.3.0 + version: 3.3.0 yggdrasil-core: {} @@ -370,6 +376,15 @@ packages: peerDependencies: '@tiptap/extension-list': 3.27.1 + '@tiptap/extension-code-block-lowlight@3.27.1': + resolution: {integrity: sha512-rDZYDwKpV3Uw8xjI5pzSQTLJ7pEyJkDrVhnzdh9mBY3sHVwP1maYh+0kRUrHi5N3EhiNp/GXG1kPjysPIQ4nqQ==} + peerDependencies: + '@tiptap/core': 3.27.1 + '@tiptap/extension-code-block': 3.27.1 + '@tiptap/pm': 3.27.1 + highlight.js: ^11 + lowlight: ^2 || ^3 + '@tiptap/extension-code-block@3.27.1': resolution: {integrity: sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==} peerDependencies: @@ -524,9 +539,15 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -583,10 +604,17 @@ packages: crelt@1.0.7: resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -619,6 +647,10 @@ packages: resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} engines: {node: '>=20.0.0'} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -696,6 +728,9 @@ packages: linkifyjs@4.3.3: resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1186,6 +1221,14 @@ snapshots: dependencies: '@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) + '@tiptap/extension-code-block-lowlight@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(highlight.js@11.11.1)(lowlight@3.3.0)': + dependencies: + '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) + '@tiptap/extension-code-block': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) + '@tiptap/pm': 3.27.1 + highlight.js: 11.11.1 + lowlight: 3.3.0 + '@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)': dependencies: '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) @@ -1356,10 +1399,16 @@ snapshots: '@types/estree@1.0.9': {} + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/node@26.0.1': dependencies: undici-types: 8.3.0 + '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': @@ -1429,8 +1478,14 @@ snapshots: crelt@1.0.7: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + entities@7.0.1: {} es-module-lexer@2.2.0: {} @@ -1461,6 +1516,8 @@ snapshots: - bufferutil - utf-8-validate + highlight.js@11.11.1: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -1512,6 +1569,12 @@ snapshots: linkifyjs@4.3.3: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/libs/tiptap-editor/package.json b/libs/tiptap-editor/package.json index 5c8e27b..1cf85db 100644 --- a/libs/tiptap-editor/package.json +++ b/libs/tiptap-editor/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@tiptap/core": "^3.27.1", + "@tiptap/extension-code-block-lowlight": "^3.27.1", "@tiptap/extension-file-handler": "^3.27.1", "@tiptap/extension-image": "^3.27.1", "@tiptap/extension-link": "^3.27.1", @@ -20,6 +21,7 @@ "@tiptap/markdown": "^3.27.1", "@tiptap/pm": "^3.27.1", "@tiptap/starter-kit": "^3.27.1", - "@tiptap/suggestion": "^3.27.1" + "@tiptap/suggestion": "^3.27.1", + "lowlight": "^3.3.0" } } diff --git a/libs/tiptap-editor/src/__tests__/highlight.test.ts b/libs/tiptap-editor/src/__tests__/highlight.test.ts new file mode 100644 index 0000000..6546a6c --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/highlight.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { extractLang, lowlight } from '../highlight'; + +/** + * extractLang 测试:从完整 fence info string 提取语言名(首个 token)。 + * 与后端 parse_fence_info(src/api/code_runner/languages.rs)对齐。 + */ +describe('extractLang', () => { + it.each([ + ['python', 'python', '纯语言名'], + ['python runnable', 'python', 'runnable 标记'], + ['python runnable {"timeout_secs":10}', 'python', 'runnable + overrides JSON'], + ['node runnable', 'node', 'node runnable'], + [' python ', 'python', '前后空格(trim)'], + ['', '', '空字符串'], + [' ', '', '纯空格'], + ])('%s → %s (%s)', (input, expected) => { + expect(extractLang(input)).toBe(expected); + }); +}); + +/** + * lowlight wrapper 测试:highlight 经过 extractLang 处理, + * runnable info string 也能按正确语言高亮。 + */ +describe('lowlight wrapper', () => { + it('runnable info string 按 python 高亮(不抛错)', () => { + // 未包装的 lowlight 遇到 'python runnable {...}' 会回退 highlightAuto 或抛错; + // wrapper 提取 'python' 后正常高亮。 + const result = lowlight.highlight('python runnable {"timeout_secs":10}', 'def f():\n pass'); + expect(result).toBeDefined(); + }); + + it('python 代码高亮输出含 hljs-keyword class', () => { + const result = lowlight.highlight('python', 'def f():\n pass'); + const html = JSON.stringify(result); + expect(html).toContain('hljs-keyword'); + }); + + it('普通语言名(无 runnable)正常工作', () => { + const result = lowlight.highlight('javascript', 'const x = 1;'); + expect(JSON.stringify(result)).toContain('hljs-'); + }); + + it('未注册语言不抛错(回退处理)', () => { + // extractLang 返回未注册语言名时,lowlight 内部会抛错, + // CodeBlockLowlight 源码用 try/catch 兜底,这里只验 wrapper 不改变此行为。 + expect(() => lowlight.highlight('totally-unknown-lang', 'code')).toThrow(); + }); +}); diff --git a/libs/tiptap-editor/src/highlight.ts b/libs/tiptap-editor/src/highlight.ts new file mode 100644 index 0000000..3e3a43f --- /dev/null +++ b/libs/tiptap-editor/src/highlight.ts @@ -0,0 +1,29 @@ +import { createLowlight, common } from 'lowlight'; + +const base = createLowlight(common); + +/** + * 从完整 fence info string 提取语言名(首个 token)。 + * + * 与后端 parse_fence_info(src/api/code_runner/languages.rs:80)对齐: + * info string 形如 `python runnable {"timeout_secs":10}`,语言是首个空白分隔的 token。 + */ +export function extractLang(info: string): string { + return info.trim().split(/\s+/)[0] || ''; +} + +/** + * lowlight 实例,包装 highlight 方法以处理完整 info string。 + * + * CodeBlockLowlight 取 `block.node.attrs.language`(如 `python runnable {...}`) + * 直接调 `lowlight.highlight(language, code)`,但 lowlight 只认 `python` 这个 token。 + * wrapper 先 extractLang 提取首 token,再高亮,使 runnable 块也能按正确语言着色。 + * + * 普通 code block(language='python')也兼容:extractLang('python') → 'python'。 + */ +export const lowlight = { + ...base, + highlight(language: string, value: string) { + return base.highlight(extractLang(language), value); + }, +}; From e86fe0767d63956abfb732d754a0ec10401c5bd1 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 15:48:02 +0800 Subject: [PATCH 08/16] =?UTF-8?q?feat(editor):=20=E7=94=A8=20CodeBlockLowl?= =?UTF-8?q?ight=20=E6=9B=BF=E6=8D=A2=20StarterKit=20=E8=87=AA=E5=B8=A6=20C?= =?UTF-8?q?odeBlock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 禁用 StarterKit codeBlock,引入 CodeBlockLowlight + lowlight 实例。 代码块现在有实时语法高亮(ProseMirror decoration),language 属性 仍承载完整 info string(markdown 往返不变)。 --- libs/tiptap-editor/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index f4fcc65..483e8c0 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -3,7 +3,9 @@ import { FileHandler } from '@tiptap/extension-file-handler'; import { TaskItem, TaskList } from '@tiptap/extension-list'; import { TableKit } from '@tiptap/extension-table'; import { Markdown } from '@tiptap/markdown'; +import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import StarterKit from '@tiptap/starter-kit'; +import { lowlight } from './highlight'; import { SlashCommand } from './slash-command'; import { TaskInputRule } from './task-input-rule'; import { @@ -98,8 +100,10 @@ class TiptapEditorInstance { linkOnPaste: true, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' }, }, + codeBlock: false, }), Markdown, + CodeBlockLowlight.configure({ lowlight }), TableKit, UploadImage, TaskList, From 8493e4c4c4997a80eb0cc76eb5d9cfb73e0744ed Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 15:50:35 +0800 Subject: [PATCH 09/16] =?UTF-8?q?feat(editor):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=9D=97=E8=AF=AD=E6=B3=95=E9=AB=98=E4=BA=AE=E9=85=8D=E8=89=B2?= =?UTF-8?q?(Catppuccin=20Latte/Mocha)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 色值取自 @catppuccin/highlightjs@1.0.1,与读者侧 highlight.css 同一 palette。 亮/暗双套,.dark 前缀切换,scope 到 .tiptap-editor 避免污染全站。 --- libs/tiptap-editor/src/style.css | 116 +++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/libs/tiptap-editor/src/style.css b/libs/tiptap-editor/src/style.css index ea18bb9..2d6a77c 100644 --- a/libs/tiptap-editor/src/style.css +++ b/libs/tiptap-editor/src/style.css @@ -820,3 +820,119 @@ .dark .tiptap-editor pre:has(code[class*='runnable']) { border-left-color: #a6e3a1; } + +/* ========== Code Block Syntax Highlighting (Catppuccin Latte) ========== */ +/* 色值取自 @catppuccin/highlightjs@1.0.1,与读者侧 public/highlight.css(syntect Catppuccin)同一 palette。 + scope 到 .tiptap-editor pre code,避免污染全站 code 元素。 + 基础文字色/背景由现有 .tiptap-editor pre code 规则覆盖,未命中 token 回退到基础色。 */ + +.tiptap-editor pre code .hljs-keyword { + color: #8839ef; +} +.tiptap-editor pre code .hljs-string, +.tiptap-editor pre code .hljs-char.escape_ { + color: #40a02b; +} +.tiptap-editor pre code .hljs-comment { + color: #7c7f93; + font-style: italic; +} +.tiptap-editor pre code .hljs-number, +.tiptap-editor pre code .hljs-literal { + color: #fe640b; +} +.tiptap-editor pre code .hljs-title, +.tiptap-editor pre code .hljs-title.function_ { + color: #1e66f5; +} +.tiptap-editor pre code .hljs-type, +.tiptap-editor pre code .hljs-title.class_ { + color: #df8e1d; +} +.tiptap-editor pre code .hljs-built_in, +.tiptap-editor pre code .hljs-doctag { + color: #d20f39; +} +.tiptap-editor pre code .hljs-operator { + color: #04a5e5; +} +.tiptap-editor pre code .hljs-property, +.tiptap-editor pre code .hljs-tag { + color: #179299; +} +.tiptap-editor pre code .hljs-regexp, +.tiptap-editor pre code .hljs-meta { + color: #ea76cb; +} +.tiptap-editor pre code .hljs-variable, +.tiptap-editor pre code .hljs-symbol { + color: #dd7878; +} +.tiptap-editor pre code .hljs-attr, +.tiptap-editor pre code .hljs-attribute { + color: #1e66f5; +} +.tiptap-editor pre code .hljs-addition { + color: #40a02b; + background: rgba(64, 160, 43, 0.15); +} +.tiptap-editor pre code .hljs-deletion { + color: #d20f39; + background: rgba(210, 15, 57, 0.15); +} + +/* ========== Dark Theme: Code Block Syntax Highlighting (Catppuccin Mocha) ========== */ +.dark .tiptap-editor pre code .hljs-keyword { + color: #cba6f7; +} +.dark .tiptap-editor pre code .hljs-string, +.dark .tiptap-editor pre code .hljs-char.escape_ { + color: #a6e3a1; +} +.dark .tiptap-editor pre code .hljs-comment { + color: #9399b2; + font-style: italic; +} +.dark .tiptap-editor pre code .hljs-number, +.dark .tiptap-editor pre code .hljs-literal { + color: #fab387; +} +.dark .tiptap-editor pre code .hljs-title, +.dark .tiptap-editor pre code .hljs-title.function_ { + color: #89b4fa; +} +.dark .tiptap-editor pre code .hljs-type, +.dark .tiptap-editor pre code .hljs-title.class_ { + color: #8839ef; +} +.dark .tiptap-editor pre code .hljs-built_in, +.dark .tiptap-editor pre code .hljs-doctag { + color: #f38ba8; +} +.dark .tiptap-editor pre code .hljs-operator { + color: #89dceb; +} +.dark .tiptap-editor pre code .hljs-property, +.dark .tiptap-editor pre code .hljs-tag { + color: #94e2d5; +} +.dark .tiptap-editor pre code .hljs-regexp, +.dark .tiptap-editor pre code .hljs-meta { + color: #f5c2e7; +} +.dark .tiptap-editor pre code .hljs-variable, +.dark .tiptap-editor pre code .hljs-symbol { + color: #f2cdcd; +} +.dark .tiptap-editor pre code .hljs-attr, +.dark .tiptap-editor pre code .hljs-attribute { + color: #89b4fa; +} +.dark .tiptap-editor pre code .hljs-addition { + color: #a6e3a1; + background: rgba(166, 227, 161, 0.15); +} +.dark .tiptap-editor pre code .hljs-deletion { + color: #f38ba8; + background: rgba(243, 139, 168, 0.15); +} From 4de48cd16c311b7ab03d4514d2e25042eb5d4b37 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 15:53:16 +0800 Subject: [PATCH 10/16] =?UTF-8?q?style(editor):=20biome=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20import=20=E6=8E=92=E5=BA=8F(highlight/index)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDD 阶段子代理未跑 biome check --write,补齐 import 字母序以过 make lint。 --- libs/tiptap-editor/src/highlight.ts | 2 +- libs/tiptap-editor/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/tiptap-editor/src/highlight.ts b/libs/tiptap-editor/src/highlight.ts index 3e3a43f..ce7a524 100644 --- a/libs/tiptap-editor/src/highlight.ts +++ b/libs/tiptap-editor/src/highlight.ts @@ -1,4 +1,4 @@ -import { createLowlight, common } from 'lowlight'; +import { common, createLowlight } from 'lowlight'; const base = createLowlight(common); diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts index 483e8c0..9267e1d 100644 --- a/libs/tiptap-editor/src/index.ts +++ b/libs/tiptap-editor/src/index.ts @@ -1,9 +1,9 @@ import { Editor } from '@tiptap/core'; +import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import { FileHandler } from '@tiptap/extension-file-handler'; import { TaskItem, TaskList } from '@tiptap/extension-list'; import { TableKit } from '@tiptap/extension-table'; import { Markdown } from '@tiptap/markdown'; -import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import StarterKit from '@tiptap/starter-kit'; import { lowlight } from './highlight'; import { SlashCommand } from './slash-command'; From 0adc6d83829d26d810cfff27ccd22e0ce953e8de Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 16:13:08 +0800 Subject: [PATCH 11/16] =?UTF-8?q?fix(editor):=20=E6=9A=97=E8=89=B2=20type?= =?UTF-8?q?=20=E8=89=B2=E5=80=BC=E4=BF=AE=E6=AD=A3=20+=20extractLang=20?= =?UTF-8?q?=E5=B0=8F=E5=86=99=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .dark .hljs-type/.hljs-title.class_ 由 #8839ef(紫) 改为 #f9e2af(黄), 与 spec 表、官方 @catppuccin/highlightjs、读者侧 highlight.css 三处对齐 - extractLang 加 toLowerCase,与后端 parse_fence_info(languages.rs:85)对齐, 避免大写语言名触发 lowlight Unknown language 抛错 --- libs/tiptap-editor/src/__tests__/highlight.test.ts | 2 ++ libs/tiptap-editor/src/highlight.ts | 10 ++++++---- libs/tiptap-editor/src/style.css | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/libs/tiptap-editor/src/__tests__/highlight.test.ts b/libs/tiptap-editor/src/__tests__/highlight.test.ts index 6546a6c..397c380 100644 --- a/libs/tiptap-editor/src/__tests__/highlight.test.ts +++ b/libs/tiptap-editor/src/__tests__/highlight.test.ts @@ -14,6 +14,8 @@ describe('extractLang', () => { [' python ', 'python', '前后空格(trim)'], ['', '', '空字符串'], [' ', '', '纯空格'], + ['PYTHON', 'python', '大写小写化(与后端对齐)'], + ['Python Runnable', 'python', '混合大小写'], ])('%s → %s (%s)', (input, expected) => { expect(extractLang(input)).toBe(expected); }); diff --git a/libs/tiptap-editor/src/highlight.ts b/libs/tiptap-editor/src/highlight.ts index ce7a524..e0b0d54 100644 --- a/libs/tiptap-editor/src/highlight.ts +++ b/libs/tiptap-editor/src/highlight.ts @@ -3,13 +3,15 @@ import { common, createLowlight } from 'lowlight'; const base = createLowlight(common); /** - * 从完整 fence info string 提取语言名(首个 token)。 + * 从完整 fence info string 提取语言名(首个 token,小写化)。 * - * 与后端 parse_fence_info(src/api/code_runner/languages.rs:80)对齐: - * info string 形如 `python runnable {"timeout_secs":10}`,语言是首个空白分隔的 token。 + * 与后端 parse_fence_info(src/api/code_runner/languages.rs:85)对齐: + * info string 形如 `python runnable {"timeout_secs":10}`,语言是首个空白分隔的 token, + * 再 to_lowercase(lowlight 注册名均为小写,大写会导致 Unknown language 抛错)。 */ export function extractLang(info: string): string { - return info.trim().split(/\s+/)[0] || ''; + const first = info.trim().split(/\s+/)[0]; + return first ? first.toLowerCase() : ''; } /** diff --git a/libs/tiptap-editor/src/style.css b/libs/tiptap-editor/src/style.css index 2d6a77c..10d7656 100644 --- a/libs/tiptap-editor/src/style.css +++ b/libs/tiptap-editor/src/style.css @@ -903,7 +903,7 @@ } .dark .tiptap-editor pre code .hljs-type, .dark .tiptap-editor pre code .hljs-title.class_ { - color: #8839ef; + color: #f9e2af; } .dark .tiptap-editor pre code .hljs-built_in, .dark .tiptap-editor pre code .hljs-doctag { From 82280e3d4f615f47453e1b2303bb070b79b205a7 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 17:53:26 +0800 Subject: [PATCH 12/16] =?UTF-8?q?feat(editor):=20CodeBlockNodeView=20?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E8=AF=AD=E8=A8=80=E6=A0=87=E7=AD=BE=E4=B8=8E?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自定义 NodeView 包裹
,顶部工具栏显示语言(extractLang 提取),
runnable 块额外显示运行按钮。contentDOM 指向 ,保证 CodeBlockLowlight
decoration(高亮)正常生效。运行走 editor.storage.__onRunCode 回调。
---
 .../src/__tests__/code-block-view.test.ts     | 119 +++++++++++
 libs/tiptap-editor/src/code-block-view.ts     | 189 ++++++++++++++++++
 2 files changed, 308 insertions(+)
 create mode 100644 libs/tiptap-editor/src/__tests__/code-block-view.test.ts
 create mode 100644 libs/tiptap-editor/src/code-block-view.ts

diff --git a/libs/tiptap-editor/src/__tests__/code-block-view.test.ts b/libs/tiptap-editor/src/__tests__/code-block-view.test.ts
new file mode 100644
index 0000000..f0e8331
--- /dev/null
+++ b/libs/tiptap-editor/src/__tests__/code-block-view.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it, vi } from 'vitest';
+import { CodeBlockNodeView } from '../code-block-view';
+
+/**
+ * 模块级共享 sentinel:对齐 upload-image.test.ts 范式。
+ * 真实 ProseMirror 每个 schema 的 NodeType 是单例,同类节点引用必等;
+ * mock 用共享引用模拟此语义,使实现的纯引用比较能命中。
+ */
+const CODEBLOCK_TYPE = { name: 'codeBlock' };
+const PARAGRAPH_TYPE = { name: 'paragraph' };
+
+/** 构造最小 mock node,只含 NodeView 需要的字段。 */
+function mockNode(language: string, textContent = '') {
+  return {
+    type: CODEBLOCK_TYPE,
+    attrs: { language },
+    textContent,
+  } as any;
+}
+
+/** 构造最小 mock editor,含 storage。 */
+function mockEditor(onRunCode?: (opts: any) => Promise) {
+  return {
+    storage: onRunCode ? { __onRunCode: onRunCode } : {},
+  } as any;
+}
+
+/**
+ * CodeBlockNodeView 测试。
+ *
+ * 验证:DOM 结构、语言标签、运行按钮显隐、update 刷新、contentDOM 指向 code。
+ */
+describe('CodeBlockNodeView', () => {
+  it('runnable 块:toolbar 显示语言 + 运行按钮', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python runnable {"timeout_secs":10}'),
+      editor: mockEditor(),
+    } as any);
+    expect(view.dom.querySelector('.tiptap-codeblock-lang')?.textContent).toBe('python');
+    expect(view.dom.querySelector('.tiptap-codeblock-run')).not.toBeNull();
+  });
+
+  it('普通块(python):显示语言标签,无运行按钮', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python'),
+      editor: mockEditor(),
+    } as any);
+    expect(view.dom.querySelector('.tiptap-codeblock-lang')?.textContent).toBe('python');
+    expect(view.dom.querySelector('.tiptap-codeblock-run')).toBeNull();
+  });
+
+  it('无 language 的块:标签为空或占位,无运行按钮', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode(''),
+      editor: mockEditor(),
+    } as any);
+    expect(view.dom.querySelector('.tiptap-codeblock-run')).toBeNull();
+  });
+
+  it('node 语言变化时 update() 刷新标签', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python'),
+      editor: mockEditor(),
+    } as any);
+    // 模拟 ProseMirror 调 update:改为 node runnable
+    const updated = view.update(mockNode('node runnable'));
+    expect(updated).toBe(true);
+    expect(view.dom.querySelector('.tiptap-codeblock-lang')?.textContent).toBe('node');
+    expect(view.dom.querySelector('.tiptap-codeblock-run')).not.toBeNull();
+  });
+
+  it('update 拒绝非同类节点(返回 false)', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python'),
+      editor: mockEditor(),
+    } as any);
+    const otherNode = { type: PARAGRAPH_TYPE, attrs: {}, textContent: '' } as any;
+    expect(view.update(otherNode)).toBe(false);
+  });
+
+  it('contentDOM 指向 code 元素(保证 decoration 生效)', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python'),
+      editor: mockEditor(),
+    } as any);
+    expect(view.contentDOM?.tagName).toBe('CODE');
+  });
+
+  it('ignoreMutation 返回 true(工具栏不触发编辑事务)', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python'),
+      editor: mockEditor(),
+    } as any);
+    expect(view.ignoreMutation()).toBe(true);
+  });
+
+  it('点击运行按钮调用 onRunCode(storage 回调)', () => {
+    const onRunCode = vi.fn(() => Promise.resolve('结果'));
+    const view = new CodeBlockNodeView({
+      node: mockNode('python runnable', 'print(1)'),
+      editor: mockEditor(onRunCode),
+    } as any);
+    view.dom.querySelector('.tiptap-codeblock-run')!.click();
+    expect(onRunCode).toHaveBeenCalledTimes(1);
+    expect(onRunCode).toHaveBeenCalledWith(
+      expect.objectContaining({ language: 'python runnable', source: 'print(1)' }),
+    );
+  });
+
+  it('无 onRunCode 时点运行不报错(优雅降级)', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python runnable', 'print(1)'),
+      editor: mockEditor(), // 无 __onRunCode
+    } as any);
+    expect(() =>
+      view.dom.querySelector('.tiptap-codeblock-run')!.click(),
+    ).not.toThrow();
+  });
+});
diff --git a/libs/tiptap-editor/src/code-block-view.ts b/libs/tiptap-editor/src/code-block-view.ts
new file mode 100644
index 0000000..117e224
--- /dev/null
+++ b/libs/tiptap-editor/src/code-block-view.ts
@@ -0,0 +1,189 @@
+import type { Editor } from '@tiptap/core';
+import type { Node as PMNode } from '@tiptap/pm/model';
+import { extractLang } from './highlight';
+
+/** editor.storage 的 key,宿主(index.ts)在此注入 onRunCode 回调。 */
+export const ON_RUN_CODE_STORAGE_KEY = '__onRunCode';
+
+/** 运行请求参数(传给 onRunCode 回调)。 */
+export interface RunCodeOpts {
+  /** 完整 info string(如 `python runnable {"timeout_secs":10}`)。 */
+  language: string;
+  /** 代码块文本内容。 */
+  source: string;
+}
+
+/**
+ * 判断节点是否为 runnable 代码块(language 属性含 'runnable' 标记)。
+ */
+function isRunnable(node: PMNode): boolean {
+  const lang = (node.attrs.language as string | null) ?? '';
+  return lang.includes('runnable');
+}
+
+/**
+ * CodeBlock 自定义 NodeView。
+ *
+ * 在标准 `
` 外包一层容器,顶部加工具栏(语言标签 + 运行按钮),
+ * 底部加运行结果区。contentDOM 仍指向 ``,保证 CodeBlockLowlight 的
+ * decoration(语法高亮)正常生效。
+ *
+ * 运行按钮仅 runnable 块显示;点击调 editor.storage.__onRunCode 回调
+ * (由 index.ts 注入,转发到 Rust server function)。
+ */
+export class CodeBlockNodeView {
+  private node: PMNode;
+  private editor: Editor;
+
+  private container: HTMLDivElement;
+  private toolbar: HTMLDivElement;
+  private langBadge: HTMLSpanElement;
+  private runBtn: HTMLButtonElement | null = null;
+  private pre: HTMLPreElement;
+  private code: HTMLElement;
+  private resultArea: HTMLDivElement | null = null;
+
+  constructor(opts: { node: PMNode; editor: Editor }) {
+    this.node = opts.node;
+    this.editor = opts.editor;
+
+    this.container = document.createElement('div');
+    this.container.classList.add('tiptap-codeblock');
+
+    // 工具栏
+    this.toolbar = document.createElement('div');
+    this.toolbar.classList.add('tiptap-codeblock-toolbar');
+    this.toolbar.setAttribute('contenteditable', 'false');
+
+    this.langBadge = document.createElement('span');
+    this.langBadge.classList.add('tiptap-codeblock-lang');
+    this.langBadge.textContent = extractLang((this.node.attrs.language as string) ?? '');
+    this.toolbar.appendChild(this.langBadge);
+
+    // 运行按钮(仅 runnable 块)
+    if (isRunnable(this.node)) {
+      this.runBtn = this.createRunButton();
+      this.toolbar.appendChild(this.runBtn);
+    }
+
+    this.container.appendChild(this.toolbar);
+
+    // pre > code(contentDOM,decoration 在此生效)
+    this.pre = document.createElement('pre');
+    this.code = document.createElement('code');
+    const lang = (this.node.attrs.language as string) ?? '';
+    if (lang) {
+      this.code.classList.add(`language-${lang}`);
+    }
+    this.pre.appendChild(this.code);
+    this.container.appendChild(this.pre);
+  }
+
+  get dom(): HTMLElement {
+    return this.container;
+  }
+
+  get contentDOM(): HTMLElement | null {
+    // contentDOM 必须是 ,ProseMirror 在此挂 decoration + 处理文本编辑。
+    return this.code;
+  }
+
+  /** ProseMirror 调用:节点属性变化时刷新工具栏。返回 false 拒绝非同类节点。 */
+  update(node: PMNode): boolean {
+    // 按 node.type 引用比较:真实 ProseMirror 每个 schema 的 NodeType 是单例,
+    // 同类节点引用必等(对齐 upload-image.ts 既有范式)。
+    if (node.type !== this.node.type) return false;
+    const oldLang = (this.node.attrs.language as string) ?? '';
+    const newLang = (node.attrs.language as string) ?? '';
+    this.node = node;
+    if (oldLang !== newLang) {
+      this.langBadge.textContent = extractLang(newLang);
+      // runnable 状态变化时重建按钮(简化:不细粒度增删,整体重建工具栏按钮区)
+      this.refreshRunButton();
+    }
+    return true;
+  }
+
+  /** 语言变化后,按 runnable 状态增删运行按钮。 */
+  private refreshRunButton(): void {
+    const shouldHave = isRunnable(this.node);
+    const has = this.runBtn !== null;
+    if (shouldHave && !has) {
+      this.runBtn = this.createRunButton();
+      this.toolbar.appendChild(this.runBtn);
+    } else if (!shouldHave && has) {
+      this.runBtn?.remove();
+      this.runBtn = null;
+    }
+  }
+
+  /** 工具栏/结果区交互不触发 ProseMirror 编辑事务。 */
+  ignoreMutation(): boolean {
+    return true;
+  }
+
+  /** 工具栏按钮点击不被编辑器拦截。 */
+  stopEvent(): boolean {
+    return false;
+  }
+
+  /** 创建运行按钮(构造与 update 增删共用)。 */
+  private createRunButton(): HTMLButtonElement {
+    const btn = document.createElement('button');
+    btn.classList.add('tiptap-codeblock-run');
+    btn.type = 'button';
+    btn.textContent = '▶ 运行';
+    btn.setAttribute('contenteditable', 'false');
+    btn.addEventListener('click', () => this.runCode());
+    return btn;
+  }
+
+  /** 点击运行:调 editor.storage.__onRunCode,结果填入结果区。 */
+  private async runCode(): Promise {
+    if (!this.runBtn) return;
+    const storage = this.editor.storage as unknown as Record;
+    const onRunCode = storage[ON_RUN_CODE_STORAGE_KEY] as
+      | ((opts: RunCodeOpts) => Promise)
+      | undefined;
+    if (!onRunCode) return; // 优雅降级:无回调时不操作
+
+    // 防重复点击
+    this.runBtn.disabled = true;
+    this.runBtn.textContent = '运行中…';
+    this.ensureResultArea('运行中…');
+
+    try {
+      const result = await onRunCode({
+        language: (this.node.attrs.language as string) ?? '',
+        source: this.node.textContent,
+      });
+      this.renderResult(result);
+    } catch (e) {
+      this.renderResult(`运行失败:${e instanceof Error ? e.message : String(e)}`);
+    } finally {
+      this.runBtn.disabled = false;
+      this.runBtn.textContent = '▶ 运行';
+    }
+  }
+
+  /** 确保 resultArea 存在,设置初始内容。 */
+  private ensureResultArea(initial: string): void {
+    if (!this.resultArea) {
+      this.resultArea = document.createElement('div');
+      this.resultArea.classList.add('tiptap-codeblock-result');
+      this.resultArea.setAttribute('contenteditable', 'false');
+      this.container.appendChild(this.resultArea);
+    }
+    this.resultArea.textContent = initial;
+  }
+
+  /** 渲染运行结果字符串到结果区。 */
+  private renderResult(result: string): void {
+    this.ensureResultArea(result);
+  }
+
+  destroy(): void {
+    this.resultArea = null;
+    this.runBtn = null;
+  }
+}

From 6e5ff72a2925b9c0867dc4579ca7f6bffb25ffd4 Mon Sep 17 00:00:00 2001
From: xfy 
Date: Mon, 6 Jul 2026 18:00:15 +0800
Subject: [PATCH 13/16] =?UTF-8?q?feat(editor):=20=E6=8C=82=E8=BD=BD=20Code?=
 =?UTF-8?q?BlockNodeView=20+=20editor.storage=20=E6=B3=A8=E5=85=A5=20onRun?=
 =?UTF-8?q?Code?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

CodeBlockLowlight.extend({ addNodeView }) 接入自定义 NodeView。
EditorOptions 新增 onRunCode 字段;编辑器初始化时挂到 editor.storage,
NodeView 经 storage key 读取(仿 upload-coordinator 范式)。
---
 libs/tiptap-editor/src/index.ts | 16 +++++++++++++++-
 1 file changed, 15 insertions(+), 1 deletion(-)

diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts
index 9267e1d..87feabe 100644
--- a/libs/tiptap-editor/src/index.ts
+++ b/libs/tiptap-editor/src/index.ts
@@ -5,6 +5,7 @@ import { TaskItem, TaskList } from '@tiptap/extension-list';
 import { TableKit } from '@tiptap/extension-table';
 import { Markdown } from '@tiptap/markdown';
 import StarterKit from '@tiptap/starter-kit';
+import { CodeBlockNodeView, ON_RUN_CODE_STORAGE_KEY } from './code-block-view';
 import { lowlight } from './highlight';
 import { SlashCommand } from './slash-command';
 import { TaskInputRule } from './task-input-rule';
@@ -38,6 +39,9 @@ class EditorOptions {
   onReady?: () => void;
   // 上传状态事件(error/success/removed + counts),替代 window.__tiptap_uploads 轮询
   onUploadEvent?: (event: UploadEvent) => void;
+  // 编辑器内运行代码回调:Rust 注入,NodeView 点击「运行」时经 editor.storage 转发。
+  // opts: { language, source };返回格式化结果字符串(Rust 拼好 stdout/stderr/状态)。
+  onRunCode?: (opts: { language: string; source: string }) => Promise;
 }
 // wasm-bindgen 生成的 glue 用裸标识符 `new EditorOptions()` 从全局解析,
 // IIFE 的 name 只能挂一个全局(TiptapEditor),这里手动把 EditorOptions 也挂到 window 上。
@@ -103,7 +107,11 @@ class TiptapEditorInstance {
           codeBlock: false,
         }),
         Markdown,
-        CodeBlockLowlight.configure({ lowlight }),
+        CodeBlockLowlight.configure({ lowlight }).extend({
+          addNodeView() {
+            return ({ node, editor }) => new CodeBlockNodeView({ node, editor });
+          },
+        }),
         TableKit,
         UploadImage,
         TaskList,
@@ -169,6 +177,12 @@ class TiptapEditorInstance {
         this.coordinator;
     }
 
+    // 把 onRunCode 回调挂到 editor.storage,供 CodeBlockNodeView 读取(仿 upload-coordinator 范式)。
+    if (this.options.onRunCode) {
+      (this.editor.storage as unknown as Record)[ON_RUN_CODE_STORAGE_KEY] =
+        this.options.onRunCode;
+    }
+
     // 通知宿主编辑器已就绪(替代 window.__tiptap_ready 轮询)
     this.options.onReady?.();
   }

From 6bdc48e84c4f11535f569422528d9a45a8f916f5 Mon Sep 17 00:00:00 2001
From: xfy 
Date: Mon, 6 Jul 2026 18:02:32 +0800
Subject: [PATCH 14/16] =?UTF-8?q?feat(editor):=20CodeBlock=20NodeView=20?=
 =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=A0=8F=E4=B8=8E=E7=BB=93=E6=9E=9C=E5=8C=BA?=
 =?UTF-8?q?=E6=A0=B7=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

语言标签 + 运行按钮 + 结果区,亮/暗双套 Catppuccin 配色。
runnable 绿条移到 .tiptap-codeblock 容器(替换旧 pre 绿条规则)。
---
 libs/tiptap-editor/src/style.css | 121 ++++++++++++++++++++++++++++---
 1 file changed, 111 insertions(+), 10 deletions(-)

diff --git a/libs/tiptap-editor/src/style.css b/libs/tiptap-editor/src/style.css
index 10d7656..1f02d5f 100644
--- a/libs/tiptap-editor/src/style.css
+++ b/libs/tiptap-editor/src/style.css
@@ -758,12 +758,6 @@
   cursor: not-allowed;
 }
 
-/* 编辑器内 runnable 代码块视觉提示:左侧绿色边框 */
-.tiptap-editor pre:has(code[class*='runnable']) {
-  border-left: 3px solid #40a02b;
-  padding-left: 12px;
-}
-
 /* ========== Dark Theme: Runnable Modal (Mocha) ========== */
 .dark .tiptap-runnable-modal {
   /* Mocha mantle */
@@ -817,10 +811,6 @@
   color: #1e1e2e;
 }
 
-.dark .tiptap-editor pre:has(code[class*='runnable']) {
-  border-left-color: #a6e3a1;
-}
-
 /* ========== Code Block Syntax Highlighting (Catppuccin Latte) ========== */
 /* 色值取自 @catppuccin/highlightjs@1.0.1,与读者侧 public/highlight.css(syntect Catppuccin)同一 palette。
    scope 到 .tiptap-editor pre code,避免污染全站 code 元素。
@@ -936,3 +926,114 @@
   color: #f38ba8;
   background: rgba(243, 139, 168, 0.15);
 }
+
+/* ========== Code Block NodeView (toolbar + result) ========== */
+.tiptap-codeblock {
+  position: relative;
+  margin: 1em 0;
+}
+
+.tiptap-codeblock-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 4px 12px;
+  /* Latte crust(与 pre 背景协调) */
+  background: #dce0e8;
+  border-radius: 6px 6px 0 0;
+  font-size: 12px;
+  font-family:
+    -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+/* pre 紧贴 toolbar 下方,合并圆角 */
+.tiptap-codeblock > pre {
+  margin: 0;
+  border-radius: 0 0 6px 6px;
+}
+
+/* runnable 块绿条移到容器左侧(避免与 pre padding 冲突) */
+.tiptap-codeblock:has(code[class*='runnable']) {
+  border-left: 3px solid #40a02b;
+  padding-left: 0;
+}
+.tiptap-codeblock:has(code[class*='runnable']) > pre {
+  padding-left: 13px;
+}
+
+.tiptap-codeblock-lang {
+  /* Latte subtext0 */
+  color: #6c6f85;
+  font-weight: 500;
+  text-transform: lowercase;
+}
+
+.tiptap-codeblock-run {
+  border: none;
+  border-radius: 4px;
+  padding: 2px 10px;
+  font-size: 12px;
+  cursor: pointer;
+  /* Catppuccin green */
+  background: #40a02b;
+  color: #fff;
+}
+
+.tiptap-codeblock-run:hover:not(:disabled) {
+  background: #3a8f26;
+}
+
+.tiptap-codeblock-run:disabled {
+  opacity: 0.6;
+  cursor: wait;
+}
+
+.tiptap-codeblock-result {
+  margin-top: 4px;
+  padding: 8px 12px;
+  border-radius: 6px;
+  /* Latte surface0 */
+  background: #ccd0da;
+  font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
+  font-size: 12px;
+  /* Latte text */
+  color: #4c4f69;
+  white-space: pre-wrap;
+  word-break: break-all;
+  max-height: 300px;
+  overflow-y: auto;
+}
+
+/* ========== Dark Theme: Code Block NodeView (Mocha) ========== */
+.dark .tiptap-codeblock-toolbar {
+  /* Mocha crust */
+  background: #11111b;
+}
+
+.dark .tiptap-codeblock:has(code[class*='runnable']) {
+  border-left-color: #a6e3a1;
+}
+
+.dark .tiptap-codeblock-lang {
+  /* Mocha subtext0 */
+  color: #a6adc8;
+}
+
+.dark .tiptap-codeblock-run {
+  /* Mocha green */
+  background: #a6e3a1;
+  /* Mocha base */
+  color: #1e1e2e;
+}
+
+.dark .tiptap-codeblock-run:hover:not(:disabled) {
+  background: #95d88f;
+}
+
+.dark .tiptap-codeblock-result {
+  /* Mocha surface0 */
+  background: #313244;
+  /* Mocha text */
+  color: #cdd6f4;
+}

From 0c1e8b7640946a630b6f59b52c54ffb2701e2183 Mon Sep 17 00:00:00 2001
From: xfy 
Date: Mon, 6 Jul 2026 18:11:28 +0800
Subject: [PATCH 15/16] =?UTF-8?q?feat(bridge):=20=E6=96=B0=E5=A2=9E=20make?=
 =?UTF-8?q?=5Frun=5Fcode=5Fclosure=20=E6=A1=A5=E6=8E=A5=E7=BC=96=E8=BE=91?=
 =?UTF-8?q?=E5=99=A8=E5=86=85=E8=BF=90=E8=A1=8C=E4=BB=A3=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

tiptap_bridge.rs:
- EditorOptions extern 追加 set_on_run_code setter (js_name=onRunCode)
- 新增 RunCodeOptsJs 映射(language/source/overridesJson getter)
- 新增 format_run_result + make_run_code_closure
  · start_exec + 500ms 轮询 get_exec_result,30s 上限
  · overridesJson 空串视为 None,畸形 JSON 静默降级
  · 不依赖 server-only 的 languages 模块(前端 extractLang 已提取纯语言名)
- EditorHandle 加 _on_run_code 字段 + new 参数
- pub use 导出 make_run_code_closure + RunCodeOptsJs

write.rs: 跟进 EditorHandle::new 新签名(构造 on_run_code + set_on_run_code)。
---
 src/pages/admin/write.rs |  13 +++-
 src/tiptap_bridge.rs     | 132 +++++++++++++++++++++++++++++++++++++--
 2 files changed, 139 insertions(+), 6 deletions(-)

diff --git a/src/pages/admin/write.rs b/src/pages/admin/write.rs
index e9a9deb..b7d836f 100644
--- a/src/pages/admin/write.rs
+++ b/src/pages/admin/write.rs
@@ -186,6 +186,8 @@ fn write_editor(post_id: Option) -> Element {
                 consume_upload_event(&ev, uploads_in_flight, upload_errors);
             }
         });
+        // 运行代码 closure:start_exec + 轮询,结果字符串回填 JS 结果区 DOM。
+        let on_run_code = crate::tiptap_bridge::make_run_code_closure();
 
         // —— 构造 options ——
         let opts = crate::tiptap_bridge::EditorOptions::new();
@@ -194,6 +196,7 @@ fn write_editor(post_id: Option) -> Element {
         opts.set_on_ready(&on_ready);
         opts.set_on_image_upload(&on_image_upload);
         opts.set_on_upload_event(&on_upload_event);
+        opts.set_on_run_code(&on_run_code);
 
         // —— create(同步返回;找不到容器返回 None,构造失败抛异常)——
         match crate::tiptap_bridge::get_module().create("tiptap-editor", &opts) {
@@ -211,8 +214,14 @@ fn write_editor(post_id: Option) -> Element {
                     }
                     editor_content_set.set(true);
                 }
-                let handle =
-                    EditorHandle::new(inst, on_update, on_image_upload, on_ready, on_upload_event);
+                let handle = EditorHandle::new(
+                    inst,
+                    on_update,
+                    on_image_upload,
+                    on_ready,
+                    on_upload_event,
+                    on_run_code,
+                );
                 editor.set(Some(handle));
             }
             Ok(None) => {
diff --git a/src/tiptap_bridge.rs b/src/tiptap_bridge.rs
index 2ec10eb..59ed01d 100644
--- a/src/tiptap_bridge.rs
+++ b/src/tiptap_bridge.rs
@@ -126,6 +126,14 @@ pub mod wasm {
         /// 上传事件回调(coordinator.emit 时触发,携带 counts)。
         #[wasm_bindgen(method, setter, js_name = onUploadEvent)]
         pub fn set_on_upload_event(this: &EditorOptions, cb: &Closure);
+
+        /// JS 侧 onRunCode: (opts: RunCodeOptsJs) => Promise。
+        /// Rust closure 返回 js_sys::Promise,内部调 start_exec + 轮询 get_exec_result。
+        #[wasm_bindgen(method, setter, js_name = onRunCode)]
+        pub fn set_on_run_code(
+            this: &EditorOptions,
+            cb: &Closure js_sys::Promise>,
+        );
     }
 
     // —— 上传事件(JS UploadEvent 的 Rust 映射)——
@@ -172,6 +180,27 @@ pub mod wasm {
         pub fn error(this: &UploadCountsJs) -> u32;
     }
 
+    // —— RunCodeOptsJs:onRunCode 回调参数(JS 侧传给 Rust 的纯数据对象)——
+    /// JS 侧传给 onRunCode 的参数对象,Rust 侧读取 getter。
+    /// language 是纯语言名(如 "python",前端已 extractLang 提取);overridesJson 是 overrides 的 JSON 字符串(可能为空)。
+    #[wasm_bindgen]
+    extern "C" {
+        pub type RunCodeOptsJs;
+
+        /// 纯语言名(前端 extractLang 从完整 info string 提取,如 "python")。
+        #[wasm_bindgen(method, getter)]
+        pub fn language(this: &RunCodeOptsJs) -> String;
+
+        /// 代码块文本内容。
+        #[wasm_bindgen(method, getter)]
+        pub fn source(this: &RunCodeOptsJs) -> String;
+
+        /// overrides 的 JSON 字符串(如 `{"timeout_secs":10}`),空串表示无 overrides。
+        /// 前端从 info string 提取(大括号部分),Rust 用 serde_json 反序列化。
+        #[wasm_bindgen(method, getter, js_name = overridesJson)]
+        pub fn overrides_json(this: &RunCodeOptsJs) -> String;
+    }
+
     // —— EditorHandle:实例 + closure 统一生命周期 ——
 
     /// 持有编辑器实例 + 其全部 closure,统一生命周期。
@@ -185,13 +214,14 @@ pub mod wasm {
         _on_image_upload: Closure js_sys::Promise>,
         _on_ready: Closure,
         _on_upload_event: Closure,
+        _on_run_code: Closure js_sys::Promise>,
     }
 
     impl EditorHandle {
-        /// 聚合编辑器实例与四个回调 closure,使其共用同一生命周期。
+        /// 聚合编辑器实例与回调 closure,使其共用同一生命周期。
         ///
         /// 调用方负责先用各 setter 把 closure 装进 [`EditorOptions`]、`create` 出实例后,
-        /// 再把实例与这四个 closure 一并交由本函数持有。返回的 [`EditorHandle`] 一旦
+        /// 再把实例与这些 closure 一并交由本函数持有。返回的 [`EditorHandle`] 一旦
         /// drop,会先 `destroy` 实例、再按字段逆序 drop closure。
         pub fn new(
             instance: EditorInstance,
@@ -199,6 +229,7 @@ pub mod wasm {
             on_image_upload: Closure js_sys::Promise>,
             on_ready: Closure,
             on_upload_event: Closure,
+            on_run_code: Closure js_sys::Promise>,
         ) -> Self {
             Self {
                 instance,
@@ -206,6 +237,7 @@ pub mod wasm {
                 _on_image_upload: on_image_upload,
                 _on_ready: on_ready,
                 _on_upload_event: on_upload_event,
+                _on_run_code: on_run_code,
             }
         }
 
@@ -348,12 +380,104 @@ pub mod wasm {
             })
         })
     }
+
+    // —— make_run_code_closure:编辑器内运行代码 ——
+
+    /// 把 ExecTask 格式化为结果字符串(供编辑器结果区展示)。
+    fn format_run_result(task: &crate::api::code_runner::ExecTask) -> String {
+        use crate::api::code_runner::ExecStatus;
+        let status_label = match task.status {
+            ExecStatus::Success => "Success",
+            ExecStatus::Error => "Error",
+            ExecStatus::Timeout => "Timeout",
+            ExecStatus::OomKilled => "OOM",
+            ExecStatus::Failed => "Failed",
+            _ => "Unknown",
+        };
+        match &task.result {
+            Some(res) => {
+                let mut out = format!("状态: {} · 耗时: {}ms", status_label, res.duration_ms);
+                if !res.stdout.is_empty() {
+                    out.push_str("\nStdout:\n");
+                    out.push_str(&res.stdout);
+                }
+                if !res.stderr.is_empty() {
+                    out.push_str("\nStderr:\n");
+                    out.push_str(&res.stderr);
+                }
+                out
+            }
+            None => format!("状态: {} · {}", status_label, task.stage),
+        }
+    }
+
+    /// 创建「编辑器内运行代码」closure:内部调 start_exec + 轮询 get_exec_result,
+    /// 把格式化结果字符串回传 JS。
+    ///
+    /// 返回的 closure 签名 `(RunCodeOptsJs) -> Promise` 对应 JS `onRunCode`。
+    /// JS 侧 NodeView await Promise,拿到字符串直接填进结果区 DOM。
+    ///
+    /// 注意:info string 的解析(提取语言名 + overrides JSON)在前端 extractLang 完成,
+    /// Rust 收到的 language 已是纯语言名(如 "python"),不依赖 server-only 的 languages 模块。
+    pub fn make_run_code_closure() -> Closure js_sys::Promise> {
+        Closure::new(move |opts: RunCodeOptsJs| -> js_sys::Promise {
+            wasm_bindgen_futures::future_to_promise(async move {
+                use crate::api::code_runner::{execute, ExecRequest, ExecStatus};
+                use crate::infra::runner_config::ResourceLimits;
+
+                let language = opts.language();
+                let source = opts.source();
+                let overrides_json = opts.overrides_json();
+
+                // 反序列化 overrides JSON(前端已提取大括号部分;空串视为 None)
+                let overrides = if overrides_json.trim().is_empty() {
+                    None
+                } else {
+                    match serde_json::from_str::(&overrides_json) {
+                        Ok(o) => Some(o),
+                        Err(_) => None, // 畸形 JSON 静默降级为无 overrides
+                    }
+                };
+
+                let req = ExecRequest {
+                    language,
+                    source,
+                    overrides,
+                };
+
+                match execute::start_exec(req).await {
+                    Ok(task_id) => {
+                        let poll_interval = 500;
+                        // 500ms * 60 = 30s 上限(编辑器内运行是写作辅助,比 reader 的 120s 短)
+                        for _ in 0..60 {
+                            crate::utils::time::sleep_ms(poll_interval).await;
+                            match execute::get_exec_result(task_id.clone()).await {
+                                Ok(task) => {
+                                    let terminal = task.status != ExecStatus::Queued
+                                        && task.status != ExecStatus::Running;
+                                    if terminal {
+                                        let s = format_run_result(&task);
+                                        return Ok(js_sys::JsString::from(s).into());
+                                    }
+                                }
+                                Err(_) => {
+                                    return Err(js_sys::Error::new("结果获取异常").into());
+                                }
+                            }
+                        }
+                        Err(js_sys::Error::new("轮询超时,请重试").into())
+                    }
+                    Err(e) => Err(js_sys::Error::new(&e.to_string()).into()),
+                }
+            })
+        })
+    }
 }
 
 /// 将 WASM 子模块中的桥接类型与函数重导出到 crate 根,供 `write.rs` 直接引用。
 /// server 构建剥离该子模块,故此重导出仅对 WASM 前端生效。
 #[cfg(target_arch = "wasm32")]
 pub use wasm::{
-    consume_upload_event, get_module, make_upload_closure, upload_image_file, EditorHandle,
-    EditorOptions, UploadEventJs,
+    consume_upload_event, get_module, make_run_code_closure, make_upload_closure,
+    upload_image_file, EditorHandle, EditorOptions, RunCodeOptsJs, UploadEventJs,
 };

From 763edc300d614d3be7a4ce10c043568ec024a20a Mon Sep 17 00:00:00 2001
From: xfy 
Date: Mon, 6 Jul 2026 18:17:33 +0800
Subject: [PATCH 16/16] =?UTF-8?q?feat(editor):=20NodeView=20=E9=80=82?=
 =?UTF-8?q?=E9=85=8D=20onRunCode=20=E5=8D=8F=E8=AE=AE(extractLang=20+=20ov?=
 =?UTF-8?q?erridesJson=20=E6=8F=90=E5=8F=96)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

runCode 改传纯语言名(extractLang) + overrides JSON 字符串(extractOverridesJson),
对齐 Task 4 Rust 桥接 RunCodeOptsJs(language/source/overrides_json):
- highlight.ts: 新增 extractOverridesJson,对齐后端 parse_fence_info 取以 { 开头的 token
- code-block-view.ts: RunCodeOpts 加 overridesJson 字段,runCode 改传 extractLang/extractOverridesJson 提取结果
- index.ts: EditorOptions.onRunCode 类型同步加 overridesJson
- 测试: extractOverridesJson 6 例 + code-block-view 点击断言更新(纯语言名 + overridesJson)
---
 .../src/__tests__/code-block-view.test.ts     |  8 ++++++--
 .../src/__tests__/highlight.test.ts           | 19 ++++++++++++++++++-
 libs/tiptap-editor/src/code-block-view.ts     | 10 +++++++---
 libs/tiptap-editor/src/highlight.ts           | 15 +++++++++++++++
 libs/tiptap-editor/src/index.ts               |  8 ++++++--
 5 files changed, 52 insertions(+), 8 deletions(-)

diff --git a/libs/tiptap-editor/src/__tests__/code-block-view.test.ts b/libs/tiptap-editor/src/__tests__/code-block-view.test.ts
index f0e8331..846dcea 100644
--- a/libs/tiptap-editor/src/__tests__/code-block-view.test.ts
+++ b/libs/tiptap-editor/src/__tests__/code-block-view.test.ts
@@ -97,13 +97,17 @@ describe('CodeBlockNodeView', () => {
   it('点击运行按钮调用 onRunCode(storage 回调)', () => {
     const onRunCode = vi.fn(() => Promise.resolve('结果'));
     const view = new CodeBlockNodeView({
-      node: mockNode('python runnable', 'print(1)'),
+      node: mockNode('python runnable {"timeout_secs":10}', 'print(1)'),
       editor: mockEditor(onRunCode),
     } as any);
     view.dom.querySelector('.tiptap-codeblock-run')!.click();
     expect(onRunCode).toHaveBeenCalledTimes(1);
     expect(onRunCode).toHaveBeenCalledWith(
-      expect.objectContaining({ language: 'python runnable', source: 'print(1)' }),
+      expect.objectContaining({
+        language: 'python',
+        source: 'print(1)',
+        overridesJson: '{"timeout_secs":10}',
+      }),
     );
   });
 
diff --git a/libs/tiptap-editor/src/__tests__/highlight.test.ts b/libs/tiptap-editor/src/__tests__/highlight.test.ts
index 397c380..67d2f91 100644
--- a/libs/tiptap-editor/src/__tests__/highlight.test.ts
+++ b/libs/tiptap-editor/src/__tests__/highlight.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest';
-import { extractLang, lowlight } from '../highlight';
+import { extractLang, extractOverridesJson, lowlight } from '../highlight';
 
 /**
  * extractLang 测试:从完整 fence info string 提取语言名(首个 token)。
@@ -21,6 +21,23 @@ describe('extractLang', () => {
   });
 });
 
+describe('extractOverridesJson', () => {
+  it.each([
+    ['python runnable {"timeout_secs":10}', '{"timeout_secs":10}', 'runnable + overrides'],
+    [
+      'python runnable {"timeout_secs":10,"memory_mb":256}',
+      '{"timeout_secs":10,"memory_mb":256}',
+      '多字段 overrides',
+    ],
+    ['python runnable', '', '无 overrides'],
+    ['python', '', '纯语言名'],
+    ['', '', '空字符串'],
+    ['python runnable {"allow_network":true}', '{"allow_network":true}', 'allow_network'],
+  ])('%s → %s (%s)', (input, expected) => {
+    expect(extractOverridesJson(input)).toBe(expected);
+  });
+});
+
 /**
  * lowlight wrapper 测试:highlight 经过 extractLang 处理,
  * runnable info string 也能按正确语言高亮。
diff --git a/libs/tiptap-editor/src/code-block-view.ts b/libs/tiptap-editor/src/code-block-view.ts
index 117e224..4b21a7d 100644
--- a/libs/tiptap-editor/src/code-block-view.ts
+++ b/libs/tiptap-editor/src/code-block-view.ts
@@ -1,16 +1,18 @@
 import type { Editor } from '@tiptap/core';
 import type { Node as PMNode } from '@tiptap/pm/model';
-import { extractLang } from './highlight';
+import { extractLang, extractOverridesJson } from './highlight';
 
 /** editor.storage 的 key,宿主(index.ts)在此注入 onRunCode 回调。 */
 export const ON_RUN_CODE_STORAGE_KEY = '__onRunCode';
 
 /** 运行请求参数(传给 onRunCode 回调)。 */
 export interface RunCodeOpts {
-  /** 完整 info string(如 `python runnable {"timeout_secs":10}`)。 */
+  /** 纯语言名(extractLang 提取,如 "python")。 */
   language: string;
   /** 代码块文本内容。 */
   source: string;
+  /** overrides 的 JSON 字符串(extractOverridesJson 提取,空串表示无 overrides)。 */
+  overridesJson: string;
 }
 
 /**
@@ -153,9 +155,11 @@ export class CodeBlockNodeView {
     this.ensureResultArea('运行中…');
 
     try {
+      const info = (this.node.attrs.language as string) ?? '';
       const result = await onRunCode({
-        language: (this.node.attrs.language as string) ?? '',
+        language: extractLang(info),
         source: this.node.textContent,
+        overridesJson: extractOverridesJson(info),
       });
       this.renderResult(result);
     } catch (e) {
diff --git a/libs/tiptap-editor/src/highlight.ts b/libs/tiptap-editor/src/highlight.ts
index e0b0d54..356c5b0 100644
--- a/libs/tiptap-editor/src/highlight.ts
+++ b/libs/tiptap-editor/src/highlight.ts
@@ -14,6 +14,21 @@ export function extractLang(info: string): string {
   return first ? first.toLowerCase() : '';
 }
 
+/**
+ * 从完整 fence info string 提取 overrides JSON 字符串。
+ *
+ * 与后端 parse_fence_info(src/api/code_runner/languages.rs:93-97)对齐:
+ * info string 形如 `python runnable {"timeout_secs":10}`,提取以 `{` 开头的 token。
+ * 无 overrides 时返回空串(Rust 侧空串视为 None)。
+ */
+export function extractOverridesJson(info: string): string {
+  const token = info
+    .trim()
+    .split(/\s+/)
+    .find((t) => t.startsWith('{'));
+  return token ?? '';
+}
+
 /**
  * lowlight 实例,包装 highlight 方法以处理完整 info string。
  *
diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts
index 87feabe..8d87df6 100644
--- a/libs/tiptap-editor/src/index.ts
+++ b/libs/tiptap-editor/src/index.ts
@@ -40,8 +40,12 @@ class EditorOptions {
   // 上传状态事件(error/success/removed + counts),替代 window.__tiptap_uploads 轮询
   onUploadEvent?: (event: UploadEvent) => void;
   // 编辑器内运行代码回调:Rust 注入,NodeView 点击「运行」时经 editor.storage 转发。
-  // opts: { language, source };返回格式化结果字符串(Rust 拼好 stdout/stderr/状态)。
-  onRunCode?: (opts: { language: string; source: string }) => Promise;
+  // opts: { language(纯语言名), source, overridesJson };返回格式化结果字符串(Rust 拼好 stdout/stderr/状态)。
+  onRunCode?: (opts: {
+    language: string;
+    source: string;
+    overridesJson: string;
+  }) => Promise;
 }
 // wasm-bindgen 生成的 glue 用裸标识符 `new EditorOptions()` 从全局解析,
 // IIFE 的 name 只能挂一个全局(TiptapEditor),这里手动把 EditorOptions 也挂到 window 上。