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