fix: 文件列表缩进/图标、代码高亮去红、代码块复制带围栏、长文档底部留白
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user