feat(info): 信息面板 UI 重做 + 双向批量关联 + 代码块内全选复制
1. 信息面板 UI 重做:设计 token 统一、lucide 图标、分区小节、星标/标签/关联 chip 化
2. 关联文档升级:新增 doc:link-batch IPC,A↔B 双向写入 links;面板支持快速双向关联 + 批量勾选关联/解除
3. 代码块内全选复制:Ctrl+A 在代码块内仅全选该代码块(TyporaKeys selectInsideCodeBlock),Ctrl+C 复制纯代码;代码块内 Ctrl+V 按纯文本粘贴
4. 修复 React error #62:btn 改为函数后遗漏 style={btn} 裸用,导致 render 期间更新
This commit is contained in:
@@ -63,6 +63,9 @@ export function registerIpc(vault: VaultService) {
|
||||
ipcMain.handle(IPC.docRead, async (_e, p: { path: string }) => fs2().readDoc(p.path));
|
||||
ipcMain.handle(IPC.docWrite, async (_e, p: { path: string; meta: any; body: string }) =>
|
||||
fs2().writeDoc({ path: p.path, meta: p.meta, body: p.body }));
|
||||
// 双向批量关联:当前文档与多个目标文档互相写入 links
|
||||
ipcMain.handle(IPC.docLinkBatch, async (_e, p: { path: string; targets: string[]; remove: boolean }) =>
|
||||
fs2().linkBatch(p.path, p.targets, p.remove));
|
||||
ipcMain.handle(IPC.search, async (_e, p: { query: string }) => vault.searchService?.search(p.query) ?? []);
|
||||
ipcMain.handle(IPC.tagStats, async () => vault.searchService?.tagStats() ?? []);
|
||||
ipcMain.handle(IPC.tagPaths, async () => vault.searchService?.tagPaths() ?? {});
|
||||
|
||||
@@ -154,4 +154,23 @@ export class FileService {
|
||||
const content = joinFrontmatter(payload.meta, payload.body);
|
||||
return withFileLock(abs, () => writeFileAtomic(abs, content));
|
||||
}
|
||||
|
||||
// 双向批量关联:当前文档与每个目标文档互相写入 links(关联/解除),返回当前文档最新 links
|
||||
async linkBatch(path: string, targets: string[], remove: boolean): Promise<{ links: string[] }> {
|
||||
const apply = (cur: string[], t: string, rem: boolean) => rem ? cur.filter(x => x !== t) : (cur.includes(t) ? cur : [...cur, t]);
|
||||
const cur = await this.readDoc(path);
|
||||
let links = [...(cur.meta.links ?? [])];
|
||||
for (const t of targets) links = apply(links, t, remove);
|
||||
await this.writeDoc({ path, meta: { ...cur.meta, links }, body: cur.body });
|
||||
// 反向:把当前文档路径写入每个目标文档的 links(双向关联的核心)
|
||||
for (const t of targets) {
|
||||
try {
|
||||
const td = await this.readDoc(t);
|
||||
let tLinks = [...(td.meta.links ?? [])];
|
||||
tLinks = apply(tLinks, path, remove);
|
||||
await this.writeDoc({ path: t, meta: { ...td.meta, links: tLinks }, body: td.body });
|
||||
} catch { /* 目标文档不存在或不可读则跳过 */ }
|
||||
}
|
||||
return { links };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useStore } from '../../store';
|
||||
import { invoke } from '../../ipc-client';
|
||||
import { IPC } from '@hunter/shared';
|
||||
import type { TreeNode } from '@hunter/shared';
|
||||
import { Star, Tag, Link2, FileText, Clock, Plus, X, Check, ListChecks, ArrowLeftRight, Folder, ChevronDown } from 'lucide-react';
|
||||
|
||||
// 信息面板:路径/标签/关联文档(双向+批量)/时间。样式统一走设计 token
|
||||
export function DocInfoPanel() {
|
||||
const currentPath = useStore(s => s.currentPath);
|
||||
const title = useStore(s => s.title);
|
||||
@@ -12,76 +11,142 @@ export function DocInfoPanel() {
|
||||
const toggleStar = useStore(s => s.toggleStar);
|
||||
const addTag = useStore(s => s.addTag);
|
||||
const removeTag = useStore(s => s.removeTag);
|
||||
const addLink = useStore(s => s.addLink);
|
||||
const removeLink = useStore(s => s.removeLink);
|
||||
const linkBatch = useStore(s => s.linkBatch);
|
||||
const openDoc = useStore(s => s.openDoc);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [linkInput, setLinkInput] = useState('');
|
||||
const [quickSel, setQuickSel] = useState('');
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
|
||||
if (!currentPath) {
|
||||
return <div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>未打开文档</div>;
|
||||
}
|
||||
|
||||
// 库内 .md 文件供关联链接选择
|
||||
// 库内 .md 文件供关联选择(排除当前文档与已关联项)
|
||||
const docs = tree.filter(n => n.type === 'doc');
|
||||
const mtime = tree.find(n => n.path === currentPath)?.mtime;
|
||||
const links = meta.links ?? [];
|
||||
const linkNames = new Map<string, string>();
|
||||
for (const d of docs) linkNames.set(d.path, d.name);
|
||||
const quickCandidates = docs.filter(d => d.path !== currentPath && !links.includes(d.path));
|
||||
|
||||
const toggleCheck = (p: string) => {
|
||||
const n = new Set(checked); if (n.has(p)) n.delete(p); else n.add(p); setChecked(n);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, overflow: 'auto', fontSize: 13 }}>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<strong>{title}</strong>
|
||||
<button onClick={() => toggleStar()} title="星标" style={{ cursor: 'pointer', background: 'none', border: 'none', fontSize: 16, color: meta.star ? 'var(--accent)' : 'var(--text-tertiary)' }}>
|
||||
{meta.star ? '★' : '☆'}
|
||||
<div style={{ padding: 14, overflow: 'auto', fontSize: 13, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{/* 标题 + 星标 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBottom: 12, borderBottom: '1px solid var(--control-border)' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</div>
|
||||
</div>
|
||||
<button onClick={toggleStar} title={meta.star ? '取消星标' : '星标'} style={iconBtn} aria-pressed={!!meta.star}>
|
||||
<Star size={16} fill={meta.star ? 'currentColor' : 'none'} color={meta.star ? 'var(--accent)' : 'var(--text-tertiary)'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Section title="路径">
|
||||
<div style={{ opacity: 0.6, wordBreak: 'break-all' }}>{currentPath}</div>
|
||||
{/* 路径 */}
|
||||
<Section icon={<FileText size={13} />} title="路径">
|
||||
<div style={{ color: 'var(--text-tertiary)', wordBreak: 'break-all', lineHeight: 1.5 }}>{currentPath}</div>
|
||||
</Section>
|
||||
|
||||
<Section title="标签">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
|
||||
{/* 标签 */}
|
||||
<Section icon={<Tag size={13} />} title="标签">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||
{(meta.tags ?? []).map(t => (
|
||||
<span key={t} style={{ background: 'var(--control-border)', borderRadius: 4, padding: '2px 8px', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{t}<span style={{ cursor: 'pointer', opacity: 0.6 }} onClick={() => removeTag(t)}>×</span>
|
||||
<span key={t} style={chip}>
|
||||
{t}
|
||||
<button style={chipX} onClick={() => removeTag(t)} title="移除标签"><X size={12} /></button>
|
||||
</span>
|
||||
))}
|
||||
{(meta.tags ?? []).length === 0 && <span style={{ opacity: 0.4 }}>无标签</span>}
|
||||
{(meta.tags ?? []).length === 0 && <span style={{ color: 'var(--text-tertiary)', opacity: 0.6 }}>暂无标签</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input value={tagInput} onChange={e => setTagInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && tagInput.trim()) { addTag(tagInput.trim()); setTagInput(''); } }} placeholder="添加标签" style={inp} />
|
||||
<button style={btn} onClick={() => { if (tagInput.trim()) { addTag(tagInput.trim()); setTagInput(''); } }}>+</button>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input value={tagInput} onChange={e => setTagInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && tagInput.trim()) { addTag(tagInput.trim()); setTagInput(''); } }} placeholder="添加标签,回车确认" style={inp} />
|
||||
<button style={btn()} title="添加标签" onClick={() => { if (tagInput.trim()) { addTag(tagInput.trim()); setTagInput(''); } }}><Plus size={14} /></button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关联文档">
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
{(meta.links ?? []).map(l => (
|
||||
<div key={l} style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
||||
<span style={{ opacity: 0.8 }}>{l}</span>
|
||||
<span style={{ cursor: 'pointer', opacity: 0.6 }} onClick={() => removeLink(l)}>×</span>
|
||||
{/* 关联文档:双向 + 批量 */}
|
||||
<Section icon={<Link2 size={13} />} title={"关联文档" + (links.length ? '(' + links.length + ')' : '')}>
|
||||
{links.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-tertiary)', opacity: 0.6, marginBottom: 8 }}>暂无关联,可在下方快速关联或批量关联</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 10 }}>
|
||||
{links.map(l => (
|
||||
<div key={l} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-secondary)', borderRadius: 6, border: '1px solid var(--control-border)' }}>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }} title={l}>{linkNames.get(l) ?? l}</span>
|
||||
<button style={iconSmall} title="打开" onClick={() => openDoc(l)}><FileText size={13} /></button>
|
||||
<button style={iconSmall} title="解除关联(双向)" onClick={() => void linkBatch([l], true)}><X size={13} /></button>
|
||||
</div>
|
||||
))}
|
||||
{(meta.links ?? []).length === 0 && <span style={{ opacity: 0.4 }}>无关联</span>}
|
||||
</div>
|
||||
<select value={linkInput} onChange={e => setLinkInput(e.target.value)} style={inp}>
|
||||
<option value="">选择库内文档…</option>
|
||||
{docs.filter(d => d.path !== currentPath && !(meta.links ?? []).includes(d.path + '')).map(d => (
|
||||
<option key={d.path} value={d.path}>{d.name}({d.path})</option>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* 快速双向关联 */}
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 10 }}>
|
||||
<ArrowLeftRight size={14} style={{ color: 'var(--text-tertiary)', flexShrink: 0 }} title="快速双向关联" />
|
||||
<select value={quickSel} onChange={e => setQuickSel(e.target.value)} style={{ ...inp, flex: 1 }}>
|
||||
<option value="">选择文档,双向关联…</option>
|
||||
{quickCandidates.map(d => (<option key={d.path} value={d.path}>{d.name}</option>))}
|
||||
</select>
|
||||
<button style={{ ...btn, marginLeft: 4 }} onClick={() => { if (linkInput) { addLink(linkInput); setLinkInput(''); } }}>关联</button>
|
||||
<button style={btnPrimary(!quickSel)} title="建立双向关联" onClick={() => { if (quickSel) { void linkBatch([quickSel], false); setQuickSel(''); } }} disabled={!quickSel}>关联</button>
|
||||
</div>
|
||||
|
||||
{/* 批量关联 */}
|
||||
<div style={{ border: '1px solid var(--control-border)', borderRadius: 6, overflow: 'hidden' }}>
|
||||
<button style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 6, padding: '6px 8px', background: 'transparent', border: 'none', color: 'var(--text-secondary)', cursor: 'pointer' }} onClick={() => setBatchOpen(o => !o)}>
|
||||
<ListChecks size={14} />批量关联
|
||||
<ChevronDown size={14} style={{ marginLeft: 'auto', transform: batchOpen ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }} />
|
||||
</button>
|
||||
{batchOpen && (
|
||||
<div style={{ borderTop: '1px solid var(--control-border)', maxHeight: 180, overflow: 'auto' }}>
|
||||
<div style={{ padding: 4, display: 'flex', flexDirection: 'column' }}>
|
||||
{quickCandidates.length === 0 ? (
|
||||
<div style={{ padding: '6px 8px', color: 'var(--text-tertiary)', opacity: 0.6 }}>没有可关联的文档</div>
|
||||
) : quickCandidates.map(d => (
|
||||
<label key={d.path} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 8px', borderRadius: 4, cursor: 'pointer' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--control-border)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} >
|
||||
<input type="checkbox" checked={checked.has(d.path)} onChange={() => toggleCheck(d.path)} style={{ accentColor: 'var(--accent)' }} />
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
|
||||
{checked.has(d.path) && <Check size={13} style={{ color: 'var(--accent)' }} />}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{batchOpen && (
|
||||
<div style={{ display: 'flex', gap: 6, padding: '6px 8px', borderTop: '1px solid var(--control-border)', alignItems: 'center' }}>
|
||||
<button style={btn(quickCandidates.length === 0)} title="全部选中" onClick={() => { const n = new Set(checked); quickCandidates.forEach(d => n.add(d.path)); setChecked(n); }} disabled={quickCandidates.length === 0}><Folder size={13} />全选</button>
|
||||
<button style={btnPrimary(checked.size === 0)} title="为勾选文档建立双向关联" disabled={checked.size === 0} onClick={() => { const arr = [...checked]; void linkBatch(arr, false); setChecked(new Set()); setBatchOpen(false); }}>关联选中({checked.size})</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="修改时间">
|
||||
<div style={{ opacity: 0.6 }}>{mtime ? new Date(mtime).toLocaleString('zh-CN') : '—'}</div>
|
||||
<div style={{ opacity: 0.4, fontSize: 12, marginTop: 4 }}>创建时间:{meta.createdAt ? new Date(meta.createdAt).toLocaleString('zh-CN') : '—'}</div>
|
||||
{/* 时间 */}
|
||||
<Section icon={<Clock size={13} />} title="时间">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, color: 'var(--text-tertiary)' }}>
|
||||
<div>修改:{mtime ? new Date(mtime).toLocaleString('zh-CN') : '—'}</div>
|
||||
<div>创建:{meta.createdAt ? new Date(meta.createdAt).toLocaleString('zh-CN') : '—'}</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (<div style={{ marginBottom: 16 }}><div style={{ opacity: 0.5, marginBottom: 6 }}>{title}</div>{children}</div>);
|
||||
function Section({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--text-tertiary)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>{icon}{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const inp: React.CSSProperties = { background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', color: 'var(--text-primary)', borderRadius: 4, padding: '3px 8px', fontSize: 13, flex: 1 };
|
||||
const btn: React.CSSProperties = { minWidth: 28, height: 26, border: '1px solid var(--control-border)', background: 'transparent', color: 'var(--text-primary)', cursor: 'pointer', borderRadius: 4 };
|
||||
const chip: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 4, background: 'var(--control-border)', color: 'var(--text-secondary)', borderRadius: 999, padding: '2px 8px', fontSize: 12 };
|
||||
const chipX: React.CSSProperties = { border: 'none', background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', display: 'inline-flex', padding: 0, borderRadius: 4 };
|
||||
const inp: React.CSSProperties = { background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', color: 'var(--text-primary)', borderRadius: 6, padding: '5px 8px', fontSize: 13, flex: 1, outline: 'none' };
|
||||
const btn = (disabled?: boolean): React.CSSProperties => ({ minWidth: 30, height: 28, border: '1px solid var(--control-border)', background: 'transparent', color: 'var(--text-secondary)', cursor: disabled ? 'not-allowed' : 'pointer', borderRadius: 6, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 4, padding: '0 8px', fontSize: 12, opacity: disabled ? 0.4 : 1 });
|
||||
const btnPrimary = (disabled?: boolean): React.CSSProperties => ({ minWidth: 52, height: 28, border: 'none', background: 'var(--accent)', color: '#fff', cursor: disabled ? 'not-allowed' : 'pointer', borderRadius: 6, padding: '0 10px', fontSize: 12, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', opacity: disabled ? 0.4 : 1 });
|
||||
const iconBtn: React.CSSProperties = { border: 'none', background: 'transparent', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 4, borderRadius: 6 };
|
||||
const iconSmall: React.CSSProperties = { border: 'none', background: 'transparent', color: 'var(--text-tertiary)', cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 2, borderRadius: 4 };
|
||||
@@ -94,9 +94,29 @@ export function TiptapEditor() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
// FR-E6:剪贴板粘贴图片 -> 入库 .attachments -> 插入相对路径
|
||||
// 剪贴板粘贴:代码块内粘贴按纯文本插入(避免 markdown 转换/围栏),其余走图片入库逻辑
|
||||
handlePaste: (_view, event) => {
|
||||
const ev = event as ClipboardEvent;
|
||||
// 若选区位于代码块内:直接插入纯文本,不触发 markdown 转换
|
||||
const { selection, doc } = _view.state;
|
||||
if (!selection.empty) {
|
||||
let inCode = false;
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'codeBlock') {
|
||||
const start = pos + 1, end = pos + node.nodeSize - 1;
|
||||
if (selection.from >= start && selection.to <= end) { inCode = true; return false; }
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (inCode) {
|
||||
const text = ev.clipboardData?.getData('text/plain') ?? '';
|
||||
if (text) {
|
||||
ev.preventDefault();
|
||||
_view.dispatch(_view.state.tr.insertText(text, selection.from, selection.to));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const items = ev.clipboardData?.items;
|
||||
if (!items) return false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
|
||||
@@ -41,6 +41,8 @@ export const TyporaKeys = Extension.create({
|
||||
'Mod-Shift-d': () => deleteWord(ed()),
|
||||
'Mod-Home': () => docJump(ed(), 'start'),
|
||||
'Mod-End': () => docJump(ed(), 'end'),
|
||||
// 代码块内 Ctrl+A 全选:仅选中该代码块内容(而非整篇文档),保证 Ctrl+C 复制纯代码
|
||||
'Mod-a': () => selectInsideCodeBlock(ed()),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -138,3 +140,19 @@ function docJump(editor: Editor, where: 'start' | 'end'): boolean {
|
||||
if (where === 'start') return editor.commands.setTextSelection(0);
|
||||
return editor.commands.setTextSelection(editor.state.doc.content.size);
|
||||
}
|
||||
|
||||
// Ctrl+A:若光标/选区位于代码块内,则仅全选该代码块内容(便于 Ctrl+C 复制纯代码);否则退回默认全选整篇
|
||||
function selectInsideCodeBlock(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
const $pos = state.doc.resolve(state.selection.from);
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
if ($pos.node(d).type.name === 'codeBlock') {
|
||||
const start = $pos.start(d);
|
||||
const end = $pos.end(d);
|
||||
if (end > start) return editor.commands.setTextSelection({ from: start, to: end });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 不在代码块内:走默认全选
|
||||
return editor.commands.selectAll();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface EditorState {
|
||||
toggleStar: () => void;
|
||||
addLink: (link: string) => void;
|
||||
removeLink: (link: string) => void;
|
||||
linkBatch: (targets: string[], remove: boolean) => Promise<void>;
|
||||
saveNow: () => Promise<void>;
|
||||
reloadDoc: () => Promise<boolean>; // FR-S6:重新加载——检测 mtime 变化,有未保存改动时返回 false 需确认
|
||||
resetEditor: () => void; // FR-V6: 切库时重置编辑区
|
||||
@@ -90,15 +91,20 @@ export const useStore = create<EditorState>((set, get) => ({
|
||||
set({ meta: { ...get().meta, star: !get().meta.star } });
|
||||
get().saveNow();
|
||||
},
|
||||
addLink: (link: string) => {
|
||||
const links = [...(get().meta.links ?? []), link].filter((l, i, a) => a.indexOf(l) === i);
|
||||
set({ meta: { ...get().meta, links } });
|
||||
get().saveNow();
|
||||
},
|
||||
removeLink: (link: string) => {
|
||||
const links = (get().meta.links ?? []).filter(l => l !== link);
|
||||
set({ meta: { ...get().meta, links } });
|
||||
get().saveNow();
|
||||
addLink: (link: string) => { void get().linkBatch([link], false); },
|
||||
removeLink: (link: string) => { void get().linkBatch([link], true); },
|
||||
// 双向批量关联:先冲刷编辑器内容落盘,再由主进程把当前文档与每个目标文档互相写入 links
|
||||
linkBatch: async (targets: string[], remove: boolean) => {
|
||||
const { currentPath } = get();
|
||||
if (!currentPath || targets.length === 0) return;
|
||||
window.dispatchEvent(new CustomEvent('dsh-flush-body'));
|
||||
await get().saveNow();
|
||||
try {
|
||||
const res = await invoke(IPC.docLinkBatch, { path: currentPath, targets, remove });
|
||||
set({ meta: { ...get().meta, links: res.links } });
|
||||
} catch (e) {
|
||||
console.error('linkBatch failed', e);
|
||||
}
|
||||
},
|
||||
setBody: (body: string) => {
|
||||
set({ body, saveState: 'saving' });
|
||||
|
||||
@@ -200,12 +200,12 @@ test('DocInfoPanel: 星标+标签+关联可用', async () => {
|
||||
await a.page.getByPlaceholder('添加标签').press('Enter');
|
||||
await a.page.waitForTimeout(300);
|
||||
await expect(a.page.getByText('testtag')).toBeVisible({ timeout: 3000 });
|
||||
// 关联文档
|
||||
// 关联文档(快速双向关联:下拉为文档名,按钮 title 建立双向关联)
|
||||
const select = a.page.locator('select').first();
|
||||
await select.selectOption('DocB.md');
|
||||
await a.page.getByRole('button', { name: '关联' }).click();
|
||||
await select.selectOption('DocB');
|
||||
await a.page.getByRole('button', { name: '关联', exact: true }).click();
|
||||
await a.page.waitForTimeout(300);
|
||||
await expect(a.page.getByText('DocB.md')).toBeVisible({ timeout: 3000 });
|
||||
await expect(a.page.getByTitle('DocB.md')).toBeVisible({ timeout: 3000 });
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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(800);
|
||||
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
|
||||
const clip = () => app.evaluate(({ clipboard }) => clipboard.readText());
|
||||
return { app, page, invoke, clip };
|
||||
}
|
||||
|
||||
// 代码块内 Ctrl+A 全选后 Ctrl+C:应只复制代码块内容(纯代码无围栏),即使文档有其他内容
|
||||
test('代码块内 Ctrl+A 全选复制纯代码(不受文档其他内容影响)', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-sel1-'));
|
||||
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
const a = await launch(ud);
|
||||
await a.invoke('vault:switch', { root: v });
|
||||
// 文档:代码块上下都有其他内容,确保 Ctrl+A 只选代码块而非整篇
|
||||
const body = '# 标题\n\n上方段落\n\n```bash\nsudo apt-get update\nls -la\n```\n\n下方段落';
|
||||
await a.invoke('doc:write', { path: 'code.md', meta: {}, body });
|
||||
await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'code.md');
|
||||
await a.page.waitForTimeout(1500);
|
||||
const code = a.page.locator('.ProseMirror pre code');
|
||||
await code.click();
|
||||
await a.page.keyboard.press('Control+a');
|
||||
await a.page.waitForTimeout(200);
|
||||
// 校验选中范围只是代码块内容(可通过 selection anchor 判断,或直接 Ctrl+C 后看剪贴板)
|
||||
await a.page.keyboard.press('Control+c');
|
||||
await a.page.waitForTimeout(300);
|
||||
const text = await a.clip();
|
||||
console.log('SELALL_CLIP', JSON.stringify(text));
|
||||
expect(text.trim().replace(/\r\n/g, '\n')).toBe('sudo apt-get update\nls -la');
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
// 代码块内 Ctrl+V 粘贴按纯文本插入(不产生 markdown 转换/围栏)
|
||||
test('代码块内 Ctrl+V 粘贴纯文本', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-sel2-'));
|
||||
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
const a = await launch(ud);
|
||||
await a.invoke('vault:switch', { root: v });
|
||||
await a.invoke('doc:write', { path: 'code2.md', meta: {}, body: '```js\nlet x = 1;\n```' });
|
||||
await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'code2.md');
|
||||
await a.page.waitForTimeout(1500);
|
||||
const code = a.page.locator('.ProseMirror pre code');
|
||||
await code.click();
|
||||
// 全选代码块,然后粘贴纯文本
|
||||
await a.page.keyboard.press('Control+a');
|
||||
await a.page.evaluate(() => navigator.clipboard.writeText('# not a heading\n- not a list'));
|
||||
await a.page.waitForTimeout(200);
|
||||
await a.page.keyboard.press('Control+v');
|
||||
await a.page.waitForTimeout(500);
|
||||
const inner = await a.page.evaluate(() => (document.querySelector('.ProseMirror pre code') as HTMLElement)?.innerText ?? '');
|
||||
console.log('PASTE_INNER', JSON.stringify(inner));
|
||||
// 粘贴内容按纯文本插入(不出现标题/列表节点结构变化)
|
||||
expect(inner).toContain('# not a heading');
|
||||
expect(inner).toContain('- not a list');
|
||||
await a.app.close();
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
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(800);
|
||||
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
|
||||
return { app, page, invoke };
|
||||
}
|
||||
|
||||
async function setupVault(a: { invoke: any; page: any }, names: string[]) {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-assoc-'));
|
||||
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
await a.invoke('vault:switch', { root: v });
|
||||
for (const n of names) {
|
||||
await a.invoke('doc:write', { path: n + '.md', meta: { title: n }, body: '# ' + n });
|
||||
}
|
||||
await a.page.evaluate(() => (globalThis as any).__STORE__?.getState?.().loadTree());
|
||||
await a.page.waitForTimeout(500);
|
||||
return { v };
|
||||
}
|
||||
|
||||
async function openInfo(a: { page: any }, docName: string) {
|
||||
await a.page.getByTestId('filetree').getByText(docName, { exact: true }).click();
|
||||
await a.page.waitForTimeout(500);
|
||||
await a.page.getByRole('button', { name: '信息', exact: true }).click();
|
||||
await a.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// 双向关联:当前文档关联目标后,目标文档的 links 也包含当前文档
|
||||
test('关联文档:快速双向关联互相写入 links', async () => {
|
||||
const a = await launch(nodePath.join(os.tmpdir(), 'hn-a1-'));
|
||||
await setupVault(a, ['DocA', 'DocB', 'DocC']);
|
||||
await openInfo(a, 'DocA');
|
||||
const select = a.page.locator('select').first();
|
||||
await select.selectOption('DocB');
|
||||
await a.page.getByRole('button', { name: '关联', exact: true }).click();
|
||||
await a.page.waitForTimeout(500);
|
||||
await expect(a.page.getByTitle('DocB.md')).toBeVisible({ timeout: 3000 });
|
||||
const docb = await a.invoke('doc:read', { path: 'DocB.md' });
|
||||
const doca = await a.invoke('doc:read', { path: 'DocA.md' });
|
||||
expect(docb.meta.links).toContain('DocA.md');
|
||||
expect(doca.meta.links).toContain('DocB.md');
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
// 批量关联:勾选多个文档一次性建立双向关联
|
||||
test('关联文档:批量勾选多文档双向关联', async () => {
|
||||
const a = await launch(nodePath.join(os.tmpdir(), 'hn-a2-'));
|
||||
await setupVault(a, ['DocA', 'DocB', 'DocC']);
|
||||
await openInfo(a, 'DocA');
|
||||
await a.page.getByRole('button', { name: /批量关联/ }).click();
|
||||
await a.page.waitForTimeout(200);
|
||||
const boxes = a.page.locator('input[type=checkbox]');
|
||||
const count = await boxes.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
await boxes.nth(0).check();
|
||||
await boxes.nth(1).check();
|
||||
await a.page.getByRole('button', { name: /关联选中/ }).click();
|
||||
await a.page.waitForTimeout(600);
|
||||
const doca = await a.invoke('doc:read', { path: 'DocA.md' });
|
||||
const docb = await a.invoke('doc:read', { path: 'DocB.md' });
|
||||
const docc = await a.invoke('doc:read', { path: 'DocC.md' });
|
||||
expect(doca.meta.links).toContain('DocB.md');
|
||||
expect(doca.meta.links).toContain('DocC.md');
|
||||
expect(docb.meta.links).toContain('DocA.md');
|
||||
expect(docc.meta.links).toContain('DocA.md');
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
// 解除关联为双向:移除后两侧 links 均清除
|
||||
test('关联文档:解除关联双向清除', async () => {
|
||||
const a = await launch(nodePath.join(os.tmpdir(), 'hn-a3-'));
|
||||
await setupVault(a, ['DocA', 'DocB']);
|
||||
await a.invoke('doc:link-batch', { path: 'DocA.md', targets: ['DocB.md'], remove: true });
|
||||
await a.invoke('doc:link-batch', { path: 'DocA.md', targets: ['DocB.md'], remove: false });
|
||||
await openInfo(a, 'DocA');
|
||||
await expect(a.page.getByTitle('DocB.md')).toBeVisible({ timeout: 3000 });
|
||||
await a.page.getByTitle('解除关联(双向)').click();
|
||||
await a.page.waitForTimeout(500);
|
||||
const doca = await a.invoke('doc:read', { path: 'DocA.md' });
|
||||
const docb = await a.invoke('doc:read', { path: 'DocB.md' });
|
||||
expect(doca.meta.links ?? []).not.toContain('DocB.md');
|
||||
expect(docb.meta.links ?? []).not.toContain('DocA.md');
|
||||
await a.app.close();
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export const IPC = {
|
||||
docRead: 'doc:read',
|
||||
docWrite: 'doc:write',
|
||||
docReveal: 'doc:reveal',
|
||||
docLinkBatch: 'doc:link-batch',
|
||||
search: 'search',
|
||||
tagStats: 'tag:stats',
|
||||
tagPaths: 'tag:paths',
|
||||
@@ -65,6 +66,7 @@ export interface IpcMap {
|
||||
[IPC.docRead]: { args: { path: string }; result: ReadDocResult };
|
||||
[IPC.docWrite]: { args: WriteDocPayload; result: void };
|
||||
[IPC.docReveal]: { args: { path: string }; result: void };
|
||||
[IPC.docLinkBatch]: { args: { path: string; targets: string[]; remove: boolean }; result: { links: string[] } };
|
||||
[IPC.search]: { args: { query: string }; result: SearchHit[] };
|
||||
[IPC.tagStats]: { args: void; result: TagStat[] };
|
||||
[IPC.tagPaths]: { args: void; result: Record<string, string[]> };
|
||||
|
||||
Reference in New Issue
Block a user