Automatically replaces $...$ and $$...$$ with Notion inline equations
// ==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();
});
})();