reader.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035
  1. let currentCategory = '';
  2. let currentDoc = '';
  3. let docList = [];
  4. let currentMarkdownContent = ''; // 保存当前文档的markdown源码
  5. let isEditMode = false; // 是否处于编辑模式
  6. let savedScrollPosition = 0; // 保存的滚动位置
  7. // DOM 元素缓存
  8. const DOM = {
  9. loading: null,
  10. content: null,
  11. docNav: null,
  12. toc: null,
  13. leftSidebar: null,
  14. rightSidebar: null,
  15. editBtn: null,
  16. editorContainer: null,
  17. markdownEditor: null,
  18. editorDocName: null
  19. };
  20. // 配置 marked
  21. marked.setOptions({
  22. highlight: function(code, lang) {
  23. if (lang && hljs.getLanguage(lang)) {
  24. try {
  25. return hljs.highlight(code, { language: lang }).value;
  26. } catch (err) {
  27. console.error('Highlight error:', err);
  28. }
  29. }
  30. return hljs.highlightAuto(code).value;
  31. },
  32. breaks: true,
  33. gfm: true
  34. });
  35. // 从 URL 获取参数
  36. function getQueryParams() {
  37. const params = new URLSearchParams(window.location.search);
  38. return {
  39. category: params.get('category'),
  40. doc: params.get('doc')
  41. };
  42. }
  43. // 加载文档列表
  44. async function loadDocList() {
  45. const { category } = getQueryParams();
  46. if (!category) {
  47. window.location.href = '/';
  48. return;
  49. }
  50. currentCategory = category;
  51. const categoryNameSpan = document.querySelector('#category-title .category-name');
  52. if (categoryNameSpan) {
  53. categoryNameSpan.textContent = category;
  54. }
  55. try {
  56. const response = await fetch(`/api/category/${encodeURIComponent(category)}`);
  57. if (!response.ok) throw new Error('获取文档列表失败');
  58. const categoryData = await response.json();
  59. docList = categoryData.docs;
  60. renderDocNav(docList);
  61. // 加载第一篇文档(或 URL 指定的文档)
  62. const { doc } = getQueryParams();
  63. const firstDoc = doc || docList[0]?.name;
  64. if (firstDoc) {
  65. loadDocument(firstDoc);
  66. }
  67. } catch (err) {
  68. console.error('Error loading doc list:', err);
  69. showError('加载文档列表失败');
  70. }
  71. }
  72. // 渲染文档导航
  73. function renderDocNav(docs) {
  74. const nav = DOM.docNav || document.getElementById('doc-nav');
  75. if (!DOM.docNav) DOM.docNav = nav;
  76. nav.innerHTML = '';
  77. docs.forEach(doc => {
  78. const item = document.createElement('div');
  79. item.className = `nav-item level-${doc.level}`;
  80. item.dataset.docName = doc.name;
  81. const link = document.createElement('a');
  82. link.href = '#';
  83. link.className = 'nav-link';
  84. link.textContent = doc.name;
  85. item.appendChild(link);
  86. nav.appendChild(item);
  87. });
  88. // 使用事件委托 - 只添加一个监听器
  89. nav.removeEventListener('click', handleNavClick);
  90. nav.addEventListener('click', handleNavClick);
  91. }
  92. // 导航点击处理函数
  93. function handleNavClick(e) {
  94. if (e.target.classList.contains('nav-link')) {
  95. e.preventDefault();
  96. const item = e.target.parentElement;
  97. const docName = item.dataset.docName;
  98. if (docName) {
  99. loadDocument(docName);
  100. updateURL(currentCategory, docName);
  101. // 更新活动状态 - 使用缓存的查询结果
  102. const navItems = DOM.docNav.querySelectorAll('.nav-item');
  103. navItems.forEach(el => el.classList.toggle('active', el === item));
  104. }
  105. }
  106. }
  107. // 加载文档内容
  108. async function loadDocument(docName, scrollToText = null) {
  109. // 使用 DOM 缓存
  110. if (!DOM.loading) DOM.loading = document.getElementById('loading');
  111. if (!DOM.content) DOM.content = document.getElementById('markdown-content');
  112. DOM.loading.style.display = 'flex';
  113. DOM.content.innerHTML = '';
  114. try {
  115. const response = await fetch(`/api/doc/${encodeURIComponent(currentCategory)}/${encodeURIComponent(docName)}`);
  116. if (!response.ok) {
  117. const errorData = await response.json().catch(() => ({}));
  118. throw new Error(errorData.error || `加载失败 (${response.status})`);
  119. }
  120. const data = await response.json();
  121. // 验证数据有效性
  122. if (!data.content || typeof data.content !== 'string') {
  123. throw new Error('文档内容格式错误');
  124. }
  125. currentDoc = docName;
  126. currentMarkdownContent = data.content; // 保存markdown源码
  127. // 渲染 Markdown
  128. const html = marked.parse(data.content);
  129. DOM.content.innerHTML = html;
  130. // 生成 TOC
  131. generateTOC();
  132. // 更新活动文档 - 使用 toggle 优化
  133. if (DOM.docNav) {
  134. const navItems = DOM.docNav.querySelectorAll('.nav-item');
  135. navItems.forEach(el => el.classList.toggle('active', el.dataset.docName === docName));
  136. }
  137. DOM.loading.style.display = 'none';
  138. // 如果指定了要滚动到的文本,等待渲染完成后滚动
  139. if (scrollToText) {
  140. setTimeout(() => {
  141. scrollToSearchMatch(scrollToText);
  142. }, 100);
  143. }
  144. } catch (err) {
  145. console.error('Error loading document:', err);
  146. DOM.loading.style.display = 'none';
  147. showError(`加载文档失败:${err.message || '请稍后重试'}`);
  148. }
  149. }
  150. // 生成 TOC
  151. function generateTOC() {
  152. // 使用 DOM 缓存
  153. if (!DOM.toc) DOM.toc = document.getElementById('toc');
  154. const headings = DOM.content.querySelectorAll('h1, h2, h3, h4, h5, h6');
  155. DOM.toc.innerHTML = '';
  156. if (headings.length === 0) {
  157. DOM.toc.innerHTML = '<p class="toc-empty">本文档没有标题</p>';
  158. return;
  159. }
  160. // 创建标题 ID 映射
  161. const headingMap = new Map();
  162. headings.forEach((heading, index) => {
  163. // 给标题添加 ID
  164. if (!heading.id) {
  165. heading.id = `heading-${index}`;
  166. }
  167. const level = parseInt(heading.tagName.substring(1));
  168. const link = document.createElement('a');
  169. link.href = `#${heading.id}`;
  170. link.className = `toc-link toc-level-${level}`;
  171. link.textContent = heading.textContent;
  172. headingMap.set(heading.id, heading);
  173. DOM.toc.appendChild(link);
  174. });
  175. // 使用事件委托 - 只添加一个监听器
  176. DOM.toc.removeEventListener('click', handleTocClick);
  177. DOM.toc.addEventListener('click', (e) => handleTocClick(e, headingMap));
  178. // 监听滚动,高亮当前标题
  179. setupScrollSpy(headings);
  180. }
  181. // TOC 点击处理函数
  182. function handleTocClick(e, headingMap) {
  183. if (e.target.classList.contains('toc-link')) {
  184. e.preventDefault();
  185. const headingId = e.target.getAttribute('href').substring(1);
  186. const heading = headingMap.get(headingId);
  187. if (heading) {
  188. heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
  189. // 更新活动状态
  190. const tocLinks = DOM.toc.querySelectorAll('.toc-link');
  191. tocLinks.forEach(el => el.classList.toggle('active', el === e.target));
  192. }
  193. }
  194. }
  195. // 滚动监听
  196. function setupScrollSpy(headings) {
  197. let activeLink = null;
  198. const observer = new IntersectionObserver((entries) => {
  199. entries.forEach(entry => {
  200. if (entry.isIntersecting) {
  201. const id = entry.target.id;
  202. const link = DOM.toc.querySelector(`.toc-link[href="#${id}"]`);
  203. if (link && link !== activeLink) {
  204. // 只在需要时更新,减少 DOM 操作
  205. if (activeLink) activeLink.classList.remove('active');
  206. link.classList.add('active');
  207. activeLink = link;
  208. }
  209. }
  210. });
  211. }, {
  212. rootMargin: '-100px 0px -80% 0px',
  213. threshold: 0
  214. });
  215. headings.forEach(heading => observer.observe(heading));
  216. }
  217. // 更新 URL
  218. function updateURL(category, doc) {
  219. const url = new URL(window.location);
  220. url.searchParams.set('category', category);
  221. url.searchParams.set('doc', doc);
  222. window.history.pushState({}, '', url);
  223. }
  224. // 显示错误
  225. function showError(message) {
  226. const content = document.getElementById('markdown-content');
  227. content.innerHTML = `<div class="error-message">${message}</div>`;
  228. }
  229. // 切换侧边栏(移动端)
  230. function setupSidebarToggles() {
  231. const toggleLeft = document.getElementById('toggle-left');
  232. const toggleRight = document.getElementById('toggle-right');
  233. // 使用 DOM 缓存
  234. if (!DOM.leftSidebar) DOM.leftSidebar = document.getElementById('left-sidebar');
  235. if (!DOM.rightSidebar) DOM.rightSidebar = document.getElementById('right-sidebar');
  236. // 移动端/平板初始化:默认折叠侧边栏
  237. if (window.innerWidth <= 768) {
  238. DOM.leftSidebar.classList.add('collapsed');
  239. DOM.rightSidebar.classList.add('collapsed');
  240. } else if (window.innerWidth <= 1024) {
  241. // 平板:只折叠右侧栏
  242. DOM.rightSidebar.classList.add('collapsed');
  243. }
  244. // 监听窗口大小变化
  245. window.addEventListener('resize', () => {
  246. if (window.innerWidth <= 768) {
  247. // 移动端:确保左右侧边栏都折叠
  248. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  249. DOM.leftSidebar.classList.add('collapsed');
  250. }
  251. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  252. DOM.rightSidebar.classList.add('collapsed');
  253. }
  254. } else if (window.innerWidth <= 1024) {
  255. // 平板:只折叠右侧栏,展开左侧栏
  256. DOM.leftSidebar.classList.remove('collapsed');
  257. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  258. DOM.rightSidebar.classList.add('collapsed');
  259. }
  260. } else {
  261. // 桌面端:展开所有侧边栏
  262. DOM.leftSidebar.classList.remove('collapsed');
  263. DOM.rightSidebar.classList.remove('collapsed');
  264. }
  265. });
  266. toggleLeft.onclick = (e) => {
  267. e.stopPropagation();
  268. // 如果右侧栏展开,先隐藏它
  269. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  270. DOM.rightSidebar.classList.add('collapsed');
  271. }
  272. DOM.leftSidebar.classList.toggle('collapsed');
  273. };
  274. toggleRight.onclick = (e) => {
  275. e.stopPropagation();
  276. // 如果左侧栏展开,先隐藏它
  277. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  278. DOM.leftSidebar.classList.add('collapsed');
  279. }
  280. DOM.rightSidebar.classList.toggle('collapsed');
  281. };
  282. // 点击侧边栏外部时隐藏所有已展开的侧边栏(仅在移动端和平板)
  283. document.addEventListener('click', (e) => {
  284. // 仅在移动端和平板上启用此功能
  285. if (window.innerWidth > 1024) return;
  286. const isClickInsideLeftSidebar = DOM.leftSidebar.contains(e.target);
  287. const isClickInsideRightSidebar = DOM.rightSidebar.contains(e.target);
  288. const isToggleButton = e.target.closest('#toggle-left') || e.target.closest('#toggle-right');
  289. // 如果点击的不是侧边栏内部,也不是切换按钮,则隐藏所有展开的侧边栏
  290. if (!isClickInsideLeftSidebar && !isClickInsideRightSidebar && !isToggleButton) {
  291. if (!DOM.leftSidebar.classList.contains('collapsed')) {
  292. DOM.leftSidebar.classList.add('collapsed');
  293. }
  294. if (!DOM.rightSidebar.classList.contains('collapsed')) {
  295. DOM.rightSidebar.classList.add('collapsed');
  296. }
  297. }
  298. });
  299. }
  300. // 搜索功能
  301. function initSearch() {
  302. const searchContainer = document.getElementById('search-container');
  303. const searchToggleBtn = document.getElementById('search-toggle-btn');
  304. const searchInput = document.getElementById('search-input');
  305. const searchBtn = document.getElementById('search-btn');
  306. const searchResults = document.getElementById('search-results');
  307. const closeSearchBoxBtn = document.getElementById('close-search-box');
  308. // 检查元素是否存在
  309. if (!searchContainer || !searchToggleBtn || !searchInput || !searchBtn || !searchResults || !closeSearchBoxBtn) {
  310. console.error('Search elements not found:', {
  311. searchContainer: !!searchContainer,
  312. searchToggleBtn: !!searchToggleBtn,
  313. searchInput: !!searchInput,
  314. searchBtn: !!searchBtn,
  315. searchResults: !!searchResults,
  316. closeSearchBoxBtn: !!closeSearchBoxBtn
  317. });
  318. return;
  319. }
  320. let searchTimeout;
  321. const SEARCH_HISTORY_KEY = 'cjydocs_search_history';
  322. // 保存搜索历史到localStorage
  323. const saveSearchHistory = (query) => {
  324. if (!query || query.trim().length < 2) return;
  325. try {
  326. let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  327. history[currentCategory] = query;
  328. localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
  329. } catch (err) {
  330. console.error('Failed to save search history:', err);
  331. }
  332. };
  333. // 搜索函数
  334. const performSearch = async () => {
  335. const query = searchInput.value.trim();
  336. if (query.length < 2) {
  337. searchResults.style.display = 'none';
  338. return;
  339. }
  340. // 保存搜索历史
  341. saveSearchHistory(query);
  342. try {
  343. const response = await fetch(
  344. `/api/search/${encodeURIComponent(currentCategory)}?q=${encodeURIComponent(query)}&currentDoc=${encodeURIComponent(currentDoc)}`
  345. );
  346. if (!response.ok) {
  347. const errorData = await response.json().catch(() => ({}));
  348. throw new Error(errorData.error || `搜索失败 (${response.status})`);
  349. }
  350. const data = await response.json();
  351. displaySearchResults(data);
  352. searchResults.style.display = 'block';
  353. } catch (err) {
  354. console.error('Search error:', err);
  355. // 向用户显示错误信息
  356. displaySearchError(err.message || '搜索失败,请稍后重试');
  357. }
  358. };
  359. // 加载搜索历史
  360. const loadSearchHistory = () => {
  361. try {
  362. const history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  363. const lastQuery = history[currentCategory];
  364. if (lastQuery) {
  365. searchInput.value = lastQuery;
  366. // 自动搜索上次的内容
  367. performSearch();
  368. }
  369. } catch (err) {
  370. console.error('Failed to load search history:', err);
  371. }
  372. };
  373. // 清除其他分类的搜索历史
  374. const clearOtherCategoryHistory = () => {
  375. try {
  376. let history = JSON.parse(localStorage.getItem(SEARCH_HISTORY_KEY) || '{}');
  377. // 只保留当前分类
  378. history = { [currentCategory]: history[currentCategory] || '' };
  379. localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
  380. } catch (err) {
  381. console.error('Failed to clear search history:', err);
  382. }
  383. };
  384. // 切换搜索框显示/隐藏
  385. searchToggleBtn.addEventListener('click', (e) => {
  386. e.stopPropagation();
  387. console.log('Search button clicked');
  388. const isActive = searchContainer.classList.toggle('active');
  389. console.log('Search container active:', isActive);
  390. if (isActive) {
  391. // 加载搜索历史
  392. loadSearchHistory();
  393. // 聚焦到搜索框
  394. setTimeout(() => searchInput.focus(), 100);
  395. } else {
  396. // 隐藏搜索结果
  397. searchResults.style.display = 'none';
  398. }
  399. });
  400. // 输入时实时搜索(防抖) - 500ms 减少 API 调用
  401. searchInput.addEventListener('input', () => {
  402. clearTimeout(searchTimeout);
  403. searchTimeout = setTimeout(performSearch, 500);
  404. });
  405. // 点击搜索按钮
  406. searchBtn.addEventListener('click', performSearch);
  407. // 按回车搜索
  408. searchInput.addEventListener('keypress', (e) => {
  409. if (e.key === 'Enter') {
  410. performSearch();
  411. }
  412. });
  413. // 关闭搜索框按钮
  414. closeSearchBoxBtn.addEventListener('click', (e) => {
  415. e.stopPropagation();
  416. searchContainer.classList.remove('active');
  417. searchResults.style.display = 'none';
  418. });
  419. // 点击搜索结果外部关闭
  420. searchResults.addEventListener('click', (e) => {
  421. // 如果点击的是搜索结果容器本身(即遮罩背景),关闭搜索
  422. if (e.target === searchResults) {
  423. searchResults.style.display = 'none';
  424. }
  425. });
  426. // 点击外部关闭
  427. document.addEventListener('click', (e) => {
  428. const searchToggle = document.getElementById('search-toggle-btn');
  429. if (!searchResults.contains(e.target) &&
  430. !searchInput.contains(e.target) &&
  431. !searchBtn.contains(e.target) &&
  432. !searchContainer.contains(e.target) &&
  433. !searchToggle.contains(e.target)) {
  434. searchResults.style.display = 'none';
  435. }
  436. });
  437. // 初始化时清除其他分类的历史
  438. clearOtherCategoryHistory();
  439. }
  440. // 显示搜索错误
  441. function displaySearchError(message) {
  442. const currentDocSection = document.querySelector('#current-doc-results .results-list');
  443. const otherDocsSection = document.querySelector('#other-docs-results .results-list');
  444. const searchResults = document.getElementById('search-results');
  445. // 清空之前的结果
  446. currentDocSection.innerHTML = '';
  447. otherDocsSection.innerHTML = '';
  448. // 隐藏分组标题
  449. document.getElementById('current-doc-results').style.display = 'none';
  450. document.getElementById('other-docs-results').style.display = 'none';
  451. // 显示错误信息
  452. currentDocSection.innerHTML = `<p class="search-error" style="color: #d73a49; padding: 20px; text-align: center;">${message}</p>`;
  453. document.getElementById('current-doc-results').style.display = 'block';
  454. // 显示搜索结果面板
  455. searchResults.style.display = 'block';
  456. }
  457. // 显示搜索结果
  458. function displaySearchResults(data) {
  459. const currentDocSection = document.querySelector('#current-doc-results .results-list');
  460. const otherDocsSection = document.querySelector('#other-docs-results .results-list');
  461. // 清空之前的结果
  462. currentDocSection.innerHTML = '';
  463. otherDocsSection.innerHTML = '';
  464. // 隐藏/显示分组标题
  465. document.getElementById('current-doc-results').style.display =
  466. data.currentDoc.length > 0 ? 'block' : 'none';
  467. document.getElementById('other-docs-results').style.display =
  468. data.otherDocs.length > 0 ? 'block' : 'none';
  469. // 渲染当前文档结果
  470. if (data.currentDoc.length > 0) {
  471. data.currentDoc.forEach(doc => {
  472. const docResults = createDocResultElement(doc, true);
  473. currentDocSection.appendChild(docResults);
  474. });
  475. } else {
  476. currentDocSection.innerHTML = '<p class="no-results">当前文档无匹配结果</p>';
  477. }
  478. // 渲染其他文档结果
  479. if (data.otherDocs.length > 0) {
  480. data.otherDocs.forEach(doc => {
  481. const docResults = createDocResultElement(doc, false);
  482. otherDocsSection.appendChild(docResults);
  483. });
  484. } else if (data.currentDoc.length === 0) {
  485. otherDocsSection.innerHTML = '<p class="no-results">未找到匹配结果</p>';
  486. }
  487. }
  488. // 创建搜索结果元素
  489. function createDocResultElement(doc, isCurrent) {
  490. const container = document.createElement('div');
  491. container.className = 'search-result-item';
  492. const header = document.createElement('div');
  493. header.className = 'result-header';
  494. header.innerHTML = `
  495. <span class="result-doc-name">${doc.docName}</span>
  496. <span class="result-count">${doc.matchCount} 个匹配</span>
  497. `;
  498. container.appendChild(header);
  499. // 添加匹配片段
  500. doc.matches.forEach(match => {
  501. const matchItem = document.createElement('div');
  502. matchItem.className = 'result-match';
  503. // 高亮搜索词
  504. const highlightedSnippet = highlightSearchTerm(match.snippet, document.getElementById('search-input').value);
  505. matchItem.innerHTML = `
  506. <div class="match-line-number">行 ${match.line}</div>
  507. <div class="match-snippet">${highlightedSnippet}</div>
  508. `;
  509. // 点击跳转
  510. matchItem.onclick = () => {
  511. // 隐藏搜索框和搜索结果
  512. const searchContainer = document.querySelector('.search-container');
  513. const searchResults = document.getElementById('search-results');
  514. searchContainer.classList.remove('active');
  515. searchResults.style.display = 'none';
  516. // 在移动端和平板上关闭所有侧边栏,确保用户能看到跳转后的内容
  517. if (window.innerWidth <= 1024) {
  518. if (DOM.leftSidebar && !DOM.leftSidebar.classList.contains('collapsed')) {
  519. DOM.leftSidebar.classList.add('collapsed');
  520. }
  521. if (DOM.rightSidebar && !DOM.rightSidebar.classList.contains('collapsed')) {
  522. DOM.rightSidebar.classList.add('collapsed');
  523. }
  524. }
  525. if (isCurrent) {
  526. // 当前文档,滚动到对应位置
  527. scrollToSearchMatch(match.fullLine);
  528. } else {
  529. // 其他文档,加载该文档并跳转到具体位置
  530. loadDocument(doc.docName, match.fullLine);
  531. }
  532. };
  533. container.appendChild(matchItem);
  534. });
  535. return container;
  536. }
  537. // 高亮搜索词
  538. function highlightSearchTerm(text, searchTerm) {
  539. const regex = new RegExp(`(${searchTerm})`, 'gi');
  540. return text.replace(regex, '<mark class="search-highlight">$1</mark>');
  541. }
  542. // 滚动到搜索匹配位置
  543. function scrollToSearchMatch(fullLine) {
  544. const content = document.getElementById('markdown-content');
  545. const searchText = fullLine.trim();
  546. if (!searchText) return;
  547. // 使用TreeWalker遍历所有文本节点
  548. const walker = document.createTreeWalker(
  549. content,
  550. NodeFilter.SHOW_TEXT,
  551. null
  552. );
  553. let found = false;
  554. let node;
  555. while (node = walker.nextNode()) {
  556. if (node.textContent.includes(searchText)) {
  557. const parentElement = node.parentElement;
  558. // 滚动到元素
  559. parentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
  560. // 临时高亮父元素
  561. parentElement.classList.add('temp-highlight');
  562. setTimeout(() => {
  563. parentElement.classList.remove('temp-highlight');
  564. }, 2000);
  565. found = true;
  566. break;
  567. }
  568. }
  569. // 如果没有找到精确匹配,尝试部分匹配
  570. if (!found) {
  571. const elements = content.querySelectorAll('p, li, td, th, h1, h2, h3, h4, h5, h6, blockquote, pre, code');
  572. for (const element of elements) {
  573. const text = element.textContent.trim();
  574. // 尝试匹配至少50%的内容
  575. if (text.length > 10 && searchText.includes(text.substring(0, Math.min(text.length, 50)))) {
  576. element.scrollIntoView({ behavior: 'smooth', block: 'center' });
  577. // 临时高亮
  578. element.classList.add('temp-highlight');
  579. setTimeout(() => {
  580. element.classList.remove('temp-highlight');
  581. }, 2000);
  582. break;
  583. }
  584. }
  585. }
  586. }
  587. // 设置回到顶部按钮
  588. function setupBackToTop() {
  589. const backToTopBtn = document.getElementById('back-to-top');
  590. const contentArea = document.getElementById('content-area');
  591. if (!backToTopBtn || !contentArea) return;
  592. // 初始状态:隐藏按钮
  593. backToTopBtn.classList.add('hidden');
  594. // 点击按钮滚动到顶部
  595. backToTopBtn.addEventListener('click', () => {
  596. contentArea.scrollTo({
  597. top: 0,
  598. behavior: 'smooth'
  599. });
  600. });
  601. // 监听滚动事件,控制按钮显示/隐藏
  602. let scrollTimeout;
  603. contentArea.addEventListener('scroll', () => {
  604. clearTimeout(scrollTimeout);
  605. scrollTimeout = setTimeout(() => {
  606. if (contentArea.scrollTop > 300) {
  607. backToTopBtn.classList.remove('hidden');
  608. } else {
  609. backToTopBtn.classList.add('hidden');
  610. }
  611. }, 100);
  612. });
  613. }
  614. // 编辑功能
  615. function setupEditFeature() {
  616. // 缓存DOM元素
  617. if (!DOM.editBtn) DOM.editBtn = document.getElementById('edit-btn');
  618. if (!DOM.editorContainer) DOM.editorContainer = document.getElementById('editor-container');
  619. if (!DOM.markdownEditor) DOM.markdownEditor = document.getElementById('markdown-editor');
  620. if (!DOM.editorDocName) DOM.editorDocName = document.getElementById('editor-doc-name');
  621. const saveBtn = document.getElementById('save-btn');
  622. const cancelBtn = document.getElementById('cancel-edit-btn');
  623. const contentArea = document.getElementById('content-area');
  624. // 编辑按钮点击事件 - 切换编辑/查看模式
  625. DOM.editBtn.addEventListener('click', () => {
  626. if (isEditMode) {
  627. exitEditMode();
  628. } else {
  629. enterEditMode();
  630. }
  631. });
  632. // 取消编辑
  633. cancelBtn.addEventListener('click', () => {
  634. exitEditMode();
  635. });
  636. // 保存按钮
  637. saveBtn.addEventListener('click', async () => {
  638. await saveDocument();
  639. });
  640. }
  641. // 进入编辑模式
  642. function enterEditMode() {
  643. if (!currentDoc || !currentMarkdownContent) {
  644. alert('请先加载一个文档');
  645. return;
  646. }
  647. isEditMode = true;
  648. // 更新编辑按钮状态
  649. DOM.editBtn.classList.add('active');
  650. DOM.editBtn.title = '退出编辑';
  651. // 保存当前滚动位置
  652. const contentArea = document.getElementById('content-area');
  653. savedScrollPosition = contentArea.scrollTop;
  654. // 找到当前视口中心附近的元素
  655. const visibleElement = findVisibleElement();
  656. // 隐藏渲染的内容
  657. if (!DOM.content) DOM.content = document.getElementById('markdown-content');
  658. DOM.content.style.display = 'none';
  659. // 显示编辑器
  660. DOM.editorContainer.style.display = 'flex';
  661. DOM.editorDocName.textContent = `${currentDoc}.md`;
  662. DOM.markdownEditor.value = currentMarkdownContent;
  663. // 定位光标到对应位置
  664. setTimeout(() => {
  665. if (visibleElement) {
  666. positionCursorByElement(visibleElement);
  667. } else {
  668. // 如果找不到元素,使用滚动比例
  669. const scrollRatio = savedScrollPosition / contentArea.scrollHeight;
  670. DOM.markdownEditor.scrollTop = DOM.markdownEditor.scrollHeight * scrollRatio;
  671. }
  672. DOM.markdownEditor.focus();
  673. }, 50);
  674. }
  675. // 找到当前视口中可见的元素
  676. function findVisibleElement() {
  677. const contentArea = document.getElementById('content-area');
  678. const viewportTop = contentArea.scrollTop;
  679. const viewportMiddle = viewportTop + contentArea.clientHeight / 3; // 视口上方1/3处
  680. // 查找所有重要元素(标题、段落等)
  681. const elements = DOM.content.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, blockquote, pre');
  682. let closestElement = null;
  683. let minDistance = Infinity;
  684. elements.forEach(el => {
  685. const rect = el.getBoundingClientRect();
  686. const elementTop = el.offsetTop;
  687. const distance = Math.abs(elementTop - viewportMiddle);
  688. if (distance < minDistance && elementTop <= viewportMiddle + 200) {
  689. minDistance = distance;
  690. closestElement = el;
  691. }
  692. });
  693. return closestElement;
  694. }
  695. // 根据元素定位光标
  696. function positionCursorByElement(element) {
  697. const elementText = element.textContent.trim();
  698. if (!elementText) {
  699. return;
  700. }
  701. // 获取元素标签类型
  702. const tagName = element.tagName.toLowerCase();
  703. let searchText = elementText;
  704. // 如果是标题,添加markdown标记进行搜索
  705. if (tagName.match(/^h[1-6]$/)) {
  706. const level = parseInt(tagName[1]);
  707. const hashes = '#'.repeat(level);
  708. // 尝试匹配标题行
  709. const lines = currentMarkdownContent.split('\n');
  710. let targetLine = -1;
  711. for (let i = 0; i < lines.length; i++) {
  712. const line = lines[i].trim();
  713. // 匹配 "# 标题" 格式
  714. if (line.startsWith(hashes + ' ') && line.substring(level + 1).trim() === elementText) {
  715. targetLine = i;
  716. break;
  717. }
  718. }
  719. if (targetLine !== -1) {
  720. setEditorCursorToLine(targetLine);
  721. return;
  722. }
  723. }
  724. // 对于其他元素,搜索文本内容
  725. const lines = currentMarkdownContent.split('\n');
  726. for (let i = 0; i < lines.length; i++) {
  727. const line = lines[i].trim();
  728. // 移除markdown标记进行比较
  729. const cleanLine = line.replace(/^[#\-*>]+\s*/, '').trim();
  730. if (cleanLine.includes(elementText.substring(0, Math.min(50, elementText.length)))) {
  731. setEditorCursorToLine(i);
  732. return;
  733. }
  734. }
  735. }
  736. // 设置编辑器光标到指定行
  737. function setEditorCursorToLine(lineNumber) {
  738. const lines = currentMarkdownContent.split('\n');
  739. // 计算目标位置的字符索引
  740. let charPosition = 0;
  741. for (let i = 0; i < lineNumber && i < lines.length; i++) {
  742. charPosition += lines[i].length + 1; // +1 for newline
  743. }
  744. // 设置光标位置
  745. DOM.markdownEditor.setSelectionRange(charPosition, charPosition);
  746. // 滚动到光标位置
  747. const linesBeforeCursor = lineNumber;
  748. const lineHeight = 22; // 大约的行高(px)
  749. const targetScrollTop = linesBeforeCursor * lineHeight - DOM.markdownEditor.clientHeight / 3;
  750. DOM.markdownEditor.scrollTop = Math.max(0, targetScrollTop);
  751. }
  752. // 退出编辑模式
  753. function exitEditMode() {
  754. isEditMode = false;
  755. // 更新编辑按钮状态
  756. DOM.editBtn.classList.remove('active');
  757. DOM.editBtn.title = '编辑文档';
  758. // 隐藏编辑器
  759. DOM.editorContainer.style.display = 'none';
  760. // 显示渲染的内容
  761. DOM.content.style.display = 'block';
  762. // 恢复滚动位置
  763. const contentArea = document.getElementById('content-area');
  764. setTimeout(() => {
  765. contentArea.scrollTop = savedScrollPosition;
  766. }, 0);
  767. }
  768. // 保存文档
  769. async function saveDocument() {
  770. const newContent = DOM.markdownEditor.value;
  771. if (!currentCategory || !currentDoc) {
  772. alert('无法保存:缺少文档信息');
  773. return;
  774. }
  775. // 禁用保存按钮,防止重复点击
  776. const saveBtn = document.getElementById('save-btn');
  777. saveBtn.disabled = true;
  778. saveBtn.textContent = '保存中...';
  779. try {
  780. const response = await fetch(`/api/doc/${encodeURIComponent(currentCategory)}/${encodeURIComponent(currentDoc)}`, {
  781. method: 'PUT',
  782. headers: {
  783. 'Content-Type': 'application/json'
  784. },
  785. body: JSON.stringify({
  786. content: newContent
  787. })
  788. });
  789. if (!response.ok) {
  790. const errorData = await response.json().catch(() => ({}));
  791. throw new Error(errorData.error || `保存失败 (${response.status})`);
  792. }
  793. const result = await response.json();
  794. // 更新本地的markdown内容
  795. currentMarkdownContent = newContent;
  796. // 重新渲染内容
  797. const html = marked.parse(newContent);
  798. DOM.content.innerHTML = html;
  799. // 重新生成TOC
  800. generateTOC();
  801. // 退出编辑模式
  802. exitEditMode();
  803. // 显示成功提示
  804. showSuccessMessage('保存成功!');
  805. } catch (error) {
  806. console.error('Save error:', error);
  807. alert(`保存失败:${error.message || '请稍后重试'}`);
  808. } finally {
  809. // 恢复保存按钮
  810. saveBtn.disabled = false;
  811. saveBtn.textContent = '保存';
  812. }
  813. }
  814. // 显示成功提示
  815. function showSuccessMessage(message) {
  816. const toast = document.createElement('div');
  817. toast.className = 'success-toast';
  818. toast.textContent = message;
  819. toast.style.cssText = `
  820. position: fixed;
  821. top: 20px;
  822. right: 20px;
  823. background: #28a745;
  824. color: white;
  825. padding: 12px 24px;
  826. border-radius: 6px;
  827. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  828. z-index: 10000;
  829. animation: slideInRight 0.3s ease-out;
  830. `;
  831. document.body.appendChild(toast);
  832. setTimeout(() => {
  833. toast.style.animation = 'slideOutRight 0.3s ease-out';
  834. setTimeout(() => {
  835. document.body.removeChild(toast);
  836. }, 300);
  837. }, 2000);
  838. }
  839. // 初始化
  840. document.addEventListener('DOMContentLoaded', () => {
  841. loadDocList();
  842. setupSidebarToggles();
  843. initSearch();
  844. setupBackToTop();
  845. setupEditFeature();
  846. });
  847. // 处理浏览器后退/前进
  848. window.addEventListener('popstate', () => {
  849. const { doc } = getQueryParams();
  850. if (doc) {
  851. loadDocument(doc);
  852. }
  853. });