'use client'; import { useState, useCallback, useEffect, useRef } from 'react'; import { useAppStore } from '../store/useAppStore'; import { useI18n } from '../lib/useI18n'; import { createChapter, deleteChapter, updateChapter, saveChapters, getChapters } from '../lib/storage'; import { exportProject, importProject, importWork, exportWorkAsTxt, exportWorkAsMarkdown, exportWorkAsDocx, exportWorkAsEpub, exportWorkAsPdf } from '../lib/project-io'; import { WRITING_MODES, getAllWorks, getSettingsNodes, createWorkNode, saveSettingsNodes, setActiveWorkId as setActiveWorkIdSetting } from '../lib/settings'; import { detectConflicts, mergeChapters } from '../lib/chapter-number'; import { estimateTokens } from '../lib/context-engine'; export default function Sidebar({ onOpenHelp, onToggle, editorRef }) { const { chapters, addChapter, setChapters, updateChapter: updateChapterStore, activeChapterId, setActiveChapterId, activeWorkId, setActiveWorkId: setActiveWorkIdStore, sidebarOpen, setSidebarOpen, theme, setTheme, writingMode, setShowSettings, setShowSnapshots, showToast } = useAppStore(); const [renameId, setRenameId] = useState(null); const [renameTitle, setRenameTitle] = useState(''); const [contextMenu, setContextMenu] = useState(null); const [showExportModal, setShowExportModal] = useState(false); const [showCurrentExportMenu, setShowCurrentExportMenu] = useState(false); const [importModal, setImportModal] = useState(null); const [conflictModal, setConflictModal] = useState(null); const [showGitPopup, setShowGitPopup] = useState(false); const [outlineCollapsed, setOutlineCollapsed] = useState(false); // 手动折叠大纲 const [headings, setHeadings] = useState([]); // 文档大纲标题列表 const [activeHeadingIndex, setActiveHeadingIndex] = useState(-1); // 当前高亮的大纲项 const isClickScrollingRef = useRef(false); // 防 scrollspy 死循环互斥锁 const { t } = useI18n(); // 切换主题 const toggleTheme = useCallback(() => { const next = theme === 'light' ? 'dark' : 'light'; setTheme(next); document.documentElement.setAttribute('data-theme', next); localStorage.setItem('author-theme', next); import('../lib/persistence').then(m => m.persistSet('author-theme', next).catch(() => { })); }, [theme, setTheme]); // 中文数字 ↔ 阿拉伯数字 互转 const cnDigits = '零一二三四五六七八九十百千万'; const parseCnNum = (s) => { if (!s) return NaN; let result = 0, current = 0; for (const ch of s) { const d = '零一二三四五六七八九'.indexOf(ch); if (d >= 0) { current = d || current; } else if (ch === '十') { result += (current || 1) * 10; current = 0; } else if (ch === '百') { result += (current || 1) * 100; current = 0; } else if (ch === '千') { result += (current || 1) * 1000; current = 0; } else if (ch === '万') { result += (current || 1) * 10000; current = 0; } } return result + current; }; const toCnNum = (n) => { if (n <= 0) return '零'; if (n <= 10) return '零一二三四五六七八九十'[n]; const units = ['', '十', '百', '千', '万']; const digits = '零一二三四五六七八九'; let result = ''; let str = String(n); let len = str.length; let lastWasZero = false; for (let i = 0; i < len; i++) { const d = parseInt(str[i]); const unit = units[len - 1 - i]; if (d === 0) { lastWasZero = true; } else { if (lastWasZero) result += '零'; if (d === 1 && unit === '十' && result === '') result += unit; else result += digits[d] + unit; lastWasZero = false; } } return result; }; // 尝试从标题提取数字并生成下一章标题,返回 null 表示无法匹配 const tryNextTitle = (title) => { // 1. "第N章" 阿拉伯数字 — 只保留章节编号,去掉后续标题名 const m1 = title.match(/第(\d+)章/); if (m1) return `第${parseInt(m1[1], 10) + 1}章`; // 2. "第X章" 中文数字(如 第三十三章)— 只保留章节编号 const m2 = title.match(/第([零一二三四五六七八九十百千万]+)章/); if (m2) { const n = parseCnNum(m2[1]); if (!isNaN(n)) return `第${toCnNum(n + 1)}章`; } // 3. 纯阿拉伯数字(如 "33") if (/^\d+$/.test(title.trim())) return String(parseInt(title.trim(), 10) + 1); // 4. 纯中文数字(如 "三十三") if (/^[零一二三四五六七八九十百千万]+$/.test(title.trim())) { const n = parseCnNum(title.trim()); if (!isNaN(n)) return toCnNum(n + 1); } // 5. 包含末尾数字(如 "Chapter 33")— 只递增数字,保留前缀 const m5 = title.match(/^(.+?)(\d+)\s*$/); if (m5) return m5[1] + String(parseInt(m5[2], 10) + 1); return null; }; // 从章节列表中向前搜索最近的带数字章节,推算下一章名 const getNextChapterTitle = useCallback(() => { if (chapters.length === 0) return t('sidebar.defaultChapterTitle').replace('{num}', 1); // 从最后一章向前找,跳过"更新说明"等非标准章节 for (let i = chapters.length - 1; i >= 0; i--) { const next = tryNextTitle(chapters[i].title); if (next) return next; } return t('sidebar.defaultChapterTitle').replace('{num}', chapters.length + 1); }, [chapters, t]); // 创建新章节 — 一键创建并进入重命名模式 const handleCreateChapter = useCallback(async () => { const title = getNextChapterTitle(); const ch = await createChapter(title, activeWorkId); addChapter(ch); setActiveChapterId(ch.id); // 立即进入重命名模式,方便用户修改标题 setRenameId(ch.id); setRenameTitle(title); showToast(t('sidebar.chapterCreated').replace('{title}', title), 'success'); }, [getNextChapterTitle, showToast, addChapter, setActiveChapterId, t, activeWorkId]); // 删除章节 const handleDeleteChapter = useCallback(async (id) => { if (!Array.isArray(chapters) || chapters.length <= 1) { showToast(t('sidebar.alertRetainOne'), 'error'); return; } const ch = chapters.find(c => c.id === id); const remaining = await deleteChapter(id, activeWorkId); setChapters(remaining); if (activeChapterId === id) { setActiveChapterId(remaining[0]?.id || null); } showToast(t('sidebar.chapterDeleted').replace('{title}', ch?.title), 'info'); setContextMenu(null); }, [chapters, activeChapterId, showToast, setChapters, setActiveChapterId, t, activeWorkId]); // 重命名章节 const handleRename = useCallback((id) => { const title = renameTitle.trim(); if (!title) return; updateChapter(id, { title }, activeWorkId); updateChapterStore(id, { title }); setRenameId(null); setRenameTitle(''); }, [renameTitle, updateChapterStore, activeWorkId]); // ===== 文档大纲:从编辑器提取标题 + Scrollspy ===== useEffect(() => { let debounceTimer = null; let observer = null; let pollTimer = null; let cleanedUp = false; // 提取标题的函数 const extractHeadings = (editor) => { const json = editor.getJSON(); const h = []; (json.content || []).forEach((node, idx) => { if (node.type === 'heading' && node.attrs?.level) { const text = (node.content || []).map(c => c.text || '').join(''); if (text.trim()) h.push({ level: node.attrs.level, text: text.trim(), index: idx }); } }); setHeadings(h); }; // 设置 IntersectionObserver const setupObserver = (editor) => { const container = document.querySelector('.editor-container'); const headingEls = editor.view?.dom?.querySelectorAll('h1, h2, h3'); if (!container || !headingEls?.length) return; observer = new IntersectionObserver( (entries) => { if (isClickScrollingRef.current) return; let topEntry = null; entries.forEach(entry => { if (entry.isIntersecting) { if (!topEntry || entry.boundingClientRect.top < topEntry.boundingClientRect.top) { topEntry = entry; } } }); if (topEntry) { const allH = Array.from(editor.view.dom.querySelectorAll('h1, h2, h3')); const idx = allH.indexOf(topEntry.target); if (idx >= 0) setActiveHeadingIndex(idx); } }, { root: container, rootMargin: '-10% 0px -80% 0px', threshold: 0 } ); headingEls.forEach(el => observer.observe(el)); }; // 当编辑器就绪时,设置监听 const initWithEditor = (editor) => { // 初始提取 extractHeadings(editor); // 监听内容变化(防抖 300ms) const onUpdate = () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => extractHeadings(editor), 300); }; editor.on('update', onUpdate); // 延迟设置 Observer setTimeout(() => { if (!cleanedUp) setupObserver(editor); }, 500); // 返回清理函数 return () => { editor.off('update', onUpdate); clearTimeout(debounceTimer); observer?.disconnect(); }; }; // 轮询等待编辑器就绪 let editorCleanup = null; const tryInit = () => { const editor = editorRef?.current?.getEditor?.(); if (editor && !cleanedUp) { clearInterval(pollTimer); editorCleanup = initWithEditor(editor); } }; // 立即尝试一次 tryInit(); // 如果还没就绪,每 200ms 重试 if (!editorRef?.current?.getEditor?.()) { pollTimer = setInterval(tryInit, 200); } return () => { cleanedUp = true; clearInterval(pollTimer); editorCleanup?.(); setHeadings([]); }; }, [editorRef, activeChapterId]); // 点击大纲项:滚动到对应位置 const handleOutlineClick = useCallback((headingIdx) => { const editor = editorRef?.current?.getEditor?.(); if (!editor) return; const headingEls = editor.view?.dom?.querySelectorAll('h1, h2, h3'); const target = headingEls?.[headingIdx]; if (!target) return; isClickScrollingRef.current = true; setActiveHeadingIndex(headingIdx); target.scrollIntoView({ behavior: 'smooth', block: 'center' }); // 滚动结束后解锁 const unlock = () => { isClickScrollingRef.current = false; }; const container = document.querySelector('.editor-container'); if (container) { container.addEventListener('scrollend', unlock, { once: true }); // 兜底:500ms 后强制解锁 setTimeout(() => { container.removeEventListener('scrollend', unlock); isClickScrollingRef.current = false; }, 600); } else { setTimeout(unlock, 600); } }, [editorRef]); // 统计标题数(作为 tab 角标) const headingCount = headings.length; // 导出 const totalWords = Array.isArray(chapters) ? chapters.reduce((sum, ch) => sum + (ch.wordCount || 0), 0) : 0; return ( <> {/* ===== Git / 社区弹窗 ===== */} {showGitPopup && (
setShowGitPopup(false)}>
e.stopPropagation()} style={{ padding: '28px', maxWidth: 360, width: '90%', borderRadius: 'var(--radius-lg)', display: 'flex', flexDirection: 'column', gap: 16, }}>

