fix: 文件列表缩进/图标、代码高亮去红、代码块复制带围栏、长文档底部留白

This commit is contained in:
hunter-dev
2026-08-30 21:52:49 +08:00
parent e20fcab837
commit bd6243950c
7 changed files with 273 additions and 17 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
],
"asar": true,
"win": {
"target": "dir"
"target": ["nsis", "dir"]
},
"nsis": {
"oneClick": false,
@@ -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 (
<NodeViewWrapper as="div" style={{ background: 'var(--bg-secondary)', 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 var(--control-border)' }}>
<div contentEditable={false} style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 8, padding: '4px 8px', borderBottom: '1px solid var(--control-border)' }}>
<button
onClick={() => void copyWithFence()}
title="复制(携带 Markdown 代码围栏 ```language)"
style={{ border: 'none', background: 'transparent', color: copied ? 'var(--accent-text)' : 'var(--text-tertiary)', cursor: 'pointer', fontSize: 12, padding: '1px 4px', borderRadius: 4 }}
onMouseEnter={e => { if (!copied) e.currentTarget.style.background = 'var(--control-border)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
>{copied ? '✓ 已复制' : '复制'}</button>
<select
value={language || 'plaintext'}
onChange={e => updateAttributes({ language: e.target.value })}
@@ -119,6 +119,31 @@ export function TiptapEditor() {
skipUpdate.current = false;
}, [currentPath, editor]);
// FR-E13:长文档底部书写——光标行距编辑区底部保持留白,避免输入内容贴着页面底缘
useEffect(() => {
if (!editor) return;
const BOTTOM_MARGIN = 72; // 光标行距底部的期望留白(px)
const adjust = () => {
// 编辑器销毁/未挂载时 view 访问会抛异常——必须 try/catch 保护,避免关闭标签后界面报错
try {
const dom = editor.view.dom as HTMLElement;
const scroller = dom.parentElement as HTMLElement | null;
if (!scroller) return;
const st = getComputedStyle(scroller);
if (st.overflowY !== 'auto' && st.overflowY !== 'scroll') return;
const sel = editor.view.state.selection;
if (!sel.empty) return;
const caret = editor.view.coordsAtPos(sel.from);
const rect = scroller.getBoundingClientRect();
const delta = caret.bottom - (rect.bottom - BOTTOM_MARGIN);
if (delta > 0) scroller.scrollTop += delta;
} catch { /* 编辑器销毁等异常:忽略 */ }
};
editor.on('update', adjust);
editor.on('selectionUpdate', adjust);
return () => { editor.off('update', adjust); editor.off('selectionUpdate', adjust); };
}, [editor]);
// 监听保存前冲刷——Ctrl+S / 关窗前同步编辑器内容到 store
useEffect(() => {
if (!editor) return;
@@ -106,9 +106,9 @@ export function FileTree() {
const isDragOver = dragOver === n.path;
return (
<div key={n.path}>
<div style={{ ...pad, display: 'flex', alignItems: 'center', cursor: 'pointer',
<div style={{ padding: '3px 4px', ...pad, display: 'flex', alignItems: 'center', cursor: 'pointer',
background: currentPath === n.path ? 'var(--control-border)' : (isDragOver ? 'var(--control-border)' : 'transparent'),
padding: '3px 4px', borderRadius: 4, position: 'relative' }}
borderRadius: 4, position: 'relative' }}
onMouseEnter={() => setHover(n.path)} onMouseLeave={() => setHover(null)}
onClick={() => isRenaming ? null : (n.type === 'folder' ? toggle(n.path) : openDoc(n.path))}
onContextMenu={e => { e.preventDefault(); setMenu({ path: n.path, x: e.clientX, y: e.clientY }); }}
@@ -117,7 +117,12 @@ export function FileTree() {
onDragOver={e => { if (n.type === 'folder') { e.preventDefault(); setDragOver(n.path); } }}
onDragLeave={() => setDragOver(null)}
onDrop={e => n.type === 'folder' && onDrop(e, n.path)}>
<span style={{ width: 14 }}>{n.type === 'folder' ? (isCollapsed ? '▶' : '▼') : '◉'}</span>
<span style={{ width: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, color: 'var(--text-tertiary)' }}>
{n.type === 'folder' ? (isCollapsed ? <ChevronRight /> : <ChevronDown />) : <span style={{ width: 16 }} />}
</span>
<span style={{ width: 18, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{n.type === 'folder' ? <FolderIcon open={!isCollapsed} /> : <FileIcon />}
</span>
{isRenaming ? (
<input autoFocus value={renameVal} onChange={e => 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 <div onClick={onClick} style={{ padding: '5px 10px', cursor: 'pointer', borderRadius: 4, fontSize: 13, color: danger ? '#f88' : 'var(--text-primary)' }} onMouseEnter={e => (e.currentTarget.style.background = 'var(--control-border)')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>{children}</div>;
}
// ---- 文件树图标(SVG,跟随主题颜色)----
function ChevronRight() {
return (
<svg width="10" height="10" viewBox="0 0 10 10" style={{ display: 'block' }}>
<path d="M3 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function ChevronDown() {
return (
<svg width="10" height="10" viewBox="0 0 10 10" style={{ display: 'block' }}>
<path d="M1 3l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function FolderIcon({ open }: { open: boolean }) {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" style={{ display: 'block', color: 'var(--text-secondary)' }}>
{open ? (
<path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h5l2 2h8A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11z" fill="currentColor" opacity="0.32" stroke="currentColor" strokeWidth="1.3" />
) : (
<path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h5l2 2h8A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11z" fill="currentColor" opacity="0.4" stroke="currentColor" strokeWidth="1.3" />
)}
</svg>
);
}
function FileIcon() {
return (
<svg width="14" height="15" viewBox="0 0 24 24" fill="none" style={{ display: 'block', color: 'var(--text-secondary)' }}>
<path d="M6 3h9l4 4v14H6V3z" fill="currentColor" opacity="0.18" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
<path d="M9 12.5h7M9 16h7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" opacity="0.8" />
</svg>
);
}
@@ -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;
+8 -5
View File
@@ -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); }
@@ -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();
});