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,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); }