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:
commit
05460f1257
63
libs/pnpm-lock.yaml
generated
63
libs/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
123
libs/tiptap-editor/src/__tests__/code-block-view.test.ts
Normal file
123
libs/tiptap-editor/src/__tests__/code-block-view.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
69
libs/tiptap-editor/src/__tests__/highlight.test.ts
Normal file
69
libs/tiptap-editor/src/__tests__/highlight.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
48
libs/tiptap-editor/src/__tests__/runnable-info.test.ts
Normal file
48
libs/tiptap-editor/src/__tests__/runnable-info.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
130
libs/tiptap-editor/src/__tests__/runnable-modal.test.ts
Normal file
130
libs/tiptap-editor/src/__tests__/runnable-modal.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
193
libs/tiptap-editor/src/code-block-view.ts
Normal file
193
libs/tiptap-editor/src/code-block-view.ts
Normal 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 > 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 必须是 <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;
|
||||
}
|
||||
}
|
||||
46
libs/tiptap-editor/src/highlight.ts
Normal file
46
libs/tiptap-editor/src/highlight.ts
Normal file
@ -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);
|
||||
},
|
||||
};
|
||||
@ -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<string>;
|
||||
}
|
||||
// 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<string, unknown>)[ON_RUN_CODE_STORAGE_KEY] =
|
||||
this.options.onRunCode;
|
||||
}
|
||||
|
||||
// 通知宿主编辑器已就绪(替代 window.__tiptap_ready 轮询)
|
||||
this.options.onReady?.();
|
||||
}
|
||||
|
||||
@ -107,6 +107,15 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
|
||||
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<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 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=确认的惯例。
|
||||
// 例外:语言 <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();
|
||||
}
|
||||
|
||||
@ -652,3 +652,388 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* ========== 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;
|
||||
}
|
||||
|
||||
@ -186,6 +186,8 @@ fn write_editor(post_id: Option<i32>) -> 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<i32>) -> 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<i32>) -> 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) => {
|
||||
|
||||
@ -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<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 映射)——
|
||||
@ -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<dyn Fn(web_sys::File) -> js_sys::Promise>,
|
||||
_on_ready: Closure<dyn FnMut()>,
|
||||
_on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
|
||||
_on_run_code: Closure<dyn Fn(RunCodeOptsJs) -> 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<dyn Fn(web_sys::File) -> js_sys::Promise>,
|
||||
on_ready: Closure<dyn FnMut()>,
|
||||
on_upload_event: Closure<dyn FnMut(UploadEventJs)>,
|
||||
on_run_code: Closure<dyn Fn(RunCodeOptsJs) -> 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<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` 直接引用。
|
||||
/// 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,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user