[js] 标题下的文字计数

前言

因为个人原因,想要在大纲视图下,把鼠标放在标题上可以显示出,这个标题到下个标题之间的字数,但无人回应,所以只好自己想办法,花了几天的时间研究,最后终于用 AI 跑了一段能用的代码。有同样需求的可以试试,

虽然我这里是没有什么问题吧,但是还是建议大家慎重使用,及时做好备份,如果有问题,欢迎各位指出。

目前仅适用于纯文本文档,有图片和表格的正在修改。

最后感谢 JeffreyChen 大佬告诉我元素的名字,不然怕是再跑一天,ai 也跑不出代码。

演示视频

代码

// 大纲字数统计代码片段 // 作者:AI Assistant // 版本:1.0.4 // 添加全局配置 const CONFIG = { HOVER_DELAY: 200, // 悬停延迟(毫秒) CACHE_TIMEOUT: 10000, // 缓存超时(毫秒) DEBUG: false // 调试模式 }; // 防抖函数 function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // 添加样式 const style = document.createElement('style'); style.textContent = ` .outline-count { margin-left: 8px; color: var(--b3-theme-on-surface); font-size: 12px; opacity: 0.8; transition: opacity 0.2s ease; background-color: var(--b3-theme-background); padding: 0 4px; border-radius: 4px; } .b3-list-item:hover .outline-count { opacity: 1; } `; document.head.appendChild(style); // 添加缓存系统 const cache = { wordCounts: new Map(), // 缓存字数统计结果 blockContents: new Map(), // 缓存块内容 nextHeadings: new Map(), // 缓存下一个标题信息 cacheTimeout: CONFIG.CACHE_TIMEOUT, // 缓存有效期 // 获取缓存 get(key, type = 'wordCounts') { const cache = this[type]; const item = cache.get(key); if (!item) return null; // 检查缓存是否过期 if (Date.now() - item.timestamp > this.cacheTimeout) { cache.delete(key); return null; } return item.value; }, // 设置缓存 set(key, value, type = 'wordCounts') { this[type].set(key, { value, timestamp: Date.now() }); }, // 清除特定块的所有相关缓存 clearBlockCache(blockId) { this.wordCounts.delete(blockId); this.blockContents.delete(blockId); this.nextHeadings.delete(blockId); }, // 清除所有缓存 clearAll() { this.wordCounts.clear(); this.blockContents.clear(); this.nextHeadings.clear(); } }; // 获取块的内容 async function getBlockContent(blockId) { try { // 检查缓存 const cachedContent = cache.get(blockId, 'blockContents'); if (cachedContent !== null) { return cachedContent; } const response = await fetch('/api/block/getBlockKramdown', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: blockId }) }); const data = await response.json(); const content = data.data?.kramdown || ''; // 缓存结果 cache.set(blockId, content, 'blockContents'); return content; } catch (error) { return ''; } } // 计算字数 function countWords(text) { if (typeof text !== 'string') { return 0; } // 移除Markdown标记 text = text.replace(/^#+\s+/gm, ''); // 移除标题标记 text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // 移除加粗 text = text.replace(/\*([^*]+)\*/g, '$1'); // 移除斜体 text = text.replace(/~~([^~]+)~~/g, '$1'); // 移除删除线 text = text.replace(/`([^`]+)`/g, '$1'); // 移除代码 text = text.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1'); // 移除链接 text = text.replace(/!\[([^\]]*)\]\([^\)]+\)/g, ''); // 移除图片 text = text.replace(/\{:[^}]*\}/g, ''); // 移除属性标记 // 移除特殊字符和空白 text = text.replace(/[\r\n\s]+/g, ''); return text.length; } // 获取子块列表 async function getSubBlocks(blockId) { try { const response = await fetch('/api/block/getChildBlocks', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: blockId }) }); const data = await response.json(); return data.data || []; } catch (error) { return []; } } // 获取下一个标题的ID async function getNextHeadingId(currentId) { try { // 获取文档信息 const response = await fetch('/api/block/getDocInfo', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: currentId }) }); const data = await response.json(); if (!data.data) { return null; } // 获取大纲 const outlineResponse = await fetch('/api/outline/getDocOutline', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ id: data.data.rootID }) }); const outlineData = await outlineResponse.json(); if (!outlineData.data) { return null; } // 找到当前标题的下一个标题 let foundCurrent = false; for (let i = 0; i < outlineData.data.length; i++) { if (foundCurrent) { return outlineData.data[i].id; } if (outlineData.data[i].id === currentId) { foundCurrent = true; } } return null; } catch (error) { return null; } } // 计算区间字数 async function calculateSectionWordCount(blockId) { try { // 检查缓存 const cachedCount = cache.get(blockId); if (cachedCount !== null) { return cachedCount; } // 获取子块列表 const subBlocks = await getSubBlocks(blockId); let totalCount = 0; // 获取下一个标题ID(使用缓存) const nextHeadingId = await getNextHeadingId(blockId); // 使用Promise.all优化并行请求 const contents = await Promise.all( subBlocks .filter(block => block.id !== nextHeadingId) .map(block => getBlockContent(block.id)) ); totalCount = contents.reduce((sum, content) => sum + countWords(content), 0); // 缓存结果 cache.set(blockId, totalCount); return totalCount; } catch (error) { return 0; } } // 显示字数 function showCount(item, count) { const countElement = document.createElement('span'); countElement.className = 'outline-count'; countElement.textContent = `${count}`; item.appendChild(countElement); } // 设置大纲悬浮事件 async function setupOutlineHover() { const outlineSelectors = [ '.sy__outline .b3-list-item[data-node-id]', // 大纲面板中的项目 '.dock__layout .b3-list-item[data-node-id]' // 侧边栏大纲中的项目 ]; const items = document.querySelectorAll(outlineSelectors.join(',')); // 确保tooltip容器存在 let tooltipElement = document.getElementById('tooltip'); if (!tooltipElement) { tooltipElement = document.createElement('div'); tooltipElement.id = 'tooltip'; tooltipElement.className = 'tooltip fn__none'; document.body.appendChild(tooltipElement); } items.forEach(item => { if (!item || !document.body.contains(item)) { return; } // 检查元素是否已经初始化过 if (item.hasAttribute('data-outline-events-initialized')) { const oldHandlers = item._outlineHandlers; if (oldHandlers) { item.removeEventListener('mouseenter', oldHandlers.handleMouseEnter); item.removeEventListener('mouseleave', oldHandlers.handleMouseLeave); // 清除可能存在的定时器 if (oldHandlers.timer) { clearTimeout(oldHandlers.timer); } } } // 标记元素已初始化 item.setAttribute('data-outline-events-initialized', 'true'); const createEventHandler = (currentItem) => { let isCalculating = false; let timer = null; const handleMouseEnter = async (event) => { if (!currentItem || !document.body.contains(currentItem)) { return; } const blockId = currentItem.getAttribute('data-node-id'); if (!blockId) { return; } // 清除之前的定时器 if (timer) { clearTimeout(timer); } // 添加新的定时器 timer = setTimeout(async () => { if (isCalculating) return; isCalculating = true; try { const count = await calculateSectionWordCount(blockId); if (!document.body.contains(currentItem)) return; const text = currentItem.textContent.trim(); const tooltipElement = document.getElementById('tooltip'); if (tooltipElement) { tooltipElement.innerHTML = `${text} ${count}`; // 6个空格 tooltipElement.classList.remove('fn__none'); const rect = currentItem.getBoundingClientRect(); const tooltipRect = tooltipElement.getBoundingClientRect(); const left = Math.min( rect.right + 8, window.innerWidth - tooltipRect.width - 8 ); const top = Math.max(0, Math.min( rect.top + (rect.height - tooltipRect.height) / 2, window.innerHeight - tooltipRect.height )); tooltipElement.style.left = `${left}px`; tooltipElement.style.top = `${top}px`; } } catch (error) { return; } finally { isCalculating = false; } }, CONFIG.HOVER_DELAY); }; const handleMouseLeave = () => { if (timer) { clearTimeout(timer); timer = null; } const tooltipElement = document.getElementById('tooltip'); if (tooltipElement) { tooltipElement.classList.add('fn__none'); } }; return { handleMouseEnter, handleMouseLeave, timer }; }; // 为每个元素创建独立的事件处理函数 const handlers = createEventHandler(item); // 保存处理函数的引用以便后续移除 item._outlineHandlers = handlers; // 添加事件监听器 item.addEventListener('mouseenter', handlers.handleMouseEnter); item.addEventListener('mouseleave', handlers.handleMouseLeave); }); } // 添加tooltip样式 const tooltipStyle = document.createElement('style'); tooltipStyle.textContent = ` .tooltip { position: fixed; z-index: 1000000; padding: 4px 8px; font-size: 12px; font-weight: normal; -webkit-font-smoothing: subpixel-antialiased; color: var(--b3-tooltips-color); word-wrap: break-word; background-color: var(--b3-tooltips-background); border-radius: var(--b3-border-radius); line-height: 17px; max-width: 320px; animation-duration: 150ms; animation-fill-mode: both; animation-name: zoomIn; max-height: 90vh; overflow: auto; box-sizing: border-box; white-space: pre; // 修改为pre以保持空格 pointer-events: none; } `; document.head.appendChild(tooltipStyle); // 创建观察器 const observer = new MutationObserver(debounce((mutations) => { let shouldSetup = false; for (const mutation of mutations) { if (mutation.addedNodes.length || mutation.type === 'childList') { shouldSetup = true; break; } } if (shouldSetup) { setupOutlineHover(); } }, 100)); // 监听文档变化以清除缓存 const docObserver = new MutationObserver(debounce((mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList') { // 如果发生了文档内容变化,清除所有缓存 cache.clearAll(); break; } } }, 1000)); // 检查大纲面板 function checkOutlinePanel() { const outlineSelectors = [ '.sy__outline', '.dock__layout' // 只监听大纲面板相关的容器 ]; try { for (const selector of outlineSelectors) { const panel = document.querySelector(selector); if (panel) { observer.observe(panel, { childList: true, subtree: true }); setupOutlineHover(); return true; } } setTimeout(checkOutlinePanel, 1000); return false; } catch (error) { setTimeout(checkOutlinePanel, 1000); return false; } } // 初始化函数 function initialize() { try { // 检查是否已经初始化 if (window.outlineCount && window.outlineCount.initialized) { return; } // 确保样式只添加一次 if (!document.querySelector('style[data-outline-count]')) { const style = document.createElement('style'); style.setAttribute('data-outline-count', 'true'); style.textContent = ` .outline-count { margin-left: 8px; color: var(--b3-theme-on-surface); font-size: 12px; opacity: 0.8; transition: opacity 0.2s ease; background-color: var(--b3-theme-background); padding: 0 4px; border-radius: 4px; } .b3-list-item:hover .outline-count { opacity: 1; } `; document.head.appendChild(style); } // 启动检查大纲面板 checkOutlinePanel(); // 监听文档变化 const editor = document.querySelector('.protyle-content'); if (editor) { docObserver.observe(editor, { childList: true, subtree: true }); } // 标记为已初始化 window.outlineCount = window.outlineCount || {}; window.outlineCount.initialized = true; window.outlineCount.cache = cache; // 添加清理函数 window.addEventListener('pagehide', () => { observer.disconnect(); docObserver.disconnect(); cache.clearAll(); // 清理所有事件监听器和属性 document.querySelectorAll('[data-word-count-initialized]') .forEach(item => { item.removeAttribute('data-word-count-initialized'); // 通过克隆节点清除所有事件监听器 item.replaceWith(item.cloneNode(true)); }); }); } catch (error) { return; } } // 确保在DOM加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } // 将关键函数挂载到window对象上(如果需要外部访问) window.outlineCount = { initialize, setupOutlineHover, checkOutlinePanel };
  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    24827 引用 • 102126 回帖
  • 代码片段

    代码片段分为 CSS 与 JS 两种代码,添加在 [设置 - 外观 - 代码片段] 中,这些代码会在思源笔记加载时自动执行,用于改善笔记的样式或功能。

    用户在该标签下分享代码片段时需在帖子标题前添加 [css] [js] 用于区分代码片段类型。

    133 引用 • 891 回帖 • 1 关注
1 操作
nightstars 在 2025-03-26 19:54:16 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...