Merge branch 'feat/editor-runnable-codeblock'

编辑器三大增强:
- 斜杠命令插入可运行代码块(模态框配置语言+overrides)
- CodeBlockLowlight 语法高亮(Catppuccin Latte/Mocha,对齐读者侧)
- CodeBlockNodeView:语言标签 + 运行按钮(仅 runnable) + 内联运行结果

运行经 onRunCode 回调桥接到 Rust(start_exec + 轮询 get_exec_result)。
markdown 往返保真(language 承载完整 info string)。
This commit is contained in:
xfy 2026-07-06 18:34:54 +08:00
commit 05460f1257
13 changed files with 1457 additions and 7 deletions

63
libs/pnpm-lock.yaml generated
View File

@ -67,6 +67,9 @@ importers:
'@tiptap/core': '@tiptap/core':
specifier: ^3.27.1 specifier: ^3.27.1
version: 3.27.1(@tiptap/pm@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': '@tiptap/extension-file-handler':
specifier: ^3.27.1 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) 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': '@tiptap/suggestion':
specifier: ^3.27.1 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) 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: {} yggdrasil-core: {}
@ -370,6 +376,15 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/extension-list': 3.27.1 '@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': '@tiptap/extension-code-block@3.27.1':
resolution: {integrity: sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==} resolution: {integrity: sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA==}
peerDependencies: peerDependencies:
@ -524,9 +539,15 @@ packages:
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} 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': '@types/node@26.0.1':
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/whatwg-mimetype@3.0.2': '@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
@ -583,10 +604,17 @@ packages:
crelt@1.0.7: crelt@1.0.7:
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
entities@7.0.1: entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
@ -619,6 +647,10 @@ packages:
resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==}
engines: {node: '>=20.0.0'} 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: lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@ -696,6 +728,9 @@ packages:
linkifyjs@4.3.3: linkifyjs@4.3.3:
resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==}
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -1186,6 +1221,14 @@ snapshots:
dependencies: dependencies:
'@tiptap/extension-list': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) '@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)': '@tiptap/extension-code-block@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)':
dependencies: dependencies:
'@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1)
@ -1356,10 +1399,16 @@ snapshots:
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/node@26.0.1': '@types/node@26.0.1':
dependencies: dependencies:
undici-types: 8.3.0 undici-types: 8.3.0
'@types/unist@3.0.3': {}
'@types/whatwg-mimetype@3.0.2': {} '@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1': '@types/ws@8.18.1':
@ -1429,8 +1478,14 @@ snapshots:
crelt@1.0.7: {} crelt@1.0.7: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
entities@7.0.1: {} entities@7.0.1: {}
es-module-lexer@2.2.0: {} es-module-lexer@2.2.0: {}
@ -1461,6 +1516,8 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - utf-8-validate
highlight.js@11.11.1: {}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
optional: true optional: true
@ -1512,6 +1569,12 @@ snapshots:
linkifyjs@4.3.3: {} 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: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5

View File

@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@tiptap/core": "^3.27.1", "@tiptap/core": "^3.27.1",
"@tiptap/extension-code-block-lowlight": "^3.27.1",
"@tiptap/extension-file-handler": "^3.27.1", "@tiptap/extension-file-handler": "^3.27.1",
"@tiptap/extension-image": "^3.27.1", "@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-link": "^3.27.1", "@tiptap/extension-link": "^3.27.1",
@ -20,6 +21,7 @@
"@tiptap/markdown": "^3.27.1", "@tiptap/markdown": "^3.27.1",
"@tiptap/pm": "^3.27.1", "@tiptap/pm": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1", "@tiptap/starter-kit": "^3.27.1",
"@tiptap/suggestion": "^3.27.1" "@tiptap/suggestion": "^3.27.1",
"lowlight": "^3.3.0"
} }
} }

View File

@ -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<string>) {
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<HTMLButtonElement>('.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<HTMLButtonElement>('.tiptap-codeblock-run')!.click(),
).not.toThrow();
});
});

