reader.js 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317
  1. let currentCategory = '';
  2. let currentDoc = '';
  3. let docList = [];
  4. let currentMarkdownContent = ''; // 保存当前文档的markdown源码
  5. let isEditMode = false; // 是否处于编辑模式
  6. let savedScrollPosition = 0; // 保存的滚动位置
  7. let currentScrollObserver = null; // 保存当前的滚动监听器
  8. // DOM 元素缓存
  9. const DOM = {
  10. loading: null,
  11. content: null,
  12. docNav: null,
  13. toc: null,
  14. leftSidebar: null,
  15. rightSidebar: null,
  16. editBtn: null,
  17. editorContainer: null,
  18. markdownEditor: null,
  19. editorDocName: null,
  20. navPrev: null,
  21. navNext: null
  22. };
  23. // 配置 marked
  24. marked.setOptions({
  25. highlight: function(code, lang) {
  26. if (lang && hljs.getLanguage(lang)) {
  27. try {
  28. const result = hljs.highlight(code, { language: lang });
  29. return result.value;
  30. } catch (err) {
  31. console.error('Highlight error:', err);
  32. }
  33. }
  34. // 自动检测语言
  35. const autoResult = hljs.highlightAuto(code);
  36. return autoResult.value;
  37. },
  38. breaks: true,
  39. gfm: true
  40. });
  41. // 从 URL 获取参数
  42. function getQueryParams() {
  43. const params = new URLSearchParams(window.location.search);
  44. return {
  45. category: params.get('category'),
  46. doc: params.get('doc')
  47. };
  48. }
  49. // 加载文档列表
  50. async function loadDocList() {
  51. const { category } = getQueryParams();
  52. if (!category) {
  53. window.location.href = '/';
  54. return;
  55. }
  56. currentCategory = category;
  57. const categoryNameSpan = document.querySelector('#category-title .category-name');
  58. if (categoryNameSpan) {
  59. categoryNameSpan.textContent = category;
  60. }
  61. try {
  62. const response = await fetch(`/api/category/${encodeURIComponent(category)}`);
  63. if (!response.ok) throw new Error('获取文档列表失败');
  64. const categoryData = await response.json();
  65. docList = categoryData.docs;
  66. renderDocNav(docList);
  67. // 加载第一篇文档(或 URL 指定的文档)
  68. const { doc } = getQueryParams();
  69. const firstDoc = doc || docList[0]?.name;
  70. if (firstDoc) {
  71. loadDocument(firstDoc);
  72. }
  73. } catch (err) {
  74. console.error('Error loading doc list:', err);
  75. showError('加载文档列表失败');
  76. }
  77. }
  78. // 渲染文档导航
  79. function renderDocNav(docs) {
  80. const nav = DOM.docNav || document.getElementById('doc-nav');
  81. if (!DOM.docNav) DOM.docNav = nav;
  82. nav.innerHTML = '';
  83. docs.forEach(doc => {
  84. const item = document.createElement('div');
  85. item.className = `nav-item level-${doc.level}`;
  86. item.dataset.docName = doc.name;
  87. const link = document.createElement('a');
  88. link.href = '#';
  89. link.className = 'nav-link';
  90. link.textContent = doc.name;
  91. item.appendChild(link);
  92. nav.appendChild(item);
  93. });
  94. // 使用事件委托 - 只添加一个监听器
  95. nav.removeEventListener('click', handleNavClick);
  96. nav.addEventListener('click', handleNavClick);
  97. }
  98. // 导航点击处理函数
  99. function handleNavClick(e) {
  100. if (e.target.classList.contains('nav-link')) {
  101. e.preventDefault();
  102. const item = e.target.parentElement;
  103. const docName = item.dataset.docName;
  104. if (docName) {
  105. loadDocument(docName);
  106. updateURL(currentCategory, docName);
  107. // 更新活动状态 - 使用缓存的查询结果
  108. const navItems = DOM.docNav.querySelectorAll('.nav-item');
  109. navItems.forEach(el => el.classList.toggle('active', el === item));
  110. }
  111. }
  112. }
  113. // 加载文档内容
  114. async function loadDocument(docName, scrollToText = null) {
  115. // 使用 DOM 缓存
  116. if (!DOM.loading) DOM.loading = document.getElementById('loading');
  117. if (!DOM.content) DOM.content = document.getElementById('markdown-content');
  118. DOM.loading.style.display = 'flex';
  119. DOM.content.innerHTML = '';
  120. try {
  121. const response = await fetch(`/api/doc/${encodeURIComponent(currentCategory)}/${encodeURIComponent(docName)}`);
  122. if (!response.ok) {
  123. const errorData = await response.json().catch(() => ({}));
  124. throw new Error(errorData.error || `加载失败 (${response.status})`);
  125. }
  126. const data = await response.json();
  127. // 验证数据有效性
  128. if (!data.content || typeof data.content !== 'string') {
  129. throw new Error('文档内容格式错误');
  130. }
  131. currentDoc = docName;
  132. currentMarkdownContent = data.content; // 保存markdown源码
  133. // 渲染 Markdown
  134. const html = marked.parse(data.content);
  135. DOM.content.innerHTML = html;
  136. // 处理图片路径,将相对路径转换为正确的URL
  137. DOM.content.querySelectorAll('img').forEach(img => {
  138. const src = img.getAttribute('src');
  139. // 如果是相对路径(不以 http/https 开头)
  140. if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('/')) {
  141. // 使用新的API路径,避免中文路径问题
  142. const newSrc = `/api/image/${encodeURIComponent(currentCategory)}/${src}`;
  143. img.setAttribute('src', newSrc);
  144. // 添加错误处理
  145. img.onerror = function() {
  146. console.error('Failed to load image:', newSrc);
  147. // 显示占位图片或错误提示
  148. this.alt = '图片加载失败: ' + src;
  149. this.style.border = '1px dashed #ccc';
  150. this.style.padding = '10px';
  151. this.style.minHeight = '100px';
  152. this.style.display = 'block';
  153. this.style.backgroundColor = '#f5f5f5';
  154. this.style.color = '#666';
  155. this.style.textAlign = 'center';
  156. this.style.lineHeight = '100px';
  157. };
  158. }
  159. });
  160. // 手动为所有代码块添加 hljs 类并确保高亮(修复 marked 高亮可能失败的问题)
  161. DOM.content.querySelectorAll('pre code').forEach((block) => {
  162. // 如果代码块没有 hljs 类,添加它
  163. if (!block.classList.contains('hljs')) {
  164. block.classList.add('hljs');
  165. }
  166. // 如果没有高亮的标记(没有 span 元素),尝试重新高亮
  167. if (!block.querySelector('span')) {
  168. const lang = block.className.match(/language-(\w+)/)?.[1];
  169. if (lang && hljs.getLanguage(lang)) {
  170. try {
  171. const highlighted = hljs.highlight(block.textContent, { language: lang });
  172. block.innerHTML = highlighted.value;
  173. block.classList.add('hljs', `language-${lang}`);
  174. } catch (err) {
  175. console.error('Error re-highlighting:', err);
  176. }
  177. } else {
  178. // 尝试自动检测
  179. try {
  180. const highlighted = hljs.highlightAuto(block.textContent);
  181. block.innerHTML = highlighted.value;
  182. block.classList.add('hljs');
  183. if (highlighted.language) {
  184. block.classList.add(`language-${highlighted.language}`);
  185. }
  186. } catch (err) {
  187. console.error('Error auto-highlighting:', err);
  188. }
  189. }
  190. }
  191. });
  192. // 为所有代码块添加复制按钮
  193. addCopyButtonsToCodeBlocks();
  194. // 生成 TOC
  195. generateTOC();
  196. // 更新文档导航(上一页/下一页)
  197. updateDocNavigation();
  198. // 更新活动文档 - 使用 toggle 优化
  199. if (DOM.docNav) {
  200. const navItems = DOM.docNav.querySelectorAll('.nav-item');
  201. navItems.forEach(el => el.classList.toggle('active', el.dataset.docName === docName));
  202. }
  203. DOM.loading.style.display = 'none';
  204. // 如果指定了要滚动到的文本,等待渲染完成后滚动
  205. if (scrollToText) {
  206. setTimeout(() => {
  207. scrollToSearchMatch(scrollToText);
  208. }, 100);
  209. }
  210. } catch (err) {
  211. console.error('Error loading document:', err);
  212. DOM.loading.style.display = 'none';
  213. showError(`加载文档失败:${err.message || '请稍后重试'}`);
  214. }
  215. }
  216. // 生成 TOC
  217. function generateTOC() {
  218. // 使用 DOM 缓存
  219. if (!DOM.toc) DOM.toc = document.getElementById('toc');
  220. // 确保 DOM.content 也被正确缓存
  221. if (!DOM.content) DOM.content = document.getElementById('markdown-content');
  222. // 先清空旧的TOC内容和事件监听器
  223. DOM.toc.innerHTML = '';
  224. DOM.toc.removeEventListener('click', handleTocClick);
  225. const headings = DOM.content.querySelectorAll('h1, h2, h3, h4, h5, h6');
  226. if (headings.length === 0) {
  227. DOM.toc.innerHTML = '<p class="toc-empty">本文档没有标题</p>';
  228. return;
  229. }
  230. // 创建标题 ID 映射
  231. const headingMap = new Map();
  232. headings.forEach((heading, index) => {
  233. // 给标题添加 ID
  234. if (!heading.id) {
  235. heading.id = `heading-${index}`;
  236. }
  237. const level = parseInt(heading.tagName.substring(1));
  238. const link = document.createElement('a');
  239. link.href = `#${heading.id}`;
  240. link.className = `toc-link toc-level-${level}`;
  241. link.textContent = heading.textContent;
  242. headingMap.set(heading.id, heading);
  243. DOM.toc.appendChild(link);
  244. });
  245. // 使用事件委托 - 只添加一个监听器
  246. DOM.toc.removeEventListener('click', handleTocClick);
  247. DOM.toc.addEventListener('click', (e) => handleTocClick(e, headingMap));
  248. // 监听滚动,高亮当前标题
  249. setupScrollSpy(headings);
  250. }
  251. // TOC 点击处理函数
  252. function handleTocClick(e, headingMap) {
  253. if (e.target.classList.contains('toc-link')) {
  254. e.preventDefault();
  255. const headingId = e.target.getAttribute('href').substring(1);
  256. const heading = headingMap.get(headingId);
  257. if (heading) {
  258. heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
  259. // 更新活动状态
  260. const tocLinks = DOM.toc.querySelectorAll('.toc-link');
  261. tocLinks.forEach(el => el.classList.toggle('active', el === e.target));
  262. }
  263. }
  264. }
  265. // 滚动监听
  266. function setupScrollSpy(headings) {
  267. // 清理之前的 observer
  268. if (currentScrollObserver) {
  269. currentScrollObserver.disconnect();
  270. currentScrollObserver = null;
  271. }
  272. // 如果没有标题,直接返回
  273. if (!headings || headings.length === 0) {
  274. return;
  275. }
  276. let activeLink = null;
  277. const observer = new IntersectionObserver((entries) => {
  278. entries.forEach(entry => {
  279. if (entry.isIntersecting) {
  280. const id = entry.target.id;
  281. const link = DOM.toc.querySelector(`.toc-link[href="#${id}"]`);
  282. if (link && link !== activeLink) {
  283. // 只在需要时更新,减少 DOM 操作
  284. if (activeLink) activeLink.classList.remove('active');
  285. link.classList.add('active');
  286. activeLink = link;
  287. }
  288. }
  289. });
  290. }, {
  291. rootMargin: '-100px 0px -80% 0px',
  292. threshold: 0
  293. });
  294. headings.forEach(heading => observer.observe(heading));
  295. // 保存当前的 observer 以便后续清理
  296. currentScrollObserver = observer;
  297. }
  298. // 更新 URL
  299. function updateURL(category, doc) {
  300. const url = new URL(window.location);
  301. url.searchParams.set('category', category);
  302. url.searchParams.set('doc', doc);
  303. window.history.pushState({}, '', url);
  304. }
  305. // 更新文档导航(上一页/下一页)
  306. function updateDocNavigation() {
  307. // 缓存 DOM 元素
  308. if (!DOM.navPrev) DOM.navPrev = document.getElementById('nav-prev');
  309. if (!DOM.navNext) DOM.navNext = document.getElementById('nav-next');
  310. if (!DOM.navPrev || !DOM.navNext || docList.length === 0) return;
  311. const docNavigation = document.getElementById('doc-navigation');
  312. if (!docNavigation) return;
  313. // 找到当前文档在列表中的索引
  314. const currentIndex = docList.findIndex(doc => doc.name === currentDoc);
  315. if (currentIndex === -1) {
  316. DOM.navPrev.style.display = 'none';
  317. DOM.navNext.style.display = 'none';
  318. docNavigation.className = 'doc-navigation';
  319. return;
  320. }
  321. const hasPrev = currentIndex > 0;
  322. const hasNext = currentIndex < docList.length - 1;
  323. // 处理上一页
  324. if (hasPrev) {
  325. const prevDoc = docList[currentIndex - 1];
  326. DOM.navPrev.style.display = 'flex';
  327. DOM.navPrev.querySelector('.nav-doc-name').textContent = prevDoc.name;
  328. DOM.navPrev.onclick = () => {
  329. loadDocument(prevDoc.name);
  330. updateURL(currentCategory, prevDoc.name);
  331. // 滚动到顶部
  332. const contentArea = document.getElementById('content-area');
  333. if (contentArea) {
  334. contentArea.scrollTo({ top: 0, behavior: 'smooth' });
  335. }
  336. };
  337. } else {
  338. DOM.navPrev.style.display = 'none';
  339. }
  340. // 处理下一页
  341. if (hasNext) {
  342. const nextDoc = docList[currentIndex + 1];
  343. DOM.navNext.style.display = 'flex';
  344. DOM.navNext.querySelector('.nav-doc-name').textContent = nextDoc.name;
  345. DOM.navNext.onclick = () => {
  346. loadDocument(nextDoc.name);
  347. updateURL(currentCategory, nextDoc.name);
  348. // 滚动到顶部
  349. const contentArea = document.getElementById('content-area');
  350. if (contentArea) {
  351. contentArea.scrollTo({ top: 0, behavior: 'smooth' });
  352. }
  353. };
  354. } else {
  355. DOM.navNext.style.display = 'none';
  356. }
  357. // 根据导航按钮状态添加类,用于CSS定位
  358. if (hasPrev && hasNext) {
  359. docNavigation.className = 'doc-navigation has-both';
  360. } else if (hasPrev) {
  361. docNavigation.className = 'doc-navigation has-prev-only';
  362. } else if (hasNext) {
  363. docNavigation.className = 'doc-navigation has-next-only';
  364. } else {
  365. docNavigation.className = 'doc-navigation';
  366. }
  367. }
  368. // 显示错误
  369. function showError(message) {
  370. const content = document.getElementById('markdown-content');
  371. content.innerHTML = `<div class="error-message">${message}</div>`;
  372. }
  373. // 切换侧边栏(移动端)
  374. function setupSidebarToggles() {
  375. const toggleLeft = document.getElementById('toggle-left');
  376. const toggleRight = document.getElementById('toggle-right');
  377. // 使用 DOM 缓存
  378. if (!DOM.leftSidebar) DOM.leftSidebar = document.getElementById('left-sidebar');
  379. if (!DOM.rightSidebar) DOM.rightSidebar = document.getElementById('right-sidebar');
  380. // 移动端/平板初始化:默认折叠侧边栏
  381. if (window.innerWidth <= 768) {
  382. DOM.leftSidebar.classList.add('collapsed');
  383. DOM.rightSidebar.classList.add('collapsed');
  384. } else if (window.innerWidth <= 1024) {
  385. // 平板:只折叠右侧栏
  386. DOM.rightSidebar.classList.add('collapsed');
  387. }
  388. // 监听窗口大小变化
  389. window.addEventListener('resize', () => {
  390. if (window.innerWidth <= 768) {
  391. // 移动端:确保左右侧边栏都折叠
  392. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  393. DOM.leftSidebar.classList.add('collapsed');
  394. }
  395. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  396. DOM.rightSidebar.classList.add('collapsed');
  397. }
  398. } else if (window.innerWidth <= 1024) {
  399. // 平板:只折叠右侧栏,展开左侧栏
  400. DOM.leftSidebar.classList.remove('collapsed');
  401. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  402. DOM.rightSidebar.classList.add('collapsed');
  403. }
  404. } else {
  405. // 桌面端:展开所有侧边栏
  406. DOM.leftSidebar.classList.remove('collapsed');
  407. DOM.rightSidebar.classList.remove('collapsed');
  408. }
  409. });
  410. toggleLeft.onclick = (e) => {
  411. e.stopPropagation();
  412. // 如果右侧栏展开,先隐藏它
  413. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  414. DOM.rightSidebar.classList.add('collapsed');
  415. }
  416. DOM.leftSidebar.classList.toggle('collapsed');
  417. };
  418. toggleRight.onclick = (e) => {
  419. e.stopPropagation();
  420. // 如果左侧栏展开,先隐藏它
  421. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  422. DOM.leftSidebar.classList.add('collapsed');
  423. }
  424. DOM.rightSidebar.classList.toggle('collapsed');
  425. };
  426. // 点击侧边栏外部时隐藏所有已展开的侧边栏(仅在移动端和平板)
  427. document.addEventListener('click', (e) => {
  428. // 仅在移动端和平板上启用此功能
  429. if (window.innerWidth > 1024) return;
  430. const isClickInsideLeftSidebar = DOM.leftSidebar.contains(e.target);
  431. const isClickInsideRightSidebar = DOM.rightSidebar.contains(e.target);
  432. const isToggleButton = e.target.closest('#toggle-left') || e.target.closest('#toggle-right');
  433. // 如果点击的不是侧边栏内部,也不是切换按钮,则隐藏所有展开的侧边栏
  434. if (!isClickInsideLeftSidebar && !isClickInsideRightSidebar && !isToggleButton) {
  435. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  436. DOM.leftSidebar.classList.add('collapsed');
  437. }
  438. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  439. DOM.rightSidebar.classList.add('collapsed');
  440. }
  441. }
  442. });
  443. }
  444. // 搜索功能
  445. function initSearch() {
  446. const searchContainer = document.getElementById('search-container');
  447. const searchToggleBtn = document.getElementById('search-toggle-btn');
  448. const searchInput = document.getElementById('search-input');
  449. const searchBtn = document.getElementById('search-btn');
  450. const searchResults = document.getElementById('search-results');
  451. const closeSearchBoxBtn = document.getElementById('close-search-box');
  452. // 检查元素是否存在
  453. if (!searchContainer || !searchToggleBtn || !searchInput || !searchBtn || !searchResults || !closeSearchBoxBtn) {
  454. console.error('Search elements not found:', {
  455. searchContainer: !!searchContainer,
  456. searchToggleBtn: !!searchToggleBtn,
  457. searchInput: !!searchInput,
  458. searchBtn: !!searchBtn,
  459. searchResults: !!searchResults,
  460. closeSearchBoxBtn: !!closeSearchBoxBtn
  461. });
  462. return;
  463. }
  464. let searchTimeout;
  465. const SEARCH_HISTORY_KEY = 'cjydocs_search_history';
  466. // 保存搜索历史到localStorage
  467. const saveSearchHistory = (query) => {
  468. if (!query || query.trim().length < 2) return;
  469. try {
  470. let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  471. history[currentCategory] = query;
  472. localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
  473. } catch (err) {
  474. console.error('Failed to save search history:', err);
  475. }
  476. };
  477. // 搜索函数
  478. const performSearch = async () => {
  479. const query = searchInput.value.trim();
  480. if (query.length < 2) {
  481. searchResults.style.display = 'none';
  482. return;
  483. }
  484. // 保存搜索历史
  485. saveSearchHistory(query);
  486. try {
  487. const response = await fetch(
  488. `/api/search/${encodeURIComponent(currentCategory)}?q=${encodeURIComponent(query)}&currentDoc=${encodeURIComponent(currentDoc)}`
  489. );
  490. if (!response.ok) {
  491. const errorData = await response.json().catch(() => ({}));
  492. throw new Error(errorData.error || `搜索失败 (${response.status})`);
  493. }
  494. const data = await response.json();
  495. displaySearchResults(data);
  496. searchResults.style.display = 'block';
  497. } catch (err) {
  498. console.error('Search error:', err);
  499. // 向用户显示错误信息
  500. displaySearchError(err.message || '搜索失败,请稍后重试');
  501. }
  502. };
  503. // 加载搜索历史
  504. const loadSearchHistory = () => {
  505. try {
  506. const history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  507. const lastQuery = history[currentCategory];
  508. if (lastQuery) {
  509. searchInput.value = lastQuery;
  510. // 自动搜索上次的内容
  511. performSearch();
  512. }
  513. } catch (err) {
  514. console.error('Failed to load search history:', err);
  515. }
  516. };
  517. // 清除其他分类的搜索历史
  518. const clearOtherCategoryHistory = () => {
  519. try {
  520. let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  521. // 只保留当前分类
  522. history = { [currentCategory]: history[currentCategory] || '' };
  523. localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
  524. } catch (err) {
  525. console.error('Failed to clear search history:', err);
  526. }
  527. };
  528. // 切换搜索框显示/隐藏
  529. searchToggleBtn.addEventListener('click', (e) => {
  530. e.stopPropagation();
  531. console.log('Search button clicked');
  532. const isActive = searchContainer.classList.toggle('active');
  533. console.log('Search container active:', isActive);
  534. if (isActive) {
  535. // 加载搜索历史
  536. loadSearchHistory();
  537. // 聚焦到搜索框
  538. setTimeout(() => searchInput.focus(), 100);
  539. } else {
  540. // 隐藏搜索结果
  541. searchResults.style.display = 'none';
  542. }
  543. });
  544. // 输入时实时搜索(防抖) - 500ms 减少 API 调用
  545. searchInput.addEventListener('input', () => {
  546. clearTimeout(searchTimeout);
  547. searchTimeout = setTimeout(performSearch, 500);
  548. });
  549. // 点击搜索按钮
  550. searchBtn.addEventListener('click', performSearch);
  551. // 按回车搜索
  552. searchInput.addEventListener('keypress', (e) => {
  553. if (e.key === 'Enter') {
  554. performSearch();
  555. }
  556. });
  557. // 关闭搜索框按钮
  558. closeSearchBoxBtn.addEventListener('click', (e) => {
  559. e.stopPropagation();
  560. searchContainer.classList.remove('active');
  561. searchResults.style.display = 'none';
  562. });
  563. // 点击搜索结果外部关闭
  564. searchResults.addEventListener('click', (e) => {
  565. // 如果点击的是搜索结果容器本身(即遮罩背景),关闭搜索
  566. if (e.target === searchResults) {
  567. searchResults.style.display = 'none';
  568. }
  569. });
  570. // 点击外部关闭
  571. document.addEventListener('click', (e) => {
  572. const searchToggle = document.getElementById('search-toggle-btn');
  573. if (!searchResults.contains(e.target) &&
  574. !searchInput.contains(e.target) &&
  575. !searchBtn.contains(e.target) &&
  576. !searchContainer.contains(e.target) &&
  577. !searchToggle.contains(e.target)) {
  578. searchResults.style.display = 'none';
  579. }
  580. });
  581. // 初始化时清除其他分类的历史
  582. clearOtherCategoryHistory();
  583. }
  584. // 显示搜索错误
  585. function displaySearchError(message) {
  586. const currentDocSection = document.querySelector('#current-doc-results .results-list');
  587. const otherDocsSection = document.querySelector('#other-docs-results .results-list');
  588. const searchResults = document.getElementById('search-results');
  589. // 清空之前的结果
  590. currentDocSection.innerHTML = '';
  591. otherDocsSection.innerHTML = '';
  592. // 隐藏分组标题
  593. document.getElementById('current-doc-results').style.display = 'none';
  594. document.getElementById('other-docs-results').style.display = 'none';
  595. // 显示错误信息
  596. currentDocSection.innerHTML = `<p class="search-error" style="color: #d73a49; padding: 20px; text-align: center;">${message}</p>`;
  597. document.getElementById('current-doc-results').style.display = 'block';
  598. // 显示搜索结果面板
  599. searchResults.style.display = 'block';
  600. }
  601. // 显示搜索结果
  602. function displaySearchResults(data) {
  603. const currentDocSection = document.querySelector('#current-doc-results .results-list');
  604. const otherDocsSection = document.querySelector('#other-docs-results .results-list');
  605. // 清空之前的结果
  606. currentDocSection.innerHTML = '';
  607. otherDocsSection.innerHTML = '';
  608. // 隐藏/显示分组标题
  609. document.getElementById('current-doc-results').style.display =
  610. data.currentDoc.length > 0 ? 'block' : 'none';
  611. document.getElementById('other-docs-results').style.display =
  612. data.otherDocs.length > 0 ? 'block' : 'none';
  613. // 渲染当前文档结果
  614. if (data.currentDoc.length > 0) {
  615. data.currentDoc.forEach(doc => {
  616. const docResults = createDocResultElement(doc, true);
  617. currentDocSection.appendChild(docResults);
  618. });
  619. } else {
  620. currentDocSection.innerHTML = '<p class="no-results">当前文档无匹配结果</p>';
  621. }
  622. // 渲染其他文档结果
  623. if (data.otherDocs.length > 0) {
  624. data.otherDocs.forEach(doc => {
  625. const docResults = createDocResultElement(doc, false);
  626. otherDocsSection.appendChild(docResults);
  627. });
  628. } else if (data.currentDoc.length === 0) {
  629. otherDocsSection.innerHTML = '<p class="no-results">未找到匹配结果</p>';
  630. }
  631. }
  632. // 创建搜索结果元素
  633. function createDocResultElement(doc, isCurrent) {
  634. const container = document.createElement('div');
  635. container.className = 'search-result-item';
  636. const header = document.createElement('div');
  637. header.className = 'result-header';
  638. header.innerHTML = `
  639. <span class="result-doc-name">${doc.docName}</span>
  640. <span class="result-count">${doc.matchCount} 个匹配</span>
  641. `;
  642. container.appendChild(header);
  643. // 添加匹配片段
  644. doc.matches.forEach(match => {
  645. const matchItem = document.createElement('div');
  646. matchItem.className = 'result-match';
  647. // 高亮搜索词
  648. const highlightedSnippet = highlightSearchTerm(match.snippet, document.getElementById('search-input').value);
  649. matchItem.innerHTML = `
  650. <div class="match-line-number">行 ${match.line}</div>
  651. <div class="match-snippet">${highlightedSnippet}</div>
  652. `;
  653. // 点击跳转
  654. matchItem.onclick = () => {
  655. // 隐藏搜索框和搜索结果
  656. const searchContainer = document.querySelector('.search-container');
  657. const searchResults = document.getElementById('search-results');
  658. searchContainer.classList.remove('active');
  659. searchResults.style.display = 'none';
  660. // 在移动端和平板上关闭所有侧边栏,确保用户能看到跳转后的内容
  661. if (window.innerWidth <= 1024) {
  662. if (DOM.leftSidebar && !DOM.leftSidebar.classList.contains('collapsed')) {
  663. DOM.leftSidebar.classList.add('collapsed');
  664. }
  665. if (DOM.rightSidebar && !DOM.rightSidebar.classList.contains('collapsed')) {
  666. DOM.rightSidebar.classList.add('collapsed');
  667. }
  668. }
  669. if (isCurrent) {
  670. // 当前文档,滚动到对应位置
  671. scrollToSearchMatch(match.fullLine);
  672. } else {
  673. // 其他文档,加载该文档并跳转到具体位置
  674. loadDocument(doc.docName, match.fullLine);
  675. }
  676. };
  677. container.appendChild(matchItem);
  678. });
  679. return container;
  680. }
  681. // 高亮搜索词
  682. function highlightSearchTerm(text, searchTerm) {
  683. const regex = new RegExp(`(${searchTerm})`, 'gi');
  684. return text.replace(regex, '<mark class="search-highlight">$1</mark>');
  685. }
  686. // 滚动到搜索匹配位置
  687. function scrollToSearchMatch(fullLine) {
  688. const content = document.getElementById('markdown-content');
  689. const searchText = fullLine.trim();
  690. if (!searchText) return;
  691. // 使用TreeWalker遍历所有文本节点
  692. const walker = document.createTreeWalker(
  693. content,
  694. NodeFilter.SHOW_TEXT,
  695. null
  696. );
  697. let found = false;
  698. let node;
  699. while (node = walker.nextNode()) {
  700. if (node.textContent.includes(searchText)) {
  701. const parentElement = node.parentElement;
  702. // 滚动到元素
  703. parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
  704. // 临时高亮父元素
  705. parentElement.classList.add('temp-highlight');
  706. setTimeout(() => {
  707. parentElement.classList.remove('temp-highlight');
  708. }, 2000);
  709. found = true;
  710. break;
  711. }
  712. }
  713. // 如果没有找到精确匹配,尝试部分匹配
  714. if (!found) {
  715. const elements = content.querySelectorAll('p, li, td, th, h1, h2, h3, h4, h5, h6, blockquote, pre, code');
  716. for (const element of elements) {
  717. const text = element.textContent.trim();
  718. // 尝试匹配至少50%的内容
  719. if (text.length > 10 && searchText.includes(text.substring(0, Math.min(text.length, 50)))) {
  720. element.scrollIntoView({ behavior: 'smooth', block: 'center' });
  721. // 临时高亮
  722. element.classList.add('temp-highlight');
  723. setTimeout(() => {
  724. element.classList.remove('temp-highlight');
  725. }, 2000);
  726. break;
  727. }
  728. }
  729. }
  730. }
  731. // 设置回到顶部按钮
  732. function setupBackToTop() {
  733. const backToTopBtn = document.getElementById('back-to-top');
  734. if (!backToTopBtn) return;
  735. // 初始状态:隐藏按钮
  736. backToTopBtn.classList.add('hidden');
  737. // 点击按钮滚动到顶部
  738. backToTopBtn.addEventListener('click', (e) => {
  739. e.preventDefault();
  740. e.stopPropagation();
  741. // 滚动到顶部
  742. window.scrollTo({
  743. top: 0,
  744. behavior: 'smooth'
  745. });
  746. });
  747. // 监听窗口滚动事件,根据滚动位置控制按钮显示/隐藏
  748. let scrollTimeout;
  749. const handleScroll = () => {
  750. clearTimeout(scrollTimeout);
  751. scrollTimeout = setTimeout(() => {
  752. const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
  753. // 滚动超过 300px 时显示按钮
  754. if (scrollTop > 300) {
  755. backToTopBtn.classList.remove('hidden');
  756. } else {
  757. backToTopBtn.classList.add('hidden');
  758. }
  759. }, 100);
  760. };
  761. window.addEventListener('scroll', handleScroll);
  762. }
  763. // 编辑功能
  764. function setupEditFeature() {
  765. // 缓存DOM元素
  766. if (!DOM.editBtn) DOM.editBtn = document.getElementById('edit-btn');
  767. if (!DOM.editorContainer) DOM.editorContainer = document.getElementById('editor-container');
  768. if (!DOM.markdownEditor) DOM.markdownEditor = document.getElementById('markdown-editor');
  769. if (!DOM.editorDocName) DOM.editorDocName = document.getElementById('editor-doc-name');
  770. const saveBtn = document.getElementById('save-btn');
  771. const cancelBtn = document.getElementById('cancel-edit-btn');
  772. const contentArea = document.getElementById('content-area');
  773. // 编辑按钮点击事件 - 切换编辑/查看模式
  774. DOM.editBtn.addEventListener('click', () => {
  775. if (isEditMode) {
  776. exitEditMode();
  777. } else {
  778. enterEditMode();
  779. }
  780. });
  781. // 取消编辑
  782. cancelBtn.addEventListener('click', () => {
  783. exitEditMode();
  784. });
  785. // 保存按钮
  786. saveBtn.addEventListener('click', async () => {
  787. await saveDocument();
  788. });
  789. }
  790. // 进入编辑模式
  791. function enterEditMode() {
  792. if (!currentDoc || !currentMarkdownContent) {
  793. alert('请先加载一个文档');
  794. return;
  795. }
  796. isEditMode = true;
  797. // 更新编辑按钮状态
  798. DOM.editBtn.classList.add('active');
  799. DOM.editBtn.title = '退出编辑';
  800. // 保存当前滚动位置
  801. const contentArea = document.getElementById('content-area');
  802. savedScrollPosition = contentArea.scrollTop;
  803. // 找到当前视口中心附近的元素
  804. const visibleElement = findVisibleElement();
  805. // 隐藏渲染的内容
  806. if (!DOM.content) DOM.content = document.getElementById('markdown-content');
  807. DOM.content.style.display = 'none';
  808. // 显示编辑器
  809. DOM.editorContainer.style.display = 'flex';
  810. DOM.editorDocName.textContent = `${currentDoc}.md`;
  811. DOM.markdownEditor.value = currentMarkdownContent;
  812. // 定位光标到对应位置
  813. setTimeout(() => {
  814. if (visibleElement) {
  815. positionCursorByElement(visibleElement);
  816. } else {
  817. // 如果找不到元素,使用滚动比例
  818. const scrollRatio = savedScrollPosition / contentArea.scrollHeight;
  819. DOM.markdownEditor.scrollTop = DOM.markdownEditor.scrollHeight * scrollRatio;
  820. }
  821. DOM.markdownEditor.focus();
  822. }, 50);
  823. }
  824. // 找到当前视口中可见的元素
  825. function findVisibleElement() {
  826. const contentArea = document.getElementById('content-area');
  827. const viewportTop = contentArea.scrollTop;
  828. const viewportMiddle = viewportTop + contentArea.clientHeight / 3; // 视口上方1/3处
  829. // 查找所有重要元素(标题、段落等)
  830. const elements = DOM.content.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, blockquote, pre');
  831. let closestElement = null;
  832. let minDistance = Infinity;
  833. elements.forEach(el => {
  834. const rect = el.getBoundingClientRect();
  835. const elementTop = el.offsetTop;
  836. const distance = Math.abs(elementTop - viewportMiddle);
  837. if (distance < minDistance && elementTop <= viewportMiddle + 200) {
  838. minDistance = distance;
  839. closestElement = el;
  840. }
  841. });
  842. return closestElement;
  843. }
  844. // 根据元素定位光标
  845. function positionCursorByElement(element) {
  846. const elementText = element.textContent.trim();
  847. if (!elementText) {
  848. return;
  849. }
  850. // 获取元素标签类型
  851. const tagName = element.tagName.toLowerCase();
  852. let searchText = elementText;
  853. // 如果是标题,添加markdown标记进行搜索
  854. if (tagName.match(/^h[1-6]$/)) {
  855. const level = parseInt(tagName[1]);
  856. const hashes = '#'.repeat(level);
  857. // 尝试匹配标题行
  858. const lines = currentMarkdownContent.split('\n');
  859. let targetLine = -1;
  860. for (let i = 0; i < lines.length; i++) {
  861. const line = lines[i].trim();
  862. // 匹配 "# 标题" 格式
  863. if (line.startsWith(hashes + ' ') && line.substring(level + 1).trim() === elementText) {
  864. targetLine = i;
  865. break;
  866. }
  867. }
  868. if (targetLine !== -1) {
  869. setEditorCursorToLine(targetLine);
  870. return;
  871. }
  872. }
  873. // 对于其他元素,搜索文本内容
  874. const lines = currentMarkdownContent.split('\n');
  875. for (let i = 0; i < lines.length; i++) {
  876. const line = lines[i].trim();
  877. // 移除markdown标记进行比较
  878. const cleanLine = line.replace(/^[#\-*>]+\s*/, '').trim();
  879. if (cleanLine.includes(elementText.substring(0, Math.min(50, elementText.length)))) {
  880. setEditorCursorToLine(i);
  881. return;
  882. }
  883. }
  884. }
  885. // 设置编辑器光标到指定行
  886. function setEditorCursorToLine(lineNumber) {
  887. const lines = currentMarkdownContent.split('\n');
  888. // 计算目标位置的字符索引
  889. let charPosition = 0;
  890. for (let i = 0; i < lineNumber && i < lines.length; i++) {
  891. charPosition += lines[i].length + 1; // +1 for newline
  892. }
  893. // 设置光标位置
  894. DOM.markdownEditor.setSelectionRange(charPosition, charPosition);
  895. // 滚动到光标位置
  896. const linesBeforeCursor = lineNumber;
  897. const lineHeight = 22; // 大约的行高(px)
  898. const targetScrollTop = linesBeforeCursor * lineHeight - DOM.markdownEditor.clientHeight / 3;
  899. DOM.markdownEditor.scrollTop = Math.max(0, targetScrollTop);
  900. }
  901. // 退出编辑模式
  902. function exitEditMode() {
  903. isEditMode = false;
  904. // 更新编辑按钮状态
  905. DOM.editBtn.classList.remove('active');
  906. DOM.editBtn.title = '编辑文档';
  907. // 隐藏编辑器
  908. DOM.editorContainer.style.display = 'none';
  909. // 显示渲染的内容
  910. DOM.content.style.display = 'block';
  911. // 恢复滚动位置
  912. const contentArea = document.getElementById('content-area');
  913. setTimeout(() => {
  914. contentArea.scrollTop = savedScrollPosition;
  915. }, 0);
  916. }
  917. // 保存文档
  918. async function saveDocument() {
  919. const newContent = DOM.markdownEditor.value;
  920. if (!currentCategory || !currentDoc) {
  921. alert('无法保存:缺少文档信息');
  922. return;
  923. }
  924. // 禁用保存按钮,防止重复点击
  925. const saveBtn = document.getElementById('save-btn');
  926. saveBtn.disabled = true;
  927. saveBtn.textContent = '保存中...';
  928. try {
  929. const response = await fetch(`/api/doc/${encodeURIComponent(currentCategory)}/${encodeURIComponent(currentDoc)}`, {
  930. method: 'PUT',
  931. headers: {
  932. 'Content-Type': 'application/json'
  933. },
  934. body: JSON.stringify({
  935. content: newContent
  936. })
  937. });
  938. if (!response.ok) {
  939. const errorData = await response.json().catch(() => ({}));
  940. throw new Error(errorData.error || `保存失败 (${response.status})`);
  941. }
  942. const result = await response.json();
  943. // 更新本地的markdown内容
  944. currentMarkdownContent = newContent;
  945. // 重新渲染内容
  946. const html = marked.parse(newContent);
  947. DOM.content.innerHTML = html;
  948. // 重新生成TOC
  949. generateTOC();
  950. // 退出编辑模式
  951. exitEditMode();
  952. // 显示成功提示
  953. showSuccessMessage('保存成功!');
  954. } catch (error) {
  955. console.error('Save error:', error);
  956. alert(`保存失败:${error.message || '请稍后重试'}`);
  957. } finally {
  958. // 恢复保存按钮
  959. saveBtn.disabled = false;
  960. saveBtn.textContent = '保存';
  961. }
  962. }
  963. // 显示成功提示
  964. function showSuccessMessage(message) {
  965. const toast = document.createElement('div');
  966. toast.className = 'success-toast';
  967. toast.textContent = message;
  968. toast.style.cssText = `
  969. position: fixed;
  970. top: 20px;
  971. right: 20px;
  972. background: #28a745;
  973. color: white;
  974. padding: 12px 24px;
  975. border-radius: 6px;
  976. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  977. z-index: 10000;
  978. animation: slideInRight 0.3s ease-out;
  979. `;
  980. document.body.appendChild(toast);
  981. setTimeout(() => {
  982. toast.style.animation = 'slideOutRight 0.3s ease-out';
  983. setTimeout(() => {
  984. document.body.removeChild(toast);
  985. }, 300);
  986. }, 2000);
  987. }
  988. // 为代码块添加复制按钮
  989. function addCopyButtonsToCodeBlocks() {
  990. const codeBlocks = document.querySelectorAll('pre');
  991. codeBlocks.forEach(pre => {
  992. // 如果已经有复制按钮,跳过
  993. if (pre.querySelector('.code-copy-btn')) {
  994. return;
  995. }
  996. // 创建包装容器
  997. const wrapper = document.createElement('div');
  998. wrapper.className = 'code-block-wrapper';
  999. // 将pre元素包装起来
  1000. pre.parentNode.insertBefore(wrapper, pre);
  1001. wrapper.appendChild(pre);
  1002. // 创建复制按钮
  1003. const copyBtn = document.createElement('button');
  1004. copyBtn.className = 'code-copy-btn';
  1005. copyBtn.innerHTML = `
  1006. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1007. <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
  1008. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
  1009. </svg>
  1010. <span>复制</span>
  1011. `;
  1012. // 添加点击事件
  1013. copyBtn.addEventListener('click', function() {
  1014. copyCodeToClipboard(pre, copyBtn);
  1015. });
  1016. wrapper.appendChild(copyBtn);
  1017. });
  1018. }
  1019. // 复制代码到剪贴板
  1020. async function copyCodeToClipboard(preElement, button) {
  1021. const codeElement = preElement.querySelector('code');
  1022. if (!codeElement) return;
  1023. // 获取纯文本内容(去除HTML标签)
  1024. const text = codeElement.textContent || codeElement.innerText;
  1025. try {
  1026. // 使用现代API复制
  1027. await navigator.clipboard.writeText(text);
  1028. // 显示成功状态
  1029. showCopySuccess(button);
  1030. } catch (err) {
  1031. // 降级方案:使用传统方法
  1032. const textArea = document.createElement('textarea');
  1033. textArea.value = text;
  1034. textArea.style.position = 'fixed';
  1035. textArea.style.left = '-9999px';
  1036. document.body.appendChild(textArea);
  1037. textArea.select();
  1038. try {
  1039. document.execCommand('copy');
  1040. showCopySuccess(button);
  1041. } catch (err) {
  1042. console.error('复制失败:', err);
  1043. showCopyError(button);
  1044. } finally {
  1045. document.body.removeChild(textArea);
  1046. }
  1047. }
  1048. }
  1049. // 显示复制成功
  1050. function showCopySuccess(button) {
  1051. // 更新按钮状态
  1052. const originalHTML = button.innerHTML;
  1053. button.classList.add('copied');
  1054. button.innerHTML = `
  1055. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1056. <polyline points="20 6 9 17 4 12"></polyline>
  1057. </svg>
  1058. <span>已复制</span>
  1059. `;
  1060. // 2秒后恢复原状
  1061. setTimeout(() => {
  1062. button.classList.remove('copied');
  1063. button.innerHTML = originalHTML;
  1064. }, 2000);
  1065. }
  1066. // 显示复制失败
  1067. function showCopyError(button) {
  1068. const originalHTML = button.innerHTML;
  1069. button.innerHTML = `
  1070. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  1071. <circle cx="12" cy="12" r="10"></circle>
  1072. <line x1="12" y1="8" x2="12" y2="12"></line>
  1073. <line x1="12" y1="16" x2="12.01" y2="16"></line>
  1074. </svg>
  1075. <span>复制失败</span>
  1076. `;
  1077. setTimeout(() => {
  1078. button.innerHTML = originalHTML;
  1079. }, 2000);
  1080. }
  1081. // 初始化
  1082. document.addEventListener('DOMContentLoaded', () => {
  1083. loadDocList();
  1084. setupSidebarToggles();
  1085. initSearch();
  1086. setupBackToTop();
  1087. setupEditFeature();
  1088. });
  1089. // 处理浏览器后退/前进
  1090. window.addEventListener('popstate', () => {
  1091. const { doc } = getQueryParams();
  1092. if (doc) {
  1093. loadDocument(doc);
  1094. }
  1095. });