Notion标题视觉编号工具

为Notion页面标题添加视觉编号效果,纯JS实现,不修改原始内容,支持目录同步更新

// ==UserScript==
// @name         Notion标题视觉编号工具
// @namespace    https://floritange.github.io/
// @version      1.0.6
// @description  为Notion页面标题添加视觉编号效果,纯JS实现,不修改原始内容,支持目录同步更新
// @author       goutan
// @match        https://www.notion.so/*
// @grant        GM_addStyle
// @run-at       document-end
// @license      Apache-2.0
// @noframes
// @icon         https://www.notion.so/front-static/favicon.ico
// ==/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();
  }
})();