From 82280e3d4f615f47453e1b2303bb070b79b205a7 Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 6 Jul 2026 17:53:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20CodeBlockNodeView=20=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E8=AF=AD=E8=A8=80=E6=A0=87=E7=AD=BE=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自定义 NodeView 包裹
,顶部工具栏显示语言(extractLang 提取),
runnable 块额外显示运行按钮。contentDOM 指向 ,保证 CodeBlockLowlight
decoration(高亮)正常生效。运行走 editor.storage.__onRunCode 回调。
---
 .../src/__tests__/code-block-view.test.ts     | 119 +++++++++++
 libs/tiptap-editor/src/code-block-view.ts     | 189 ++++++++++++++++++
 2 files changed, 308 insertions(+)
 create mode 100644 libs/tiptap-editor/src/__tests__/code-block-view.test.ts
 create mode 100644 libs/tiptap-editor/src/code-block-view.ts

diff --git a/libs/tiptap-editor/src/__tests__/code-block-view.test.ts b/libs/tiptap-editor/src/__tests__/code-block-view.test.ts
new file mode 100644
index 0000000..f0e8331
--- /dev/null
+++ b/libs/tiptap-editor/src/__tests__/code-block-view.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it, vi } from 'vitest';
+import { CodeBlockNodeView } from '../code-block-view';
+
+/**
+ * 模块级共享 sentinel:对齐 upload-image.test.ts 范式。
+ * 真实 ProseMirror 每个 schema 的 NodeType 是单例,同类节点引用必等;
+ * mock 用共享引用模拟此语义,使实现的纯引用比较能命中。
+ */
+const CODEBLOCK_TYPE = { name: 'codeBlock' };
+const PARAGRAPH_TYPE = { name: 'paragraph' };
+
+/** 构造最小 mock node,只含 NodeView 需要的字段。 */
+function mockNode(language: string, textContent = '') {
+  return {
+    type: CODEBLOCK_TYPE,
+    attrs: { language },
+    textContent,
+  } as any;
+}
+
+/** 构造最小 mock editor,含 storage。 */
+function mockEditor(onRunCode?: (opts: any) => Promise) {
+  return {
+    storage: onRunCode ? { __onRunCode: onRunCode } : {},
+  } as any;
+}
+
+/**
+ * CodeBlockNodeView 测试。
+ *
+ * 验证:DOM 结构、语言标签、运行按钮显隐、update 刷新、contentDOM 指向 code。
+ */
+describe('CodeBlockNodeView', () => {
+  it('runnable 块:toolbar 显示语言 + 运行按钮', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python runnable {"timeout_secs":10}'),
+      editor: mockEditor(),
+    } as any);
+    expect(view.dom.querySelector('.tiptap-codeblock-lang')?.textContent).toBe('python');
+    expect(view.dom.querySelector('.tiptap-codeblock-run')).not.toBeNull();
+  });
+
+  it('普通块(python):显示语言标签,无运行按钮', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python'),
+      editor: mockEditor(),
+    } as any);
+    expect(view.dom.querySelector('.tiptap-codeblock-lang')?.textContent).toBe('python');
+    expect(view.dom.querySelector('.tiptap-codeblock-run')).toBeNull();
+  });
+
+  it('无 language 的块:标签为空或占位,无运行按钮', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode(''),
+      editor: mockEditor(),
+    } as any);
+    expect(view.dom.querySelector('.tiptap-codeblock-run')).toBeNull();
+  });
+
+  it('node 语言变化时 update() 刷新标签', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python'),
+      editor: mockEditor(),
+    } as any);
+    // 模拟 ProseMirror 调 update:改为 node runnable
+    const updated = view.update(mockNode('node runnable'));
+    expect(updated).toBe(true);
+    expect(view.dom.querySelector('.tiptap-codeblock-lang')?.textContent).toBe('node');
+    expect(view.dom.querySelector('.tiptap-codeblock-run')).not.toBeNull();
+  });
+
+  it('update 拒绝非同类节点(返回 false)', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python'),
+      editor: mockEditor(),
+    } as any);
+    const otherNode = { type: PARAGRAPH_TYPE, attrs: {}, textContent: '' } as any;
+    expect(view.update(otherNode)).toBe(false);
+  });
+
+  it('contentDOM 指向 code 元素(保证 decoration 生效)', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python'),
+      editor: mockEditor(),
+    } as any);
+    expect(view.contentDOM?.tagName).toBe('CODE');
+  });
+
+  it('ignoreMutation 返回 true(工具栏不触发编辑事务)', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python'),
+      editor: mockEditor(),
+    } as any);
+    expect(view.ignoreMutation()).toBe(true);
+  });
+
+  it('点击运行按钮调用 onRunCode(storage 回调)', () => {
+    const onRunCode = vi.fn(() => Promise.resolve('结果'));
+    const view = new CodeBlockNodeView({
+      node: mockNode('python runnable', 'print(1)'),
+      editor: mockEditor(onRunCode),
+    } as any);
+    view.dom.querySelector('.tiptap-codeblock-run')!.click();
+    expect(onRunCode).toHaveBeenCalledTimes(1);
+    expect(onRunCode).toHaveBeenCalledWith(
+      expect.objectContaining({ language: 'python runnable', source: 'print(1)' }),
+    );
+  });
+
+  it('无 onRunCode 时点运行不报错(优雅降级)', () => {
+    const view = new CodeBlockNodeView({
+      node: mockNode('python runnable', 'print(1)'),
+      editor: mockEditor(), // 无 __onRunCode
+    } as any);
+    expect(() =>
+      view.dom.querySelector('.tiptap-codeblock-run')!.click(),
+    ).not.toThrow();
+  });
+});
diff --git a/libs/tiptap-editor/src/code-block-view.ts b/libs/tiptap-editor/src/code-block-view.ts
new file mode 100644
index 0000000..117e224
--- /dev/null
+++ b/libs/tiptap-editor/src/code-block-view.ts
@@ -0,0 +1,189 @@
+import type { Editor } from '@tiptap/core';
+import type { Node as PMNode } from '@tiptap/pm/model';
+import { extractLang } from './highlight';
+
+/** editor.storage 的 key,宿主(index.ts)在此注入 onRunCode 回调。 */
+export const ON_RUN_CODE_STORAGE_KEY = '__onRunCode';
+
+/** 运行请求参数(传给 onRunCode 回调)。 */
+export interface RunCodeOpts {
+  /** 完整 info string(如 `python runnable {"timeout_secs":10}`)。 */
+  language: string;
+  /** 代码块文本内容。 */
+  source: string;
+}
+
+/**
+ * 判断节点是否为 runnable 代码块(language 属性含 'runnable' 标记)。
+ */
+function isRunnable(node: PMNode): boolean {
+  const lang = (node.attrs.language as string | null) ?? '';
+  return lang.includes('runnable');
+}
+
+/**
+ * CodeBlock 自定义 NodeView。
+ *
+ * 在标准 `
` 外包一层容器,顶部加工具栏(语言标签 + 运行按钮),
+ * 底部加运行结果区。contentDOM 仍指向 ``,保证 CodeBlockLowlight 的
+ * decoration(语法高亮)正常生效。
+ *
+ * 运行按钮仅 runnable 块显示;点击调 editor.storage.__onRunCode 回调
+ * (由 index.ts 注入,转发到 Rust server function)。
+ */
+export class CodeBlockNodeView {
+  private node: PMNode;
+  private editor: Editor;
+
+  private container: HTMLDivElement;
+  private toolbar: HTMLDivElement;
+  private langBadge: HTMLSpanElement;
+  private runBtn: HTMLButtonElement | null = null;
+  private pre: HTMLPreElement;
+  private code: HTMLElement;
+  private resultArea: HTMLDivElement | null = null;
+
+  constructor(opts: { node: PMNode; editor: Editor }) {
+    this.node = opts.node;
+    this.editor = opts.editor;
+
+    this.container = document.createElement('div');
+    this.container.classList.add('tiptap-codeblock');
+
+    // 工具栏
+    this.toolbar = document.createElement('div');
+    this.toolbar.classList.add('tiptap-codeblock-toolbar');
+    this.toolbar.setAttribute('contenteditable', 'false');
+
+    this.langBadge = document.createElement('span');
+    this.langBadge.classList.add('tiptap-codeblock-lang');
+    this.langBadge.textContent = extractLang((this.node.attrs.language as string) ?? '');
+    this.toolbar.appendChild(this.langBadge);
+
+    // 运行按钮(仅 runnable 块)
+    if (isRunnable(this.node)) {
+      this.runBtn = this.createRunButton();
+      this.toolbar.appendChild(this.runBtn);
+    }
+
+    this.container.appendChild(this.toolbar);
+
+    // pre > code(contentDOM,decoration 在此生效)
+    this.pre = document.createElement('pre');
+    this.code = document.createElement('code');
+    const lang = (this.node.attrs.language as string) ?? '';
+    if (lang) {
+      this.code.classList.add(`language-${lang}`);
+    }
+    this.pre.appendChild(this.code);
+    this.container.appendChild(this.pre);
+  }
+
+  get dom(): HTMLElement {
+    return this.container;
+  }
+
+  get contentDOM(): HTMLElement | null {
+    // contentDOM 必须是 ,ProseMirror 在此挂 decoration + 处理文本编辑。
+    return this.code;
+  }
+
+  /** ProseMirror 调用:节点属性变化时刷新工具栏。返回 false 拒绝非同类节点。 */
+  update(node: PMNode): boolean {
+    // 按 node.type 引用比较:真实 ProseMirror 每个 schema 的 NodeType 是单例,
+    // 同类节点引用必等(对齐 upload-image.ts 既有范式)。
+    if (node.type !== this.node.type) return false;
+    const oldLang = (this.node.attrs.language as string) ?? '';
+    const newLang = (node.attrs.language as string) ?? '';
+    this.node = node;
+    if (oldLang !== newLang) {
+      this.langBadge.textContent = extractLang(newLang);
+      // runnable 状态变化时重建按钮(简化:不细粒度增删,整体重建工具栏按钮区)
+      this.refreshRunButton();
+    }
+    return true;
+  }
+
+  /** 语言变化后,按 runnable 状态增删运行按钮。 */
+  private refreshRunButton(): void {
+    const shouldHave = isRunnable(this.node);
+    const has = this.runBtn !== null;
+    if (shouldHave && !has) {
+      this.runBtn = this.createRunButton();
+      this.toolbar.appendChild(this.runBtn);
+    } else if (!shouldHave && has) {
+      this.runBtn?.remove();
+      this.runBtn = null;
+    }
+  }
+
+  /** 工具栏/结果区交互不触发 ProseMirror 编辑事务。 */
+  ignoreMutation(): boolean {
+    return true;
+  }
+
+  /** 工具栏按钮点击不被编辑器拦截。 */
+  stopEvent(): boolean {
+    return false;
+  }
+
+  /** 创建运行按钮(构造与 update 增删共用)。 */
+  private createRunButton(): HTMLButtonElement {
+    const btn = document.createElement('button');
+    btn.classList.add('tiptap-codeblock-run');
+    btn.type = 'button';
+    btn.textContent = '▶ 运行';
+    btn.setAttribute('contenteditable', 'false');
+    btn.addEventListener('click', () => this.runCode());
+    return btn;
+  }
+
+  /** 点击运行:调 editor.storage.__onRunCode,结果填入结果区。 */
+  private async runCode(): Promise {
+    if (!this.runBtn) return;
+    const storage = this.editor.storage as unknown as Record;
+    const onRunCode = storage[ON_RUN_CODE_STORAGE_KEY] as
+      | ((opts: RunCodeOpts) => Promise)
+      | undefined;
+    if (!onRunCode) return; // 优雅降级:无回调时不操作
+
+    // 防重复点击
+    this.runBtn.disabled = true;
+    this.runBtn.textContent = '运行中…';
+    this.ensureResultArea('运行中…');
+
+    try {
+      const result = await onRunCode({
+        language: (this.node.attrs.language as string) ?? '',
+        source: this.node.textContent,
+      });
+      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;
+  }
+}