// ==UserScript== // @name 粉笔背题增强(ABCD字母盒 + 下一题 + 草稿尺寸锁定 + 自动全开草稿) // @namespace marvis.fenbi.enhance // @version 2.2.2 // @description 每题注入 ABCD 字母选择盒(D 后带「下一题」),草稿画布锁定为题干+选项高度,页面加载后自动打开全部草稿;新增「笔刷设置」面板(黑/蓝/红笔色切换 + 粗细无级调节);MutationObserver 适配 SPA 增量渲染,按题目作用域去重。 // @match *://spa.fenbi.com/* // @run-at document-idle // @grant none // ==/UserScript== // // 功能清单 // ① 每道题 .title-left 内追加 .marvis-lbox,内含 A/B/C/D 四个字母按钮, // 点击即选中该题对应选项(label.click());盒子 z-index:260 高于草稿画板 130。 // ② 盒子内 D 之后追加「下一题」按钮,点击滚动到下一题并强制打开该题草稿。 // ③ 每个 .draft-container 高度锁定为「题干+选项」区域高度(app-question-choice // 的 bottom 相对 .ti 顶部),top 固定 0,解析展开时 .ti 变高不拉伸画布。 // ④ 页面加载后自动批量打开所有题的草稿(点击 li.draft.tool); // 自动打开每题只尝试一次,用户手动关闭后不会被重新打开。 // ⑤ 「笔刷设置」面板:笔色黑(默认)/蓝/红三色切换 + 粗细滑块(0.4×~4× 无级); // 默认内联显示在页面顶部「设置」按钮左侧(锚点 app-setting,插到其前一个兄弟位置, // 与工具栏同一行);锚点始终缺失时才兜底为左下角 fixed(答题卡 z-index 500 会遮挡, // 故 fixed 不作常驻位置),锚点出现后自动挪回工具栏。 // 通过 hook CanvasRenderingContext2D 的 strokeStyle / fillStyle / lineWidth / arc // 作用于 canvas#draft,改写时保留原有 alpha(预览 0.1 / 终稿 1); // 只影响绘制调用,不写页面状态、不改动上面 ①~④ 的任何逻辑。 // 适配:MutationObserver + rAF 节流,覆盖 SPA 增量渲染; // 注入以「题号」为 key 去重,元素复用渲染新题时会自动重建。 // (function () { 'use strict'; if (window.__MARVIS_FENBI_ENHANCE__) return; window.__MARVIS_FENBI_ENHANCE__ = true; var DLOCK_ATTR = 'data-marvis-dlock'; var AUTO_ATTR = 'data-marvis-autoopen'; var BOX_ATTR = 'data-marvis-boxkey'; /* ---------- 样式 ---------- */ var STYLE_ID = 'marvis-fenbi-enhance-style'; if (!document.getElementById(STYLE_ID)) { var st = document.createElement('style'); st.id = STYLE_ID; st.textContent = [ '.marvis-lbox{display:flex;align-items:center;gap:15px;margin-left:10px;position:relative;z-index:260;}', '.marvis-lbox .marvis-lbtn{box-sizing:border-box;width:22px;height:22px;padding:0;border-radius:4px;', 'border:1px solid #3b6ef0;background:#fff;color:#3b6ef0;font-size:12px;line-height:20px;text-align:center;cursor:pointer;}', '.marvis-lbox .marvis-lbtn:hover{background:#3b6ef0;color:#fff;}', '.marvis-lbox .marvis-lbtn:active{background:#2b55c8;color:#fff;}', '.marvis-lbox .marvis-next{box-sizing:border-box;height:22px;margin-left:6px;padding:0 8px;border-radius:4px;', 'border:1px solid #3b6ef0;background:#3b6ef0;color:#fff;font-size:12px;line-height:20px;cursor:pointer;white-space:nowrap;}', '.marvis-lbox .marvis-next:hover{background:#2b55c8;}', '.draft-container{background:transparent!important;}', '.draft-container{outline:1px solid rgba(0,0,0,0.12);outline-offset:-1px;}', '.ti, .draft-container{--color-bg-draft:transparent!important;}', '.draft-tool .draft-tool-mark{display:none!important;}', /* ⑤ 笔刷设置面板 */ '.marvis-brush-panel{position:fixed;left:20px;bottom:20px;z-index:400;display:flex;align-items:center;gap:5px;', 'box-sizing:border-box;padding:2px 5px;border-radius:8px;background:rgba(255,255,255,.96);', 'border:1px solid rgba(0,0,0,.12);box-shadow:0 2px 10px rgba(0,0,0,.15);', 'font-size:12px;line-height:1;color:#333;font-family:inherit;user-select:none;}', /* inline 模式:插在顶部 app-setting 之前(header-right 行容器),覆盖 fixed 定位 */ '.marvis-brush-panel.mbp-inline{position:static;display:flex;align-items:center;gap:5px;flex:0 0 auto;align-self:center;height:30px;margin-right:8px;white-space:nowrap;box-shadow:none;border:1px solid rgba(0,0,0,.1);padding:2px 5px;}', '.marvis-brush-panel .mbp-dot{box-sizing:border-box;width:15px;height:15px;padding:0;border-radius:50%;', 'border:2px solid transparent;box-shadow:inset 0 0 0 1px rgba(0,0,0,.18);cursor:pointer;}', '.marvis-brush-panel .mbp-dot-on{border-color:#3b6ef0;box-shadow:0 0 0 2px rgba(59,110,240,.25);}', '.marvis-brush-panel .mbp-range{width:70px;margin:0;cursor:pointer;}', '.marvis-brush-panel .mbp-val{min-width:30px;text-align:right;color:#666;}' ].join(''); (document.head || document.documentElement).appendChild(st); } /* 页面整体左右各缩 20px */ if (!document.getElementById('marvis-page-pad')) { var pp = document.createElement('style'); pp.id = 'marvis-page-pad'; pp.textContent = '.ti{margin:0 40px!important}'; document.head.appendChild(pp); } /* ---------- ⑤ 笔刷设置:笔色 + 粗细 ---------- */ /* 原理:粉笔在每一笔绘制前都会实时写入 ctx.strokeStyle / fillStyle / lineWidth, 主路径的粗细由 arc() 半径决定;因此 hook 这些 setter / arc 即可在不改页面状态、 不碰 strokeStack 与 sessionStorage 的前提下控制笔色与粗细。 风险:颜色不落库,切换笔色后旧笔迹会在下次重绘时同步变色(无法逐笔分色)。 */ var BRUSH_LS_KEY = 'marvis-fenbi-brush'; var brushColor = '#000000'; // 默认黑色(页面自带浅色主题默认笔色为红) var brushWidth = 1; // 粗细倍率:lineWidth 与 arc 半径共用 try { var savedBrush = JSON.parse(localStorage.getItem(BRUSH_LS_KEY) || 'null'); if (savedBrush) { if (typeof savedBrush.color === 'string' && /^#[0-9a-f]{6}$/i.test(savedBrush.color)) brushColor = savedBrush.color; if (typeof savedBrush.width === 'number' && isFinite(savedBrush.width)) { brushWidth = Math.min(4, Math.max(0.4, savedBrush.width)); } } } catch (e) { /* 隐私模式等场景下 localStorage 不可用,忽略即可 */ } function saveBrush() { try { localStorage.setItem(BRUSH_LS_KEY, JSON.stringify({ color: brushColor, width: brushWidth })); } catch (e) {} } // 仅作用于草稿画布:id 为 draft 或带 pen 类(廉价判断,避免拖慢书写) function isDraftCtx(ctx) { var c = ctx && ctx.canvas; if (!c) return false; return c.id === 'draft' || (c.classList && c.classList.contains('pen')); } function hexToRgb(hex) { var h = String(hex).replace('#', ''); if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2); var n = parseInt(h, 16); if (isNaN(n)) return [0, 0, 0]; return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } // 只替换 RGB,保留原值的 alpha(预览 0.1 / 终稿 1);无法识别的值(如渐变对象)原样放行 function brushPaint(v) { if (typeof v !== 'string') return v; var alpha = 1; var m = /^rgba?\(([^)]+)\)/i.exec(v.trim()); if (m) { var parts = m[1].split(','); if (parts.length >= 4) { var a = parseFloat(parts[3]); if (!isNaN(a)) alpha = a; } } else if (!/^#[0-9a-f]{3}([0-9a-f]{3})?$/i.test(v.trim())) { return v; } var rgb = hexToRgb(brushColor); return 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + alpha + ')'; } function installBrushHook() { var P = window.CanvasRenderingContext2D && window.CanvasRenderingContext2D.prototype; if (!P || P.__marvisBrushHooked) return; try { P.__marvisBrushHooked = true; // 颜色:strokeStyle / fillStyle 必须成对 hook,否则预览段(fillStyle)会花斑 ['strokeStyle', 'fillStyle'].forEach(function (name) { var d = Object.getOwnPropertyDescriptor(P, name); if (!d || !d.get || !d.set) return; Object.defineProperty(P, name, { configurable: true, enumerable: d.enumerable, get: function () { return d.get.call(this); }, set: function (v) { d.set.call(this, isDraftCtx(this) ? brushPaint(v) : v); } }); }); // 粗细(短笔画路径):只对画笔量级的线宽(页面硬编码 0.4)缩放,避开橡皮等其它用途 var dLW = Object.getOwnPropertyDescriptor(P, 'lineWidth'); if (dLW && dLW.get && dLW.set) { Object.defineProperty(P, 'lineWidth', { configurable: true, enumerable: dLW.enumerable, get: function () { return dLW.get.call(this); }, set: function (v) { var out = v; if (isDraftCtx(this) && brushWidth !== 1 && typeof v === 'number' && v > 0 && v <= 1.5) { out = v * brushWidth; } dLW.set.call(this, out); } }); } // 粗细(主路径):粉笔用 arc() 逐点画圆,半径 = clamp(pressure*ratio, 2, 4)/2,即 1~2px; // 只缩放这一量级的半径,避免误伤橡皮/清空等大半径 arc 调用 var oArc = P.arc; P.arc = function (x, y, r) { if (brushWidth !== 1 && isDraftCtx(this) && typeof r === 'number' && r > 0 && r <= 3) { var args = Array.prototype.slice.call(arguments); args[2] = r * brushWidth; return oArc.apply(this, args); } return oArc.apply(this, arguments); }; } catch (e) { /* hook 失败不影响原有功能 */ } } installBrushHook(); /* ---------- ⑤ 笔刷设置面板(ensure 建 DOM / place 定位,每轮扫描均可调用) ---------- */ var brushPanelEl = null; // 模块级面板引用:place 逻辑不依赖 getElementById // ensureBrushPanel:创建面板 DOM、绑定事件并接入文档树(已存在则直接返回,可重复调用) function ensureBrushPanel() { try { if (!document.body) return; if (brushPanelEl && brushPanelEl.isConnected) return; // 已创建且仍在文档树中 var existed = document.getElementById('marvis-brush-panel'); // 兜底:DOM 中已有同 id 节点 if (existed) { brushPanelEl = existed; return; } var panel = document.createElement('div'); panel.id = 'marvis-brush-panel'; panel.className = 'marvis-brush-panel'; panel.title = '笔刷设置'; [['#000000', '黑'], ['#1e5eff', '蓝'], ['#e02020', '红']].forEach(function (pair) { var dot = document.createElement('button'); dot.type = 'button'; dot.className = 'mbp-dot'; dot.setAttribute('data-color', pair[0]); dot.title = pair[1] + '色'; dot.style.background = pair[0]; dot.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); brushColor = pair[0]; saveBrush(); syncBrushPanel(); }); panel.appendChild(dot); }); var range = document.createElement('input'); range.type = 'range'; range.className = 'mbp-range'; range.min = '0.4'; range.max = '4'; range.step = '0.1'; range.value = String(brushWidth); range.title = '笔画粗细(0.4×~4×)'; range.addEventListener('input', function (ev) { ev.stopPropagation(); var v = parseFloat(range.value); if (!isNaN(v)) { brushWidth = v; saveBrush(); syncBrushPanel(); } }); panel.appendChild(range); var val = document.createElement('span'); val.className = 'mbp-val'; panel.appendChild(val); // 事件在创建时统一绑定一次(inline / fixed 两种模式共用),避免冒泡干扰页面与草稿画布的 pointer 事件 ['pointerdown', 'mousedown', 'click', 'dblclick', 'touchstart', 'keydown', 'wheel'].forEach(function (t) { panel.addEventListener(t, function (ev) { ev.stopPropagation(); }); }); // 关键:先接入文档树,placeBrushPanel 才能取到并把面板搬到目标位置 brushPanelEl = panel; document.body.appendChild(panel); syncBrushPanel(); } catch (e) { /* 面板构建失败不影响 scan() 中后续题目处理 */ } } // placeBrushPanel:把已存在的面板放到正确位置,每次扫描都执行 function placeBrushPanel() { try { var panel = brushPanelEl || document.getElementById('marvis-brush-panel'); if (!panel || !document.body) return; /* 锚点:app-setting(父级为顶部 header-right flex 行容器),插到它的前一个兄弟位置 */ var appSetting = document.querySelector('app-setting'); if (appSetting && appSetting.parentElement) { if (panel.parentElement !== appSetting.parentElement || panel.nextElementSibling !== appSetting) { appSetting.parentElement.insertBefore(panel, appSetting); // insertBefore 会自动搬移已存在节点 } panel.classList.add('mbp-inline'); return; } /* 兜底:锚点始终缺失时才回到左下角 fixed 模式;后续扫描一旦锚点出现仍会挪过去 */ panel.classList.remove('mbp-inline'); if (panel.parentElement !== document.body) document.body.appendChild(panel); } catch (e) { /* 定位失败不影响 scan() 中后续题目处理 */ } } function syncBrushPanel() { var panel = brushPanelEl || document.getElementById('marvis-brush-panel'); if (!panel) return; var dots = panel.querySelectorAll('.mbp-dot'); for (var i = 0; i < dots.length; i++) { var on = String(dots[i].getAttribute('data-color')).toLowerCase() === String(brushColor).toLowerCase(); dots[i].className = 'mbp-dot' + (on ? ' mbp-dot-on' : ''); } var r = panel.querySelector('.mbp-range'); if (r) r.value = String(brushWidth); var v = panel.querySelector('.mbp-val'); if (v) v.textContent = brushWidth.toFixed(1) + '×'; } /* ---------- 工具 ---------- */ function tis() { return [].slice.call(document.querySelectorAll('.ti')); } // 题目作用域 key:题号(SPA 复用同一 .ti 元素渲染新题时 key 会变化) function tiKey(ti) { var i = ti.querySelector('.title-index'); return i ? (i.textContent || '').trim() : ''; } // 「题干+选项」区域相对 .ti 顶部的高度 function measureTi(ti) { var qc = ti.querySelector('app-question-choice'); if (!qc) return null; var tiR = ti.getBoundingClientRect(); var r = qc.getBoundingClientRect(); var h = Math.round(r.bottom - tiR.top); return h > 0 ? h : null; } /* ---------- ③ 草稿尺寸锁定 ---------- */ function lockDraft(ti) { var dc = ti.querySelector('.draft-container'); if (!dc || dc.getAttribute(DLOCK_ATTR)) return; var h = measureTi(ti); if (!h) return; dc.style.setProperty('height', (h + 48) + 'px', 'important'); dc.style.setProperty('top', '0px', 'important'); dc.setAttribute(DLOCK_ATTR, '1'); } /* ---------- ④ 自动打开草稿 ---------- */ function clickDraftTool(ti) { if (ti.querySelector('.draft-container')) return; // 已开 var tool = ti.querySelector('li.draft.tool'); if (tool) tool.click(); } // 自动打开:每题只尝试一次(key = 题号),避免重扫时把用户手动关掉的草稿又打开 function autoOpenDraft(ti) { var key = tiKey(ti); if (ti.getAttribute(AUTO_ATTR) === key) return; ti.setAttribute(AUTO_ATTR, key); clickDraftTool(ti); } /* ---------- ①② ABCD 字母盒 + 下一题 ---------- */ function buildBox(ti) { var tl = ti.querySelector('.title-left'); if (!tl) return; var key = tiKey(ti); var old = tl.querySelector('.marvis-lbox'); if (old) { if (old.getAttribute(BOX_ATTR) === key) return; // 同一题已注入,跳过 old.remove(); // 元素复用渲染新题,重建 } var box = document.createElement('div'); box.className = 'marvis-lbox'; box.setAttribute(BOX_ATTR, key); var radios = [].slice.call(ti.querySelectorAll('li.choice-radio')); var letters = ['A', 'B', 'C', 'D']; radios.forEach(function (cr, i) { var letter = letters[i]; if (!letter) return; var b = document.createElement('button'); b.type = 'button'; b.className = 'marvis-lbtn'; b.setAttribute('data-letter', letter); b.textContent = letter; b.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); var lab = cr.querySelector('label.choice-radio-label') || cr.querySelector('label'); if (lab) lab.click(); // 触发原生选项选择 }); box.appendChild(b); }); var nb = document.createElement('button'); nb.type = 'button'; nb.className = 'marvis-next'; nb.textContent = '下一题'; nb.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); var all = tis(); var idx = all.indexOf(ti); var next = idx > -1 ? all[idx + 1] : null; if (!next) return; next.scrollIntoView({ behavior: 'smooth', block: 'start' }); clickDraftTool(next); // 用户主动:强制打开 setTimeout(function () { lockDraft(next); }, 450); }); box.appendChild(nb); tl.appendChild(box); // 追加到 .title-left 末尾,避免负 left 被父级 overflow 裁切 } /* ---------- 单题处理 ---------- */ function processTi(ti) { buildBox(ti); autoOpenDraft(ti); lockDraft(ti); } /* ---------- 全量扫描 ---------- */ function scan() { ensureBrushPanel(); placeBrushPanel(); var list = tis(); for (var i = 0; i < list.length; i++) processTi(list[i]); } /* ---------- SPA 增量渲染适配 ---------- */ var pending = false; var mo = new MutationObserver(function () { if (pending) return; pending = true; requestAnimationFrame(function () { pending = false; scan(); }); }); function startMO() { mo.observe(document.body, { childList: true, subtree: true }); } if (document.body) { scan(); startMO(); } else { document.addEventListener('DOMContentLoaded', function () { scan(); startMO(); }); } // 草稿层动态插入后补偿锁定一次 setTimeout(function () { scan(); }, 800); window.__MARVIS_FENBI_SCAN__ = scan; })();