View File

@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { extractLang, extractOverridesJson, lowlight } from '../highlight';
/**
* extractLang fence info string token
* parse_fence_infosrc/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();
});
});

View File

@ -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']);
});
});
});

View File

@ -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<HTMLInputElement>('#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<HTMLInputElement>('#runnable-timeout')!;
timeoutInput.value = '10';
timeoutInput.dispatchEvent(new Event('input', { bubbles: true }));
// 点插入
const insertBtn = document.querySelector<HTMLButtonElement>(
'.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<HTMLButtonElement>('.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<HTMLInputElement>('#runnable-timeout')!;
timeoutInput.value = '0';
timeoutInput.dispatchEvent(new Event('input', { bubbles: true }));
const insertBtn = document.querySelector<HTMLButtonElement>(
'.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<HTMLInputElement>('#runnable-timeout')!;
const insertBtn = document.querySelector<HTMLButtonElement>(
'.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<HTMLButtonElement>('.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();
});
});

View File

@ -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
*
* `<pre><code>` +
* contentDOM `<code>` 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 > codecontentDOMdecoration 在此生效)
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 必须是 <code>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<void> {
if (!this.runBtn) return;
const storage = this.editor.storage as unknown as Record<string, unknown>;
const onRunCode = storage[ON_RUN_CODE_STORAGE_KEY] as
| ((opts: RunCodeOpts) => Promise<string>)
| 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;
}
}

View File

@ -0,0 +1,46 @@
import { common, createLowlight } from 'lowlight';
const base = createLowlight(common);
/**
* fence info string token
*
* parse_fence_infosrc/api/code_runner/languages.rs:85
* info string `python runnable {"timeout_secs":10}` token
* to_lowercaselowlight 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_infosrc/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 blocklanguage='python'extractLang('python') 'python'
*/
export const lowlight = {
...base,
highlight(language: string, value: string) {
return base.highlight(extractLang(language), value);
},
};

View File

@ -1,9 +1,12 @@
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { FileHandler } from '@tiptap/extension-file-handler'; import { FileHandler } from '@tiptap/extension-file-handler';
import { TaskItem, TaskList } from '@tiptap/extension-list'; import { TaskItem, TaskList } from '@tiptap/extension-list';
import { TableKit } from '@tiptap/extension-table'; import { TableKit } from '@tiptap/extension-table';
import { Markdown } from '@tiptap/markdown'; import { Markdown } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit'; 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 { SlashCommand } from './slash-command';
import { TaskInputRule } from './task-input-rule'; import { TaskInputRule } from './task-input-rule';
import { import {
@ -36,6 +39,13 @@ class EditorOptions {
onReady?: () => void; onReady?: () => void;
// 上传状态事件error/success/removed + counts替代 window.__tiptap_uploads 轮询 // 上传状态事件error/success/removed + counts替代 window.__tiptap_uploads 轮询
onUploadEvent?: (event: UploadEvent) => void; onUploadEvent?: (event: UploadEvent) => void;
// 编辑器内运行代码回调Rust 注入NodeView 点击「运行」时经 editor.storage 转发。
// opts: { language(纯语言名), source, overridesJson }返回格式化结果字符串Rust 拼好 stdout/stderr/状态)。
onRunCode?: (opts: {
language: string;
source: string;
overridesJson: string;
}) => Promise<string>;
} }
// wasm-bindgen 生成的 glue 用裸标识符 `new EditorOptions()` 从全局解析, // wasm-bindgen 生成的 glue 用裸标识符 `new EditorOptions()` 从全局解析,
// IIFE 的 name 只能挂一个全局TiptapEditor这里手动把 EditorOptions 也挂到 window 上。 // IIFE 的 name 只能挂一个全局TiptapEditor这里手动把 EditorOptions 也挂到 window 上。
@ -98,8 +108,14 @@ class TiptapEditorInstance {
linkOnPaste: true, linkOnPaste: true,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' }, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
}, },
codeBlock: false,
}), }),
Markdown, Markdown,
CodeBlockLowlight.configure({ lowlight }).extend({
addNodeView() {
return ({ node, editor }) => new CodeBlockNodeView({ node, editor });
},
}),
TableKit, TableKit,
UploadImage, UploadImage,
TaskList, TaskList,
@ -165,6 +181,12 @@ class TiptapEditorInstance {
this.coordinator; this.coordinator;
} }
// 把 onRunCode 回调挂到 editor.storage供 CodeBlockNodeView 读取(仿 upload-coordinator 范式)。
if (this.options.onRunCode) {
(this.editor.storage as unknown as Record<string, unknown>)[ON_RUN_CODE_STORAGE_KEY] =
this.options.onRunCode;
}
// 通知宿主编辑器已就绪(替代 window.__tiptap_ready 轮询) // 通知宿主编辑器已就绪(替代 window.__tiptap_ready 轮询)
this.options.onReady?.(); this.options.onReady?.();
} }