社区与源码

setShowGitPopup(false)} style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none', color: 'var(--text-primary)', fontSize: 14, padding: '10px 14px', borderRadius: 'var(--radius-md)', background: 'var(--bg-secondary)', transition: 'background 0.15s' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}> GitHub → setShowGitPopup(false)} style={{ display: 'flex', alignItems: 'center', gap: 10, textDecoration: 'none', color: 'var(--text-primary)', fontSize: 14, padding: '10px 14px', borderRadius: 'var(--radius-md)', background: 'var(--bg-secondary)', transition: 'background 0.15s' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}> Gitee(国内镜像) →
QQ群:1087016949 setShowGitPopup(false)}>直达
)} {/* ===== 右键菜单 ===== */} {contextMenu && (
setContextMenu(null)}>
)} {/* ===== 导入作品弹窗 ===== */} {importModal && ( setImportModal(null)} onImport={async (targetWorkId) => { try { const existingChapters = await getChapters(targetWorkId); if (existingChapters.length === 0) { await saveChapters(importModal.chapters, targetWorkId); setActiveWorkIdSetting(targetWorkId); setChapters(importModal.chapters); if (importModal.chapters.length > 0) setActiveChapterId(importModal.chapters[0].id); setActiveWorkIdStore(targetWorkId); showToast(t('sidebar.importWorkSuccess').replace('{count}', importModal.chapters.length), 'success'); setImportModal(null); return; } const { conflicts, noConflictExisting, noConflictImported } = detectConflicts(existingChapters, importModal.chapters); if (conflicts.length === 0) { const merged = mergeChapters(noConflictExisting, noConflictImported, []); await saveChapters(merged, targetWorkId); setActiveWorkIdSetting(targetWorkId); setChapters(merged); if (merged.length > 0) setActiveChapterId(merged[0].id); setActiveWorkIdStore(targetWorkId); showToast(t('sidebar.importWorkSuccess').replace('{count}', importModal.chapters.length), 'success'); setImportModal(null); } else { setConflictModal({ conflicts, noConflictExisting, noConflictImported, targetWorkId, importedCount: importModal.chapters.length }); setImportModal(null); } } catch (err) { showToast(t('sidebar.importWorkFailed').replace('{error}', err.message), 'error'); } }} t={t} /> )} {/* ===== 章节冲突弹窗 ===== */} {conflictModal && ( setConflictModal(null)} onConfirm={async (resolvedConflicts) => { try { const merged = mergeChapters(conflictModal.noConflictExisting, conflictModal.noConflictImported, resolvedConflicts); await saveChapters(merged, conflictModal.targetWorkId); setActiveWorkIdSetting(conflictModal.targetWorkId); setChapters(merged); if (merged.length > 0) setActiveChapterId(merged[0].id); setActiveWorkIdStore(conflictModal.targetWorkId); showToast(t('sidebar.importWorkSuccess').replace('{count}', conflictModal.importedCount), 'success'); setConflictModal(null); } catch (err) { showToast(t('sidebar.importWorkFailed').replace('{error}', err.message), 'error'); } }} t={t} /> )} {/* ===== 导出更多弹窗 ===== */} {showExportModal && ( setShowExportModal(false)} onExport={(selectedChapters, format) => { const fns = { txt: exportWorkAsTxt, md: exportWorkAsMarkdown, docx: exportWorkAsDocx, epub: exportWorkAsEpub, pdf: exportWorkAsPdf }; const fn = fns[format]; if (fn) fn(selectedChapters); setShowExportModal(false); showToast(t('sidebar.exportedAll'), 'success'); }} t={t} /> )} ); } /** * 导入作品时的目标作品选择弹窗 */ function ImportWorkModal({ chapters, totalWords, onClose, onImport, t }) { const [works, setWorks] = useState([]); const [newWorkName, setNewWorkName] = useState(''); const [showNewInput, setShowNewInput] = useState(false); // 加载作品列表 useEffect(() => { (async () => { const nodes = await getSettingsNodes(); setWorks(getAllWorks(nodes)); })(); }, []); const handleCreateAndImport = async () => { const name = newWorkName.trim(); if (!name) return; const { workNode, subNodes } = createWorkNode(name); const allNodes = await getSettingsNodes(); const updatedNodes = [...allNodes, workNode, ...subNodes]; await saveSettingsNodes(updatedNodes); onImport(workNode.id); }; return (
e.stopPropagation()} style={{ padding: '24px', maxWidth: 420, width: '90%', borderRadius: 'var(--radius-lg)', display: 'flex', flexDirection: 'column', gap: 16, }}>

