yggdrasil/libs/tiptap-editor/src/code-block-view.ts
xfy 763edc300d feat(editor): NodeView 适配 onRunCode 协议(extractLang + overridesJson 提取)
runCode 改传纯语言名(extractLang) + overrides JSON 字符串(extractOverridesJson),
对齐 Task 4 Rust 桥接 RunCodeOptsJs(language/source/overrides_json):
- highlight.ts: 新增 extractOverridesJson,对齐后端 parse_fence_info 取以 { 开头的 token
- code-block-view.ts: RunCodeOpts 加 overridesJson 字段,runCode 改传 extractLang/extractOverridesJson 提取结果
- index.ts: EditorOptions.onRunCode 类型同步加 overridesJson
- 测试: extractOverridesJson 6 例 + code-block-view 点击断言更新(纯语言名 + overridesJson)
2026-07-06 18:17:33 +08:00

194 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}