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