feat(editor): CodeBlockNodeView 显示语言标签与运行按钮
自定义 NodeView 包裹 <pre><code>,顶部工具栏显示语言(extractLang 提取), runnable 块额外显示运行按钮。contentDOM 指向 <code>,保证 CodeBlockLowlight decoration(高亮)正常生效。运行走 editor.storage.__onRunCode 回调。
This commit is contained in:
parent
0adc6d8382
commit
82280e3d4f
119
libs/tiptap-editor/src/__tests__/code-block-view.test.ts
Normal file
119
libs/tiptap-editor/src/__tests__/code-block-view.test.ts
Normal file
@ -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<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', '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 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<HTMLButtonElement>('.tiptap-codeblock-run')!.click(),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
189
libs/tiptap-editor/src/code-block-view.ts
Normal file
189
libs/tiptap-editor/src/code-block-view.ts
Normal file
@ -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。
|
||||||
|
*
|
||||||
|
* 在标准 `<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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user