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__/code-block-view.test.ts b/libs/tiptap-editor/src/__tests__/code-block-view.test.ts new file mode 100644 index 0000000..846dcea --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/code-block-view.test.ts @@ -0,0 +1,123 @@ +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 {"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', + source: 'print(1)', + overridesJson: '{"timeout_secs":10}', + }), + ); + }); + + 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/__tests__/highlight.test.ts b/libs/tiptap-editor/src/__tests__/highlight.test.ts new file mode 100644 index 0000000..67d2f91 --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/highlight.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { extractLang, extractOverridesJson, 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)'], + ['', '', '空字符串'], + [' ', '', '纯空格'], + ['PYTHON', 'python', '大写小写化(与后端对齐)'], + ['Python Runnable', 'python', '混合大小写'], + ])('%s → %s (%s)', (input, expected) => { + expect(extractLang(input)).toBe(expected); + }); +}); + +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 也能按正确语言高亮。 + */ +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/__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/__tests__/runnable-modal.test.ts b/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts new file mode 100644 index 0000000..a9163a6 --- /dev/null +++ b/libs/tiptap-editor/src/__tests__/runnable-modal.test.ts @@ -0,0 +1,130 @@ +import type { Editor } from '@tiptap/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +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/code-block-view.ts b/libs/tiptap-editor/src/code-block-view.ts new file mode 100644 index 0000000..4b21a7d --- /dev/null +++ b/libs/tiptap-editor/src/code-block-view.ts @@ -0,0 +1,193 @@ +import type { Editor } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { extractLang, extractOverridesJson } from './highlight'; + +/** editor.storage 的 key,宿主(index.ts)在此注入 onRunCode 回调。 */ +export const ON_RUN_CODE_STORAGE_KEY = '__onRunCode'; + +/** 运行请求参数(传给 onRunCode 回调)。 */ +export interface RunCodeOpts { + /** 纯语言名(extractLang 提取,如 "python")。 */ + language: string; + /** 代码块文本内容。 */ + source: string; + /** overrides 的 JSON 字符串(extractOverridesJson 提取,空串表示无 overrides)。 */ + overridesJson: 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 info = (this.node.attrs.language as string) ?? '';
+      const result = await onRunCode({
+        language: extractLang(info),
+        source: this.node.textContent,
+        overridesJson: extractOverridesJson(info),
+      });
+      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;
+  }
+}
diff --git a/libs/tiptap-editor/src/highlight.ts b/libs/tiptap-editor/src/highlight.ts
new file mode 100644
index 0000000..356c5b0
--- /dev/null
+++ b/libs/tiptap-editor/src/highlight.ts
@@ -0,0 +1,46 @@
+import { common, createLowlight } from 'lowlight';
+
+const base = createLowlight(common);
+
+/**
+ * 从完整 fence info string 提取语言名(首个 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 {
+  const first = info.trim().split(/\s+/)[0];
+  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。
+ *
+ * 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);
+  },
+};
diff --git a/libs/tiptap-editor/src/index.ts b/libs/tiptap-editor/src/index.ts
index f4fcc65..8d87df6 100644
--- a/libs/tiptap-editor/src/index.ts
+++ b/libs/tiptap-editor/src/index.ts
@@ -1,9 +1,12 @@
 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 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';
 import {
@@ -36,6 +39,13 @@ class EditorOptions {
   onReady?: () => void;
   // 上传状态事件(error/success/removed + counts),替代 window.__tiptap_uploads 轮询
   onUploadEvent?: (event: UploadEvent) => void;
+  // 编辑器内运行代码回调:Rust 注入,NodeView 点击「运行」时经 editor.storage 转发。
+  // 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 上。
@@ -98,8 +108,14 @@ class TiptapEditorInstance {
             linkOnPaste: true,
             HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
           },
+          codeBlock: false,
         }),
         Markdown,
+        CodeBlockLowlight.configure({ lowlight }).extend({
+          addNodeView() {
+            return ({ node, editor }) => new CodeBlockNodeView({ node, editor });
+          },
+        }),
         TableKit,
         UploadImage,
         TaskList,
@@ -165,6 +181,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?.();
   }
diff --git a/libs/tiptap-editor/src/slash-command.ts b/libs/tiptap-editor/src/slash-command.ts
index 0afee58..ce796d6 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: '插入水平分割线',
@@ -363,3 +372,230 @@ 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}`;
+}
+
+/**
+ * 受支持的语言(与 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=确认的惯例。
+      // 例外:语言