Notion标题自动编号

为Notion页面中的标题(H1-H3)自动添加编号,支持目录同步更新

当前为 2025-08-10 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Notion标题自动编号
// @namespace    https://floritange.github.io/
// @version      1.0.4
// @description  为Notion页面中的标题(H1-H3)自动添加编号,支持目录同步更新
// @description:zh-CN  为Notion页面中的标题(H1-H3)自动添加编号,支持目录同步更新
// @author       goutan
// @match        https://www.notion.so/*
// @grant        GM_addStyle
// @run-at       document-end
// @license      MIT
// @noframes
// ==/UserScript==

(function() {
  'use strict';
  
  // 使用 GM_addStyle 注入基础样式
  GM_addStyle(`
    .notion-page-content [placeholder^="Heading"] { 
      position: relative; 
    }
    .notion-page-content [placeholder^="Heading"]::before,
    .table_of_contents .text::before {
      content: "";
      margin-right: .1em;
      pointer-events: none;
      user-select: none;
      font-weight: bold;
    }
    .notion-page-content [placeholder^="Heading"]::before { 
      color: #37352F; 
    }
    h1[placeholder="New page"]::before,
    h1[placeholder*="Untitled"]::before { 
      content: "" !important; 
    }
  `);
  
  // 动态样式元素,用于添加编号规则
  const dynamicStyle = document.createElement("style");
  dynamicStyle.id = "notion-numbering-dynamic";
  
  // 确保动态样式已注入
  const ensureDynamicStyle = () => {
    if (!document.getElementById("notion-numbering-dynamic")) {
      (document.head || document.documentElement).appendChild(dynamicStyle);
    }
  };

  // DOM查询工具函数
  const querySelector = (selector, root = document) => root.querySelector(selector);
  const querySelectorAll = (selector, root = document) => Array.from(root.querySelectorAll(selector));
  
  // 32位字符串转UUID格式
  const formatToUUID = (str) => {
    if (!str || str.length !== 32) return str;
    return str.replace(/^(.{8})(.{4})(.{4})(.{4})(.{12})$/, "\$1-\$2-\$3-\$4-\$5");
  };

  // 从目录获取标题列表
  const getTableOfContentsItems = () => {
    const tocContainer = querySelector(".table_of_contents");
    if (!tocContainer) return [];
    
    return querySelectorAll(".block[hash]", tocContainer).reduce((accumulator, block) => {
      const hashValue = block.getAttribute("hash");
      if (!hashValue || hashValue.length !== 32) return accumulator;
      
      const alignElement = block.querySelector(".align");
      let headingLevel = 0;
      
      // 检测标题级别
      if (alignElement?.classList.contains("nb-h1")) headingLevel = 1;
      else if (alignElement?.classList.contains("nb-h2")) headingLevel = 2;
      else if (alignElement?.classList.contains("nb-h3")) headingLevel = 3;
      
      if (headingLevel > 0) {
        accumulator.push({ 
          id: formatToUUID(hashValue),
          rawId: hashValue,
          level: headingLevel
        });
      }
      return accumulator;
    }, []);
  };

  // 从页面内容获取标题元素
  const getPageHeadings = () => {
    const processedIds = new Set();
    const headingSelectors = [
      '.notion-page-content [placeholder="Heading 1"]',
      '.notion-page-content [placeholder="Heading 2"]', 
      '.notion-page-content [placeholder="Heading 3"]'
    ].join(', ');
    
    return querySelectorAll(headingSelectors).reduce((accumulator, element) => {
      // 跳过隐藏元素
      if (element.closest('[aria-hidden="true"]')) return accumulator;
      
      const blockElement = element.closest("[data-block-id]");
      if (!blockElement) return accumulator;
      
      const blockId = blockElement.getAttribute("data-block-id");
      if (!blockId || processedIds.has(blockId)) return accumulator;
      
      processedIds.add(blockId);
      
      const placeholder = element.getAttribute("placeholder") || "";
      let level = 0;
      if (placeholder === "Heading 1") level = 1;
      else if (placeholder === "Heading 2") level = 2;
      else if (placeholder === "Heading 3") level = 3;
      
      if (level > 0) {
        accumulator.push({ 
          id: blockId, 
          rawId: blockId.replace(/-/g, ""), 
          level 
        });
      }
      return accumulator;
    }, []);
  };

  // 生成标题编号
  const generateNumbering = (headingItems) => {
    const counters = [0, 0, 0]; // [H1, H2, H3] 计数器
    const numberingMap = new Map();
    
    headingItems.forEach(item => {
      if (item.level === 1) {
        counters[0]++;
        counters[1] = 0;
        counters[2] = 0;
        numberingMap.set(item.id, `${counters[0]}. `);
      } else if (item.level === 2) {
        counters[1]++;
        counters[2] = 0;
        numberingMap.set(item.id, `${counters[0]}.${counters[1]} `);
      } else if (item.level === 3) {
        counters[2]++;
        numberingMap.set(item.id, `${counters[0]}.${counters[1]}.${counters[2]} `);
      }
    });
    
    return numberingMap;
  };

  // 应用编号样式
  const applyNumberingStyles = (numberingMap, tocItems) => {
    const cssRules = [];
    
    // 为页面标题生成CSS规则
    numberingMap.forEach((numberLabel, blockId) => {
      cssRules.push(
        `[data-block-id="${blockId}"] [placeholder^="Heading"]::before { content: "${numberLabel}"; }`
      );
    });
    
    // 为目录标题生成CSS规则
    tocItems.forEach(item => {
      const numberLabel = numberingMap.get(item.id);
      if (numberLabel) {
        cssRules.push(
          `.table_of_contents .block[hash="${item.rawId}"] .text::before { content: "${numberLabel}"; }`
        );
      }
    });
    
    // 更新动态样式
    dynamicStyle.textContent = cssRules.join('\n');
  };

  // 防抖控制
  let isScheduled = false;
  
  // 主要重建逻辑
  const rebuildNumbering = () => {
    ensureDynamicStyle();
    
    const tocItems = getTableOfContentsItems();
    const sourceItems = tocItems.length > 0 ? tocItems : getPageHeadings();
    
    if (sourceItems.length > 0) {
      const numberingMap = generateNumbering(sourceItems);
      applyNumberingStyles(numberingMap, tocItems);
    }
  };

  // 防抖调度器
  const scheduleRebuild = () => {
    if (!isScheduled) {
      isScheduled = true;
      setTimeout(() => {
        isScheduled = false;
        rebuildNumbering();
      }, 120);
    }
  };

  // 启动观察器
  const initializeObservers = () => {
    // DOM变化观察器
    const observer = new MutationObserver(scheduleRebuild);
    observer.observe(document.documentElement, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['hash', 'data-block-id', 'placeholder']
    });
    
    // 立即执行一次
    rebuildNumbering();
    
    // 延迟执行,确保页面完全加载
    setTimeout(rebuildNumbering, 800);
    
    // 定期检查更新
    setInterval(rebuildNumbering, 3000);
  };

  // 脚本初始化
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initializeObservers, { once: true });
  } else {
    initializeObservers();
  }
  
})();