您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add a notes sidebar with a rich text editor to Marumori.io lesson pages
当前为
// ==UserScript== // @name Marumori.io Notes Sidebar with Rich Text Editor // @namespace http://tampermonkey.net/ // @version 2.0 // @description Add a notes sidebar with a rich text editor to Marumori.io lesson pages // @author Matskye // @icon https://www.google.com/s2/favicons?sz=64&domain=marumori.io // @match https://marumori.io/* // @grant none // @license MIT // ==/UserScript== (() => { 'use strict'; /* ========= CONFIG ========= */ const DB_NAME = 'MarumoriNotesDB'; const STORE_NAME = 'notes'; const DB_VERSION = 1; const LOCAL_KEY_OPEN = 'mm_notes_sidebar_open'; const LOCAL_KEY_WIDTH = 'mm_notes_sidebar_width'; const SIDEBAR_ID = 'mm-notes-sidebar'; const TOGGLE_ID = 'mm-notes-toggle'; const EDITOR_ID = 'mm-notes-editor'; const SAVED_BADGE_ID = 'mm-notes-saved-badge'; const DRAGGER_ID = 'mm-notes-resize-handle'; const DEFAULT_WIDTH = 36; const MIN_WIDTH = 24; const MAX_WIDTH = 50; const LESSON_WRAPPER_SEL = 'main.lesson-wrapper'; const LESSON_PAGE_SENTINEL = '.lesson-background-wrapper'; const LESSON_TAG_SEL = '.tag.default'; const QUILL_JS = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/quill.min.js'; const QUILL_CSS = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/quill.snow.css'; const TURNDOWN_JS = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/turndown.browser.umd.js'; /* ========= HELPERS ========= */ const sleep = ms => new Promise(r => setTimeout(r, ms)); const getLocalJSON = (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } }; const setLocalJSON = (k, v) => localStorage.setItem(k, JSON.stringify(v)); const toFileDownload = (name, content, mime) => { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }; const loadScript = src => new Promise((res, rej) => { if (document.querySelector(`script[src="${src}"]`)) return res(); const s = document.createElement('script'); s.src = src; s.onload = res; s.onerror = rej; document.head.appendChild(s); }); const loadStyle = href => new Promise((res, rej) => { if (document.querySelector(`link[href="${href}"]`)) return res(); const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href; l.onload = res; l.onerror = rej; document.head.appendChild(l); }); const htmlToPlainText = html => { const tmp = document.createElement('div'); tmp.innerHTML = html; return tmp.textContent || tmp.innerText || ''; }; /* ========= INDEXED DB ========= */ const NotesDB = (() => { let db; const open = () => new Promise((res, rej) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onerror = () => rej(req.error); req.onupgradeneeded = () => { const _db = req.result; if (!_db.objectStoreNames.contains(STORE_NAME)) _db.createObjectStore(STORE_NAME, { keyPath: 'lesson' }); }; req.onsuccess = () => { db = req.result; res(db); }; }); const ensure = async () => db || open(); const get = async k => { const _db = await ensure(); return new Promise((res, rej) => { const tx = _db.transaction(STORE_NAME, 'readonly'); const r = tx.objectStore(STORE_NAME).get(k); r.onsuccess = () => res(r.result || null); r.onerror = () => rej(r.error); }); }; const put = async n => { const _db = await ensure(); return new Promise((res, rej) => { const tx = _db.transaction(STORE_NAME, 'readwrite'); const r = tx.objectStore(STORE_NAME).put(n); r.onsuccess = () => res(); r.onerror = () => rej(r.error); }); }; const getAll = async () => { const _db = await ensure(); return new Promise((res, rej) => { const tx = _db.transaction(STORE_NAME, 'readonly'); const r = tx.objectStore(STORE_NAME).getAll(); r.onsuccess = () => res(r.result || []); r.onerror = () => rej(r.error); }); }; return { open, get, put, getAll }; })(); /* ========= DETECTION ========= */ const isLessonPage = () => document.querySelector(LESSON_PAGE_SENTINEL); const currentLessonId = () => { const tag = document.querySelector(LESSON_TAG_SEL); if (tag) { const text = tag.textContent.trim(); const m = text.match(/#\d+/); if (m) return m[0]; } const u = location.pathname.match(/lesson\/(\d+)/i); return u ? `#${u[1]}` : null; }; /* ========= STYLING ========= */ function injectStyles() { if (document.getElementById('mm-style')) return; const s = document.createElement('style'); s.id = 'mm-style'; s.textContent = ` :root { --mm-bg: #171a1c; --mm-panel: #1f2326; --mm-border: #2b3236; --mm-fg: #eef1f3; --mm-btn: #262c30; --mm-btn-hover: #30383d; --mm-shadow: 0 10px 28px rgba(0,0,0,0.35); --mm-radius: 12px; } #${SIDEBAR_ID}{ position:fixed; top:0; right:0; height:100vh; width:36vw; background:var(--mm-bg); color:var(--mm-fg); border-left:1px solid var(--mm-border); transform:translateX(100%); transition:transform .3s ease; z-index:10000; display:flex; flex-direction:column; box-shadow:var(--mm-shadow); } #${SIDEBAR_ID}.open{ transform:translateX(0); } #${TOGGLE_ID}{ position:fixed; top:12px; right:12px; background:var(--mm-btn); color:var(--mm-fg); border:1px solid var(--mm-border); border-radius:var(--mm-radius); padding:6px 10px; cursor:pointer; box-shadow:var(--mm-shadow); z-index:10001; } #${TOGGLE_ID}:hover{ background:var(--mm-btn-hover); } #${DRAGGER_ID}{ position:absolute; left:-8px; top:0; width:8px; height:100%; cursor:ew-resize; } .mm-header{ display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:var(--mm-panel); border-bottom:1px solid var(--mm-border); } .mm-btn, .mm-select{ background:var(--mm-btn); color:var(--mm-fg); border:1px solid var(--mm-border); border-radius:8px; padding:6px 8px; cursor:pointer; font-size:13px; } .mm-btn:hover{ background:var(--mm-btn-hover); } .mm-controls{ display:flex; gap:8px; align-items:center; } #${SAVED_BADGE_ID}{ font-size:12px; opacity:0; transition:opacity .2s; color:#ccc; } #${SAVED_BADGE_ID}.visible{ opacity:1; } .ql-toolbar.ql-snow{ border:none!important; border-bottom:1px solid var(--mm-border)!important; background:var(--mm-panel)!important; padding:6px!important; display:flex; flex-wrap:wrap; gap:6px; } .ql-container.ql-snow{ border:none!important; flex:1; } .ql-editor{ color:var(--mm-fg); min-height:60vh; } .ql-editor a{ color:#8ab4f8; text-decoration:underline; } #${EDITOR_ID} { flex: 1; overflow-y: auto; min-height: 60vh; display: flex; flex-direction: column; } ${LESSON_WRAPPER_SEL} { transition: margin-right .3s ease; } `; document.head.appendChild(s); } function createToggle() { if (document.getElementById(TOGGLE_ID)) return; const b = document.createElement('button'); b.id = TOGGLE_ID; b.textContent = '📝'; b.title = 'Toggle notes (Alt+N)'; b.addEventListener('click', () => toggleSidebar()); document.body.appendChild(b); } function setupResizer(sidebar) { const handle = sidebar.querySelector(`#${DRAGGER_ID}`); let startX = 0, startW = DEFAULT_WIDTH; const move = e => { const dx = startX - e.clientX; const vw = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startW + (dx / innerWidth) * 100)); sidebar.style.width = `${vw}vw`; const wrap = document.querySelector(LESSON_WRAPPER_SEL); if (wrap && sidebar.classList.contains('open')) wrap.style.marginRight = `${vw}vw`; }; const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); setLocalJSON(LOCAL_KEY_WIDTH, parseFloat(sidebar.style.width)); }; handle.addEventListener('mousedown', e => { startX = e.clientX; startW = parseFloat(sidebar.style.width) || DEFAULT_WIDTH; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }); } /* ========= SIDEBAR & EDITOR ========= */ let quill, currentLesson = null; const savingState = { pending: false, timer: null }; async function buildSidebar() { injectStyles(); createToggle(); let sb = document.getElementById(SIDEBAR_ID); if (!sb) { sb = document.createElement('aside'); sb.id = SIDEBAR_ID; sb.style.width = `${getLocalJSON(LOCAL_KEY_WIDTH, DEFAULT_WIDTH)}vw`; sb.innerHTML = ` <div id="${DRAGGER_ID}"></div> <div class="mm-header"> <strong>Lesson Notes</strong> <span id="${SAVED_BADGE_ID}">Saved</span> </div> <div id="${EDITOR_ID}"></div> <div class="mm-header"> <div class="mm-controls"> <select class="mm-select" id="mm-scope"><option value="current">Current</option><option value="all">All</option></select> <select class="mm-select" id="mm-format"><option value="json">JSON</option><option value="markdown">Markdown</option><option value="text">Text</option></select> <button class="mm-btn" id="mm-export">Export</button> <button class="mm-btn" id="mm-import">Import</button> </div> </div>`; document.body.appendChild(sb); setupResizer(sb); setSidebarOpen(!!getLocalJSON(LOCAL_KEY_OPEN, false), true); setupExportImport(); } await Promise.all([loadStyle(QUILL_CSS), loadScript(QUILL_JS), loadScript(TURNDOWN_JS)]); if (!quill) { const toolbarOptions = [ [{ header: [1, 2, 3, false] }, { size: [] }], ['bold', 'italic', 'underline', 'strike', 'blockquote', 'code'], [{ list: 'ordered' }, { list: 'bullet' }, { indent: '-1' }, { indent: '+1' }], [{ align: [] }], ['link'], ['clean'] ]; quill = new window.Quill(`#${EDITOR_ID}`, { theme: 'snow', modules: { toolbar: { container: toolbarOptions } } }); quill.on('text-change', handleEditorChange); } } /* ========= EXPORT / IMPORT ========= */ function setupExportImport() { document.getElementById('mm-export').onclick = async () => { const scope = document.getElementById('mm-scope').value; const fmt = document.getElementById('mm-format').value; if (scope === 'current' && currentLesson) { const n = await getOrCreate(currentLesson); const live = quill ? { ...n, delta: quill.getContents(), html: quill.root.innerHTML, updatedAt: new Date().toISOString() } : n; exportNotes([live], fmt, `note_${currentLesson.replace(/[^\w#-]+/g, '_')}.${fmt}`); } else { const all = await NotesDB.getAll(); exportNotes(all, fmt, `all_notes.${fmt}`); } }; document.getElementById('mm-import').onclick = () => { const i = document.createElement('input'); i.type = 'file'; i.accept = '.json'; i.onchange = async e => { const f = e.target.files[0]; if (!f) return; const arr = JSON.parse(await f.text()); const notes = Array.isArray(arr) ? arr : [arr]; for (const n of notes) await NotesDB.put(n); location.reload(); }; i.click(); }; } async function exportNotes(notes, fmt, name) { if (notes.length === 1 && currentLesson && quill) { notes[0] = { ...notes[0], delta: quill.getContents(), html: quill.root.innerHTML }; } if (fmt === 'json') { return toFileDownload(name, JSON.stringify(notes, null, 2), 'application/json'); } if (fmt === 'markdown') { const td = window.TurndownService ? new window.TurndownService() : null; const md = notes .map( n => `## ${n.lesson}\n` + `_Last updated: ${n.updatedAt}_\n\n` + `${td ? td.turndown(n.html || '') : htmlToPlainText(n.html || '')}` ) .join('\n---\n'); return toFileDownload(name, md, 'text/markdown'); } const txt = notes .map(n => `${n.lesson}\n${htmlToPlainText(n.html || '')}`) .join('\n---\n'); toFileDownload(name, txt, 'text/plain'); } /* ========= SAVING ========= */ const handleEditorChange = async () => { if (!currentLesson || !quill || savingState.pending) return; savingState.pending = true; showBadge('Saving…'); try { await NotesDB.put({ lesson: currentLesson, delta: quill.getContents(), html: quill.root.innerHTML, updatedAt: new Date().toISOString() }); showBadge('Saved', true); } catch (e) { console.error(e); } savingState.pending = false; }; const showBadge = (t, flash) => { const b = document.getElementById(SAVED_BADGE_ID); if (!b) return; b.textContent = t; b.classList.add('visible'); clearTimeout(savingState.timer); savingState.timer = setTimeout(() => b.classList.remove('visible'), flash ? 1200 : 0); }; window.addEventListener('beforeunload', async () => { if (currentLesson && quill) await NotesDB.put({ lesson: currentLesson, delta: quill.getContents(), html: quill.root.innerHTML, updatedAt: new Date().toISOString() }); }); async function getOrCreate(lesson) { const ex = await NotesDB.get(lesson); if (ex) return ex; const n = { lesson, delta: [], html: '', updatedAt: new Date().toISOString() }; await NotesDB.put(n); return n; } async function loadLesson(lesson) { currentLesson = lesson; await buildSidebar(); const n = await getOrCreate(lesson); quill.setContents(n.delta || []); } /* ========= SIDEBAR TOGGLE ========= */ function setSidebarOpen(o) { const s = document.getElementById(SIDEBAR_ID); const w = document.querySelector(LESSON_WRAPPER_SEL); if (!s) return; s.classList.toggle('open', o); setLocalJSON(LOCAL_KEY_OPEN, !!o); const vw = parseFloat(s.style.width) || DEFAULT_WIDTH; if (w) w.style.marginRight = o ? `${vw}vw` : ''; } const toggleSidebar = f => setSidebarOpen(f ?? !document.getElementById(SIDEBAR_ID)?.classList.contains('open')); /* ========= OBSERVER ========= */ let obs; function startObserver() { if (obs) return; obs = new MutationObserver(() => checkRoute()); obs.observe(document.documentElement, { childList: true, subtree: true }); } async function checkRoute() { // if not on a lesson page, clean up if (!isLessonPage()) { document.getElementById(SIDEBAR_ID)?.remove(); document.getElementById(TOGGLE_ID)?.remove(); return; } // ignore "mark words known" page const markBtn = Array.from(document.querySelectorAll('span')).find(e => e.textContent?.includes('I want to mark words as known') ); if (markBtn) { document.getElementById(SIDEBAR_ID)?.remove(); document.getElementById(TOGGLE_ID)?.remove(); return; } const lesson = currentLessonId(); if (!lesson) return; await NotesDB.open(); // 🕒 Wait until lesson wrapper exists & is visible let wrapperReady = 0; while (!document.querySelector(LESSON_WRAPPER_SEL) && wrapperReady < 25) { await sleep(100); wrapperReady++; } // also wait for Quill container to be mounted (avoid early blank editor) if (!document.getElementById(EDITOR_ID)) { await sleep(100); } if (lesson !== currentLesson) { await loadLesson(lesson); } else if (quill && currentLesson) { // Reload content if Quill got wiped by re-render const n = await NotesDB.get(currentLesson); if (n && (!quill.getText().trim() || quill.getLength() <= 1)) { quill.setContents(n.delta || []); } } else { await buildSidebar(); } } function setupShortcuts() { document.addEventListener( 'keydown', e => { if ( e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && e.key.toLowerCase() === 'n' ) { e.preventDefault(); toggleSidebar(); } }, { passive: false } ); } /* ========= SELF-HEAL WATCHDOG ========= */ function startSelfHealWatcher() { let intervalId = null; async function healCheck() { const isLesson = isLessonPage(); if (!isLesson) return; // skip when not in a lesson const sidebar = document.getElementById(SIDEBAR_ID); const editor = document.getElementById(EDITOR_ID); // Case 1: lesson exists but sidebar vanished entirely if (isLesson && !sidebar) { console.warn('[Marumori Notes] Sidebar missing — rebuilding'); await buildSidebar(); return; } // Case 2: sidebar exists but Quill isn't initialized or lost content if (isLesson && sidebar && (!window.Quill || !quill)) { console.warn('[Marumori Notes] Quill instance missing — rebuilding'); await buildSidebar(); return; } // Case 3: Quill exists but its editor is blank while notes exist in DB if (isLesson && quill && currentLesson) { const n = await NotesDB.get(currentLesson); if (n && (!quill.getText().trim() || quill.getLength() <= 1) && n.delta?.ops?.length) { console.warn('[Marumori Notes] Empty editor detected — restoring note'); quill.setContents(n.delta); } } } // start the loop function start() { if (intervalId) return; intervalId = setInterval(healCheck, 3000); console.debug('[Marumori Notes] Self-heal watcher active'); } // stop the loop function stop() { if (intervalId) { clearInterval(intervalId); intervalId = null; console.debug('[Marumori Notes] Self-heal watcher paused'); } } // Observe page changes to start/stop automatically const observer = new MutationObserver(() => { if (isLessonPage()) start(); else stop(); }); observer.observe(document.body, { childList: true, subtree: true }); // start immediately if we’re already on a lesson if (isLessonPage()) start(); } /* ========= INIT ========= */ (async function init() { await sleep(150); await NotesDB.open(); setupShortcuts(); startObserver(); startSelfHealWatcher(); // 🩹 added line await checkRoute(); })(); })();