yggdrasil/libs/tiptap-editor/src/code-block-view.ts
xfy 22d05a98b3
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
chore(lint): 修复 Biome 告警(schema 版本/optional chain/CSS !important 抑制)
- biome.json: $schema 2.5.1 → 2.5.3,对齐已安装的 CLI 版本(消除 info)
- code-block-view.ts:157: `a && a.b()` → `a?.b()`(等价 optional chain)
- style.css:56: 保留刻意的 `transform: none !important`(删除会重新引入
  animation-fill-mode: both 导致的堆叠上下文 bug),仅加 scoped biome-ignore
  抑制 noImportantStyles,不做全局禁用
2026-07-15 18:12:04 +08:00

252 lines
9.9 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 type { ViewMutationRecord } from '@tiptap/pm/view';
import { extractLang, extractOverridesJson } from './highlight';
import { openRunnableModal } from './slash-command';
/** 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;
private getPos: (() => number | undefined) | undefined;
constructor(opts: { node: PMNode; editor: Editor; getPos?: () => number | undefined }) {
this.node = opts.node;
this.editor = opts.editor;
this.getPos = opts.getPos;
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) ?? '');
// runnable 块的语言标签可点击,触发编辑模态框(改语言/overrides
if (isRunnable(this.node)) {
this.langBadge.classList.add('tiptap-codeblock-lang-editable');
this.langBadge.title = '点击修改语言与运行配置';
this.langBadge.addEventListener('click', () => this.openEditModal());
}
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');
// 只挂纯语言名的 classextractLang 提取首个 token
// 不能用完整 info string——`python runnable {...}` 含空格classList.add 会抛
// InvalidCharacterError。高亮靠 CodeBlockLowlight 的 decoration不依赖此 class。
const langClass = extractLang((this.node.attrs.language as string) ?? '');
if (langClass) {
this.code.classList.add(`language-${langClass}`);
}
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);
// 更新 <code> 的 language class低亮按新语言重算
this.code.className = '';
const langClass = extractLang(newLang);
if (langClass) this.code.classList.add(`language-${langClass}`);
// 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;
}
}
/**
* 判断 DOM mutation 是否应被 ProseMirror 忽略。
*
* contentDOM<code>内的编辑characterData/childList必须返回 false
* 让 ProseMirror 正常处理事务——否则输入/退格会让文档状态与 DOM 失同步
* (表现为 Backspace 删整块而非删字符)。
*
* 仅对工具栏/结果区等装饰元素返回 true避免它们的变化触发无谓的事务
* 对齐 Tiptap NodeViewWrapper 默认 ignoreMutation 逻辑。
*/
ignoreMutation(mutation: ViewMutationRecord): boolean {
// selection让 ProseMirror 管光标
if (mutation.type === 'selection') return false;
// contentDOM 自身的 attributes 变化(如高亮 decoration 改 class忽略
if (mutation.target === this.contentDOM && mutation.type === 'attributes') return true;
// contentDOM 内的 mutationcharacterData/childList交给 ProseMirror编辑核心
// 注意 contains 对 target===contentDOM 自身也返回 true故上面的 attributes 判断须在前。
if (this.contentDOM?.contains(mutation.target)) return false;
// 工具栏/结果区等装饰元素的 mutation忽略
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结果填入结果区。 */
/** 点击语言标签:打开编辑模态框,修改当前 runnable 块的语言/overrides。 */
private openEditModal(): void {
const pos = this.getPos?.();
const currentInfo = (this.node.attrs.language as string) ?? '';
if (pos === undefined) return;
openRunnableModal(this.editor, pos, currentInfo);
}
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);
}
// resultArea 位于 ProseMirror 可编辑文档树内部。浏览器对 contenteditable 子树强制
// white-space 折叠(此处 computed 为 collapseCSS 里的 white-space: pre-wrap 被覆盖,
// 导致 textContent 里的 \n 与行首空格被吞掉、输出挤成一行。这里改用 <br> 渲染换行、
// \u00A0 替换空格与制表符以保留对齐——二者均不依赖 white-space。用 createTextNode
// 而非 innerHTML避免 stdout 内含 <script> 等标签时造成注入。
this.resultArea.replaceChildren();
const lines = initial.split('\n');
for (let i = 0; i < lines.length; i++) {
if (i > 0) this.resultArea.appendChild(document.createElement('br'));
if (lines[i].length > 0) {
const preserved = lines[i]
.replace(/\t/g, '\u00A0\u00A0\u00A0\u00A0')
.replace(/ /g, '\u00A0');
this.resultArea.appendChild(document.createTextNode(preserved));
}
}
}
/** 渲染运行结果字符串到结果区。 */
private renderResult(result: string): void {
this.ensureResultArea(result);
}
destroy(): void {
this.resultArea = null;
this.runBtn = null;
}
}