diff --git a/apps/desktop/src/renderer/features/editor/CodeBlockView.tsx b/apps/desktop/src/renderer/features/editor/CodeBlockView.tsx new file mode 100644 index 0000000..08e65f5 --- /dev/null +++ b/apps/desktop/src/renderer/features/editor/CodeBlockView.tsx @@ -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 ( + +
+ +
+
+        
+      
+
+ ); +} diff --git a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx index d7af543..fde0886 100644 --- a/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx +++ b/apps/desktop/src/renderer/features/editor/TiptapEditor.tsx @@ -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, diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 66ec921..9627e40 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -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; } diff --git a/apps/desktop/tests/e2e/editor-codeblock.spec.ts b/apps/desktop/tests/e2e/editor-codeblock.spec.ts new file mode 100644 index 0000000..e5368a1 --- /dev/null +++ b/apps/desktop/tests/e2e/editor-codeblock.spec.ts @@ -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(); +});