MathToNotion

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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();
    });
})();