{t('sidebar.importWorkSelectTitle')}

{t('sidebar.importWorkSelectDesc') .replace('{count}', chapters.length) .replace('{words}', totalWords.toLocaleString())}

{works.map(w => ( ))} {showNewInput ? (
setNewWorkName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleCreateAndImport()} placeholder={t('sidebar.importWorkNewPlaceholder')} autoFocus />
) : ( )}
); } /** * 章节冲突解决弹窗 * 显示编号冲突的章节分组,用户可勾选保留哪些 */ function ChapterConflictModal({ conflicts, onClose, onConfirm, t }) { // 初始化选择状态:默认全选 const [selections, setSelections] = useState(() => { const init = {}; for (const group of conflicts) { init[group.num] = {}; for (const ch of group.existing) init[group.num][ch.id] = true; for (const ch of group.imported) init[group.num][ch.id] = true; } return init; }); const toggleChapter = (num, id) => { setSelections(prev => ({ ...prev, [num]: { ...prev[num], [id]: !prev[num][id] }, })); }; const isAllSelected = () => { for (const num in selections) { for (const id in selections[num]) { if (!selections[num][id]) return false; } } return true; }; const toggleAll = () => { const allSelected = isAllSelected(); const next = {}; for (const num in selections) { next[num] = {}; for (const id in selections[num]) { next[num][id] = !allSelected; } } setSelections(next); }; // 全选已有 const selectAllExisting = () => { const next = {}; for (const group of conflicts) { next[group.num] = {}; for (const ch of group.existing) next[group.num][ch.id] = true; for (const ch of group.imported) next[group.num][ch.id] = false; } setSelections(next); }; // 全选导入 const selectAllImported = () => { const next = {}; for (const group of conflicts) { next[group.num] = {}; for (const ch of group.existing) next[group.num][ch.id] = false; for (const ch of group.imported) next[group.num][ch.id] = true; } setSelections(next); }; // 单组全选 const toggleGroupAll = (group) => { const ids = [...group.existing, ...group.imported].map(ch => ch.id); const allSel = ids.every(id => selections[group.num]?.[id]); setSelections(prev => { const next = { ...prev, [group.num]: { ...prev[group.num] } }; ids.forEach(id => { next[group.num][id] = !allSel; }); return next; }); }; // 单组全选已有 const selectGroupExisting = (group) => { setSelections(prev => { const next = { ...prev, [group.num]: { ...prev[group.num] } }; for (const ch of group.existing) next[group.num][ch.id] = true; for (const ch of group.imported) next[group.num][ch.id] = false; return next; }); }; // 单组全选导入 const selectGroupImported = (group) => { setSelections(prev => { const next = { ...prev, [group.num]: { ...prev[group.num] } }; for (const ch of group.existing) next[group.num][ch.id] = false; for (const ch of group.imported) next[group.num][ch.id] = true; return next; }); }; const handleConfirm = () => { const resolved = conflicts.map(group => { const selected = []; for (const ch of group.existing) { if (selections[group.num]?.[ch.id]) selected.push(ch); } for (const ch of group.imported) { if (selections[group.num]?.[ch.id]) selected.push(ch); } return { num: group.num, selected }; }); onConfirm(resolved); }; const btnStyle = (active) => ({ padding: '2px 8px', fontSize: 11, borderRadius: 4, border: '1px solid var(--border-light)', background: active ? 'var(--accent)' : 'transparent', color: active ? '#fff' : 'var(--text-secondary)', cursor: 'pointer', whiteSpace: 'nowrap', transition: 'all 0.15s', }); return (
e.stopPropagation()} style={{ padding: '24px', maxWidth: 520, width: '90%', borderRadius: 'var(--radius-lg)', display: 'flex', flexDirection: 'column', gap: 16, maxHeight: '70vh', overflow: 'hidden', }}>

