feat(M2): FR-E5 代码块语言切换——CodeBlockView 自定义节点视图(语言下拉21种)+保留 lowlight 装饰高亮;styles.css hljs 暗色主题(atom-one-dark);editor-codeblock E2E 1 项

This commit is contained in:
hunter-dev
2026-08-26 13:10:25 +08:00
parent 78843ce5cc
commit 4beebbc8d0
4 changed files with 126 additions and 2 deletions
@@ -0,0 +1,47 @@
import { NodeViewWrapper, NodeViewContent, type NodeViewProps } from '@tiptap/react';
// FR-E5 代码块语言切换:代码块顶部语言下拉(Shell/YAML/JSON/JS/TS/Python 等)
const LANGS: { label: string; value: string }[] = [
{ label: '纯文本', value: 'plaintext' },
{ label: 'Shell', value: 'shell' },
{ label: 'Bash', value: 'bash' },
{ label: 'YAML', value: 'yaml' },
{ label: 'JSON', value: 'json' },
{ label: 'JavaScript', value: 'javascript' },
{ label: 'TypeScript', value: 'typescript' },
{ label: 'Python', value: 'python' },
{ label: 'SQL', value: 'sql' },
{ label: 'CSS', value: 'css' },
{ label: 'SCSS', value: 'scss' },
{ label: 'HTML/XML', value: 'xml' },
{ label: 'Markdown', value: 'markdown' },
{ label: 'C', value: 'c' },
{ label: 'C++', value: 'cpp' },
{ label: 'C#', value: 'csharp' },
{ label: 'Go', value: 'go' },
{ label: 'Rust', value: 'rust' },
{ label: 'Java', value: 'java' },
{ label: 'INI', value: 'ini' },
{ label: 'Diff', value: 'diff' },
];
export function CodeBlockView({ node, updateAttributes, selected }: NodeViewProps) {
const language = (node.attrs.language as string) || '';
const codeClass = language ? `language-${language} hljs` : 'hljs';
return (
<NodeViewWrapper as="div" style={{ background: '#161616', borderRadius: 6, margin: '0.6em 0', overflow: 'hidden', outline: selected ? '1px solid #4a6a8a' : 'none' }}>
<div contentEditable={false} style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px 8px', borderBottom: '1px solid #2a2a2a' }}>
<select
value={language || 'plaintext'}
onChange={e => updateAttributes({ language: e.target.value })}
style={{ background: '#222', color: '#ccc', border: '1px solid #444', borderRadius: 4, fontSize: 12, padding: '1px 4px', cursor: 'pointer' }}
>
{LANGS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
</select>
</div>
<pre style={{ margin: 0, padding: '12px 16px', overflow: 'auto', background: 'transparent', borderRadius: 0 }}>
<NodeViewContent as={"code" as any} className={codeClass} />
</pre>
</NodeViewWrapper>
);
}
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
@@ -19,9 +19,15 @@ import { EditorToolbar } from './EditorToolbar';
import { FindReplace } from './FindReplace';
import { SlashMenu } from './SlashMenu';
import { BlockOps } from './BlockOps';
import { CodeBlockView } from './CodeBlockView';
const lowlight = createLowlight(common);
// FR-E5:代码块自定义节点视图——顶部语言下拉 + 保留 lowlight 装饰高亮
const CodeBlock = CodeBlockLowlight.extend({
addNodeView() { return ReactNodeViewRenderer(CodeBlockView); },
});
export function TiptapEditor() {
const { currentPath, body, setBody } = useStore();
const skipUpdate = useRef(false);
@@ -30,7 +36,7 @@ export function TiptapEditor() {
const editor = useEditor({
extensions: [
StarterKit.configure({ codeBlock: false }),
CodeBlockLowlight.configure({ lowlight }),
CodeBlock.configure({ lowlight }),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: true }), TableRow, TableCell, TableHeader,
+12
View File
@@ -26,6 +26,18 @@ html, body, #root { margin: 0; height: 100%; }
background: #161616; padding: 12px 16px; border-radius: 6px; overflow: auto;
}
.tiptap-wrap .ProseMirror pre code { background: none; padding: 0; }
/* highlight.js 暗色主题(CodeBlockLowlight 装饰 span,FR-E5) */
.tiptap-wrap .ProseMirror .hljs { color: #abb2bf; }
.tiptap-wrap .ProseMirror .hljs-comment, .tiptap-wrap .ProseMirror .hljs-quote { color: #5c6370; font-style: italic; }
.tiptap-wrap .ProseMirror .hljs-doctag, .tiptap-wrap .ProseMirror .hljs-keyword, .tiptap-wrap .ProseMirror .hljs-formula, .tiptap-wrap .ProseMirror .hljs-name, .tiptap-wrap .ProseMirror .hljs-section, .tiptap-wrap .ProseMirror .hljs-selector-tag, .tiptap-wrap .ProseMirror .hljs-tag { color: #e06c75; }
.tiptap-wrap .ProseMirror .hljs-addition, .tiptap-wrap .ProseMirror .hljs-attribute, .tiptap-wrap .ProseMirror .hljs-attr, .tiptap-wrap .ProseMirror .hljs-string, .tiptap-wrap .ProseMirror .hljs-template-tag, .tiptap-wrap .ProseMirror .hljs-template-variable, .tiptap-wrap .ProseMirror .hljs-value { color: #98c379; }
.tiptap-wrap .ProseMirror .hljs-built_in, .tiptap-wrap .ProseMirror .hljs-class .hljs-title, .tiptap-wrap .ProseMirror .hljs-title, .tiptap-wrap .ProseMirror .hljs-title.class_, .tiptap-wrap .ProseMirror .hljs-type { color: #e6c07b; }
.tiptap-wrap .ProseMirror .hljs-function .hljs-title, .tiptap-wrap .ProseMirror .hljs-title.function_ { color: #61afef; }
.tiptap-wrap .ProseMirror .hljs-literal, .tiptap-wrap .ProseMirror .hljs-number, .tiptap-wrap .ProseMirror .hljs-operator, .tiptap-wrap .ProseMirror .hljs-variable, .tiptap-wrap .ProseMirror .hljs-params { color: #d19a66; }
.tiptap-wrap .ProseMirror .hljs-bullet, .tiptap-wrap .ProseMirror .hljs-link, .tiptap-wrap .ProseMirror .hljs-meta, .tiptap-wrap .ProseMirror .hljs-regexp, .tiptap-wrap .ProseMirror .hljs-selector-attr, .tiptap-wrap .ProseMirror .hljs-selector-class, .tiptap-wrap .ProseMirror .hljs-selector-id, .tiptap-wrap .ProseMirror .hljs-symbol, .tiptap-wrap .ProseMirror .hljs-variable.constant_ { color: #56b6c2; }
.tiptap-wrap .ProseMirror .hljs-deletion { color: #e06c75; }
.tiptap-wrap .ProseMirror .hljs-emphasis { font-style: italic; }
.tiptap-wrap .ProseMirror .hljs-strong { font-weight: 700; }
.tiptap-wrap .ProseMirror blockquote {
border-left: 3px solid #444; padding-left: 12px; color: #aaa; margin: 0.6em 0;
}
@@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';
import { _electron as electron } from 'playwright';
import { promises as fs } from 'node:fs';
import nodePath from 'node:path';
import os from 'node:os';
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
async function launch(userData: string) {
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
const page = await app.firstWindow();
await page.waitForLoadState('domcontentloaded');
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
return { app, page, invoke };
}
// FR-E5 代码块语言切换:每个代码块顶部语言下拉 -> 切换 language 属性
test('FR-E5:代码块语言下拉切换生效', async () => {
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-e5-'));
const userData = nodePath.join(tmp, 'userdata');
const vault = nodePath.join(tmp, 'vault');
await fs.mkdir(userData, { recursive: true });
await fs.mkdir(vault, { recursive: true });
const a = await launch(userData);
await a.invoke('vault:switch', { root: vault });
await a.page.getByRole('button', { name: /新建文档/ }).click();
await a.page.getByPlaceholder(/输入文档标题/).fill('代码块');
await a.page.getByPlaceholder(/输入文档标题/).press('Enter');
await expect(a.page.getByTestId('tabbar').getByText('代码块', { exact: true })).toBeVisible({ timeout: 10000 });
const editor = a.page.locator('.ProseMirror');
await editor.click();
// 插入代码块并输入内容
await a.page.locator('button[title="代码块 Ctrl+Shift+M"]').click();
await a.page.keyboard.type('key: value');
// 代码块顶部应渲染语言下拉
const select = a.page.locator('.ProseMirror select');
await expect(select).toBeVisible({ timeout: 10000 });
// 切换到 YAML -> code 元素带 language-yaml
await select.selectOption('yaml');
let cls: string = await a.page.locator('.ProseMirror pre code').evaluate(el => (el as HTMLElement).className);
expect(cls).toContain('language-yaml');
// 切换到 JSON -> language-json
await select.selectOption('json');
cls = await a.page.locator('.ProseMirror pre code').evaluate(el => (el as HTMLElement).className);
expect(cls).toContain('language-json');
// 切换到 TypeScript -> language-typescript
await select.selectOption('typescript');
cls = await a.page.locator('.ProseMirror pre code').evaluate(el => (el as HTMLElement).className);
expect(cls).toContain('language-typescript');
await a.app.close();
});