View File

@ -107,6 +107,15 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
}, },
}, },
{
title: '可运行代码块',
description: '插入可被读者执行的代码块',
icon: '▶',
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
openRunnableModal(editor);
},
},
{ {
title: '分割线', title: '分割线',
description: '插入水平分割线', description: '插入水平分割线',
@ -363,3 +372,230 @@ function createPopup(props: SuggestionProps<CommandItem>): 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 stringmarked
*
* overrides dirtydirty=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=确认的惯例。
// 例外:语言 <select> 下拉展开时 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();
insert();
}
}
}
document.addEventListener('keydown', onKeydown);
document.body.appendChild(mask);
updatePreview();
updateInsertEnabled();
langSelect.focus();
}

View File

@ -652,3 +652,388 @@
/* Mocha overlay0 */ /* Mocha overlay0 */
color: #6c7086; 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;
}
/* ========== 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;
}
/* ========== 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: #f9e2af;
}
.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);
}
/* ========== 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;
}

View File

@ -186,6 +186,8 @@ fn write_editor(post_id: Option<i32>) -> Element {
consume_upload_event(&ev, uploads_in_flight, upload_errors); consume_upload_event(&ev, uploads_in_flight, upload_errors);
} }
}); });
// 运行代码 closurestart_exec + 轮询,结果字符串回填 JS 结果区 DOM。
let on_run_code = crate::tiptap_bridge::make_run_code_closure();
// —— 构造 options —— // —— 构造 options ——
let opts = crate::tiptap_bridge::EditorOptions::new(); let opts = crate::tiptap_bridge::EditorOptions::new();
@ -194,6 +196,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
opts.set_on_ready(&on_ready); opts.set_on_ready(&on_ready);
opts.set_on_image_upload(&on_image_upload); opts.set_on_image_upload(&on_image_upload);
opts.set_on_upload_event(&on_upload_event); opts.set_on_upload_event(&on_upload_event);
opts.set_on_run_code(&on_run_code);
// —— create同步返回找不到容器返回 None构造失败抛异常—— // —— create同步返回找不到容器返回 None构造失败抛异常——
match crate::tiptap_bridge::get_module().create("tiptap-editor", &opts) { match crate::tiptap_bridge::get_module().create("tiptap-editor", &opts) {
@ -211,8 +214,14 @@ fn write_editor(post_id: Option<i32>) -> Element {
} }
editor_content_set.set(true); editor_content_set.set(true);
} }
let handle = let handle = EditorHandle::new(
EditorHandle::new(inst, on_update, on_image_upload, on_ready, on_upload_event); inst,
on_update,
on_image_upload,
on_ready,
on_upload_event,
on_run_code,
);
editor.set(Some(handle)); editor.set(Some(handle));
} }
Ok(None) => { Ok(None) => {

View File

@ -126,6 +126,14 @@ pub mod wasm {
/// 上传事件回调coordinator.emit 时触发,携带 counts /// 上传事件回调coordinator.emit 时触发,携带 counts
#[wasm_bindgen(method, setter, js_name = onUploadEvent)] #[wasm_bindgen(method, setter, js_name = onUploadEvent)]
pub fn set_on_upload_event(this: &EditorOptions, cb: &Closure<dyn FnMut(UploadEventJs)>); pub fn set_on_upload_event(this: &EditorOptions, cb: &Closure<dyn FnMut(UploadEventJs)>);
/// JS 侧 onRunCode: (opts: RunCodeOptsJs) => Promise<string>。
/// 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<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
);
} }
// —— 上传事件JS UploadEvent 的 Rust 映射)—— // —— 上传事件JS UploadEvent 的 Rust 映射)——
@ -172,6 +180,27 @@ pub mod wasm {
pub fn error(this: &UploadCountsJs) -> u32; pub fn error(this: &UploadCountsJs) -> u32;
} }
// —— RunCodeOptsJsonRunCode 回调参数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 统一生命周期 —— // —— EditorHandle实例 + closure 统一生命周期 ——
/// 持有编辑器实例 + 其全部 closure统一生命周期。 /// 持有编辑器实例 + 其全部 closure统一生命周期。
@ -185,13 +214,14 @@ pub mod wasm {
_on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>, _on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
_on_ready: Closure<dyn FnMut()>, _on_ready: Closure<dyn FnMut()>,
_on_upload_event: Closure<dyn FnMut(UploadEventJs)>, _on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
_on_run_code: Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
} }
impl EditorHandle { impl EditorHandle {
/// 聚合编辑器实例与四个回调 closure使其共用同一生命周期。 /// 聚合编辑器实例与回调 closure使其共用同一生命周期。
/// ///
/// 调用方负责先用各 setter 把 closure 装进 [`EditorOptions`]、`create` 出实例后, /// 调用方负责先用各 setter 把 closure 装进 [`EditorOptions`]、`create` 出实例后,
/// 再把实例与这四个 closure 一并交由本函数持有。返回的 [`EditorHandle`] 一旦 /// 再把实例与这 closure 一并交由本函数持有。返回的 [`EditorHandle`] 一旦
/// drop会先 `destroy` 实例、再按字段逆序 drop closure。 /// drop会先 `destroy` 实例、再按字段逆序 drop closure。
pub fn new( pub fn new(
instance: EditorInstance, instance: EditorInstance,
@ -199,6 +229,7 @@ pub mod wasm {
on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>, on_image_upload: Closure<dyn Fn(web_sys::File) -> js_sys::Promise>,
on_ready: Closure<dyn FnMut()>, on_ready: Closure<dyn FnMut()>,
on_upload_event: Closure<dyn FnMut(UploadEventJs)>, on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
on_run_code: Closure<dyn Fn(RunCodeOptsJs) -> js_sys::Promise>,
) -> Self { ) -> Self {
Self { Self {
instance, instance,
@ -206,6 +237,7 @@ pub mod wasm {
_on_image_upload: on_image_upload, _on_image_upload: on_image_upload,
_on_ready: on_ready, _on_ready: on_ready,
_on_upload_event: on_upload_event, _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<dyn Fn(RunCodeOptsJs) -> 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::<ResourceLimits>(&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` 直接引用。 /// 将 WASM 子模块中的桥接类型与函数重导出到 crate 根,供 `write.rs` 直接引用。
/// server 构建剥离该子模块,故此重导出仅对 WASM 前端生效。 /// server 构建剥离该子模块,故此重导出仅对 WASM 前端生效。
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub use wasm::{ pub use wasm::{
consume_upload_event, get_module, make_upload_closure, upload_image_file, EditorHandle, consume_upload_event, get_module, make_run_code_closure, make_upload_closure,
EditorOptions, UploadEventJs, upload_image_file, EditorHandle, EditorOptions, RunCodeOptsJs, UploadEventJs,
}; };