[js] 标题下的文字计数

本贴最后更新于 270 天前,其中的信息可能已经斗转星移

前言

因为个人原因,想要在大纲视图下,把鼠标放在标题上可以显示出,这个标题到下个标题之间的字数,但无人回应,所以只好自己想办法,花了几天的时间研究,最后终于用 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
};
  • 思源笔记

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

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

    28446 引用 • 119786 回帖
  • 代码片段

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

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

    285 引用 • 1986 回帖
1 操作
nightstars 在 2025-03-26 19:54:16 更新了该帖

相关帖子

欢迎来到这里!

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

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

    好的,大佬,我这就去试试

  • 其他回帖
  • 88250

    可以考虑用内核接口 /api/block/getBlocksWordCount 来获取内容统计 :)

    1 回复