Notion-Formula-Auto-Conversion-Tool

Notion 自动公式转换工具

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Notion-Formula-Auto-Conversion-Tool
// @namespace    http://tampermonkey.net/
// @version      3.3.1
// @description  Notion 自动公式转换工具
// @author       skyance、0xstrid、fengjy73、Sparidae、ckrvxr
// @match        https://www.notion.so/*
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @github       https://github.com/skyance/Notion-Formula-Auto-Conversion-Tool
// ==/UserScript==

(function () {
  "use strict";

  GM_addStyle(`
    #formula-helper {
      position: absolute;
      bottom: 100px;
      right: 30px;
      z-index: 1;
      height: 40px;
      width: 40px;
      border-radius: 22px;
      background: #ffffff;
      box-shadow: 0px 6px 16px -4px rgba(0, 0, 0, 0.08),
                  0px 8px 12px 0px rgba(25,25,25,.027),
                  0px 2px 6px 0px rgba(25,25,25,.027),
                  0px 0px 0px 1px rgba(42,28,0,.10);
      display: flex;
      align-items: center;
      overflow: hidden;
      cursor: pointer;
      transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1),
                  border-radius 0.25s cubic-bezier(0.4, 0, 0.2, 1),
                  transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
      font-family: 'Apple Chancery', 'Gabriola', 'Georgia', 'Times New Roman', serif;
      font-weight: 700;
      user-select: none;
    }
    #formula-helper.hover,
    #formula-helper.processing {
      width: 200px;
      border-radius: 22px;
      transform: scale(1.08);
    }
    #formula-helper > * {
      pointer-events: none;
    }
    .button-icon {
      width: 40px;
      height: 40px;
      flex-shrink: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      font-family: 'Apple Chancery', 'Gabriola', 'Georgia', 'Times New Roman', serif;
      font-size: 19px;
      font-weight: 700;
      color: rgb(55, 53, 47);
      line-height: 1;
    }
    .progress-wrapper {
      display: flex;
      align-items: center;
      flex-grow: 1;
      overflow: hidden;
      padding-right: 12px;
      white-space: nowrap;
    }
    .progress-bar-container {
      flex-grow: 1;
      height: 4px;
      background: rgba(55, 53, 47, 0.09);
      border-radius: 2px;
      margin-right: 8px;
    }
    .progress-bar-fill {
      width: 0%;
      height: 100%;
      background: rgb(35, 131, 226);
      border-radius: 2px;
      transition: width 0.3s ease;
    }
    .progress-text {
      font-size: 14px;
      color: rgba(55, 53, 47, 0.7);
      font-variant-numeric: tabular-nums;
    }
    @media (prefers-color-scheme: dark) {
      #formula-helper {
        background: rgb(211, 211, 211);
      }
    }
    .notion-assistant-corner-origin-container > div[style*="display: flex"] {
      inset-inline-end: unset !important;
      right: 4px !important;
    }
  `);

  let panel, progressBar, progressText;
  let isProcessing = false;
  let shouldStop = false;
  let hoverTimer = null;
  const DEBUG_MODE = false;

  // ---------- 速度配置 ----------
  const SPEED_PRESETS = {
    slow:   { label: "慢速",   delay: 111 },
    normal: { label: "中速",   delay: 11 },
    fast:   { label: "快速",   delay: 1  },
    custom: { label: "自定义", delay: null },
  };

  const getDelay = () => {
    const speed = GM_getValue("speed", "normal");
    return speed === "custom" ? GM_getValue("customDelay", 30) : SPEED_PRESETS[speed].delay;
  };

  // ---------- 工具函数 ----------
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, getDelay()));

  // ---------- 菜单状态 ----------
  let totalConverted = GM_getValue("totalConverted", 0);
  let totalMenuId = null;
  let panelVisible = GM_getValue("panelVisible", true);

  function refreshTotalMenu() {
    if (totalMenuId !== null) GM_unregisterMenuCommand(totalMenuId);
    totalMenuId = GM_registerMenuCommand(
      `📊 累计转换: ${totalConverted} 个公式`,
      () => {}
    );
  }

  function updateProgress(current, total, textOverride = null) {
    const percent = total > 0 ? (current / total) * 100 : 0;
    progressBar.style.width = `${percent}%`;
    progressText.textContent = textOverride || `${current}/${total}`;
  }

  function createPanel() {
    panel = document.createElement("div");
    panel.id = "formula-helper";
    panel.innerHTML = `
        <span class="button-icon">M</span>
        <div class="progress-wrapper">
            <div class="progress-bar-container">
                <div class="progress-bar-fill"></div>
            </div>
            <span class="progress-text">0</span>
        </div>
    `;
    document.body.appendChild(panel);
    if (!panelVisible) panel.style.display = "none";

    progressBar = panel.querySelector(".progress-bar-fill");
    progressText = panel.querySelector(".progress-text");

    // 自动检测待处理个数
    let lastCount = -1;
    const updateCount = () => {
      if (isProcessing) return;
      let count = 0;
      for (const editor of getEditableEditors()) {
        count += findFormulas(editor.textContent).length;
      }
      if (count !== lastCount) {
        lastCount = count;
        progressText.textContent = count ? `${count}` : "0";
      }
    };

    // 初始检测
    updateCount();

    const observer = new MutationObserver(() => {
      // 仅用于其他 UI 变化检测(如侧边栏显隐),不再触发公式扫描
    });
    observer.observe(document.body, {
      childList: true,
      subtree: true,
      characterData: true
    });

    // Hover 逻辑
    panel.addEventListener("mouseenter", () => {
      clearTimeout(hoverTimer);
      updateCount();
      hoverTimer = setTimeout(() => {
        if (!panel.classList.contains("hover")) {
          panel.classList.add("hover");
        }
      }, 150);
    });

    panel.addEventListener("mouseleave", () => {
      clearTimeout(hoverTimer);
      if (isProcessing) return;
      hoverTimer = setTimeout(() => {
        panel.classList.remove("hover");
      }, 800);
    });

    // 点击处理
    panel.addEventListener("click", (e) => {
      e.stopPropagation();
      if (isProcessing) {
        shouldStop = true;
        progressText.textContent = "Stopping…";
      } else {
        convertFormulas();
      }
    });
  }

  async function waitForCondition(
    checkFn,
    { timeout = 240, interval = 12 } = {},
  ) {
    const startTime = Date.now();
    while (Date.now() - startTime < timeout) {
      const result = checkFn();
      if (result) {
        return result;
      }
      await sleep(interval);
    }
    return null;
  }

  function updateStatus(text, timeout = 0) {
    console.log("[状态]", text);
  }

  function describeElement(element) {
    if (!element) {
      return "[null]";
    }

    const tag = element.tagName ? element.tagName.toLowerCase() : "unknown";
    const id = element.id ? `#${element.id}` : "";
    const className =
      typeof element.className === "string"
        ? element.className
          .trim()
          .split(/\s+/)
          .filter(Boolean)
          .slice(0, 4)
          .join(".")
        : "";
    const classes = className ? `.${className}` : "";
    const role = element.getAttribute?.("role");
    const roleText = role ? `[role="${role}"]` : "";
    const aria =
      element.getAttribute?.("aria-roledescription") ||
      element.getAttribute?.("aria-label") ||
      "";
    const text = (element.textContent || "")
      .replace(/\s+/g, " ")
      .trim()
      .slice(0, 40);
    return `${tag}${id}${classes}${roleText}${aria ? `("${aria}")` : ""}${text ? ` text="${text}"` : ""}`;
  }

  function debugLog(...args) {
    if (!DEBUG_MODE) {
      return;
    }
    console.log("[FormulaDebug]", ...args);
  }

  function isEscaped(text, index) {
    let slashCount = 0;
    for (let i = index - 1; i >= 0 && text[i] === "\\"; i--) {
      slashCount++;
    }
    return slashCount % 2 === 1;
  }

  function findLineBoundaries(text, start, end) {
    const lineStart = text.lastIndexOf("\n", start - 1) + 1;
    const lineEndIndex = text.indexOf("\n", end);
    const lineEnd = lineEndIndex === -1 ? text.length : lineEndIndex;
    const before = text.slice(lineStart, start).trim();
    const after = text.slice(end, lineEnd).trim();

    return {
      lineStart,
      lineEnd,
      standaloneBlock: before === "" && after === "",
    };
  }

  // 公式查找
  function findFormulas(text) {
    const formulas = [];
    const re = /(?:(\$\$)([\s\S]*?)\1)|(?:\\\[([\s\S]*?)\\\])|(?:\\\(([\s\S]*?)\\\))|(?<![\w\\$])(\$)(?!\d)([^\$\n]+?)\5(?![$\w])/g;

    let m;
    while ((m = re.exec(text)) !== null) {
      let content, type, syntax;
      if (m[1]) { content = m[2]; type = "block"; syntax = "$$"; }
      else if (m[3]) { content = m[3]; type = "block"; syntax = "\\[\\]"; }
      else if (m[4]) { content = m[4]; type = "inline"; syntax = "\\(\\)"; }
      else if (m[5]) { content = m[6]; type = "inline"; syntax = "$"; }
      if (!content || !content.trim()) continue;

      const start = m.index;
      const end = m.index + m[0].length;
      const b = findLineBoundaries(text, start, end);
      formulas.push({
        raw: m[0], start, end, type, syntax,
        content: content.trim(),
        lineStart: b.lineStart, lineEnd: b.lineEnd,
        standaloneBlock: type === "block" ? b.standaloneBlock : false,
      });
    }
    return formulas;
  }

  function getTextSegments(editor) {
    const segments = [];
    const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
    let currentOffset = 0;
    let node;

    while ((node = walker.nextNode())) {
      if (!node.textContent) {
        continue;
      }
      segments.push({
        node,
        start: currentOffset,
        end: currentOffset + node.textContent.length,
      });
      currentOffset += node.textContent.length;
    }

    return segments;
  }

  function resolveTextPosition(segments, offset, preferEnd = false) {
    if (!segments.length) {
      return null;
    }

    if (offset <= 0) {
      return { node: segments[0].node, offset: 0 };
    }

    for (const segment of segments) {
      const inSegment = preferEnd
        ? offset > segment.start && offset <= segment.end
        : offset >= segment.start && offset < segment.end;

      if (inSegment) {
        return {
          node: segment.node,
          offset: Math.min(
            segment.node.textContent.length,
            offset - segment.start,
          ),
        };
      }

      if (preferEnd && offset === segment.end) {
        return { node: segment.node, offset: segment.node.textContent.length };
      }
    }

    const lastSegment = segments[segments.length - 1];
    return {
      node: lastSegment.node,
      offset: lastSegment.node.textContent.length,
    };
  }

  function createRangeFromOffsets(editor, start, end) {
    const segments = getTextSegments(editor);
    if (!segments.length) {
      return null;
    }

    const startPos = resolveTextPosition(segments, start, false);
    const endPos = resolveTextPosition(segments, end, true);
    if (!startPos || !endPos) {
      return null;
    }

    const range = document.createRange();
    range.setStart(startPos.node, startPos.offset);
    range.setEnd(endPos.node, endPos.offset);
    return range;
  }

  async function focusEditor(editor) {
    if (!editor) return;
    await ensureFocus(editor);
    await sleep(10);
  }

  async function selectRange(editor, start, end) {
    const range = createRangeFromOffsets(editor, start, end);
    if (!range) {
      return null;
    }

    await focusEditor(editor);
    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
    await sleep(10);
    return range;
  }

  function normalizeFormulaContent(content, { useDisplayStyle = false } = {}) {
    let normalized = content.trim();
    if (
      useDisplayStyle &&
      normalized &&
      !/^\\displaystyle\b/.test(normalized)
    ) {
      normalized = `\\displaystyle ${normalized}`;
    }
    return normalized;
  }

  function isSafeInlineInputElement(element, editor) {
    if (!element || element === editor) {
      return false;
    }

    if (element.closest("#formula-helper")) {
      return false;
    }

    if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") {
      return true;
    }

    if (!element.isContentEditable) {
      return false;
    }

    if (editor.contains(element)) {
      return true;
    }

    return !!element.closest(
      '.notion-overlay-container, [role="dialog"], .notion-modal',
    );
  }

  function selectElementContents(element) {
    if (!element) {
      return false;
    }

    element.focus();

    if (typeof element.select === "function") {
      element.select();
      return true;
    }

    if (element.isContentEditable) {
      const selection = window.getSelection();
      const range = document.createRange();
      range.selectNodeContents(element);
      selection.removeAllRanges();
      selection.addRange(range);
      return true;
    }

    return false;
  }

  function isVisibleElement(element) {
    if (!element) {
      return false;
    }
    const style = window.getComputedStyle(element);
    if (style.display === "none" || style.visibility === "hidden") {
      return false;
    }
    return element.getClientRects().length > 0;
  }

  function findInlineInputCandidate(editor) {
    const localSelectors = 'input, textarea, [contenteditable="true"]';
    const globalSelectors = [
      ".notion-overlay-container input",
      ".notion-overlay-container textarea",
      '.notion-overlay-container [contenteditable="true"]',
      '[role="dialog"] input',
      '[role="dialog"] textarea',
      '[role="dialog"] [contenteditable="true"]',
      ".notion-modal input",
      ".notion-modal textarea",
      '.notion-modal [contenteditable="true"]',
    ];

    const candidates = [
      ...Array.from(editor.querySelectorAll(localSelectors)),
      ...Array.from(document.querySelectorAll(globalSelectors.join(","))),
    ];
    debugLog("inline input candidates", candidates.map(describeElement));
    return (
      candidates.find(
        (candidate) =>
          isVisibleElement(candidate) &&
          isSafeInlineInputElement(candidate, editor),
      ) || null
    );
  }

  async function waitForInlineInput(editor, attempts = 8, interval = 12) {
    for (let i = 0; i < attempts; i++) {
      const activeElement = document.activeElement;
      debugLog(`waitForInlineInput attempt ${i + 1}`, {
        activeElement: describeElement(activeElement),
        editor: describeElement(editor),
      });
      if (isSafeInlineInputElement(activeElement, editor)) {
        return activeElement;
      }

      const candidate = findInlineInputCandidate(editor);
      if (candidate) {
        return candidate;
      }

      await sleep(interval);
    }
    return null;
  }

  function isSimpleTableCellEditor(editor) {
    return (
      !!editor &&
      !!editor.closest("td, th") &&
      editor.matches(
        '.notion-table-cell-text[contenteditable="true"], [data-content-editable-leaf="true"][contenteditable="true"]',
      )
    );
  }

  function getEditableEditors() {
    return Array.from(
      document.querySelectorAll('[contenteditable="true"]'),
    ).filter((editor) => {
      const simpleTableCell = isSimpleTableCellEditor(editor);
      if (editor.closest("#formula-helper")) {
        return false;
      }
      if (editor.closest('.notion-table-view, [role="gridcell"], [role="cell"]')) {
        return false;
      }
      if (!simpleTableCell && editor.closest('.notion-simple-table-block, td, th')) {
        return false;
      }
      if (!editor.textContent || !editor.textContent.trim()) {
        return false;
      }
      if (editor.getClientRects().length === 0) {
        return false;
      }
      return !editor.querySelector('[contenteditable="true"]');
    });
  }

  function collectFormulaTasks(filterFn = () => true) {
    const tasks = [];

    for (const editor of getEditableEditors()) {
      const simpleTableCell = isSimpleTableCellEditor(editor);
      const formulas = findFormulas(editor.textContent).filter(
        (formula) => filterFn(formula) && (!simpleTableCell || formula.type === "inline"),
      );
      if (!formulas.length) {
        continue;
      }
      tasks.push({ editor, formulas });
    }

    return tasks;
  }

  function restoreSelection(range) {
    if (!range) {
      return;
    }
    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
    debugLog("selection restored", {
      text: selection.toString(),
      anchorNode: selection.anchorNode?.textContent?.slice(0, 60) || null,
    });
  }

  async function openInlineEquationEditor(editor, selectedRange = null) {
    const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
    await ensureFocus(editor);
    await sleep(isSimpleTableCellEditor(editor) ? 16 : 8);
    restoreSelection(selectedRange);
    await sleep(isSimpleTableCellEditor(editor) ? 16 : 8);
    debugLog("openInlineEquationEditor shortcut mode", {
      editor: describeElement(editor),
      selectedText: selectedRange?.toString() || "",
      activeElement: describeElement(document.activeElement),
    });
    await simulateShortcut(isMac ? "Meta+Shift+E" : "Ctrl+Shift+E", document.activeElement || editor);
    await sleep(isSimpleTableCellEditor(editor) ? 20 : 10);
    return true;
  }

  // 文本输入模拟
  async function simulateTyping(text, quick = false) {
    const activeElement = document.activeElement;
    if (activeElement) {
      if (quick) {
        // 快速模式:直接插入整段文本 (模拟粘贴)
        const inputEvent = new InputEvent("beforeinput", {
          bubbles: true,
          cancelable: true,
          inputType: "insertText",
          data: text,
        });
        activeElement.dispatchEvent(inputEvent);

        document.execCommand("insertText", false, text);

        const inputEventAfter = new InputEvent("input", {
          bubbles: true,
          cancelable: false,
          inputType: "insertText",
          data: text,
        });
        activeElement.dispatchEvent(inputEventAfter);
      } else {
        // 普通模式:逐字输入 (用于触发命令菜单等)
        for (const char of text) {
          const inputEvent = new InputEvent("beforeinput", {
            bubbles: true,
            cancelable: true,
            inputType: "insertText",
            data: char,
          });
          activeElement.dispatchEvent(inputEvent);

          document.execCommand("insertText", false, char);

          const inputEventAfter = new InputEvent("input", {
            bubbles: true,
            cancelable: false,
            inputType: "insertText",
            data: char,
          });
          activeElement.dispatchEvent(inputEventAfter);

          await sleep(5);
        }
      }
    }
  }

  // 单个按键模拟
  async function simulateKey(keyName, target = null) {
    const keyInfo = getKeyCode(keyName);
    const eventTarget =
      target || document.activeElement || document.body || document;
    const keydownEvent = new KeyboardEvent("keydown", {
      key: keyInfo.key,
      code: keyInfo.code,
      keyCode: keyInfo.keyCode,
      bubbles: true,
    });
    const keyupEvent = new KeyboardEvent("keyup", {
      key: keyInfo.key,
      code: keyInfo.code,
      keyCode: keyInfo.keyCode,
      bubbles: true,
    });

    eventTarget.dispatchEvent(keydownEvent);
    await sleep(10);
    eventTarget.dispatchEvent(keyupEvent);
  }

  // 聚焦到目标元素,避免行顺序错位
  async function ensureFocus(element) {
    if (!element) return;

    // 表格单元格需要先激活 td 父元素,才能让内部编辑器真正进入编辑状态
    if (isSimpleTableCellEditor(element)) {
      const td = element.closest("td, th");
      if (td && document.activeElement !== element) {
        await simulateClick(td);
        await sleep(40);
      }
    }

    element.focus();
    await sleep(8);
    if (document.activeElement !== element) {
      await simulateClick(element);
    }
  }

  // 优化的公式转换
  async function convertFormula(editor, formulaObj) {
    try {
      let { type, content, start, end, lineStart, lineEnd, standaloneBlock } =
        formulaObj;
      let renderMode = type;
      let useDisplayStyle = false;

      debugLog("convertFormula start", {
        editor: describeElement(editor),
        type,
        syntax: formulaObj.syntax,
        start,
        end,
        content,
      });

      if (type === "block" && !standaloneBlock) {
        console.log("检测到非独立行块公式,自动降级为行内公式");
        renderMode = "inline";
        useDisplayStyle = true;
      }

      const rangeStart = renderMode === "block" ? lineStart : start;
      const rangeEnd = renderMode === "block" ? lineEnd : end;
      const range = await selectRange(editor, rangeStart, rangeEnd);
      if (!range) {
        console.warn("未找到匹配的文本范围");
        return false;
      }

      debugLog("convertFormula range selected", {
        renderMode,
        selectedText: range.toString(),
        activeElement: describeElement(document.activeElement),
      });

      const normalizedContent = normalizeFormulaContent(content, {
        useDisplayStyle,
      });

      if (renderMode === "block") {
        // 块公式:清空整行,再创建 block equation
        const originalText = editor.textContent;
        document.execCommand("delete");
        await waitForCondition(
          () =>
            editor.textContent !== originalText ||
            document.activeElement !== editor,
          {
            timeout: 120,
            interval: 10,
          },
        );

        // 重新焦点聚焦,确保光标留在当前块
        await ensureFocus(editor);
        await sleep(16);

        // 输入 /block equation 命令
        await simulateTyping("/block equation", true);
        await sleep(40);

        // 优先按 Enter 选择命令
        await simulateKey("Enter");
        const blockInput = await waitForInlineInput(editor, 10, 15);
        if (!blockInput) {
          updateStatus("块公式输入框未打开,已跳过当前公式", 4000);
          return false;
        }

        // 输入公式内容
        if (!selectElementContents(blockInput)) {
          updateStatus("无法安全选中块公式输入框,已跳过当前公式", 4000);
          return false;
        }
        await simulateTyping(normalizedContent, true);
        await sleep(16);

        // 按 Enter 完成编辑(而非 Escape),避免行序错乱
        await simulateKey("Enter", blockInput);
        await waitForCondition(() => document.activeElement !== blockInput, {
          timeout: 140,
          interval: 10,
        });

        // 再次焦点回到原编辑区域,稳定行顺序
        await ensureFocus(editor);
        await sleep(16);
      } else {
        const opened = await openInlineEquationEditor(editor, range);
        if (!opened) {
          return false;
        }

        // 仅在焦点进入独立公式输入框时继续,避免误伤正文
        const inlineInput = await waitForInlineInput(
          editor,
          isSimpleTableCellEditor(editor) ? 30 : 8,
          isSimpleTableCellEditor(editor) ? 25 : 12,
        );
        if (!inlineInput) {
          updateStatus(
            "行内公式输入框未打开,已跳过当前公式以避免误替换",
            4000,
          );
          return false;
        }

        if (!selectElementContents(inlineInput)) {
          updateStatus("无法安全选中行内公式输入框,已跳过当前公式", 4000);
          return false;
        }

        debugLog("inline input ready", {
          inlineInput: describeElement(inlineInput),
          activeElement: describeElement(document.activeElement),
          selectedText: window.getSelection()?.toString() || "",
        });

        await simulateTyping(normalizedContent, true);
        await sleep(10);

        debugLog("inline input typed", {
          activeElement: describeElement(document.activeElement),
          inlineInputText: inlineInput.value || inlineInput.textContent || "",
        });

        // 按 Enter 确认
        await simulateKey("Enter", inlineInput);
        await waitForCondition(() => document.activeElement !== inlineInput, {
          timeout: 140,
          interval: 10,
        });
        debugLog("inline input confirmed", {
          activeElement: describeElement(document.activeElement),
          editorText: editor.textContent,
        });
      }

      return renderMode;
    } catch (error) {
      console.error("转换公式时出错:", error);
      updateStatus(`错误: ${error.message}`);
      throw error;
    }
  }

  // ---------- 核心转换 ----------
  async function convertFormulas() {
    if (isProcessing) return;
    isProcessing = true; shouldStop = false;
    panel.classList.add("processing");
    try {
      // 扫描并获取总数
      const initialTasks = collectFormulaTasks();
      let totalFormulas = initialTasks.reduce((sum, item) => sum + item.formulas.length, 0);
      if (totalFormulas === 0) {
        progressText.textContent = "0";
        progressBar.style.width = "0%";
        return;
      }

      let formulaCount = 0;
      updateProgress(0, totalFormulas, "scanning");

      const phases = [
        { name: "Inline", getTasks: () => initialTasks.map(({ editor, formulas }) => ({ editor, formulas: formulas.filter(f => f.type === "inline") })).filter(item => item.formulas.length) },
        { name: "Block", getTasks: () => collectFormulaTasks(f => f.type === "block") }
      ];

      for (const phase of phases) {
        if (shouldStop) break;
        const phaseTasks = phase.getTasks();
        for (const { editor, formulas } of phaseTasks.slice().reverse()) {
          if (shouldStop) break;
          for (const formulaObj of formulas.slice().reverse()) {
            if (shouldStop) break;
            const result = await convertFormula(editor, formulaObj);
            if (result) {
              formulaCount++;
              totalConverted++;
              GM_setValue("totalConverted", totalConverted);
              updateProgress(formulaCount, totalFormulas, `${formulaCount}/${totalFormulas}`);
            }
          }
        }
      }

      updateProgress(totalFormulas, totalFormulas, shouldStop ? "Stopped" : "Done");
      refreshTotalMenu();
    } finally {
      isProcessing = false;
      panel.classList.remove("processing");
      if (!shouldStop) {
        setTimeout(() => {
          panel.classList.remove("hover");
        }, 1200);
      }
    }
  }

  // 点击事件模拟
  async function simulateClick(element) {
    const rect = element.getBoundingClientRect();
    const centerX = rect.left + rect.width / 2;
    const centerY = rect.top + rect.height / 2;

    const events = [
      new MouseEvent("mousemove", {
        bubbles: true,
        clientX: centerX,
        clientY: centerY,
      }),
      new MouseEvent("mouseenter", {
        bubbles: true,
        clientX: centerX,
        clientY: centerY,
      }),
      new MouseEvent("mousedown", {
        bubbles: true,
        clientX: centerX,
        clientY: centerY,
      }),
      new MouseEvent("mouseup", {
        bubbles: true,
        clientX: centerX,
        clientY: centerY,
      }),
      new MouseEvent("click", {
        bubbles: true,
        clientX: centerX,
        clientY: centerY,
      }),
    ];

    for (const event of events) {
      element.dispatchEvent(event);
      await sleep(8);
    }
  }

  // 键盘快捷键模拟
  async function simulateShortcut(keyCombination, target = null) {
    const keys = keyCombination.split("+");
    const keyEvents = [];
    const eventTarget =
      target || document.activeElement || document.body || document;

    // 创建键盘事件
    for (const key of keys) {
      const keyCode = getKeyCode(key);

      keyEvents.push({
        key: keyCode.key,
        code: keyCode.code,
        keyCode: keyCode.keyCode,
        ctrlKey: keys.includes("Ctrl"),
        shiftKey: keys.includes("Shift"),
        altKey: keys.includes("Alt"),
        metaKey: keys.includes("Meta"),
        bubbles: true,
      });
    }

    // 先按下所有修饰键
    for (let i = 0; i < keyEvents.length - 1; i++) {
      const event = keyEvents[i];
      eventTarget.dispatchEvent(new KeyboardEvent("keydown", event));
    }

    // 按下最终按键
    const finalEvent = keyEvents[keyEvents.length - 1];
    eventTarget.dispatchEvent(new KeyboardEvent("keydown", finalEvent));
    eventTarget.dispatchEvent(new KeyboardEvent("keyup", finalEvent));

    // 释放所有修饰键
    for (let i = keyEvents.length - 2; i >= 0; i--) {
      const event = keyEvents[i];
      eventTarget.dispatchEvent(new KeyboardEvent("keyup", event));
    }

    await sleep(10);
  }

  // 获取键盘按键信息
  function getKeyCode(key) {
    const keyMap = {
      ctrl: { key: "Control", code: "ControlLeft", keyCode: 17 },
      shift: { key: "Shift", code: "ShiftLeft", keyCode: 16 },
      alt: { key: "Alt", code: "AltLeft", keyCode: 18 },
      meta: { key: "Meta", code: "MetaLeft", keyCode: 91 },
      enter: { key: "Enter", code: "Enter", keyCode: 13 },
      escape: { key: "Escape", code: "Escape", keyCode: 27 },
      e: { key: "e", code: "KeyE", keyCode: 69 },
    };

    return (
      keyMap[key.toLowerCase()] || {
        key: key,
        code: `Key${key.toUpperCase()}`,
        keyCode: key.toUpperCase().charCodeAt(0),
      }
    );
  }

  // 初始化
  createPanel();

  // ---------- 注册菜单 ----------
  const speedOrder = ["slow", "normal", "fast", "custom"];
  const menuIds = { toggle: null, speed: null };

  function refreshToggleMenu() {
    if (menuIds.toggle !== null) GM_unregisterMenuCommand(menuIds.toggle);
    menuIds.toggle = GM_registerMenuCommand(`👀 悬浮按钮: ${panelVisible ? "隐藏" : "显示"}`, () => {
      panelVisible = !panelVisible;
      GM_setValue("panelVisible", panelVisible);
      const helper = document.getElementById("formula-helper");
      if (helper) helper.style.display = panelVisible ? "" : "none";
      refreshToggleMenu();
    });
  }

  function refreshSpeedMenu() {
    if (menuIds.speed !== null) GM_unregisterMenuCommand(menuIds.speed);
    const speed = GM_getValue("speed", "normal");
    const label = speed === "custom"
      ? `自定义(${GM_getValue("customDelay", 30)}ms)`
      : SPEED_PRESETS[speed].label;
    menuIds.speed = GM_registerMenuCommand(`⚡ 转换速度: ${label}`, () => {
      const cur = GM_getValue("speed", "normal");
      const idx = speedOrder.indexOf(cur);
      const next = speedOrder[(idx + 1) % speedOrder.length];
      if (next === "custom") {
        const input = prompt("请输入自定义延迟(毫秒):", GM_getValue("customDelay", 30));
        const val = parseInt(input, 10);
        if (!isNaN(val) && val >= 0) GM_setValue("customDelay", val);
        else return;
      }
      GM_setValue("speed", next);
      refreshSpeedMenu();
    });
  }

  GM_registerMenuCommand("🔄 执行公式转换", () => { if (!isProcessing) convertFormulas(); });

  refreshToggleMenu();
  refreshSpeedMenu();

  GM_registerMenuCommand("🔗 反馈问题", () => window.open("https://github.com/skyance/Notion-Formula-Auto-Conversion-Tool/issues"));

  refreshTotalMenu();

  // ===== 检测 Notion 侧边栏/设置面板/对话框/其他页面,自动隐藏按钮 =====
  function shouldHide() {
    return (
      !document.querySelector('.notion-topbar-share-menu') ||
      document.querySelector('.chat_sidebar') ||
      document.querySelector('.notion-space-settings') ||
      document.querySelector('.notion-dialog')
    );
  }

  const sidebarObserver = new MutationObserver(() => {
    const helper = document.getElementById('formula-helper');
    if (!helper) return;

    if (!panelVisible) {
      if (helper.style.display !== 'none') {
        helper.style.display = 'none';
      }
      return;
    }

    if (shouldHide()) {
      if (helper.style.display !== 'none') {
        helper.style.display = 'none';
      }
    } else {
      if (helper.style.display !== '') {
        helper.style.display = '';
      }
    }
  });

  sidebarObserver.observe(document.body, {
    childList: true,
    subtree: true,
    attributes: true,
    attributeFilter: ['class']
  });

  // 监听ESC键取消
  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape" && isProcessing) {
      shouldStop = true;
      progressText.textContent = "Stopping…";
    }
  });

  console.log("Formula hover-to-convert tool loaded");
})();