{t('sidebar.conflictTitle') || '章节编号冲突'}

{t('sidebar.conflictDesc') || '以下章节编号相同,请选择保留哪些:'}

{conflicts.map((group, gi) => { const groupIds = [...group.existing, ...group.imported].map(ch => ch.id); const groupAllSel = groupIds.every(id => selections[group.num]?.[id]); return (
{(t('sidebar.conflictGroup') || '第 {index} 组冲突(编号 {num}):') .replace('{index}', gi + 1) .replace('{num}', group.num)}
{/* 组级快捷按钮 */}
{group.existing.map(ch => ( ))} {group.imported.map(ch => ( ))}
); })}
{/* 底部:全局快捷按钮 + 操作 */}
); } // 导出更多弹窗 — 选择章节 + 格式 function ExportModal({ chapters, onClose, onExport, t }) { const [selected, setSelected] = useState(new Set()); const [format, setFormat] = useState('txt'); // 按每 10 章分组 const groups = []; for (let i = 0; i < chapters.length; i += 10) { groups.push(chapters.slice(i, i + 10)); } const toggleChapter = (id) => { setSelected(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }; const toggleGroup = (group) => { const ids = group.map(ch => ch.id); const allSelected = ids.every(id => selected.has(id)); setSelected(prev => { const next = new Set(prev); if (allSelected) { ids.forEach(id => next.delete(id)); } else { ids.forEach(id => next.add(id)); } return next; }); }; const toggleAll = () => { if (selected.size === chapters.length) { setSelected(new Set()); } else { setSelected(new Set(chapters.map(ch => ch.id))); } }; const formats = [ { value: 'txt', label: '📄 TXT' }, { value: 'md', label: '📝 Markdown' }, { value: 'docx', label: '📘 DOCX' }, { value: 'epub', label: '📚 EPUB' }, { value: 'pdf', label: '🖨️ PDF' }, ]; return (
e.stopPropagation()} style={{ width: '90vw', maxWidth: 500, maxHeight: '85vh', display: 'flex', flexDirection: 'column', background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-light)', boxShadow: '0 24px 48px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.05)', overflow: 'hidden', }}> {/* 头部 */}
📤

{t('sidebar.exportMoreTitle') || '导出更多'}

{t('sidebar.exportSelectHint') || '选择要导出的章节'}
{/* 全选栏 */}
0 ? 'var(--accent)' : 'var(--bg-tertiary, #888)', color: selected.size > 0 ? '#fff' : 'var(--text-muted)', padding: '2px 10px', borderRadius: 12, transition: 'all 0.2s', }}> {selected.size} / {chapters.length}
{/* 章节分组列表 */}
{groups.map((group, gi) => { const startIdx = gi * 10 + 1; const endIdx = gi * 10 + group.length; const groupIds = group.map(ch => ch.id); const allGroupSelected = groupIds.every(id => selected.has(id)); const someGroupSelected = groupIds.some(id => selected.has(id)); return (
{/* 组标题 */} {/* 组内章节 */} {group.map(ch => ( ))}
); })}
{/* 底部操作栏 */}
{formats.map(f => ( ))}
); }