Notion标题真实编号工具

为Notion页面提供标题编号和目录管理功能,支持一键添加/移除编号、创建/删除目录,直接修改文本内容,永久保存到文档中

// ==UserScript==
// @name         Notion标题真实编号工具
// @namespace    https://floritange.github.io/
// @version      1.0.1
// @description  为Notion页面提供标题编号和目录管理功能,支持一键添加/移除编号、创建/删除目录,直接修改文本内容,永久保存到文档中
// @author       goutan
// @match        https://www.notion.so/*
// @grant        none
// @run-at       document-end
// @license      Apache-2.0
// @noframes
// @icon         https://www.notion.so/front-static/favicon.ico
// ==/UserScript==

(function () {
  "use strict";

  // 全局状态管理
  const state = {
    isEnabled: false, // 当前是否启用真实编号
    processedHeadings: new Map(), // 记录已处理的标题 {blockId: originalText}
    isProcessing: false, // 防止重复处理
  };

  // console.log("[HeadingNumbering] 脚本启动");

  // 工具函数:延迟执行
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  // 检查文本是否已有编号 - 扩展匹配模式
  const hasNumbering = (text) => {
    const trimmed = text.trim();
    // 匹配 "1. " "1.1 " "1.1.1 " 等各种编号格式,包括末尾可能没有空格的情况
    return (
      /^\d+(\.\d+)*\.(\s|$)/.test(trimmed) || /^\d+(\.\d+)*\s/.test(trimmed)
    );
  };

  // 移除文本中的编号 - 扩展替换模式
  const stripNumbering = (text) => {
    // 移除 "1. " "1.1 " "1.1.1 " 等格式,处理末尾空格变化
    return text.replace(/^\d+(\.\d+)*\.?\s*/, "").trim();
  };

  // 模拟用户输入到元素
  const simulateInput = async (element, newText) => {
    try {
      element.focus(); // 聚焦元素
      await sleep(20);

      // 全选现有内容
      const selection = window.getSelection();
      const range = document.createRange();
      range.selectNodeContents(element);
      selection.removeAllRanges();
      selection.addRange(range);
      await sleep(10);

      // 触发输入事件序列
      element.dispatchEvent(
        new InputEvent("beforeinput", {
          bubbles: true,
          inputType: "insertReplacementText",
          data: newText,
        })
      );

      element.textContent = newText; // 设置新文本
      element.dispatchEvent(new InputEvent("input", { bubbles: true }));
      element.dispatchEvent(new Event("change", { bubbles: true }));

      // 光标移到末尾
      range.setStart(element, element.childNodes.length);
      range.collapse(true);
      selection.removeAllRanges();
      selection.addRange(range);

      return true;
    } catch (error) {
      return false;
    }
  };

  // 获取页面所有标题元素,按DOM顺序排序
  const getHeadingElements = () => {
    const allHeadings = []; // 存储所有找到的标题
    const processedIds = new Set();

    // 获取所有标题元素(不分类型)
    const headingElements = document.querySelectorAll(
      [
        '.notion-page-content [placeholder="Heading 1"]',
        '.notion-page-content [placeholder="Heading 2"]',
        '.notion-page-content [placeholder="Heading 3"]',
      ].join(", ")
    );

    headingElements.forEach((element) => {
      // 跳过隐藏或不可编辑的元素
      if (
        element.closest('[aria-hidden="true"]') ||
        element.getAttribute("contenteditable") === "false"
      )
        return;

      const blockElement = element.closest("[data-block-id]");
      if (!blockElement) return;

      const blockId = blockElement.getAttribute("data-block-id");
      if (!blockId || processedIds.has(blockId)) return;
      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) {
        allHeadings.push({
          id: blockId,
          level: level,
          element: element,
          text: element.textContent || "",
          position: element.getBoundingClientRect().top, // 添加位置信息用于排序
        });
      }
    });

    // 按页面位置排序,确保编号顺序正确
    return allHeadings.sort((a, b) => a.position - b.position);
  };

  // 生成编号文本
  const generateNumbers = (headings) => {
    const counters = [0, 0, 0]; // [H1, H2, H3] 计数器
    const numberedHeadings = [];

    headings.forEach((heading) => {
      let numberText = "";

      if (heading.level === 1) {
        counters[0]++;
        counters[1] = 0;
        counters[2] = 0;
        numberText = `${counters[0]}. `;
      } else if (heading.level === 2) {
        counters[1]++;
        counters[2] = 0;
        numberText = `${counters[0]}.${counters[1]} `;
      } else if (heading.level === 3) {
        counters[2]++;
        numberText = `${counters[0]}.${counters[1]}.${counters[2]} `;
      }

      numberedHeadings.push({
        ...heading,
        numberText: numberText,
        originalText: stripNumbering(heading.text),
      });
    });

    return numberedHeadings;
  };

  // 模拟Ctrl+S保存操作
  const saveDocument = async () => {
    try {
      // 模拟Ctrl+S快捷键
      const saveEvent = new KeyboardEvent("keydown", {
        key: "s",
        code: "KeyS",
        ctrlKey: true,
        metaKey: navigator.platform.includes("Mac"), // Mac使用Cmd键
        bubbles: true,
        cancelable: true,
      });
      document.dispatchEvent(saveEvent);
    } catch (error) {
      // 保存失败静默处理
    }
  };

  // 添加/刷新编号到所有标题 - 智能处理已有编号
  const addOrRefreshNumbering = async () => {
    if (state.isProcessing) return;
    state.isProcessing = true;

    try {
      // console.log("[HeadingNumbering] 开始处理编号");

      const headings = getHeadingElements();
      if (headings.length === 0) return;

      // 先清理所有现有编号,获取纯净文本
      const cleanHeadings = headings.map((heading) => ({
        ...heading,
        originalText: stripNumbering(heading.text), // 统一清理编号
      }));

      const numberedHeadings = generateNumbers(cleanHeadings);
      let processedCount = 0;

      // 逐个添加编号
      for (const heading of numberedHeadings) {
        const newText = heading.numberText + heading.originalText;
        if (heading.text !== newText) {
          // 只处理需要更新的标题
          const success = await simulateInput(heading.element, newText);
          if (success) {
            processedCount++;
            await sleep(50);
          }
        }
      }

      state.isEnabled = true;
      // console.log(
      //   `[HeadingNumbering] 编号完成,处理了 ${processedCount} 个标题`
      // );

      // 操作完成后保存文档
      await sleep(100); // 等待DOM更新
      await saveDocument();
    } catch (error) {
      console.error("[HeadingNumbering] 编号失败:", error);
    } finally {
      state.isProcessing = false;
    }
  };

  // 移除所有标题编号
  const clearNumbering = async () => {
    if (state.isProcessing) return;
    state.isProcessing = true;

    try {
      // console.log("[HeadingNumbering] 开始移除编号");

      const headings = getHeadingElements();
      let processedCount = 0;

      for (const heading of headings) {
        const currentText = heading.text;
        if (hasNumbering(currentText)) {
          const cleanText = stripNumbering(currentText);
          const success = await simulateInput(heading.element, cleanText);
          if (success) {
            processedCount++;
            await sleep(50);
          }
        }
      }

      state.isEnabled = false;
      state.processedHeadings.clear();
      // console.log(
      //   `[HeadingNumbering] 移除完成,处理了 ${processedCount} 个标题`
      // );

      // 操作完成后保存文档
      await sleep(100);
      await saveDocument();
    } catch (error) {
      console.error("[HeadingNumbering] 移除编号失败:", error);
    } finally {
      state.isProcessing = false;
    }
  };

  // 查找目录块和分割线块
  const findTocAndDivider = () => {
    // 查找目录块:使用更精确的选择器
    const tocBlock =
      document
        .querySelector("[data-block-id] .notion-table_of_contents-block")
        ?.closest("[data-block-id]") ||
      document.querySelector(".notion-table_of_contents-block");

    if (!tocBlock) {
      return { tocBlock: null, dividerBlock: null };
    }

    // 查找下一个分割线块
    let dividerBlock = null;
    let nextSibling = tocBlock.nextElementSibling;

    // 查找紧邻的分割线块
    while (nextSibling && !dividerBlock) {
      // 检查是否为分割线块
      if (
        nextSibling.querySelector(".notion-divider-block") ||
        nextSibling.classList.contains("notion-divider-block") ||
        nextSibling.querySelector('[role="separator"]')
      ) {
        dividerBlock = nextSibling;
        break;
      }
      nextSibling = nextSibling.nextElementSibling;
    }

    return { tocBlock, dividerBlock };
  };

  // 获取页面标题(h1元素)
  const getPageTitle = () => {
    // 查找页面标题 h1 元素
    const pageTitle = document.querySelector(
      'h1[placeholder="New page"][contenteditable="true"]'
    );
    return pageTitle;
  };

  // 字符输入函数 - 使用更直接的方式
  const typeCharacters = async (element, text) => {
    try {
      // 确保元素处于编辑状态
      element.click();
      element.focus();
      await sleep(50);

      // 逐字符输入
      for (const char of text) {
        // 使用document.execCommand插入字符(更可靠)
        if (document.execCommand) {
          document.execCommand("insertText", false, char);
        } else {
          // 备用方案:直接操作textContent
          const currentText = element.textContent || "";
          element.textContent = currentText + char;

          // 触发input事件
          element.dispatchEvent(
            new InputEvent("input", {
              bubbles: true,
              inputType: "insertText",
              data: char,
            })
          );
        }

        // 特殊字符延时
        if (char === "/") {
          await sleep(200); // 斜杠后等待菜单
        } else {
          await sleep(20); // 普通字符延时
        }
      }

      return true;
    } catch (error) {
      return false;
    }
  };

  // 回车函数
  const pressEnter = async (element) => {
    try {
      element.focus();
      await sleep(20);

      // 使用更简单的回车事件
      const enterEvent = new KeyboardEvent("keydown", {
        key: "Enter",
        code: "Enter",
        keyCode: 13,
        bubbles: true,
        cancelable: true,
      });

      element.dispatchEvent(enterEvent);
      await sleep(100);

      return true;
    } catch (error) {
      return false;
    }
  };

  // 创建目录
  const createTableOfContents = async () => {
    if (state.isProcessing) return;
    state.isProcessing = true;

    try {
      // console.log("[TOC] 开始创建目录");

      const { tocBlock } = findTocAndDivider();
      if (tocBlock) return;

      // 1. 找到页面标题
      const pageTitle = getPageTitle();
      if (!pageTitle) return;

      // 2. 点击标题进入编辑,光标到末尾
      pageTitle.click();
      pageTitle.focus();
      await sleep(100);

      // 光标移到末尾
      const selection = window.getSelection();
      const range = document.createRange();
      range.selectNodeContents(pageTitle);
      range.collapse(false);
      selection.removeAllRanges();
      selection.addRange(range);
      await sleep(50);

      // 3. 回车创建新行
      await pressEnter(pageTitle);
      await sleep(300);

      // 4. 重新定位到第一个空行
      let targetElement = null;
      const editableElements = document.querySelectorAll(
        '[contenteditable="true"]'
      );

      // 查找第一个空的可编辑元素
      for (const elem of editableElements) {
        if (!elem.textContent.trim() && elem !== pageTitle) {
          targetElement = elem;
          break;
        }
      }

      if (!targetElement) return;

      // 5. 点击目标元素进入编辑状态
      targetElement.click();
      targetElement.focus();
      await sleep(100);

      // 6. 输入斜杠命令
      await typeCharacters(targetElement, "/table of contents");
      await sleep(100); // 等待菜单

      // 7. 回车选择
      await pressEnter(targetElement);
      await sleep(200);

      // 8. 查找下一个空行输入分割线
      const nextEditableElements = document.querySelectorAll(
        '[contenteditable="true"]'
      );
      let dividerTarget = null;

      for (const elem of nextEditableElements) {
        if (
          !elem.textContent.trim() &&
          elem !== targetElement &&
          elem !== pageTitle
        ) {
          dividerTarget = elem;
          break;
        }
      }

      if (dividerTarget) {
        dividerTarget.click();
        dividerTarget.focus();
        await sleep(100);
        await typeCharacters(dividerTarget, "---");
      }

      // console.log("[TOC] 创建完成");
      await saveDocument();
    } catch (error) {
      console.error("[TOC] 创建失败:", error);
    } finally {
      state.isProcessing = false;
    }
  };

  // 检测页面内容中是否有目录和紧跟的分割线
  const detectTocAndDivider = () => {
    // 在页面内容区域查找目录块
    const tocBlock = document
      .querySelector(".notion-page-content .notion-table_of_contents-block")
      ?.closest("[data-block-id]");

    if (!tocBlock) {
      return {
        hasToc: false,
        hasDivider: false,
        tocBlock: null,
        dividerBlock: null,
      };
    }

    // 检查目录块的直接下一个兄弟元素是否为分割线
    const nextSibling = tocBlock.nextElementSibling;
    let dividerBlock = null;

    // 严格检查:必须是直接相邻且包含分割线标识
    if (
      nextSibling &&
      nextSibling.classList.contains("notion-divider-block") &&
      nextSibling.querySelector('[role="separator"]')
    ) {
      dividerBlock = nextSibling;
    }

    return {
      hasToc: true,
      hasDivider: !!dividerBlock,
      tocBlock,
      dividerBlock,
    };
  };

  // 删除目录
  const removeTableOfContents = async () => {
    if (state.isProcessing) return;
    state.isProcessing = true;

    try {
      // console.log("[TOC] 开始删除目录");

      // 检测目录和分割线
      const { hasToc, hasDivider, tocBlock, dividerBlock } =
        detectTocAndDivider();

      if (hasDivider) {
        // 步骤1:先删除分割线块
        dividerBlock.click(); // 聚焦到分割线
        await sleep(50);

        const deleteEvent1 = new KeyboardEvent("keydown", {
          key: "Backspace",
          code: "Backspace",
          keyCode: 8,
          bubbles: true,
          cancelable: true,
        });
        document.dispatchEvent(deleteEvent1);
        await sleep(200); // 等待分割线删除完成

        // 步骤2:点击目录块重新聚焦
        tocBlock.click(); // 重新点击目录块获得焦点
        await sleep(200); // 等待焦点切换

        // 步骤3:删除目录块
        const deleteEvent2 = new KeyboardEvent("keydown", {
          key: "Backspace",
          code: "Backspace",
          keyCode: 8,
          bubbles: true,
          cancelable: true,
        });
        document.dispatchEvent(deleteEvent2);
        await sleep(200); // 等待目录删除完成
      } else {
        // 只有目录,直接删除目录
        tocBlock.click();
        await sleep(50);

        const deleteEvent = new KeyboardEvent("keydown", {
          key: "Backspace",
          code: "Backspace",
          keyCode: 8,
          bubbles: true,
          cancelable: true,
        });
        document.dispatchEvent(deleteEvent);
        await sleep(200);
      }

      // console.log("[TOC] 删除完成");
      await saveDocument();
    } catch (error) {
      console.error("[TOC] 删除失败:", error);
    } finally {
      state.isProcessing = false;
    }
  };

  // 创建四个控制按钮
  const createControlButtons = () => {
    if (document.getElementById("heading-add-btn")) return;

    const container = document.createElement("div");
    container.style.cssText = `
      position: fixed;
      top: 40px;
      right: 20px;
      display: flex;
      gap: 2px;
      z-index: 9999;
    `;

    // 创建按钮
    const addButton = document.createElement("button");
    addButton.id = "heading-add-btn";
    addButton.innerHTML = "✨";
    addButton.title = "添加/刷新标题编号";

    const clearButton = document.createElement("button");
    clearButton.id = "heading-clear-btn";
    clearButton.innerHTML = "🧹";
    clearButton.title = "移除标题编号";

    const tocButton = document.createElement("button");
    tocButton.id = "toc-add-btn";
    tocButton.innerHTML = "📄";
    tocButton.title = "创建目录";

    const tocRemoveButton = document.createElement("button");
    tocRemoveButton.id = "toc-remove-btn";
    tocRemoveButton.innerHTML = "🔖";
    tocRemoveButton.title = "删除目录";

    const buttonStyle = `
      width: 30px;
      height: 30px;
      border-radius: 50%;
      border: 2px solid #e1e5e9;
      background: white;
      color: #37352f;
      font-size: 14px;
      cursor: pointer;
      box-shadow: 0 2px 8px rgba(0,0,0,0.1);
      transition: all 0.2s ease;
      display: flex;
      align-items: center;
      justify-content: center;
    `;

    [addButton, clearButton, tocButton, tocRemoveButton].forEach((btn) => {
      btn.style.cssText = buttonStyle;
    });

    // 绑定按钮事件
    addButton.addEventListener("click", async () => {
      if (state.isProcessing) return;
      addButton.style.opacity = "0.5";
      try {
        await addOrRefreshNumbering();
        addButton.style.background = "#e8f5e8";
      } finally {
        addButton.style.opacity = "1";
      }
    });

    clearButton.addEventListener("click", async () => {
      if (state.isProcessing) return;
      clearButton.style.opacity = "0.5";
      try {
        await clearNumbering();
        addButton.style.background = "white";
      } finally {
        clearButton.style.opacity = "1";
      }
    });

    tocButton.addEventListener("click", async () => {
      if (state.isProcessing) return;
      tocButton.style.opacity = "0.5";
      try {
        await createTableOfContents();
      } finally {
        tocButton.style.opacity = "1";
      }
    });

    tocRemoveButton.addEventListener("click", async () => {
      if (state.isProcessing) return;
      tocRemoveButton.style.opacity = "0.5";
      try {
        await removeTableOfContents();
      } finally {
        tocRemoveButton.style.opacity = "1";
      }
    });

    container.appendChild(addButton);
    container.appendChild(clearButton);
    container.appendChild(tocButton);
    container.appendChild(tocRemoveButton);
    document.body.appendChild(container);
    // console.log("[HeadingNumbering] 按钮创建完成");
  };

  // 检查是否在Notion页面
  const isNotionPage = () => {
    return (
      window.location.hostname.includes("notion.so") ||
      window.location.hostname.includes("notion.site")
    );
  };

  // 等待页面加载完成
  const waitForPageLoad = () => {
    return new Promise((resolve) => {
      if (document.querySelector(".notion-page-content")) {
        resolve();
      } else {
        const observer = new MutationObserver(() => {
          if (document.querySelector(".notion-page-content")) {
            observer.disconnect();
            resolve();
          }
        });
        observer.observe(document.body, { childList: true, subtree: true });

        // 设置超时保护,防止无限等待
        setTimeout(() => {
          observer.disconnect();
          resolve();
        }, 10000);
      }
    });
  };

  // 初始化脚本
  const initialize = async () => {
    if (!isNotionPage()) return;

    // console.log("[HeadingNumbering] 页面加载中...");
    await waitForPageLoad();

    setTimeout(() => {
      createControlButtons();
      // console.log("[HeadingNumbering] 初始化完成");
    }, 1500);
  };

  // 启动脚本
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initialize, { once: true });
  } else {
    initialize();
  }
})();