MathToNotion

Automatically replaces $...$ and $$...$$ with Notion inline equations

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         MathToNotion
// @name:zh-CN   Notion 方程转换助手
// @namespace    http://tampermonkey.net/
// @version      1.9
// @description  Automatically replaces $...$ and $$...$$ with Notion inline equations
// @description:zh-CN 自动将 $...$ 和 $$...$$ 转换为 Notion 内联方程
// @author       Ekalos
// @match        https://*.notion.so/*
// @match        https://*.notion.com/*
// @noframes
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // 1. 定义语言包
    const i18n = {
        zh: {
            menu: "🚀 开始全能转换 ($/$$/多行)",
            finish: "✨ 所有公式已处理完毕!",
            hudTitle: "Notion 公式全能转换",
            guide: (isDbl) => `检测到 ${isDbl ? '多行($$)' : '单行($)'} 公式<br/>请按 <b>Ctrl+Shift+E</b>`,
            skipWarn: "公式跨越了区块,已跳过。"
        },
        en: {
            menu: "🚀 Start Universal Conversion ($/$$/Multi)",
            finish: "✨ All equations processed!",
            hudTitle: "Notion Equation Guide",
            guide: (isDbl) => `Detected ${isDbl ? 'Multi-line($$)' : 'Inline($)'} formula<br/>Press <b>Ctrl+Shift+E</b>`,
            skipWarn: "Equation spans across blocks, skipping."
        }
    };

    // 2. 自动获取当前语言
    const lang = navigator.language.startsWith('zh') ? i18n.zh : i18n.en;

    const HUD_Z = 2147483646;
    const sleep = ms => new Promise(r => setTimeout(r, ms));

    const isEditable = el => !!el && (el.getAttribute("contenteditable") === "true" || el.isContentEditable);
    const isCodeCtx = el => el.closest?.(".notion-code-block, pre, code");
    const isMathAlready = el => el.closest?.(".notion-equation, .katex, .notion-text-equation-token");

    let guideActive = false;
    let observer = null;
    let trackingRAF = null; // 用于存储动画帧ID

    function findNextEquationRange() {
        const rootEl = document.querySelector(".notion-page-content") || document.body;
        const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, {
            acceptNode(n) {
                if (!n.parentElement || !isEditable(n.parentElement)) return NodeFilter.FILTER_REJECT;
                if (isCodeCtx(n.parentElement) || isMathAlready(n.parentElement)) return NodeFilter.FILTER_REJECT;
                return NodeFilter.FILTER_ACCEPT;
            }
        });

        let node;
        const nodes = [];
        while (node = walker.nextNode()) nodes.push(node);

        for (let i = 0; i < nodes.length; i++) {
            const tn = nodes[i];
            const text = tn.nodeValue;

            let match = text.match(/\$\$?|\\\$/g);
            if (!match) continue;

            for (let m of match) {
                if (m === '\\$') continue;

                const isDbl = (m === '$$');
                const startOffset = text.indexOf(m);

                const result = findClosingTag(nodes, i, startOffset + m.length, isDbl);
                if (result) {
                    return {
                        startNode: tn,
                        startOffset: startOffset,
                        endNode: result.node,
                        endOffset: result.offset,
                        isDbl: isDbl
                    };
                }
            }
        }
        return null;
    }

    function findClosingTag(nodes, startIdx, charOffset, isDbl) {
        const target = isDbl ? '$$' : '$';

        for (let i = startIdx; i < nodes.length; i++) {
            const text = nodes[i].nodeValue;
            const searchFrom = (i === startIdx) ? charOffset : 0;
            let idx = text.indexOf(target, searchFrom);

            while (idx !== -1) {
                if (idx > 0 && text[idx-1] === '\\') {
                    idx = text.indexOf(target, idx + 1);
                    continue;
                }
                return { node: nodes[i], offset: idx + target.length };
            }

            if (i - startIdx > 20) return null;
        }
        return null;
    }

    async function nextStep() {
        if (!guideActive) return;

        const rangeInfo = findNextEquationRange();
        if (!rangeInfo) {
            stopGuide();
            alert(lang.finish);
            return;
        }

        const sel = window.getSelection();
        const r = document.createRange();

        try {
            r.setStart(rangeInfo.startNode, rangeInfo.startOffset);
            r.setEnd(rangeInfo.endNode, rangeInfo.endOffset);

            if (rangeInfo.startNode.parentElement.closest('[contenteditable]') !==
                rangeInfo.endNode.parentElement.closest('[contenteditable]')) {
                console.warn(lang.skipWarn);
                return;
            }

            sel.removeAllRanges();
            sel.addRange(r);

            let rawText = r.toString();
            let innerText = rangeInfo.isDbl ? rawText.slice(2, -2) : rawText.slice(1, -1);

            rangeInfo.startNode.parentElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
            await sleep(50);

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

            await sleep(30);
            const postSel = window.getSelection();
            const endNode = postSel.focusNode;
            const endOffset = postSel.focusOffset;

            const finalRange = document.createRange();
            if (endNode.nodeType === 3) {
                finalRange.setStart(endNode, Math.max(0, endOffset - innerText.length));
                finalRange.setEnd(endNode, endOffset);
            } else {
                finalRange.setStart(endNode, endOffset);
            }

            postSel.removeAllRanges();
            postSel.addRange(finalRange);

            startTrackingHighlight(); // 启动动态追踪,替代原来的静态高亮
            updateHUD(rangeInfo.isDbl);
            armAutoAdvance();

        } catch (e) {
            console.error("Selection error:", e);
            stopGuide();
        }
    }

    function armAutoAdvance() {
        if (observer) observer.disconnect();
        let hasAdvanced = false;

        observer = new MutationObserver(() => {
            if (hasAdvanced) return;

            const dlg = document.querySelector('div[role="dialog"]');
            if (dlg) {
                const btn = Array.from(dlg.querySelectorAll('div[role="button"]'))
                    .find(b => {
                        const t = b.textContent.trim().toLowerCase();
                        return t === 'done' || t === '完成';
                    });
                if (btn) {
                    hasAdvanced = true;
                    setTimeout(() => {
                        btn.click();
                        setTimeout(nextStep, 400);
                    }, 50);
                }
            }

            const sel = window.getSelection();
            if (sel.anchorNode?.parentElement?.closest(".notion-equation, .katex")) {
                hasAdvanced = true;
                setTimeout(nextStep, 350);
            }
        });

        observer.observe(document.body, { childList: true, subtree: true, characterData: true });
    }

    // --- 界面辅助(新增动态追踪逻辑) ---
    function startTrackingHighlight() {
        cancelAnimationFrame(trackingRAF);

        function update() {
            let box = document.getElementById("eq-box");
            if (!guideActive) {
                if (box) box.style.display = "none";
                return;
            }

            const sel = window.getSelection();
            if (sel.rangeCount > 0) {
                const range = sel.getRangeAt(0);
                const r = range.getBoundingClientRect();

                if (!box) {
                    box = document.createElement("div");
                    box.id = "eq-box";
                    Object.assign(box.style, {
                        position: "fixed", border: "2px solid #3fb950", borderRadius: "4px",
                        background: "rgba(63, 185, 80, 0.15)", zIndex: String(HUD_Z), pointerEvents: "none",
                        transition: "left 0.05s linear, top 0.05s linear" // 添加一点平滑过渡
                    });
                    document.documentElement.appendChild(box);
                }

                if (r.width > 0 && r.height > 0) {
                    Object.assign(box.style, {
                        left: `${r.left - 2}px`, top: `${r.top - 2}px`,
                        width: `${r.width + 4}px`, height: `${r.height + 4}px`, display: "block"
                    });
                } else {
                    box.style.display = "none";
                }
            }
            // 只要向导还在活动,就以 60FPS 频率锁定坐标
            trackingRAF = requestAnimationFrame(update);
        }

        update();
    }

    function updateHUD(isDbl) {
        let hud = document.getElementById("eq-hud");
        if (!hud) {
            hud = document.createElement("div");
            hud.id = "eq-hud";
            Object.assign(hud.style, {
                position: "fixed", top: "20px", right: "20px", background: "#1a1a1a",
                color: "#fff", padding: "14px", borderRadius: "10px", zIndex: String(HUD_Z),
                fontSize: "13px", boxShadow: "0 8px 24px rgba(0,0,0,0.7)", border: "1px solid #333"
            });
            document.documentElement.appendChild(hud);
        }
        hud.style.display = "block";
        hud.innerHTML = `<div style="color:#3fb950;font-weight:bold;margin-bottom:6px">${lang.hudTitle}</div>${lang.guide(isDbl)}`;
    }

    function stopGuide() {
        guideActive = false;
        if (observer) observer.disconnect();
        const h = document.getElementById("eq-hud"); if (h) h.style.display = "none";
        // 框会因为 guideActive 为 false 在下一帧自动隐藏
    }

    GM_registerMenuCommand(lang.menu, () => {
        guideActive = true;
        nextStep();
    });

    window.addEventListener("keydown", e => {
        if (e.key === "Escape") stopGuide();
    });
})();