diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b173acf..7b100f9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,7 @@ ], "asar": true, "win": { - "target": "dir" + "target": ["nsis", "dir"] }, "nsis": { "oneClick": false, diff --git a/apps/desktop/src/renderer/features/editor/CodeBlockView.tsx b/apps/desktop/src/renderer/features/editor/CodeBlockView.tsx index f6b56b8..f617497 100644 --- a/apps/desktop/src/renderer/features/editor/CodeBlockView.tsx +++ b/apps/desktop/src/renderer/features/editor/CodeBlockView.tsx @@ -1,4 +1,5 @@ import { NodeViewWrapper, NodeViewContent, type NodeViewProps } from '@tiptap/react'; +import { useState } from 'react'; // FR-E5 代码块语言切换:代码块顶部语言下拉(Shell/YAML/JSON/JS/TS/Python 等) const LANGS: { label: string; value: string }[] = [ @@ -28,9 +29,42 @@ const LANGS: { label: string; value: string }[] = [ export function CodeBlockView({ node, updateAttributes, selected }: NodeViewProps) { const language = (node.attrs.language as string) || ''; const codeClass = language ? `language-${language} hljs` : 'hljs'; + const [copied, setCopied] = useState(false); + // FR-E5 扩展:复制时携带 Markdown 代码围栏(```language),方便粘贴到其他 Markdown 应用 + const copyWithFence = async () => { + const code = node.textContent ?? ''; + const lang = language && language !== 'plaintext' ? language : ''; + const text = lang ? '```' + lang + '\n' + code + '\n```' : '```\n' + code + '\n```'; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // 剪贴板不可用时回退到 execCommand + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { /* 忽略 */ } + } + }; return ( -
+
+ setRenameVal(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { setRenaming(null); invoke(IPC.fileRename, { from: n.path, toName: renameVal }).then(r => { useTabs.getState().renamePath(n.path, r.path); syncCurrentPath(n.path, r.path); loadTree(); }); } if (e.key === 'Escape') setRenaming(null); }} @@ -168,4 +173,39 @@ const miniBtn: React.CSSProperties = { border: 'none', background: 'transparent' function MenuItem({ children, onClick, danger }: { children: React.ReactNode; onClick: () => void; danger?: boolean }) { return
(e.currentTarget.style.background = 'var(--control-border)')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>{children}
; +} + +// ---- 文件树图标(SVG,跟随主题颜色)---- +function ChevronRight() { + return ( + + + + ); +} +function ChevronDown() { + return ( + + + + ); +} +function FolderIcon({ open }: { open: boolean }) { + return ( + + {open ? ( + + ) : ( + + )} + + ); +} +function FileIcon() { + return ( + + + + + ); } \ No newline at end of file diff --git a/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx b/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx index 51b71eb..3a530a9 100644 --- a/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx +++ b/apps/desktop/src/renderer/features/outline/OutlinePanel.tsx @@ -35,7 +35,12 @@ export function OutlinePanel() { const activeRef = useRef(-1); useEffect(() => { if (!editor || headings.length === 0) return; - const dom = editor.view.dom as HTMLElement; + let dom: HTMLElement; + try { + dom = editor.view.dom as HTMLElement; + } catch { + return; // 编辑器已销毁/未挂载——关闭标签后不抛错 + } // 找到可滚动父容器 let scrollEl: HTMLElement | null = dom.parentElement; while (scrollEl && getComputedStyle(scrollEl).overflowY !== 'auto' && getComputedStyle(scrollEl).overflowY !== 'scroll') { @@ -51,11 +56,13 @@ export function OutlinePanel() { requestAnimationFrame(() => { const containerTop = scrollEl!.getBoundingClientRect().top; let current = 0; - for (let i = 0; i < headings.length; i++) { - const coords = editor!.view.coordsAtPos(headings[i].pos + 1); - if (coords.top - containerTop <= 100) current = i; - else break; - } + try { + for (let i = 0; i < headings.length; i++) { + const coords = editor!.view.coordsAtPos(headings[i].pos + 1); + if (coords.top - containerTop <= 100) current = i; + else break; + } + } catch { /* 编辑器销毁后滚动事件忽略 */ } if (current !== activeRef.current) { activeRef.current = current; setActiveIdx(current); @@ -74,7 +81,10 @@ export function OutlinePanel() { // 不用 smooth scroll(导航场景应瞬时跳转),不用 editor.commands.focus(会触发 scrollIntoView) const scrollTo = useCallback((h: HeadingItem) => { if (!editor) return; - const el = editor.view.nodeDOM(h.pos) as HTMLElement | null; + let el: HTMLElement | null = null; + try { + el = editor.view.nodeDOM(h.pos) as HTMLElement | null; + } catch { return; } if (!el) return; // 找到编辑器的可滚动父容器 let scrollEl: HTMLElement | null = el.parentElement; diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index 09042d4..0bf22ea 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -13,7 +13,8 @@ html, body, #root { margin: 0; height: 100%; } font-family: var(--editor-font-family, Consolas, monospace) !important; white-space: var(--editor-white-space, pre-wrap); min-height: 100%; - padding: 16px 24px; + /* 底部加大留白:长文档写到末尾时,光标行不会贴着编辑区底缘(配合光标跟随滚动) */ + padding: 16px 24px 140px; font-family: 'Consolas', 'Source Code Pro', system-ui, sans-serif; font-size: 14px; line-height: 1.7; @@ -36,25 +37,27 @@ html, body, #root { margin: 0; height: 100%; } /* 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-doctag, .tiptap-wrap .ProseMirror .hljs-keyword, .tiptap-wrap .ProseMirror .hljs-formula, .tiptap-wrap .ProseMirror .hljs-selector-tag, .tiptap-wrap .ProseMirror .hljs-tag { color: #c678dd; } +.tiptap-wrap .ProseMirror .hljs-name, .tiptap-wrap .ProseMirror .hljs-section { color: #61afef; } .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-deletion { color: #d19a66; } .tiptap-wrap .ProseMirror .hljs-emphasis { font-style: italic; } .tiptap-wrap .ProseMirror .hljs-strong { font-weight: 700; } /* highlight.js 浅色主题——当 --bg-primary 为浅色时生效(latte/daylight/plain) */ :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs { color: #24292e; } :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-comment, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-quote { color: #6a737d; } -:root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-doctag, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-keyword, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-formula, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-name, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-section, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-selector-tag, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-tag { color: #d73a49; } +:root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-doctag, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-keyword, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-formula, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-selector-tag, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-tag { color: #8250df; } +:root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-name, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-section { color: #0969da; } :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-addition, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-attribute, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-attr, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-string, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-template-tag, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-template-variable, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-value { color: #032f62; } :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-built_in, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-class .hljs-title, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-title, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-title.class_, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-type { color: #6f42c1; } :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-function .hljs-title, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-title.function_ { color: #005cc5; } :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-literal, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-number, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-operator, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-variable, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-params { color: #005cc5; } :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-bullet, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-link, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-meta, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-regexp, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-selector-attr, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-selector-class, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-selector-id, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-symbol, :root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-variable.constant_ { color: #005cc5; } -:root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-deletion { color: #d73a49; } +:root[data-theme-group="light"] .tiptap-wrap .ProseMirror .hljs-deletion { color: #6a737d; } /* FR-E11 查找高亮 */ .tiptap-wrap .ProseMirror .find-match { background: rgba(255, 213, 79, 0.35); border-radius: 2px; } .tiptap-wrap .ProseMirror .find-match-active { background: rgba(255, 145, 0, 0.65); } diff --git a/apps/desktop/tests/e2e/fix-regression.spec.ts b/apps/desktop/tests/e2e/fix-regression.spec.ts new file mode 100644 index 0000000..b764bdf --- /dev/null +++ b/apps/desktop/tests/e2e/fix-regression.spec.ts @@ -0,0 +1,144 @@ +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'); + await page.waitForTimeout(1000); + const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args }); + return { app, page, invoke }; +} + +// 回归:文件列表缩进逐级递进 + 每行带 SVG 图标(FR-反馈1) +test('回归:文件列表缩进递进且带图标', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-reg1-')); + const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v'); + 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.invoke('file:create', { basePath: '', name: '一层', isFolder: true }); + await a.invoke('file:create', { basePath: '一层', name: '二层', isFolder: true }); + await a.invoke('file:create', { basePath: '一层/二层', name: '深层文档.md', isFolder: false }); + await a.invoke('file:create', { basePath: '', name: '根文档.md', isFolder: false }); + await a.page.evaluate(() => (globalThis as any).__STORE__?.getState?.().loadTree()); + await a.page.waitForTimeout(1200); + const rows = await a.page.evaluate(() => { + const ft = document.querySelector('[data-testid="filetree"]'); + const out: { padLeft: string; svgs: number; text: string }[] = []; + for (const el of ft?.querySelectorAll('div') ?? []) { + const cs = getComputedStyle(el); + if (cs.cursor === 'pointer' && cs.position === 'relative') { + out.push({ padLeft: cs.paddingLeft, svgs: el.querySelectorAll('svg').length, text: el.textContent.trim().slice(0, 20) }); + } + } + return out; + }); + const pad = rows.map(r => parseInt(r.padLeft, 10)); + // 根文档(0级) vs 一层(0级) vs 二层(1级) vs 深层文档(2级) + expect(rows.length).toBeGreaterThanOrEqual(4); + const rootPad = pad[0]; + const deepRow = rows.find(r => r.text.includes('深层文档')); + const l2Row = rows.find(r => r.text.includes('二层')); + expect(deepRow).toBeTruthy(); + expect(l2Row).toBeTruthy(); + expect(parseInt(deepRow!.padLeft, 10)).toBeGreaterThan(parseInt(l2Row!.padLeft, 10)); + expect(parseInt(l2Row!.padLeft, 10)).toBeGreaterThan(rootPad); + // 每个文件夹/文档行都有 SVG 图标 + for (const r of rows) expect(r.svgs).toBeGreaterThan(0); + await a.app.close(); +}); + +// 回归:代码块复制携带 Markdown 围栏(FR-反馈3) +test('回归:复制代码块携带 ```language 围栏', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-reg3-')); + const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v'); + await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true }); + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + const body = '# 复制测试\n\n```bash\necho hello\nls -la\n```'; + await a.invoke('doc:write', { path: 'copy.md', meta: {}, body }); + await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'copy.md'); + await a.page.waitForTimeout(1500); + // 拦截剪贴板写入 + await a.page.evaluate(() => { + (globalThis as any).__cap = []; + navigator.clipboard.writeText = (t: string) => { (globalThis as any).__cap.push(t); return Promise.resolve(); }; + }); + const copyBtn = a.page.locator('.ProseMirror button', { hasText: '复制' }).first(); + await expect(copyBtn).toBeVisible({ timeout: 10000 }); + await copyBtn.click(); + await a.page.waitForTimeout(400); + const text = await a.page.evaluate(() => (globalThis as any).__cap?.[0] ?? null); + expect(text).toBe('```bash\necho hello\nls -la\n```'); + await a.app.close(); +}); + +// 回归:长文档底部书写时光标不贴底(FR-反馈4) +test('回归:长文档底部书写光标距底留白', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-reg4-')); + const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v'); + await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true }); + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + const paras: string[] = []; + for (let i = 1; i <= 600; i++) paras.push('段落 ' + i + ' 内容内容内容内容内容内容内容内容内容内容内容内容内容内容'); + const body = '# 长文档\n\n' + paras.join('\n\n'); + await a.invoke('doc:write', { path: 'long.md', meta: {}, body }); + await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'long.md'); + await a.page.waitForTimeout(1500); + const editor = a.page.locator('.ProseMirror'); + await editor.click(); + await a.page.keyboard.press('Control+End'); + await a.page.waitForTimeout(400); + await a.page.keyboard.type('底部输入内容'); + await a.page.waitForTimeout(700); + const m = await a.page.evaluate(() => { + const prose = document.querySelector('.ProseMirror'); + const sc = prose?.closest('div[style]') as HTMLElement | null; + if (!sc) return null; + const rect = sc.getBoundingClientRect(); + const sel = window.getSelection(); + const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null; + const caret = range ? range.getBoundingClientRect() : null; + return caret ? Math.round(rect.bottom - caret.bottom) : null; + }); + expect(m).not.toBeNull(); + // 光标至少距底部 30px 留白(修复前为 0~5px 贴底) + expect(m!).toBeGreaterThanOrEqual(30); + await a.app.close(); +}); + +// 回归:代码块语法高亮不再出现红色(FR-反馈2) +test('回归:代码块语法高亮无红色', async () => { + const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-reg2-')); + const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v'); + await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true }); + const a = await launch(userData); + await a.invoke('vault:switch', { root: vault }); + const body = '# 高亮\n\n```bash\ntime for in\n# 中文注释\n```\n\n```markdown\n## 标题\n- 列表项\n```'; + await a.invoke('doc:write', { path: 'hl.md', meta: {}, body }); + await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'hl.md'); + await a.page.waitForTimeout(1500); + const reds = await a.page.evaluate(() => { + const prose = document.querySelector('.ProseMirror'); + const bad: { cls: string; color: string; text: string }[] = []; + for (const el of prose?.querySelectorAll('.hljs-keyword, .hljs-section, .hljs-tag, .hljs-doctag, .hljs-deletion') ?? []) { + const c = getComputedStyle(el).color; + const mm = c.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!mm) continue; + const r = +mm[1], g = +mm[2], b = +mm[3]; + if (r > 130 && r > g * 1.4 && r > b * 1.4) { + bad.push({ cls: String(el.className), color: c, text: (el.textContent || '').slice(0, 20) }); + } + } + return bad; + }); + expect(reds).toEqual([]); + await a.app.close(); +});