// ==UserScript== // @name 粉笔自动全开草稿 // @namespace marvis.fenbi.enhance // @version 2.8.3 // @description 页面加载后自动打开全部题的草稿;每题注入 ABCD 字母选择盒(D 后带「下一题」),草稿画布锁定为题干+选项高度;新增「笔刷设置」面板(黑/蓝/红笔色切换 + 粗细无级调节);MutationObserver 适配 SPA 增量渲染,按题目作用域去重;同步 canvas#draft 绘图缓冲区尺寸为容器显示尺寸,修复退出草稿重开后笔迹被垂直压缩;新增 ABCD 点击按钮本身变色(背题对错 / 做题选中),修复判分重建选项节点后 ABCD 按钮不变色;退出重进后按页面既有作答状态自动回填 ABCD 按钮颜色(做题选中深蓝 / 背题对错绿红);修复做题模式退出重进后 ABCD 按钮空白不回填:选中态改多信号识别(checked 属性 / aria / class),MutationObserver 增补属性级监听并按题精准同步,定时补偿改为前 30 秒持续扫描 + pageshow/visibilitychange/focus 兜底;新增资料分析「一拖五」支持:每个子题独立注入字母盒与草稿画布(tab 切换即就位),「下一题」优先推进组内子题,材料区配独立草稿画布(可完全打开、五题共享、笔迹存 localStorage 退出重进重放),隐藏会吞掉全组指针事件的组级原生草稿;新增性能护栏:草稿与画布只在题目接近视口时才自动创建,低配环境不再 110 个画布全开卡死。 // @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 之后追加「下一题」按钮,点击滚动到下一题并强制打开该题草稿; // 点击后目标题滚动到视口垂直居中位置(不再贴顶); // 题目高于一屏时改为顶部留白对齐,保证题干头部可见; // 按钮样式与 ABCD 字母按钮保持一致(白底蓝字蓝边,hover/active 实心蓝白字)。 // ③ 每个 .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); // 只影响绘制调用,不写页面状态、不改动上面 ①~④ 的任何逻辑。 // ⑥ ABCD 按钮本身变色:点击后按判分结果显示绿/红(背题),做题模式无判分时显示深蓝。 // ⑦ 退出重进回填:页面重新渲染时按 DOM 既有状态给按钮补色—— // 已判分看 div.input-radio 的 correct/correctLost/wrong;未判分(做题选中)多信号识别: // input.checked 属性 / checked 内容属性 / aria-checked / div.input-radio 与 label 的 // selected 类。粉笔选中样式是 input:checked + .input-radio 兄弟选择器、本身不加 class, // 但重进恢复可能走 setAttribute / class / aria 等路径(property 赋值不产生 childList // 变化,childList-only 的 MutationObserver 完全看不到——做题模式不回填的根因), // 因此补:setter hook + change 捕获 + MO 属性级监听(checked/class/aria,按题精准同步) // + 前 30s 持续定时补偿 + pageshow / visibilitychange / focus 生命周期兜底。 // ⑧ 资料分析「一拖五」支持(v2.3.0,基于 2026-10-23 实测结构): // 组 = 一个 div.ti,内部 app-resizable 左右分栏:左 .left-section > app-materials // (材料,页面无草稿入口);右 .questions-anchors(a.tab#nav0..4 受控 tab) // + .questions-container(5 个 app-ti.question-multiple 预渲染堆叠、容器滚动切换, // tab 点击只切 active 类、不滚动容器)。子题不是 .ti,旧版「一个 .ti = 一道题」 // 的假设在组上全面失效(盒只装第一个子题 / 按钮映射错题 / 下一题跳过组内子题 / // 组级原生草稿 absolute 覆盖全组、吃掉全部指针事件——「不可用」的直接原因)。 // 现按组作用域重构: // - 每个子题独立注入字母盒(含下一题)与草稿画布,tab 切换即就位; // - 「下一题」优先推进组内下一个 tab(并自行滚动 questions-container),组末才进下一题; // - 材料区配独立草稿画布(B2):absolute 覆盖整个材料、可完全打开、五题共享, // 笔迹按归一化坐标存 localStorage、退出重进自动重放;带「演算/穿透」切换 // (穿透模式下可选中/复制材料文字)与两步确认的「清空」; // - 组级原生草稿直接隐藏(实测无副作用;粉笔重载会自动恢复打开,故每次扫描都压制)。 // ⑨ 性能护栏(v2.3.0):110 题全开画布在低配/软件渲染环境 tile 内存超限卡死(实测复现)。 // 草稿与自制画布改为只在题目接近视口(上 0.6 / 下 1.6 屏)时才自动创建,滚动即按需补开; // 字母盒与回填(廉价)仍保持全量就绪。控制台 __MARVIS_FENBI_OPEN_ALL__() 可强制全开。 // ⑩ 材料智能适应(v2.4.0 引入缩放;v2.7.0 升级为三层自适应): // 材料画布工具条「适应」按钮 = 一键让整块材料一屏完整显示、文字尽量大: // L1 缩放:CSS zoom 布局级缩放 + 宽度补偿,可读性下限 55%; // L2 宽度:缩放低于 GOOD(0.82) 时自动收窄材料栏(直接设左栏宽度,实测 Angular // 不重置)——图表 max-width:100% 随栏变矮、材料整体变矮,从而在 100%(或更高 // 缩放倍率)下完整显示;正文文字不缩只重排。分隔条本就可供用户手动拖,此处是 // 程序化代拖; // L3 兜底:最窄栏(420px) + 下限缩放,仍装不下则提示滚动查看剩余部分。 // 关键修复(旧版量错地方白点):真实高度锁在内层 .material-body(固定高 + // overflow:auto,内容 901px 裁在 572px 盒里),宿主 scrollHeight 被内层裁成盒高 // → 算出缩放比 ≥1 直接不缩(用户反馈「点了没反应」的根因)。现在先解锁内层滚动盒 // 再量(取子树最大 scrollHeight);锁态布局下还会连带把该组高度链展平让内容能流动, // 还原时若展平由适应触发(用户关了全局展平)则一并收回。 // 另一修复:zoom 继承会二次缩放子孙 px 长度(实测 computed 1751px 渲染成 1051px), // 画布 CSS 高度改按 视觉高/zoom 设置,缩放态下画布仍满覆盖材料。 // 缩放/栏宽偏好按组存 localStorage,切题重进自动恢复并重新断言。 // ⑪ 一拖五「解锁盒子」(v2.6.x,按用户澄清迭代):痛点本源是盒子高度写死 70vh // (.resizable-container / .nz-resizable.left 内联 height:70vh),5 道子题只能盒内 // 滚动翻题。做法(v2.6.2 定稿):**保持左右两栏**(材料在左向下延伸、题目在右向下 // 延伸,页面整体滚动),只解锁高度(relaxCol:height auto / max-height none, // 仅 absolute/fixed 层连带解锁宽度/偏移);两栏宽度不动(左栏 615px、分隔条保留 // 可拖);tab 栏隐藏;questions-container overflow visible;子题逐层解锁宽度 // (relaxEl,原始 style 存 data-marvis-relax 供还原);expandScrollBoxes 解除组内 // 残留内滚盒子;expandAncestorBoxes(v2.6.3)把组外祖先滚动壳一并解除——SPA 的 // 滚动可能发生在任意祖先层,只展开组内仍会被外层壳兜住;body/html overflow // hidden 时显式放开。材料画布/子题画布照常,「下一题」组内推进 = 页面滚动。 // 默认开启;控制台 __MARVIS_FENBI_FLATTEN__() 切换(持久化),__MARVIS_FENBI_DIAG__() // 输出结构自检(含祖先壳链路)。 // ⑫ 脚本总开关(v2.6.6):底部水印条样式的胶囊(「粉笔自动全开草稿 / 粉笔脚本君出品 / // 脚本 开 · v版本」三段同字体同字号 15px,蓝/灰/绿三色),点按 = 整个脚本开/关 // (刷新生效;关闭态不注入任何功能,胶囊是唯一重新入口)。 // 画布默认模式按类型且不记忆(v2.7.6 起)——材料默认「当前可书写」(写写画画), // 子题默认「当前可触摸」(题目区常需滚动/点选),每次打开/刷新都按此重置; // 「穿透」改名「当前可触摸」;两档按钮文案均为短词, // 用户切到「当前可书写」时顶部弹全句提示:材料=「材料区域草稿已打开,当前可书写, // 不可触摸。」,子题=「第N题的题目区域草稿已打开,当前可书写,不可触摸。」; // 切到「当前可触摸」同样弹(…已让位,当前可触摸,不可书写。); // 胶囊字体 15px → 7.5px(用户要求减半,v2.6.7);v2.6.8 曾对齐水印栏改回 15px // (状态+版本文字缩为 10px);v2.7.3 用户反馈「占的地方太大」再次减半——主文字 // 7.5px、状态+版本 5px,padding 6px 12px→3px 6px、gap 10px→5px、圆角 10px→5px // 等比缩,占地约 350×34 → ~172×18;画布工具按钮字号保持 12px。 // ⑬ 材料工具条改顶部通栏 band + 左栏吸顶(v2.7.1,用户两个实测痛点): // 痛点1「左边三个按钮把材料文字挡了」:材料工具条(当前可书写/适应/清空)从 // 悬浮右上角改为钉在材料顶部的通栏 band(白底+下边框),材料内容用 // .marvis-mat-host .material-body 的 padding-top 让位——按钮有独立一行,不再压字; // 痛点2「滚右侧小题时材料要一直完整贴在左边」:两栏延伸形态下左栏 // position:sticky(top=固定页头高+8),align-self:flex-start 中和 flex 拉伸 // (否则左栏被拉到右栏等高、sticky 无行程),max-height 收进视口、装不下时 // 栏内 overflow-y:auto 自滚;滚过组内最后一题(分栏容器底边过去)后自然释放 // 随页面滚走。配套修复:cvMeasure 量尺寸时不再无脑 removeProperty('align-self') // (会把吸顶样式抹掉),改为原样写回;expandScrollBoxes 跳过吸顶栏。 // ⑭ 修复「材料显示不全」(v2.7.2,用户实测:图片/文字都显示不全): // 根因(真机 CDP 去因实验定位):v2.7.1 为打断 flex 拉伸链给材料链五层写了 // flex:0 0 auto + align-self:flex-start——flex-basis:auto 让各层按内容 // max-content 宽度排布,材料文字 max-content 1664px 时整条链被撑到 1704px, // 而左栏只有 996px,overflow-x:hidden 把右半全部裁掉(文字横切、表格/图片横切)。 // 修复:链上各层显式 width:100%(宿主按当前 zoom 补偿 100/s%,且不用 !important, // cvZoomApply 的补偿值要能覆盖)+ min-width:0 + box-sizing:border-box——横向恒等 // 栏宽(拖分隔条/适应收窄时跟随重排),纵向拉伸链修复不受影响;另加 CSS 规则 // .materials-container img/table/svg{max-width:100%} 让超宽素材收缩适应 // (覆盖 Angular 动态渲染的新素材);cvZoomApply 还原时 width 固定回 100% // (置空会退回 max-content 排布再次撑爆)。 // 同批修复(真机实测):① cvMeasure 量尺寸时宿主宽度也不能置空(同样触发 // max-content:文字材料整块不换行塌成一行,1196px 被量成 ~250px → 误判「装得下」 // 不缩放),固定 100% 量可见排版下的真实高度;② 被栏宽钳制的媒体(图片/表格, // 渲染宽≈栏宽)缩放会被宽度补偿抵消(媒体随补偿放大回原尺寸)——这类材料优先走 // L2 收窄(往往 100% 装下),必须缩放时不做补偿,让缩放真正生效。 // 二批修复(v2.7.2 收尾,真机 CDP 定位):③ L2 收窄档位去重写反(w<=lastW // continue)——档位比值递减,第一档之后全部被跳过,高图材料只量一档就收尾、 // 最优窄档永远摸不到;改为「没有变得更窄才跳」。④ 补偿三处复活路径封死: // stickLeftCol 每次重扫都按 zoom 重算补偿(不认媒体标记会把 '100%' 覆盖回 // 100/s%,图被横向裁出栏外 271px);cvFitRestoreState 在适应成功后被 // cvToast 触发的全量重扫二次施加(此时 zoom 已生效,探测器坐标系不一致会假阴性) // ——加「已在目标状态不重复断言」门(含拖栏采纳新宽)+ 用户拖动分隔条后采纳 // 新栏宽不被还原;cvHasWidthBoundMedia 改 zoom 归一(测量前临时摘 zoom), // 任何调用时机可跑,自愈路径随之闭合(刷新后图片才到达:先按文字口径补状态, // 图到位后自愈撤补偿、补标记,不再横向裁切)。 // ⑮ 左下角「答题卡」折叠把手全透明(v2.7.4,用户反馈小白卡挡题面文字): // 白卡不是背景色而是 .answer-card-tag 内 SVG path 的 fill/stroke(另有一条 // ::after 白条),只能 CSS 褪;折叠/展开是 fixed 容器 transform 位移——不增删 // 节点不挂类(MutationObserver 无声),三态类名又完全相同,没有状态类可钩; // 且展开态下该 tag 就是答题卡面板头部,一概全透会穿帮。故按容器位置 // (rect.top ≥ 视口高 = 折叠态)打 .marvis-ac-clear 类只透折叠态: // scan() 内调用 + 400ms 轮询兜底。该把手是全页唯一答题卡入口,只褪色不隐藏。 // ⑯ 资料分析「适应」按钮默认就是适应而非百分之百(v2.7.5,用户要求): // 原来每次扫描都从 localStorage 恢复上次的缩放/收窄,打开资料分析题(含切题 // 重渲染)按钮直接显「100%」、材料被自动缩放。cvFitRestoreState 改为不再恢复: // 打开即原始 100%,要缩放用户自己点;按钮文字只按 DOM 实际状态校正(点了适应 // 再被 cvToast 触发的全量重扫时文字不能错);顺手清掉旧版遗留的持久化键。 // ⑰ 资料分析子题「整页草稿纸」(v2.7.7,用户要求:可书写区从题目框扩到整页, // 且明确只在资料分析生效,别的题型不许变): // 子题画布锚在题目框里、书写区只有「单选题这一篇」。fpEligible 的画布 // (仅资料分析组内子题——普通题走粉笔原生草稿、材料画布本就通栏,都不动) // 切到「当前可书写」时展开 body 级透明全屏层,整页即这道题的草稿纸。 // 机制(真机 CDP 实验 4/4 验证,tools/cdp-research-fp.js): // ① 层 fixed inset:0 + pointer-events:none——对 HitTesting 完全隐身, // 手指滚动/点击/选答案全部原生直达页面(实测轻点穿透到 // choice-radio-label、拖动 scrollY 0→235); // ② window 捕获层监听 pointerdown:笔/鼠标落笔时 setPointerCapture 到层内 // canvas(捕获可打在 pointer-events:none 元素上,move/up 照达——实测 // pen/mouse 均全程出字)+ preventDefault/stopPropagation 抢在原生处理器前; // ③ 鼠标点到按钮(button/label/radio…)不捕获→原生点击;笔在按钮上轻点 // (短按小位移)转发一次 click 并丢掉该点——平板上用笔点答案不误画; // ④ 我们自己的悬浮控件(工具条/笔刷面板/胶囊/答题卡) closest 排除,保持可点。 // 工具条收进右上角常驻胶囊(题目工具条会随滚动离开视口,退出/清空要随时够得着)。 // 笔迹按题独立存储(复用 sub-,归一化到视口),刷新保留、模式照旧不记忆; // 旧版题目框坐标系的笔迹首次展开时按框位置重映射进视口坐标系,老笔迹不丢。 // ⑱ 修复「适应后材料右侧仍被裁」+ 画布模式不再记忆(v2.7.6,用户实测反馈): // 问题A(裁剪根因,真机 CDP 定位):v2.7.2 给缩放加的宽度补偿 100/s% 是错的—— // Blink 的 zoom 模型里百分比宽度在被缩放元素的自身坐标系解析:探针实测(父 600px) // width:100%+zoom:0.858 视觉宽恰 600px(本已铺满),width:116.55%+zoom:0.858 // 视觉宽 699px=600/0.858。补偿让宿主视觉宽=栏宽/s,右侧溢出 (1/s-1)×栏宽 被 // .left-section 的 overflow-x:hidden 裁掉(真机:s=0.858 时视觉 1161px vs // 栏宽 996px,右 165px 被裁——即用户报「材料图片还是会被裁剪」)。修复: // cvZoomApply/stickLeftCol 缩放时宽度恒 100%,补偿与 CV_NOCOMP_ATTR 机制 // 整体删除(cvHasWidthBoundMedia 保留:媒体布局宽被钳在栏宽/zoom 上,视觉尺寸 // 恒由栏宽决定、zoom 缩不动它——「优先 L2 收窄」的策略结论不变)。 // 问题B(模式记忆):画布模式原来从 localStorage 恢复(用户切过一次就记住)。 // 改为不读不依:材料画布恒默认「当前可书写」,题目画布恒默认「当前可触摸」, // 每次打开/刷新都按类型重置(笔迹存取不受影响)。 // ⑲ 整页草稿态锁定滚动 + 删材料区草稿 + 删红框提示 + 笔迹落盘坐标修正(v2.7.8): // ① 整页草稿打开时,笔书写和手指滚动冲突(写写画画时页面被带着滚)。 // 定稿:可书写态 = 纯书写状态,页面不许滚。实现上**不用** html/body // overflow:hidden——文档滚动容器一消失 Chrome 会把 scrollY 钳回 0, // 用户正在看的那道题瞬间跳走(真机实测 42934→0);而且粉笔自己的 JS // 还会把 html 行内 style 重写成「overflow-y:auto !important」盖回来。 // 改为 document 捕获层拦 touchmove + wheel(passive:false),页面停在原位、 // 手指拖不动、滚轮也不滚;笔的 pointer 流不受影响(书写照常),轻点也 // 不受影响。弹窗文案同步改为「当前整个页面都可书写,页面滚动已锁定」。 // ② 材料区的「可书写/可触摸」按钮 + 画布整个删除(整页草稿已覆盖材料书写, // 按钮无用);「适应」是独立功能保留,材料工具条只剩「适应」一个按钮。 // ③ 草稿范围淡红框提示删除(用户觉得没用):fpHintRect/fpHintViewport/ // fpHintSub/fpScanHints 及 CSS 全部移除。 // ④ 修 v2.7.7 的笔迹落盘 bug(真机 E2E 才发现):fpFinish 把 [x,y] 数组当 // 对象读(p.x/p.y → NaN → JSON 存成 null),结果「当次会话看得见、刷新后 // 全丢」;这些 null 再被 fpMigrate 迁移会压成同一个常量点,整笔退化成一点。 // 修正为下标读取;存储版本升到 v=2 标记「已迁移到视口坐标」(原先迁移标记 // 只在内存,刷新即丢 → 每次重开又迁一次、笔迹越跑越偏);加载时 // cvSanitize 清洗历史坏数据(丢 null 点、丢所有点重合的坏笔)。 // ⑳ 整页草稿两态化 + 工具条不再飞右上角(v2.7.9,用户 iPad 实测反馈): // ① 工具条留在原地:用户「非得把当前可触摸这个按钮移动再点击变成当前可书写后, // 就要飞到右上角吗?我觉得停留在原地就挺好的」——右上角常驻胶囊(pill)整体 // 删除,toggle/清空永远留在题目工具条原位。书写态滚动已锁、按钮必在视口内, // 不需要再靠胶囊兜「够得着」。 // ② 可触摸 = 锁定态而非退出:会话仍然开着(笔迹照旧显示),但页面只放行 // 这道题的 ABCD + 下一题 + 清空 + 本切换按钮,其余点击一律吃掉 // (用户:「别的按钮应该都覆盖掉」)。实现用 document 捕获层 click 过滤 // (放行我们自己注入的 .marvis-lbox/.marvis-lbtn/.marvis-next 与工具条), // 不遮罩、不改布局、不影响滚动;只拦可信(isTrusted)的真实点击,页面 // 自己的程序化点击照常。 // ③ 可书写态手指点按无效并提示「当前不可触摸」(用户要求):pointerdown + // touchstart 双保险拦掉手指(preventDefault touchstart 才能确保不合成 // click),只有工具条(切换/清空)放行——否则退不出去。 // ④ 退出会话:长按切换按钮 650ms,或点「下一题」(换题即退出)。退出后 // 页面完全恢复普通态,笔迹已落盘,回来点「当前可触摸」可再开。 // ㉑ 草稿竖条:切换/清空/退出收进「原生草稿工具条」形态(v2.8.0,用户 iPad 反馈): // 用户原话「把『当前可触摸』和『当前可书写』集成到原生那个草稿按钮吧,然后清空 // 也集成到原生的这个草稿按钮中;当前可书写时弹出『当前不可触摸』这个弹窗没用, // 取消掉」。实测(tools/cdp-probe-native-draft*.js):资料分析组的原生草稿是 // **整组一条**(.ti > .draft-container > app-draft > canvas#draft + .tools-container, // 撤销/反撤/橡皮/清空/退出),且被本脚本整体隐藏(组级画布会吃掉全组指针事件); // 每道子题自己的「草稿」入口是 .title-right > li.draft.tool(笔图标)。 // 所以: // ① 子题宿主不再挂任何悬浮小白按钮(toggle/清空全删),改为一条与原生同款 // (#3c464f 深色、竖排、贴屏幕右边缘、12px 圆角、20px 描边图标 + 11px 白字) // 的「草稿竖条」#marvis-fp-dock,只放这套草稿真正用得上的三项: // 当前可书写 / 当前可触摸(切换)· 清空(两步确认)· 退出 // 竖条 position:fixed 右侧居中——原生那条是绝对定位钉在题目顶部的,长题滚下去 // 就够不着了,钉屏幕才能随时按。 // ② 入口 = 原生草稿笔图标(li.draft.tool,每道子题一个):点它打开这道题的整页 // 草稿(document 捕获层拦掉粉笔自己的组级草稿逻辑),再点一次即退出;会话所属 // 那道题的图标高亮(.marvis-fp-pen-on)。普通题的笔图标完全不动(照旧开原生草稿)。 // ③ 可书写态手指点按从「拦截 + 弹提示」改为**静默拦截**(用户:弹窗没用,取消掉)。 // ④ 锁定态白名单扩到:ABCD / 下一题 / 竖条 / 本题草稿图标;别处点击照旧吃掉并 // 提示「当前只可点 ABCD / 下一题 / 右侧草稿按钮」。 // ⑤ 出口变明确:竖条「退出」按钮(替代 v2.7.9 的长按 650ms)、再点草稿图标、 // 或点「下一题」,三条路都退出会话并立即落盘。 // ㉒ v2.8.1 修 v2.8.0 的越界(用户:这功能只能在一拖五用,别把别的题也改了): // `app-ti.question-multiple` 是**全站题目**的组件名——普通单题也是它(真机实测 // 第 1~8 题全是),v2.8.0 用 `closest('app-ti.question-multiple')` 当子题判据, // 结果普通题一按草稿笔图标也被拽进整页草稿 + 冒出一条右侧竖条。 // 现在唯一判据是 fpGroupOfSub():所在的 .ti 必须含 .materials-container / // .questions-anchors(与 processTi 建画布口径完全一致),并且 // fpPenEntry / fpToggleSession / ensureSubCanvas 三处都上这道闸; // scan() 还会自愈清掉 v2.8.0 误建在普通题上的 fpEligible 画布(cvPurgeStrayFp)。 // ㉓ v2.8.2 右下角胶囊改成**可展开的设置面板**(用户:「帮我做一个可以展开的……再给我 // 加一个选项,就是我可以停掉资料分析这种 1 拖 5 类似的板块的功能」「再搞一个全部 // 关闭」):点胶囊 = 展开/收起(不再点一下就刷新),面板两行开关: // ① 资料分析 · 1拖5 → localStorage `marvis-fb-group`,关掉 = 该板块完全不介入 // (processTi 组分支直接 return:不隐藏原生组草稿、不展平、不加字母盒、 // 不建材料画布;fpGroupOfSub 同时返回 null,整页草稿/竖条/笔图标一律不受理); // ② 全部题目 → `marvis-fb-enabled`(原总开关 =「全部关闭」)。 // 两个开关都走刷新生效(半途拆装会留下内联样式/属性残渣);胶囊状态串会显示 // 「脚本 开 · 分析关 · v2.8.2」,一眼能看出当前是部分关闭。 // 适配:MutationObserver(childList + 属性级 checked/class/aria)+ rAF 节流与按题精准同步, // 覆盖 SPA 增量渲染与属性级作答恢复; // 注入以「题号 + 版本戳」为 key 去重,元素复用渲染新题、脚本升级时自动重建。 // (function () { 'use strict'; // ---------- 脚本总开关 + 板块开关(v2.8.2 可展开)---------- // 右下角胶囊 = 设置入口(v2.8.2 起不再「点一下就刷新」):点它展开/收起面板。 // 面板里两个开关,都以「刷新页面生效」实现——半途拆装会把 Angular 渲染好的 DOM // 留在半改状态(内联样式/属性残留),刷新是唯一能保证干净的方式: // ① 资料分析 · 1拖5:关掉 = 这一整个板块彻底不介入(不展平、不隐藏原生组草稿、 // 不加字母盒、不建材料画布、不提供整页草稿与草稿竖条),完全交还粉笔原生; // ② 全部题目:关掉 = 整套脚本不注入(原来的总开关,也就是「全部关闭」)。 var MASTER_LS = 'marvis-fb-enabled'; var GROUP_LS = 'marvis-fb-group'; // v2.8.2:资料分析(1拖5)板块开关 var SCRIPT_VER = '2.8.3'; function masterEnabled() { try { return localStorage.getItem(MASTER_LS) !== '0'; } catch (e) { return true; } } function groupEnabled() { try { return localStorage.getItem(GROUP_LS) !== '0'; } catch (e) { return true; } } function chipReload() { try { location.reload(); } catch (e) {} } // 面板里一行开关的样式/文案同步(浅绿=开,灰=关,整行无效时压暗) function chipSetRow(row, on, effective) { if (!row) return; var sw = row.querySelector('.mv-sw'); if (sw) { sw.textContent = on ? '开' : '关'; sw.style.background = on ? '#E8F5E9' : '#F1F3F5'; sw.style.color = on ? '#3B6D11' : '#8A9099'; } row.style.opacity = effective ? '1' : '.45'; row.style.pointerEvents = effective ? 'auto' : 'none'; } function chipSync(el) { var on = masterEnabled(); var g = groupEnabled(); var t3 = el.querySelector('.mv-t3'); if (t3) { var txt = (on ? '脚本 开' : '脚本 关') + (on && !g ? ' · 分析关' : '') + ' · v' + SCRIPT_VER; if (t3.textContent !== txt) t3.textContent = txt; t3.style.color = on ? '#3B6D11' : '#9AA0A8'; t3.style.fontWeight = '600'; } var p = el.querySelector('#marvis-flat-panel'); if (p) { chipSetRow(p.querySelector('.mv-row[data-k="group"]'), g, on); // 全关时该行无效 chipSetRow(p.querySelector('.mv-row[data-k="all"]'), on, true); } el.title = '点按展开/收起设置:可单独关掉「资料分析 · 1拖5」板块,或全部关闭'; } function masterChip() { var el = document.getElementById('marvis-flat-chip'); if (!el) { if (!document.body) { // document-start 兜底:等 body 就绪再挂 if (document.addEventListener) document.addEventListener('DOMContentLoaded', masterChip); return; } el = document.createElement('div'); el.id = 'marvis-flat-chip'; // 样式对齐粉笔底部水印条:「粉笔一页成卷 / 粉笔脚本君出品」那条的观感 // v2.8.2:胶囊尺寸/字号保持原样(面板是它的绝对定位子元素,不撑大胶囊) el.style.cssText = 'position:fixed;right:10px;bottom:66px;z-index:2147483000;' + 'display:inline-flex;align-items:center;gap:5px;max-width:calc(100vw - 20px);' + 'padding:3px 6px;border-radius:5px;background:rgba(248,249,250,.97);' + 'border:1px solid #E4E6EB;box-shadow:0 2px 10px rgba(0,0,0,.08);' + 'font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",Arial,sans-serif;' + 'font-size:10.5px;font-weight:600;line-height:1.3;letter-spacing:.01em;' + 'cursor:pointer;user-select:none;-webkit-user-select:none;'; el.innerHTML = '粉笔自动全开草稿' + '粉笔脚本君出品' + '' + '▾' + // 展开面板:向上弹(胶囊贴在屏幕右下,下面没有空间) '
' + '
' + '资料分析 · 1拖5' + '开' + '
' + '
' + '全部题目' + '开' + '
' + '
' + '关闭后刷新页面生效
' + '
'; // 点胶囊本体 = 展开/收起面板(旧版是「点一下就关脚本」,v2.8.2 改成入口) el.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); var p = el.querySelector('#marvis-flat-panel'); if (!p) return; var open = p.style.display === 'flex'; p.style.display = open ? 'none' : 'flex'; var caret = el.querySelector('.mv-caret'); if (caret) caret.textContent = open ? '▾' : '▴'; el.setAttribute('data-open', open ? '0' : '1'); }); // 面板两行:各自独立的开关,改完刷新 [].slice.call(el.querySelectorAll('.mv-row')).forEach(function (row) { row.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); var k = row.getAttribute('data-k'); if (k === 'group') { if (!masterEnabled()) return; // 「全部关闭」时本行无效(已压暗) try { localStorage.setItem(GROUP_LS, groupEnabled() ? '0' : '1'); } catch (e) {} } else { try { localStorage.setItem(MASTER_LS, masterEnabled() ? '0' : '1'); } catch (e) {} } chipReload(); }); }); // 点空白处收起(capture:整页点击过滤器 stopPropagation 也挡不住它) if (!document.__marvisChipOutside) { document.__marvisChipOutside = 1; document.addEventListener('click', function (ev) { var c = document.getElementById('marvis-flat-chip'); var p = c && c.querySelector('#marvis-flat-panel'); if (!p || !c || c.contains(ev.target)) return; if (p.style.display === 'flex') { p.style.display = 'none'; var caret = c.querySelector('.mv-caret'); if (caret) caret.textContent = '▾'; c.setAttribute('data-open', '0'); } }, true); } document.body.appendChild(el); } chipSync(el); } // 自检/单测入口:按当前 localStorage 重画胶囊与面板开关状态(不注入任何功能) window.__MARVIS_FENBI_CHIP__ = masterChip; if (!masterEnabled()) { masterChip(); return; } // 关闭态:不注入任何功能,只留设置入口 if (window.__MARVIS_FENBI_ENHANCE__) return; window.__MARVIS_FENBI_ENHANCE__ = true; masterChip(); var DLOCK_ATTR = 'data-marvis-dlock'; var AUTO_ATTR = 'data-marvis-autoopen'; var BOX_ATTR = 'data-marvis-boxkey'; var BOXVER_ATTR = 'data-marvis-boxver'; // 盒子结构版本戳:升级换代时强制重建 var BOX_VER = '2'; // v2:下一题支持组内子题推进 var CV_ATTR = 'data-marvis-cv'; // 自制画布标记(材料/子题) var CV_VER = '1'; var HIDE_ATTR = 'data-marvis-hidden'; // 被本脚本压制(组级原生草稿) /* ---------- 样式 ---------- */ var STYLE_ID = 'marvis-fenbi-enhance-style'; // 版本戳:脚本升级(无刷新重注入 / 旧版残留)时样式也要跟着换, // 否则新 CSS 被 ID 守卫跳过——曾因此丢掉 .marvis-pen 的 position:absolute, // 画布退化成 flex 项把宿主撑爆、高度滚雪球(E2E 实测复现)。 var STYLE_VER_ATTR = 'data-marvis-style-ver'; var oldStyle = document.getElementById(STYLE_ID); if (oldStyle && oldStyle.getAttribute(STYLE_VER_ATTR) === SCRIPT_VER) { // 同版本已注入,跳过 } else { if (oldStyle) oldStyle.remove(); // 旧版残留:整块换掉 var st = document.createElement('style'); st.id = STYLE_ID; st.setAttribute(STYLE_VER_ATTR, SCRIPT_VER); 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:0;padding:0 8px;border-radius:4px;', 'border:1px solid #3b6ef0;background:#fff;color:#3b6ef0;font-size:12px;line-height:20px;text-align:center;cursor:pointer;white-space:nowrap;}', '.marvis-lbox .marvis-next:hover{background:#3b6ef0;color:#fff;}', '.marvis-lbox .marvis-next:active{background:#2b55c8;color:#fff;}', '.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-container(z-index:130) 绝对定位会盖住右上角工具条, 导致「标记 / 收藏 / 草稿」按钮不可点击;把 ul.title-right 层级提到画布之上(140) */ 'ul.title-right{position:relative;z-index:140;}', '.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;}', /* ⑥ ABCD 点击反馈按钮本身变色 */ 'button.marvis-feedback-correct{background:#00a05a!important;color:#fff!important;border-color:#00a05a!important;}', 'button.marvis-feedback-wrong{background:#e02020!important;color:#fff!important;border-color:#e02020!important;}', 'button.marvis-feedback-selected{background:#1a3f8f!important;color:#fff!important;border-color:#1a3f8f!important;}', /* ⑧ 自制画布(材料草稿 / 子题草稿):z-index 130 与原生草稿同级, 字母盒 260、题目工具条 140 仍在其上;touch-action 避免触摸绘制时滚动页面 */ '.marvis-pen{position:absolute;left:0;top:0;z-index:130;touch-action:none;}', '.marvis-cv-tools{position:absolute;right:8px;z-index:260;display:flex;gap:6px;user-select:none;}', /* 材料工具条:通栏 band 钉在材料顶部(不再悬浮压住材料文字), 材料内容用 .marvis-mat-host .material-body 的 padding-top 让位 */ '.marvis-cv-tools-top{top:0;left:0;right:0;height:auto;justify-content:flex-end;', 'background:#fff;border-bottom:1px solid #e8e8e8;padding:4px 8px;box-sizing:border-box;}', '.marvis-mat-host .material-body{padding-top:34px !important;}', /* v2.7.2:材料内超宽素材(图片/表格/SVG)收缩到栏宽——栏宽由分隔条拖动或 「适应」收窄决定,素材不收缩就会被左栏 overflow-x:hidden 裁掉右半 (实测:表格/图片/文字全部显示不全的根因之一)。用规则而非行内样式, Angular 动态渲染出来的新素材同样生效 */ '.materials-container img,.materials-container table,.materials-container svg{max-width:100%!important;}', '.materials-container img{height:auto!important;}', /* v2.7.4:左下角「答题卡」折叠把手全透明(用户反馈小白卡挡题面文字)。 白卡不是背景色,是 .answer-card-tag 内 SVG path 用 fill/stroke 画的, 另有一条 ::after 白条——三处都要褪。只褪折叠态(JS 按容器位置打 .marvis-ac-clear 类):展开态这个 tag 就是答题卡面板的头部,必须保持原样。 该把手是全页唯一答题卡入口,只褪色、不隐藏、不拦点击 */ '.marvis-ac-clear .answer-card-tag .answer-card-tag-icon path{fill:transparent!important;stroke:transparent!important;}', '.marvis-ac-clear .answer-card-tag .answer-card-tag-icon{filter:none!important;}', '.marvis-ac-clear .answer-card-tag::after{background:transparent!important;}', '.marvis-cv-tools-bottom{bottom:6px;}', '.marvis-cv-tools .marvis-cvbtn{box-sizing:border-box;width:auto;min-height:22px;margin:0;padding:3px 8px;', 'border-radius:4px;border:1px solid #3b6ef0;background:#fff;color:#3b6ef0;', 'font-size:12px;line-height:1.35;text-align:center;cursor:pointer;white-space:normal;max-width:300px;', /* v2.7.9:切换按钮要长按退出,禁掉长按选中/系统菜单(iPad 上长按文字会弹菜单) */ 'user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;}', '.marvis-cv-tools .marvis-cvbtn:hover{background:#3b6ef0;color:#fff;}', '.marvis-cv-tools .marvis-cvbtn-on{background:#3b6ef0;color:#fff;}', '.marvis-cv-tools .marvis-cvbtn-warn{background:#e02020;border-color:#e02020;color:#fff;}', /* v2.7.7 整页草稿层(仅资料分析子题):透明全屏层对 HitTesting 隐身, 只靠 window 捕获层 + setPointerCapture 收笔/鼠标。 v2.7.9:右上角胶囊(pill)整体删除——工具条留在题目原地不再飞走。 */ '#marvis-fp-layer{position:fixed;inset:0;z-index:2147481000;pointer-events:none;}', '#marvis-fp-layer canvas{position:absolute;left:0;top:0;width:100%;height:100%;touch-action:none;}', /* v2.8.0 整页草稿「草稿竖条」:仿粉笔原生草稿工具条(撤销/橡皮/清空/退出那条)的 深色竖排样式,贴屏幕右边居中常驻。用户要求把「当前可触摸 / 当前可书写」和 「清空」都收进草稿按钮区,不要另外两个悬浮小白按钮。 */ '#marvis-fp-dock{position:fixed;right:0;top:50%;transform:translateY(-50%);z-index:2147482000;', 'display:none;flex-direction:column;width:64px;box-sizing:border-box;padding:6px 0;', 'border-radius:12px 0 0 12px;background:#3c464f;box-shadow:0 2px 12px rgba(0,0,0,.3);', 'user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;}', '#marvis-fp-dock.on{display:flex;}', '#marvis-fp-dock .marvis-fpd-item{display:flex;flex-direction:column;align-items:center;gap:2px;', 'width:100%;margin:0;padding:7px 2px;border:0;background:transparent;cursor:pointer;', 'color:rgba(255,255,255,.62);font-family:-apple-system,"PingFang SC",sans-serif;', 'user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;}', '#marvis-fp-dock .marvis-fpd-item+.marvis-fpd-item{border-top:1px solid rgba(255,255,255,.08);}', '#marvis-fp-dock .marvis-fpd-item:active{background:rgba(255,255,255,.1);}', '#marvis-fp-dock .marvis-fpd-item.on{color:#fff;}', '#marvis-fp-dock .marvis-fpd-item.warn{color:#ff8b7d;}', '#marvis-fp-dock .fpd-ico{display:block;width:20px;height:20px;}', '#marvis-fp-dock .fpd-name{display:block;font-size:11px;line-height:13px;letter-spacing:0;', 'white-space:nowrap;text-align:center;}', /* 会话归属提示:当前这道题的草稿笔图标高亮(再点一次即退出整页草稿) */ 'li.draft.tool.marvis-fp-pen-on{color:#3b6ef0!important;}', 'li.draft.tool.marvis-fp-pen-on svg{fill:#3b6ef0!important;}' ].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 会变化); // 无题号时退化为 data-question-key(子题兜底) function tiKey(ti) { var i = ti.querySelector('.title-index'); var t = i ? (i.textContent || '').trim() : ''; return t || (ti.getAttribute && ti.getAttribute('data-question-key')) || ''; } // 「题干+选项」区域相对 .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; } // 锁定目标高度 = 题干+选项区底边相对 .ti 顶部 + 48(与 lockDraft 同一口径) function draftTargetHeight(ti) { var h = measureTi(ti); return h ? h + 48 : null; } /* ---------- ⑧ 资料分析「一拖五」组识别(2026-10-23 实测) ---------- */ // 组 = 含 .materials-container / .questions-anchors 的 .ti;其子题为 // app-ti.question-multiple(无 ti class,旧版完全没覆盖到)。 function isGroupTi(ti) { return !!(ti.querySelector('.materials-container') || ti.querySelector('.questions-anchors')); } function subsOf(group) { return [].slice.call(group.querySelectorAll('app-ti.question-multiple')).filter(function (st) { return typeof st.closest === 'function' && st.closest('.ti') === group; }); } // 元素所属的「题目作用域」:子题优先,否则所在的 .ti(组本身不是作用域) function questionScopeOf(el) { if (!el || typeof el.closest !== 'function') return null; var sub = el.closest('app-ti.question-multiple'); if (sub) return sub; var ti = el.closest('.ti'); return ti && !isGroupTi(ti) ? ti : null; } // 全量作用域列表:组展开为各个子题,普通题即自身 function allScopes() { var out = []; tis().forEach(function (ti) { if (isGroupTi(ti)) subsOf(ti).forEach(function (s) { out.push(s); }); else out.push(ti); }); return out; } // 组的稳定 key:优先子题 data-question-key 的公共前缀(3_1_ijsk5..9 → 3_1_ijsk), // 退化用材料文本哈希(同一份材料重进后一致)。 // 撞名保护(v2.7.0 修复存量 bug):题号是三十六进制计数器,相邻两组的公共前缀 // 可能相同(3_1_ijsi4~8 与 3_1_ijsi9~ijsid 前缀都是 3_1_ijsi)——旧版因此让相邻组 // 的材料画布共享同一份 localStorage(笔迹/缩放串组,真机实测复现)。 // 现按文档序先到先得(旧 key 不变、老笔迹不丢),撞名的组追加首道子题完整 key。 function hashText(s) { var h = 2166136261; for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0).toString(36); } var groupKeyClaims = {}; // base → 首道子题 data-question-key(稳定身份) function groupKeyOf(group) { var subs = subsOf(group); var keys = subs.map(function (s) { return s.getAttribute('data-question-key') || ''; }).filter(Boolean); var base = ''; if (keys.length) { var prefix = keys[0].replace(/\d+$/, ''); base = (prefix && keys.every(function (k) { return k.indexOf(prefix) === 0; })) ? prefix : keys[0]; } else { var mc = group.querySelector('.material-content') || group.querySelector('.materials-container'); base = 'h' + hashText((mc && mc.textContent || '').replace(/\s+/g, ' ').slice(0, 400)); var t0 = subs[0] ? (subs[0].querySelector('.title-index') || { textContent: '' }).textContent : ''; var idx = (t0 || '').replace(/[^\w.]/g, ''); if (idx) base += '-' + idx; // 文本哈希也可能撞(空材料):追加入组内首道子题题号 } var ident = keys[0] || ''; // 组的稳定身份(Angular 重建元素后仍一致) if (ident) { var owner = groupKeyClaims[base]; if (owner && owner !== ident) { base = base + '-' + ident; if (groupKeyClaims[base] && groupKeyClaims[base] !== ident) base += '-' + hashText(ident); } groupKeyClaims[base] = ident; } return base; } /* ---------- ⑨ 性能护栏:视口附近才自动开草稿/建画布 ---------- */ // 110 题全开画布在低配/软件渲染环境 tile 内存超限(实测卡死渲染进程)。 // 只有接近视口的题才做「重活」(开原生草稿 / 建画布);字母盒与回填保持全量。 function nearViewport(ti) { try { var r = ti.getBoundingClientRect(); var y = window.pageYOffset || document.documentElement.scrollTop || 0; var vh = window.innerHeight || document.documentElement.clientHeight || 0; var top = r.top + y, bottom = r.bottom + y; return bottom > y - vh * 0.6 && top < y + vh * 1.6; } catch (e) { return true; } } // 修复「退出草稿重开后笔迹被压缩到最上」的根因: // 页面重开草稿时会重建 canvas#draft,并按整个 .ti(含已展开的解析区)高度设置 // 绘图缓冲区(height attribute),而容器 CSS 高度被本脚本锁定为「题干+选项」高度, // 缓冲区高度远大于显示高度 → 笔迹被垂直压缩(实测约 26%)。 // 这里在粉笔设置 canvas.height/width 的瞬间把缓冲区尺寸改写为容器显示尺寸: // 此刻画布刚创建、内容为空,改写不会丢笔迹;之后粉笔重放/书写都落在正确尺寸上, // 显示即 1:1。宽度仅在确实失配时改写(避免无谓触发 canvas 清空)。 function installDraftSizeHook() { var P = window.HTMLCanvasElement && window.HTMLCanvasElement.prototype; if (!P || P.__marvisDraftSizeHooked) return; try { P.__marvisDraftSizeHooked = true; var dW = Object.getOwnPropertyDescriptor(P, 'width'); var dH = Object.getOwnPropertyDescriptor(P, 'height'); function syncSetter(kind) { var d = kind === 'height' ? dH : dW; if (!d || !d.get || !d.set) return null; return function (v) { var target = null; if (this && this.id === 'draft' && typeof this.closest === 'function') { var dc = this.closest('.draft-container'); if (kind === 'height') { var ti = dc && typeof dc.closest === 'function' ? dc.closest('.ti') : null; // 一拖五组的原生草稿会被整体隐藏(见 neutralizeGroupDraft),不锁定其尺寸 target = ti && !isGroupTi(ti) ? draftTargetHeight(ti) : null; } else if (dc) { target = dc.clientWidth || null; } } if (target && Math.abs(target - v) > 1) { d.set.call(this, target); return; } d.set.call(this, v); }; } var setW = syncSetter('width'); var setH = syncSetter('height'); if (setW) { Object.defineProperty(P, 'width', { configurable: true, enumerable: dW.enumerable, get: function () { return dW.get.call(this); }, set: setW }); } if (setH) { Object.defineProperty(P, 'height', { configurable: true, enumerable: dH.enumerable, get: function () { return dH.get.call(this); }, set: setH }); } } catch (e) { /* 拦截失败不影响原有功能 */ } } installDraftSizeHook(); /* ---------- ③ 草稿尺寸锁定 ---------- */ function lockDraft(ti) { var dc = ti.querySelector('.draft-container'); if (!dc) return; var h = measureTi(ti); if (!h) return; var target = (h + 48) + 'px'; if (dc.style.getPropertyValue('height') !== target) { dc.style.setProperty('height', target, '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); } // 把目标题目滚动到视口垂直居中位置。 // 题目高度不超过一屏时整题居中;题目高于一屏时改为「顶部保留可见余量」, // 保证题干头部与首行选项不会被顶出屏幕外。 function centerTi(ti) { if (!ti) return; var r = ti.getBoundingClientRect(); var vh = window.innerHeight || document.documentElement.clientHeight; var y = window.pageYOffset || document.documentElement.scrollTop || 0; var target; if (r.height <= vh) { target = y + r.top + r.height / 2 - vh / 2; // 常规题:整题中心对齐视口中心 } else { var pad = Math.min(Math.round(vh / 6), 120); // 超高题:顶部留出约 1/6 屏空白(上限 120px) target = y + r.top - pad; } var maxTop = Math.max(0, (document.documentElement.scrollHeight || 0) - vh); if (target < 0) target = 0; if (target > maxTop) target = maxTop; // 末尾题目滚不到目标位时贴底,避免超界空白 window.scrollTo({ top: Math.round(target), behavior: 'smooth' }); } /* ---------- ①② ABCD 字母盒 + 下一题 ---------- */ /* ⑥ ABCD 点击反馈按钮本身变色 */ function clearFeedback(ti) { var btns = ti.querySelectorAll('.marvis-lbtn'); [].slice.call(btns).forEach(function (b) { b.classList.remove('marvis-feedback-correct', 'marvis-feedback-wrong', 'marvis-feedback-selected'); }); } function applyFeedback(ti, cr) { // 动态索引映射:ti 内 li.choice-radio 顺序 = .marvis-lbox 内 .marvis-lbtn 顺序。 // Angular 判分会重建 li 节点,按钮不随重建丢失,按顺序动态查找即可上色。 var lbox = ti.querySelector('.marvis-lbox'); var radios = [].slice.call(ti.querySelectorAll('li.choice-radio')); var btns = lbox ? [].slice.call(lbox.querySelectorAll('.marvis-lbtn')) : []; function btnOf(li) { var idx = radios.indexOf(li); return idx > -1 ? btns[idx] : null; } var correctEls = ti.querySelectorAll('li.choice-radio div.input-radio.correct, li.choice-radio div.input-radio.correctLost'); var wrongEls = ti.querySelectorAll('li.choice-radio div.input-radio.wrong'); if (correctEls.length > 0 || wrongEls.length > 0) { [].slice.call(correctEls).forEach(function (el) { var btn = btnOf(el.closest('li.choice-radio')); if (btn) btn.classList.add('marvis-feedback-correct'); }); [].slice.call(wrongEls).forEach(function (el) { var btn = btnOf(el.closest('li.choice-radio')); if (btn) btn.classList.add('marvis-feedback-wrong'); }); } else if (cr) { var b = btnOf(cr); if (b) b.classList.add('marvis-feedback-selected'); } } // 单选选中态识别(做题模式)。粉笔选中样式是 input[type=radio]:checked + .input-radio // 的兄弟选择器,选中时 div.input-radio 本身不加 class(页面 CSS 里只有 correct/wrong), // 理论上 input.checked 是唯一标识;但不同入口(重进恢复)可能走 setAttribute / aria / // class 等路径,这里多信号并取,任一命中即视为已选,避免漏判导致按钮空白。 function isRadioSelected(li) { var inp = li.querySelector('input.option-radio') || li.querySelector('input[type="radio"]'); if (inp) { if (inp.checked) return true; // property(主路径) if (inp.hasAttribute && inp.hasAttribute('checked')) return true; // setAttribute 恢复 if (inp.getAttribute && inp.getAttribute('aria-checked') === 'true') return true; } // class 兜底:只信 div.input-radio / label(页面自身 CSS 从不给这两个加 selected 类, // 一旦出现基本就是状态标记);li 不查,避免容器级 class 误伤。 var marks = [li.querySelector('div.input-radio'), li.querySelector('label')]; for (var i = 0; i < marks.length; i++) { var el = marks[i]; if (!el || !el.classList) continue; if (el.classList.contains('checked') || el.classList.contains('selected') || el.classList.contains('chosen') || el.classList.contains('active')) return true; if (el.getAttribute && el.getAttribute('aria-checked') === 'true') return true; } return false; } /* ⑦ 按页面既有作答状态回填按钮颜色(重进页面 / 答案异步回填 / Angular 重建选项节点均适用) */ function syncFeedback(ti) { if (!ti || !ti.isConnected) return; if (isGroupTi(ti)) return; // 组不是题目作用域:其子题各自同步(见 allScopes) // 刚点过按钮的题在短时间内交给点击回调着色,避免判分 class 尚未落地时被清空造成闪烁 if (ti.__marvisClickTs && Date.now() - ti.__marvisClickTs < 1200) return; var lbox = ti.querySelector('.marvis-lbox'); if (!lbox) return; var radios = [].slice.call(ti.querySelectorAll('li.choice-radio')); var btns = [].slice.call(lbox.querySelectorAll('.marvis-lbtn')); if (!radios.length || !btns.length) return; var target = {}; var judged = false; // ① 已判分(背题 / 交卷后):correct、correctLost 上绿,wrong 上红 radios.forEach(function (li, i) { var ir = li.querySelector('div.input-radio'); if (!ir) return; if (ir.classList.contains('correct') || ir.classList.contains('correctLost')) { target[i] = 'marvis-feedback-correct'; judged = true; } else if (ir.classList.contains('wrong')) { target[i] = 'marvis-feedback-wrong'; judged = true; } }); // ② 未判分(做题模式):选中态走 isRadioSelected 多信号识别 if (!judged) { radios.forEach(function (li, i) { if (isRadioSelected(li)) target[i] = 'marvis-feedback-selected'; }); } // 幂等写入:仅在 class 与目标不一致时改动,避免每次扫描都触发重绘 btns.forEach(function (b, i) { var want = target[i] || ''; var has = b.classList.contains('marvis-feedback-correct') ? 'marvis-feedback-correct' : b.classList.contains('marvis-feedback-wrong') ? 'marvis-feedback-wrong' : b.classList.contains('marvis-feedback-selected') ? 'marvis-feedback-selected' : ''; if (has === want) return; b.classList.remove('marvis-feedback-correct', 'marvis-feedback-wrong', 'marvis-feedback-selected'); if (want) b.classList.add(want); }); } // 同一帧内合并多次同步请求(key 按元素唯一分配,避免子题与普通题题号相撞时互相吞掉) var syncKeySeq = 0; var syncPendingKey = {}; function syncKeyOf(ti) { if (!ti.__marvisSyncKey) ti.__marvisSyncKey = 's' + (++syncKeySeq); return ti.__marvisSyncKey; } function scheduleSync(ti) { if (!ti) return; var key = syncKeyOf(ti); if (syncPendingKey[key]) return; syncPendingKey[key] = true; requestAnimationFrame(function () { syncPendingKey[key] = false; syncFeedback(ti); }); } // 重进页面后作答数据异步回填时,Angular 只对 input.checked 做 property 赋值: // 不新增节点(MutationObserver 观察不到)、也不挂 class,因此 hook setter 精准触发回填。 function installCheckedHook() { var P = window.HTMLInputElement && window.HTMLInputElement.prototype; if (!P || P.__marvisCheckedHooked) return; try { var d = Object.getOwnPropertyDescriptor(P, 'checked'); if (!d || !d.get || !d.set) return; P.__marvisCheckedHooked = true; Object.defineProperty(P, 'checked', { configurable: true, enumerable: d.enumerable, get: function () { return d.get.call(this); }, set: function (v) { d.set.call(this, v); try { if (this.classList && this.classList.contains('option-radio')) { // 子题内的 radio:作用域是子题本身(不是外层组 .ti) var scope = questionScopeOf(this); if (scope) scheduleSync(scope); } } catch (e) {} } }); } catch (e) { /* hook 失败时仍有 change 事件与定时补偿兜底 */ } } installCheckedHook(); // 兜底:用户点选后浏览器会改 checked 且派发 change,捕获阶段统一同步 document.addEventListener('change', function (ev) { var t = ev.target; if (!t || !t.classList || !t.classList.contains('option-radio')) return; var scope = questionScopeOf(t); if (scope) scheduleSync(scope); }, true); // 兜底:页面加载初期 / 重进恢复时作答数据可能晚于按钮注入才落地。 // 早期只一次性补偿到 6s,且恢复若走属性级路径(setAttribute/class/aria)不会被触发,会漏; // 改为前 30s 每秒一次的全量补偿(syncFeedback 幂等,无变化不写 DOM,空转开销可忽略), // 之后交给 MO 属性监听 / setter hook / change 捕获持续兜底。 // 组要展开成子题同步(组本身不是作用域)。 function syncAllFeedback() { allScopes().forEach(syncFeedback); } window.__MARVIS_FENBI_SYNC__ = syncAllFeedback; // 控制台手动补偿:__MARVIS_FENBI_SYNC__() setTimeout(syncAllFeedback, 0); var sweepLeft = 30; var sweepTimer = setInterval(function () { syncAllFeedback(); if (--sweepLeft <= 0) clearInterval(sweepTimer); }, 1000); // 生命周期兜底:bfcache 恢复 / 切回标签页 / 窗口聚焦时立即补偿一次 window.addEventListener('pageshow', syncAllFeedback); document.addEventListener('visibilitychange', function () { if (document.hidden) cvFlushAll(); // ⑧ 离页前把自制画布笔迹落盘 else syncAllFeedback(); }); window.addEventListener('focus', syncAllFeedback); window.addEventListener('pagehide', cvFlushAll); function buildBox(ti) { var tl = ti.querySelector('.title-left'); if (!tl) return; var key = tiKey(ti); var old = tl.querySelector('.marvis-lbox'); if (old) { // 同一题且结构版本一致才跳过;版本戳变化(脚本升级)时强制重建, // 例如 v2.3.0 起「下一题」要支持组内子题推进,旧盒子的闭包 handler 必须换掉。 if (old.getAttribute(BOX_ATTR) === key && old.getAttribute(BOXVER_ATTR) === BOX_VER) return; clearFeedback(ti); // 旧反馈 class 不残留到新题 old.remove(); // 元素复用渲染新题,重建 } var box = document.createElement('div'); box.className = 'marvis-lbox'; box.setAttribute(BOX_ATTR, key); box.setAttribute(BOXVER_ATTR, BOX_VER); 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; cr.__marvisBtn = b; // 兼容旧引用;判分重建后新 li 无此属性,上色以 applyFeedback 动态索引为准 b.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); ti.__marvisClickTs = Date.now(); // 短暂锁定:此期间同步逻辑不覆盖点击着色 clearFeedback(ti); // 先清旧反馈 // Angular 判分会重建 li.choice-radio,闭包旧 cr 会失效; // 点击时按 ti 内 li 顺序动态取与按钮同序的当前 li,再取 label 触发原生判分 var liNow = [].slice.call(ti.querySelectorAll('li.choice-radio'))[i] || cr; var lab = liNow.querySelector('label.choice-radio-label') || liNow.querySelector('label'); // v2.7.9:锁定态(可触摸)下合成 click 走放行计数,否则会被自己的点击过滤器吃掉 if (lab) fpAllowOnce(function () { lab.click(); }); // 触发原生选项选择 setTimeout(function () { applyFeedback(ti, liNow); }, 250); // 等原生对错 class 落地后着色 }); 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(); fpExit(); // v2.7.9:换题即退出整页草稿(新题自己再决定要不要写) // 组内推进要合成点 tab,同样走放行计数(否则被锁定态的点击过滤器吃掉) fpAllowOnce(function () { goNextFrom(ti); }); }); box.appendChild(nb); tl.appendChild(box); // 追加到 .title-left 末尾,避免负 left 被父级 overflow 裁切 } /* ---------- ⑧ 组内导航:tab 工具与容器滚动 ---------- */ // 实测:tab 是 app-expand 内 .tabs-content > a.tab#nav0..4,与子题一一对应; // 点击只切 active 类、不滚动 questions-container(滚动需自行处理)。 function groupTabs(group) { return [].slice.call(group.querySelectorAll('.tabs-content a.tab')); } function activateGroupTab(group, idx) { var tabs = groupTabs(group); if (tabs.length === subsOf(group).length && tabs[idx]) { tabs[idx].click(); return true; } return false; } // 把子题滚进 questions-container 可视区(顶部对齐留 12px;超高子题同样适用) function ensureSubVisible(group, sub) { try { var qc = group.querySelector('.questions-container'); if (!qc) return; var qcr = qc.getBoundingClientRect(); var r = sub.getBoundingClientRect(); var delta = r.top - qcr.top - 12; if (delta < 0 || r.bottom > qcr.bottom - 8) { qc.scrollTop = Math.max(0, qc.scrollTop + delta); } } catch (e) {} } // 「下一题」统一入口:子题优先推进组内下一个 tab(组末才进下一道 .ti); // 进入组时复位到第一个子题;普通题保持原行为(滚动居中 + 强制开草稿)。 function goNextFrom(scope) { var group = scope && typeof scope.closest === 'function' ? scope.closest('.ti') : null; var inGroup = group && isGroupTi(group); if (inGroup) { var subs = subsOf(group); var i = subs.indexOf(scope); if (i > -1 && i < subs.length - 1) { activateGroupTab(group, i + 1); if (group.getAttribute(FLAT_ATTR) === '1') centerTi(subs[i + 1]); // 展平:页面滚动 else ensureSubVisible(group, subs[i + 1]); // 原生:容器滚动 return; } } var all = tis(); var idx = all.indexOf(inGroup ? group : scope); var next = idx > -1 ? all[idx + 1] : null; if (!next) return; centerTi(next); if (isGroupTi(next)) activateGroupTab(next, 0); // 进入组时从第一个子题开始 processTi(next, { force: true, sync: true }); // 草稿/画布立即就位(不等滚动扫描) if (!isGroupTi(next)) { clickDraftTool(next); // 用户主动:强制打开原生草稿 setTimeout(function () { lockDraft(next); }, 450); } } // 组级原生草稿:absolute top:0 覆盖整个 .ti(含材料与 tab 栏),吃掉全部指针事件, // 是「一拖五草稿不可用」的直接原因;粉笔重载还会自动恢复打开,故每次扫描都压制。 // 实测 display:none 无副作用(不影响 tab 切换与页面其它交互)。 function neutralizeGroupDraft(group) { [].slice.call(group.children).forEach(function (ch) { if (!ch.classList || !ch.classList.contains('draft-container')) return; if (ch.getAttribute(HIDE_ATTR) === '1') return; ch.style.setProperty('display', 'none', 'important'); ch.setAttribute(HIDE_ATTR, '1'); }); } /* ---------- ⑪ 一拖五「解锁盒子」(v2.6.x,按用户澄清迭代) ---------- */ // 需求本源:盒子高度被写死 70vh(.resizable-container / .nz-resizable.left 内联 height:70vh), // 5 道子题只能在盒子内滚动翻题。要的不是打散重组,而是**把盒子做高**: // - 保持左右两栏(材料在左、题目在右,双双向下延伸,页面整体滚动); // - 全部高度锁(70vh)解锁 → height auto;questions-container overflow visible; // - 两栏宽度一个不动(左栏 615px 定宽保留、分隔条保留仍可拖宽材料栏); // - tab 栏隐藏(整列延伸后无意义);子题逐层解锁宽度(relaxEl,原始 style // 存 data-marvis-relax 供还原);通用兜底 expandScrollBoxes 解除任何残留内滚盒子; // - 材料共享画布保留、子题画布照常、「下一题」组内推进 = 页面滚动到下一子题。 // 子题节点原地不动(Angular 无感);每次扫描幂等重断言(MO 兜底粉笔重渲染)。 // 默认开启;控制台 __MARVIS_FENBI_FLATTEN__() 切换(持久化),关掉回原生分栏。 // v2.6.1:开关状态换新 key(旧 key 可能被逃生门持久化成 '0' 导致"改了没效果"); // 页面右下角加可见状态胶囊(展开 开/关/失败 · 版本号,点按即切换), // 展开异常时胶囊变红并在控制台给出原因;__MARVIS_FENBI_DIAG__() 输出结构自检。 // v2.6.3:解锁范围扩展到组的祖先滚动壳(整页外层 overflow 滚动壳不解锁的话, // 组内撑开了仍被外层兜住——「依然没有向下延伸」的根因);body/html overflow // hidden 时显式放开;DIAG 增加祖先壳链路信息。 var FLAT_ATTR = 'data-marvis-flat'; var FLAT_BOX = 'data-marvis-flatbox'; // v2.6.1:换新 key——旧 key 可能因用户跑过逃生门 __MARVIS_FENBI_FLATTEN__(false) // 被持久化成 '0',导致每次打开都是原生分栏(这正是「改了没效果」的真凶)。 var FLAT_LS = 'marvis-fb-flatten2'; var FLAT_LS_OLD = 'marvis-fb-flatten'; try { localStorage.removeItem(FLAT_LS_OLD); } catch (e) {} // 一次性清掉旧 key function flattenEnabled() { try { return localStorage.getItem(FLAT_LS) !== '0'; } catch (e) { return true; } } // 通用兜底:组内任何仍有内滚的盒子(overflow auto/scroll 且内容超高)一律解除 // 限制、整页向下拓展——不依赖白名单,粉笔改版多套一层容器也能展开。 // 计算样式判定天然幂等(展开后 overflowY=visible 不再命中);每次扫描都跑,代价 O(组内节点数)。 function expandScrollBoxes(group) { var els = [group].concat([].slice.call(group.querySelectorAll('*'))); for (var i = 0; i < els.length; i++) { var el = els[i]; if (el.tagName === 'CANVAS') continue; // 画布 absolute 覆盖,永远不滚 if (el.hasAttribute(STICKY_ATTR)) continue; // 吸顶左栏:overflow/高度是吸顶机制的一部分,不能解锁 var cs = null; try { cs = getComputedStyle(el); } catch (e) {} var oy = (cs && cs.overflowY) ? cs.overflowY : el.style.overflowY; if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight + 2) { el.style.overflowY = 'visible'; el.style.height = 'auto'; el.style.maxHeight = 'none'; el.setAttribute(FLAT_BOX, '1'); } } } // v2.6.3 关键补漏:组外面的祖先滚动容器也要解锁——70vh 盒子只在组内, // 整个题目列表外层很可能还有一层 overflow:auto/hidden 的滚动壳(截图里贯穿全屏的 // 滚动条就是它);只展开组内的话内容撑开了还是被外层壳兜住,用户看到的仍是"没延伸"。 // 祖先链逐层上溯到 body:内容真实超高(scrollHeight > clientHeight)才解锁, // 记入 flatAncestors 供关展平还原;body/html 只修 overflow(hidden→auto),不动高度。 var flatAncestors = []; function unlockScroller(el) { if (!el || !el.style || el.tagName === 'CANVAS') return; if (el.hasAttribute(FLAT_BOX)) return; var cs = null; try { cs = getComputedStyle(el); } catch (e) {} var oy = (cs && cs.overflowY) ? cs.overflowY : el.style.overflowY; if ((oy === 'auto' || oy === 'scroll' || oy === 'hidden') && el.scrollHeight > el.clientHeight + 2) { if (!el.hasAttribute(RELAX_ATTR)) el.setAttribute(RELAX_ATTR, el.getAttribute('style') || ''); el.style.setProperty('overflow-y', 'visible', 'important'); el.style.setProperty('height', 'auto', 'important'); el.style.setProperty('max-height', 'none', 'important'); el.setAttribute(FLAT_BOX, '1'); if (flatAncestors.indexOf(el) === -1) flatAncestors.push(el); } } function expandAncestorBoxes(group) { var el = group.parentElement; while (el && el !== document.body && el !== document.documentElement) { unlockScroller(el); el = el.parentElement; } // 顶层兜底:SPA 常把 body/html 设 overflow:hidden、改用内层滚动壳—— // 内层解锁后若 body 不能滚,页面会整页卡死,这里显式放开 [document.body, document.documentElement].forEach(function (el) { if (!el) return; var cs = null; try { cs = getComputedStyle(el); } catch (e) {} if (cs && cs.overflowY === 'hidden') el.style.setProperty('overflow-y', 'auto', 'important'); }); } // 逐层解锁布局:记下原始 style(data-marvis-relax)供关展平精确还原,然后: // 宽度/max-width 100%、min-width 0、left/right auto、margin/float 归零、transform none; // absolute/fixed 转 relative(脱离分栏偏移锚定,同时保住绝对定位子元素的包含块)。 var RELAX_ATTR = 'data-marvis-relax'; function relaxEl(el, keepPosition) { if (!el || !el.style) return; var first = !el.hasAttribute(RELAX_ATTR); if (first) el.setAttribute(RELAX_ATTR, el.getAttribute('style') || ''); el.style.setProperty('width', '100%', 'important'); el.style.setProperty('max-width', '100%', 'important'); el.style.setProperty('min-width', '0', 'important'); el.style.setProperty('left', 'auto', 'important'); el.style.setProperty('right', 'auto', 'important'); el.style.setProperty('margin-left', '0', 'important'); el.style.setProperty('margin-right', '0', 'important'); el.style.setProperty('float', 'none', 'important'); el.style.setProperty('box-sizing', 'border-box', 'important'); el.style.setProperty('transform', 'none', 'important'); if (first && !keepPosition) { var cs = null; try { cs = getComputedStyle(el); } catch (e) {} if (cs && (cs.position === 'absolute' || cs.position === 'fixed')) { el.style.setProperty('position', 'relative', 'important'); } } } // 高度解锁:盒子高度锁(70vh 等)→ auto(含 max-height),记 style 供还原 function relaxHeight(el) { if (!el || !el.style) return; if (!el.hasAttribute(RELAX_ATTR)) el.setAttribute(RELAX_ATTR, el.getAttribute('style') || ''); el.style.setProperty('height', 'auto', 'important'); el.style.setProperty('max-height', 'none', 'important'); } // 子题内容层拉满整宽(只解锁祖先链,不动题意/选项自身的内联样式) function relaxSubLayout(sub) { relaxEl(sub, true); sub.style.setProperty('display', 'block', 'important'); var probe = sub.querySelector('app-question-choice') || sub.querySelector('.ti-container') || sub.firstElementChild; var el = probe; while (el && el !== sub) { relaxEl(el); el = el.parentElement; } } // v2.7.1 左栏吸顶(两栏延伸形态):滚动右侧小题时材料始终钉在左侧完整可见, // 滚过组内最后一题(.resizable-container 底边过去)后自然释放、随页面滚走。 // 三个要点: // ① align-self:flex-start——resizable-container 默认 align-items:stretch // 会把左栏拉到右栏题目等高,sticky 就没有行程可走; // ② max-height 收进视口(扣除固定页头),装不下时栏内 overflow-y:auto 自滚; // ③ 每次扫描重断言(Angular 重渲染会重置左栏行内样式)。 var STICKY_ATTR = 'data-marvis-sticky'; var stickyTopOff = 0; function stickyTopOffset() { if (stickyTopOff) return stickyTopOff; var off = 8; // 没有固定页头时的回退 try { var els = document.querySelectorAll('body *'); for (var i = 0; i < els.length; i++) { var el = els[i]; if (!el || !el.tagName) continue; var cs = getComputedStyle(el); if (cs.position === 'fixed') { var r = el.getBoundingClientRect(); if (r.top < 4 && r.height >= 20 && r.height <= 240 && r.width > 300) { off = Math.round(r.height) + 8; break; } } } } catch (e) {} stickyTopOff = off; return off; } function stickLeftCol(group) { var left = group.querySelector('.nz-resizable.left') || group.querySelector('.left'); if (!left || !left.style) return; var top = stickyTopOffset(); left.style.setProperty('position', 'sticky', 'important'); left.style.setProperty('top', top + 'px', 'important'); left.style.setProperty('align-self', 'flex-start', 'important'); left.style.setProperty('max-height', 'calc(100vh - ' + (top + 8) + 'px)', 'important'); left.style.setProperty('overflow-x', 'hidden', 'important'); left.style.setProperty('overflow-y', 'auto', 'important'); left.setAttribute(STICKY_ATTR, '1'); // 打断 flex 拉伸链(v2.7.1 实测修复):栏被 max-height 收进视口后,若链条继续 // align-items:stretch,每层都被拉成栏高——材料多出的部分被 .left-section 的 // overflow:hidden 裁掉,且栏内滚动失效(scrollHeight 被锁成栏高:实测材料 // 901px 只露出 776px、scrollTop 置 300 也弹回 0)。让链上各层按内容高度排 // (flex:0 0 auto),溢出统一交给栏内 overflow-y:auto。 // 原始 style 记 RELAX_ATTR,关展平时随还原链路一起恢复。 ['.left-section', '.left-part', 'app-materials', '.materials-container', '.material-body'].forEach(function (sel) { var el = group.querySelector(sel); if (!el || !el.style) return; if (!el.hasAttribute(RELAX_ATTR)) el.setAttribute(RELAX_ATTR, el.getAttribute('style') || ''); el.style.setProperty('align-self', 'flex-start', 'important'); el.style.setProperty('flex', '0 0 auto', 'important'); // v2.7.2 修复(真机实测定位):flex-basis:auto + align-self:flex-start 会让链上 // 各层按内容的 max-content 宽度排布——材料文字 max-content 1664px 时整条链被撑到 // 1704px,而左栏只有 996px,overflow-x:hidden 把右半全部裁掉:文字被横切、 // 表格/图片被横切(用户报「图片显示不全、文字也显示不全」)。显式 width 压住 // max-content:横向恒等栏宽(拖分隔条/适应收窄时跟随重排),纵向仍靠 // flex:0 0 auto 按内容高度流式排布(拉伸链修复不受影响)。 // 宿主 .materials-container 是 cvZoomApply 的缩放对象:宽度恒 100%—— // v2.7.6 实测:zoom 坐标系里 width:100% 的视觉宽本就等于栏宽(百分比在 // 元素自身坐标系解析),补偿 100/s% 反而溢出 (1/s-1)×栏宽 被裁。 // 宿主缩放时 stickLeftCol 不得按 zoom 改宽度,只保留 100%。 el.style.setProperty('width', '100%'); el.style.setProperty('min-width', '0', 'important'); el.style.setProperty('box-sizing', 'border-box', 'important'); }); } try { window.addEventListener('resize', function () { stickyTopOff = 0; }); } catch (e) {} function flattenGroup(group) { if (group.getAttribute(FLAT_ATTR) !== '1') { relaxEl(group); relaxHeight(group); // 分栏骨架:只解锁高度——两栏布局原样保留(材料在左向下延伸、题目在右向下延伸, // 页面整体滚动);宽度一个不动(左栏 615px 定宽、分隔条都还能拖)。 // 例外:某层若是 absolute/fixed 定位(分栏偏移锚定),才连带解锁宽度与偏移。 function relaxCol(el) { if (!el) return; relaxHeight(el); var cs = null; try { cs = getComputedStyle(el); } catch (e) {} if (cs && (cs.position === 'absolute' || cs.position === 'fixed')) relaxEl(el); } relaxCol(group.querySelector('.resizable-container')); relaxCol(group.querySelector('app-resizable')); ['.nz-resizable.left', '.left', '.left-section', '.left-part', 'app-materials'].forEach(function (sel) { relaxCol(group.querySelector(sel)); }); var mat = group.querySelector('.materials-container'); if (mat) relaxHeight(mat); var mb = group.querySelector('.material-body'); if (mb) relaxHeight(mb); ['.right', '.right-section', '.right-part'].forEach(function (sel) { relaxCol(group.querySelector(sel)); }); var qc = group.querySelector('.questions-container'); if (qc) { relaxCol(qc); qc.style.setProperty('overflow', 'visible', 'important'); } // tab 栏隐藏(整列延伸后无意义);分隔条保留(仍可拖宽材料栏) var anchors = group.querySelector('.questions-anchors'); if (anchors) anchors.style.setProperty('display', 'none', 'important'); [].slice.call(group.querySelectorAll('a.label-tab')).forEach(function (el) { el.style.setProperty('display', 'none', 'important'); }); group.setAttribute(FLAT_ATTR, '1'); } // 左栏吸顶(幂等重断言: Angular 重渲染会重置左栏行内样式) stickLeftCol(group); // 子题逐层解锁宽度(幂等;重渲染后 Angular 可能重置,扫描时重断言) subsOf(group).forEach(relaxSubLayout); // 材料大图/表格不超出所在栏 [].slice.call(group.querySelectorAll('.material-body img, .material-body table')).forEach(function (el) { el.style.setProperty('max-width', '100%', 'important'); }); expandScrollBoxes(group); expandAncestorBoxes(group); // 组外的祖先滚动壳同样解锁(v2.6.3 关键补漏) } // 关闭展平(控制台切换):还原内联样式(relax 记录的原始 style 优先,最后写回) function unflattenGroup(group) { if (group.getAttribute(FLAT_ATTR) !== '1') return; [].slice.call(group.querySelectorAll('[' + STICKY_ATTR + ']')).forEach(function (el) { el.removeAttribute(STICKY_ATTR); // 行内样式由下方 RELAX_ATTR 还原链路恢复 }); ['.nz-resizable-handle', '.nz-resize-handle', '.questions-anchors'].forEach(function (sel) { var el = group.querySelector(sel); if (el) el.style.display = ''; }); [].slice.call(group.querySelectorAll('a.label-tab')).forEach(function (el) { el.style.display = ''; }); [].slice.call(group.querySelectorAll('[' + FLAT_BOX + ']')).forEach(function (el) { el.style.overflowY = ''; el.style.height = ''; el.style.maxHeight = ''; el.removeAttribute(FLAT_BOX); }); // 还原组外被解锁的祖先滚动壳(v2.6.3) flatAncestors.forEach(function (el) { if (!el || !el.isConnected) return; var saved = el.getAttribute(RELAX_ATTR); el.setAttribute('style', saved || ''); if (!saved) el.removeAttribute('style'); el.removeAttribute(FLAT_BOX); el.removeAttribute(RELAX_ATTR); }); flatAncestors.length = 0; var relaxed = [group].concat([].slice.call(group.querySelectorAll('[' + RELAX_ATTR + ']'))); relaxed.forEach(function (el) { var saved = el.getAttribute(RELAX_ATTR); el.setAttribute('style', saved || ''); if (!saved) el.removeAttribute('style'); el.removeAttribute(RELAX_ATTR); }); group.removeAttribute(FLAT_ATTR); } /* ---------- ⑧ B2 自制画布:材料草稿 + 子题草稿 ---------- */ // 为什么自制:粉笔 web 端材料区没有草稿入口;子题的 li.draft.tool 实测点击无效 // (草稿由组级托管,且组级草稿会覆盖全组)。因此材料与每个子题各挂一个自制画布: // - absolute 覆盖宿主(材料=整个材料区;子题=题干+选项区),z-index 130 与原生草稿同级, // 字母盒 260 / 工具条 140 仍在其上; // - 模式两档循环:演算(手/笔皆可写)↔ 穿透(画布不挡指针); // - 笔迹按归一化坐标(0~1)存 localStorage,退出重进自动重放,容器尺寸变化不失真; // - 笔画用 arc 逐点画圆(与粉笔同款渲染路径),笔色/粗细走⑤笔刷 hook(class 含 pen)。 var CV_LS_PREFIX = 'marvis-fb-cv-'; var CV_MAX_STROKES = 300; // 单画布笔画数上限(超出丢最旧) var CV_MAX_POINTS = 1200; // 单笔画点数上限 var CV_MAX_BYTES = 200000; // 单 key 存储上限(约 200KB) var CV_ZOOM_MIN = 0.55; // 材料自适应缩小的可读性下限 // (材料含被栏宽钳制的图片/表格时 zoom 缩不动媒体, // 收窄也装不下就缩到下限、剩余滚动查看) var CV_MODES = ['draw', 'pass']; // 演算(全写,默认) ↔ 穿透,点按循环 var cvRegistry = []; // 所有存活画布(pagehide 时统一落盘) var cvSaveTimers = {}; var cvStorageOk = true; function cvLoad(key) { try { var raw = localStorage.getItem(CV_LS_PREFIX + key); if (!raw) return null; var d = JSON.parse(raw); return d && Array.isArray(d.strokes) ? d : null; } catch (e) { return null; } } // 笔画统一用归一化坐标 [x, y](0~1,相对画布尺寸)存储,分辨率无关 function cvCap(strokes) { var out = strokes.slice(-CV_MAX_STROKES); for (var i = 0; i < out.length; i++) { if (out[i] && out[i].length > CV_MAX_POINTS) out[i] = out[i].slice(-CV_MAX_POINTS); } return out; } // v2.7.8:清洗笔迹——旧版 fpFinish 的坐标 bug(把 [x,y] 数组当对象读)会往存储里 // 写一堆 null;这些 null 再被 fpMigrate 迁移会压成同一个常量点(整笔退化成一个点, // 画出来什么都没有)。加载时直接丢弃「非法点」和「所有点重合」的坏笔。 function cvSanitize(strokes) { var out = []; for (var i = 0; i < strokes.length; i++) { var s = strokes[i]; if (!s || !s.length) continue; var pts = []; for (var j = 0; j < s.length; j++) { var p = s[j]; if (!p || p.length < 2) continue; var x = +p[0], y = +p[1]; if (!isFinite(x) || !isFinite(y)) continue; pts.push([x, y]); } if (pts.length < 2) continue; // 单点笔迹不算笔(tap 本来就不落笔) var degenerate = true; for (var k = 1; k < pts.length; k++) { if (Math.abs(pts[k][0] - pts[0][0]) > 1e-6 || Math.abs(pts[k][1] - pts[0][1]) > 1e-6) { degenerate = false; break; } } if (!degenerate) out.push(pts); } return out; } function cvSave(key, strokes, mode, coordVer) { if (!cvStorageOk) return; try { // v2.7.8:v=2 表示「视口归一化坐标」(整页草稿层);v=1 是旧版题目框坐标。 // 记录版本是为了让刷新后的新 cv 知道笔迹已迁移过——否则每次重开都会把 // 视口坐标当框坐标再迁移一次,笔迹越跑越偏(真机 E2E 实测:刷新后重开 // 笔迹数据还在但画到视口外、看不见)。 var ver = coordVer === 2 ? 2 : 1; var capped = cvCap(strokes); var payload = JSON.stringify({ v: ver, mode: mode || 'draw', strokes: capped }); if (payload.length > CV_MAX_BYTES) { payload = JSON.stringify({ v: ver, mode: mode || 'draw', strokes: capped.slice(-40) }); } localStorage.setItem(CV_LS_PREFIX + key, payload); } catch (e) { cvStorageOk = false; // 配额溢出等:本会话不再落盘(内存笔迹不受影响) } } function cvScheduleSave(cv) { var key = cv.key; if (cvSaveTimers[key]) clearTimeout(cvSaveTimers[key]); cvSaveTimers[key] = setTimeout(function () { cvSaveTimers[key] = 0; cvSave(key, cv.strokes, cv.mode, cv.coordVer); }, 500); } function cvFlushAll() { Object.keys(cvSaveTimers).forEach(function (k) { if (cvSaveTimers[k]) { clearTimeout(cvSaveTimers[k]); cvSaveTimers[k] = 0; } }); cvRegistry.forEach(function (cv) { cvSave(cv.key, cv.strokes, cv.mode, cv.coordVer); }); } function penPoint(canvas, e) { var r = canvas.getBoundingClientRect(); var sx = r.width ? canvas.width / r.width : 1; var sy = r.height ? canvas.height / r.height : 1; return { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy }; } function penDot(ctx, x, y) { if (!ctx) return; if (!isFinite(x) || !isFinite(y)) return; // 防御:历史坏数据里的 null/NaN 不画 ctx.fillStyle = brushColor; // 笔刷 hook 统一改写为当前笔色(保留 alpha) ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); // 半径 2 量级,落在 hook 的缩放区间内 ctx.fill(); } function penReplay(canvas, strokes) { var ctx = null; try { ctx = canvas.getContext('2d'); } catch (e) {} if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); var w = canvas.width, h = canvas.height; for (var i = 0; i < strokes.length; i++) { var s = strokes[i]; for (var j = 0; j < s.length; j++) penDot(ctx, s[j][0] * w, s[j][1] * h); } } // 找元素向上最近的「真滚动容器」(内容超高且 overflow 可滚);找不到返回 null = 滚页面 function scrollerOf(el) { var n = el; for (var i = 0; n && i < 12; i++) { try { if (n.scrollHeight - n.clientHeight > 4) { var win = n.ownerDocument && n.ownerDocument.defaultView; var oy = (win || window).getComputedStyle(n).overflowY; if (oy === 'auto' || oy === 'scroll') return n; } } catch (e) {} n = n.parentElement; } return null; } function attachPen(canvas, cv) { var ctx = null; try { ctx = canvas.getContext('2d'); } catch (e) {} var drawing = false, pts = [], last = null; cv.__drawing = false; cv.__abort = function () { // 模式切换/重建时中断进行中的书写 drawing = false; pts = []; last = null; cv.__drawing = false; }; canvas.addEventListener('pointerdown', function (e) { if (cv.mode === 'pass') return; // 穿透模式 pointer-events:none,理论收不到,兜底 if (e.pointerType === 'mouse' && e.button !== 0) return; drawing = true; cv.__drawing = true; try { canvas.setPointerCapture(e.pointerId); } catch (err) {} last = penPoint(canvas, e); pts = [last]; penDot(ctx, last.x, last.y); e.preventDefault(); }); canvas.addEventListener('pointermove', function (e) { if (!drawing) return; var p = penPoint(canvas, e); var dx = p.x - last.x, dy = p.y - last.y; var dist = Math.sqrt(dx * dx + dy * dy); if (dist >= 0.6) { var steps = Math.max(1, Math.min(80, Math.floor(dist / 2))); for (var i = 1; i <= steps; i++) { var q = { x: last.x + dx * i / steps, y: last.y + dy * i / steps }; pts.push(q); penDot(ctx, q.x, q.y); } last = p; } e.preventDefault(); }); ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(function (t) { canvas.addEventListener(t, function () { if (!drawing) return; drawing = false; cv.__drawing = false; if (pts.length > 1) { var w = canvas.width || 1, h = canvas.height || 1; cv.strokes.push(pts.map(function (p) { return [p.x / w, p.y / h]; })); cvScheduleSave(cv); } pts = []; last = null; }); }); } // 模式全句提示(弹窗用):材料=材料区域;子题=第 N 题的题目区域 function cvSubNo(cv) { var idx = cv.host && cv.host.querySelector ? cv.host.querySelector('.title-index') : null; return idx ? (idx.textContent || '').replace(/[^\d]/g, '') : ''; } function drawLabel(cv) { if (cv.key && cv.key.indexOf('mat-') === 0) return '材料区域草稿已打开,当前可书写,不可触摸。'; var no = cvSubNo(cv); return (no ? '第' + no + '题的题目区域' : '题目区域') + '草稿已打开,当前可书写,不可触摸。'; } function passLabel(cv) { if (cv.key && cv.key.indexOf('mat-') === 0) return '材料区域草稿已让位,当前可触摸,不可书写。'; var no = cvSubNo(cv); return (no ? '第' + no + '题的题目区域' : '题目区域') + '草稿已让位,当前可触摸,不可书写。'; } // v2.7.8 整页草稿提示:整页都可写、滚动已锁定(用户 iPad 实测书写/滚动冲突后定稿) // v2.7.9:两态化——可触摸不再是「退出」,而是「只放行 ABCD/下一题」的锁定态 // v2.8.0:入口改成题目右上角的原生草稿笔图标,出口在右侧草稿竖条上 function fpDrawLabel(cv) { var no = cvSubNo(cv); return (no ? '第' + no + '题' : '本题') + '整页草稿已打开:整个页面都可书写(滚动已锁定);写完点右侧草稿栏的「当前可触摸」答题。'; } function fpPassLabel(cv) { var no = cvSubNo(cv); return (no ? '第' + no + '题' : '本题') + '当前可触摸:只可点 ABCD / 下一题 / 右侧草稿栏,其余按钮已锁定;点「当前可书写」可继续写。'; } function fpExitLabel() { return '已退出整页草稿:页面恢复正常,笔迹已保存;再点这道题的草稿笔图标可继续写。'; } /* ---- v2.7.8 整页草稿态锁定滚动 ---- 用户 iPad 实测:整页草稿开着时笔在写、手指一碰页面就滚,两个手势互相打架。 定稿:可书写态 = 纯书写状态,页面不许滚。 实现选择(真机两次实测对比后定): ① 不用 html/body overflow:hidden 锁——文档滚动容器一消失,Chrome 会把 scrollY 钳回 0(实测:43k 高的文档一开层就从 42934 跳回顶部,用户正在 看的那道题瞬间「跳走」);且粉笔自己的 JS 还会把 html 行内 style 重写成 「overflow-y:auto !important」覆盖我们的样式,要不停补锁; ② 改为拦输入事件:document 捕获层 touchmove / wheel preventDefault—— 页面停在原位,手指拖不动、滚轮也滚不动;笔的 pointer 流不受影响 (书写照常),轻点(tap)也不受影响(只拦 move,我们自己的按钮照常点)。 fpClose 移除监听即恢复。 */ var fpScrollLock = null; function fpLockScroll() { if (fpScrollLock) return; fpScrollLock = { touch: function (e) { e.preventDefault(); }, // 手指/笔的拖动手势一律不给页面 wheel: function (e) { e.preventDefault(); } // 桌面滚轮/触控板同样不滚 }; document.addEventListener('touchmove', fpScrollLock.touch, { capture: true, passive: false }); document.addEventListener('wheel', fpScrollLock.wheel, { capture: true, passive: false }); } function fpUnlockScroll() { if (!fpScrollLock) return; document.removeEventListener('touchmove', fpScrollLock.touch, { capture: true }); document.removeEventListener('wheel', fpScrollLock.wheel, { capture: true }); fpScrollLock = null; } // 全句提示弹窗(顶部居中淡入淡出,不打断操作;仅用户切到演算态时弹出) var cvToastTimer = 0; function cvToast(msg) { if (!document.body) return; var el = document.getElementById('marvis-cv-toast'); if (!el) { el = document.createElement('div'); el.id = 'marvis-cv-toast'; el.style.cssText = 'position:fixed;left:50%;top:12%;transform:translateX(-50%);z-index:2147483000;' + 'padding:10px 18px;border-radius:10px;background:rgba(28,32,38,.88);color:#fff;' + 'font:600 15px/1.5 -apple-system,"PingFang SC",sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.25);' + 'pointer-events:none;opacity:0;transition:opacity .25s;max-width:80vw;text-align:center;'; document.body.appendChild(el); } el.textContent = msg; el.style.opacity = '1'; clearTimeout(cvToastTimer); cvToastTimer = setTimeout(function () { el.style.opacity = '0'; }, 2600); } function applyCvMode(cv) { if (!cv) return; if (cv.fpEligible) { // v2.8.0:整页草稿是一个「会话」,会话内两态:可书写(draw) ↔ 可触摸(pass)。 // 未开会话时(__fpSess=false)什么也不做:不建层、不锁滚动、不拦点击。 if (!cv.__fpSess) { cvSyncBtn(cv); return; } if (cv.mode === 'draw') fpOpen(cv); // 可书写:开层 + 锁滚动 + 手指点按无效 else fpEnterPass(cv); // 可触摸:层留着(笔迹可见)+ 解锁滚动 + 只放行 ABCD cvSyncBtn(cv); return; } if (!cv.canvas) return; cv.canvas.style.pointerEvents = cv.mode === 'pass' ? 'none' : 'auto'; cvSyncBtn(cv); if (cv.__abort) cv.__abort(); // 模式切换即中断进行中的书写/滚动手势 } function cvSyncBtn(cv) { // v2.8.0:资料分析子题没有自己的悬浮按钮,状态同步全交给右侧草稿竖条 if (cv && cv.fpEligible) { fpDockSync(); return; } if (!cv || !cv.toggleBtn) return; var meta = { draw: ['当前可书写', '演算中:手/笔皆可书写;点按切到「当前可触摸」'], pass: ['当前可触摸', '画布不挡指针:可滚动/选中/点击下方内容;点按切回「当前可书写」'] }[cv.mode] || ['当前可书写', '']; cv.toggleBtn.textContent = meta[0]; cv.toggleBtn.title = meta[1]; cv.toggleBtn.className = 'marvis-cvbtn' + (cv.mode === 'pass' ? '' : ' marvis-cvbtn-on'); } /* ---------- v2.7.7 整页草稿层(仅资料分析子题,fpEligible) ---------- */ // 展开态:body 级透明层(fixed inset:0, pointer-events:none)盖住视口,笔/鼠标经 // window 捕获层 + setPointerCapture 在层内 canvas 上书写;层对 HitTesting 隐身, // 页面本体的滚动/点击照旧。任何时候最多一层。 // v2.7.9:会话两态(可书写 / 可触摸·锁定),工具条不再飞右上角。 var FP_LAYER_ID = 'marvis-fp-layer'; var FP_DOCK_ID = 'marvis-fp-dock'; // v2.8.0 草稿竖条(替代原来的两个悬浮按钮) var FP_PILL_ID = 'marvis-fp-pill'; // 仅用于清理旧版残留的右上角胶囊 // 我们自己的悬浮控件:落在上面一律放行(保持原生点击),绝不当成笔迹 var FP_CTRL_SEL = '#marvis-fp-dock, #marvis-flat-chip, .marvis-brush-panel, ' + '.answer-card-container, #marvis-cv-toast, #marvis-fp-pill'; // 书写态手指唯一可点的东西:草稿竖条(切换/清空/退出都在里面,必须够得着) var FP_TOUCH_SEL = '#marvis-fp-dock, #marvis-flat-chip, .marvis-brush-panel, #marvis-cv-toast'; // 触摸态唯一可点的东西:上面这些 + 本题字母盒(ABCD + 下一题)+ 本子的草稿笔图标 var FP_CLICK_SEL = FP_TOUCH_SEL + ', .marvis-lbox, .marvis-lbtn, .marvis-next, li.draft.tool'; // 页面可交互元素:鼠标落上去放行(点选项=点击);笔落上去轻点则转发点击 var FP_BTN_SEL = 'button, a[href], input, select, textarea, label, [role="button"], ' + '[role="radio"], [role="checkbox"], [nz-radio], nz-radio-group, li.choice-radio, .marvis-lbtn, ' + 'li.draft.tool'; var fpOwner = null; // 会话归属画布(draw/pass 两态都算会话开着) var fpPen = null; // 层内 canvas(全局唯一,随层创建) var fpDown = null; // 进行中的笔势 { pts, last, t0, target, cv } var fpTipAt = 0; // 「当前不可触摸 / 已锁定」提示节流时间戳 function fpHitSel(el, sel) { return !!(el && typeof el.closest === 'function' && el.closest(sel)); } /* v2.8.1:整页草稿的唯一准入判据 =「一拖五」资料分析组内子题。 `app-ti.question-multiple` 是全站题目的组件名(普通单题也用它,2026-10-26 真机实测:题 1~8 全是 app-ti.question-multiple),所以它**绝不能**当子题判据 ——v2.8.0 就栽在这里:普通题点草稿笔图标也被拽进整页草稿 + 右侧竖条。 真判据只有一个,与 processTi 建画布的口径完全一致:所在的 .ti 是组 (含 .materials-container / .questions-anchors)。 */ function fpInGroup(sub) { // 纯结构判定(不看开关) if (!sub || typeof sub.closest !== 'function') return null; var ti = sub.closest('.ti'); if (!ti) return null; return isGroupTi(ti) ? ti : null; } // 决策版(三道闸都用它):v2.8.2 起再叠一层「板块开关」——用户可以在右下角 // 胶囊面板里把「资料分析 · 1拖5」单独关掉,那时整页草稿一律不受理。 function fpGroupOfSub(sub) { if (!groupEnabled()) return null; return fpInGroup(sub); } // 自愈:清掉 v2.8.0 在普通题(非组内子题)上误建的 fpEligible 画布。 // 只认「被我们打过 CV_ATTR 的 app-ti」,正常资料分析子题原样保留。 function cvPurgeStrayFp() { var tagged; try { tagged = document.querySelectorAll('app-ti.question-multiple[' + CV_ATTR + ']'); } catch (e) { return; } for (var i = 0; i < tagged.length; i++) { var sub = tagged[i]; var cv = sub.__marvisCv; if (!cv || !cv.fpEligible) continue; // 不是整页草稿画布,不动 if (fpInGroup(sub)) continue; // 结构上确实是(一拖五)子题:保留 if (fpOwner === cv) fpExit(); try { delete sub.__marvisCv; } catch (e2) { sub.__marvisCv = null; } try { sub.removeAttribute(CV_ATTR); } catch (e3) {} var junk = sub.querySelectorAll('canvas.marvis-pen, .marvis-cv-tools'); for (var j = 0; j < junk.length; j++) junk[j].remove(); } } function fpIsInteractive(el) { return fpHitSel(el, FP_BTN_SEL); } // 指针分流总闸(纯函数,单测直接打表):这一笔到底抓不抓 function fpGate(e) { if (!fpOwner || fpOwner.mode !== 'draw') return false; if (e.pointerType === 'touch') return false; // 手指走 fpOnDown 的拦截分支 if (e.pointerType !== 'pen' && e.pointerType !== 'mouse') return false; var t = e.target; if (fpHitSel(t, FP_CTRL_SEL)) return false; if (e.pointerType === 'mouse' && fpIsInteractive(t)) return false; // 鼠标点按钮=点击 return true; } // 提示节流:手指连点时不要把 toast 刷成闪烁 function fpTip(msg, gap) { var now = Date.now(); if (now - fpTipAt < (gap || 1500)) return; fpTipAt = now; cvToast(msg); } /* 书写态:手指点按一律无效(纯书写状态,笔写/手指滚动手势不能打架)。 pointerdown 的 preventDefault 在部分浏览器仍会合成 click,故再拦一层 touchstart(preventDefault 掉 touchstart 可确保不合成 click)。两者都只在 我们自己的控件(右侧草稿竖条)上放行——否则连「切回可触摸/退出」都按不了。 v2.8.0:用户「当前可书写时弹出『当前不可触摸』这个弹窗没用,取消掉」—— 静默拦截,不再弹提示。 */ function fpTouchStart(e) { if (!fpOwner || fpOwner.mode !== 'draw') return; if (fpHitSel(e.target, FP_TOUCH_SEL)) return; e.preventDefault(); e.stopPropagation(); } /* 触摸态(锁定):只放行 ABCD / 下一题 / 草稿竖条 / 本题草稿图标,其余点击全部吃掉 (用户:「别的按钮应该都覆盖掉」)。用捕获层 click 过滤而不是遮罩层: 不改布局、不影响滚动、也不影响笔迹重放。 我们自己为了「点 ABCD 就等于点原生选项」而合成的 click(label.click())走 fpAllowOnce 记数放行,否则会被自己拦掉、选项永远选不上。 */ var fpClickBypass = 0; function fpAllowOnce(fn) { fpClickBypass++; try { fn(); } finally { fpClickBypass--; } } function fpPassClick(e) { if (!fpOwner || fpOwner.mode !== 'pass') return; if (fpClickBypass > 0) return; // 我们自己的合成点击 if (fpHitSel(e.target, FP_CLICK_SEL)) return; // 白名单:ABCD/下一题/竖条/草稿图标 e.preventDefault(); e.stopPropagation(); if (e.stopImmediatePropagation) e.stopImmediatePropagation(); fpTip('当前只可点 ABCD / 下一题 / 右侧草稿按钮'); } /* ---------- v2.8.0 草稿竖条(仿粉笔原生草稿工具条) ---------- 用户要求:「把『当前可触摸』和『当前可书写』集成到原生那个草稿按钮,清空也集成进去」。 实测结构(tools/cdp-probe-native-draft*.js):资料分析组的原生草稿是**整组一条** (.ti > .draft-container > app-draft > canvas#draft + .tools-container),而且被本 脚本整体隐藏(组成员画布会给全组指针事件兜底,见 neutralizeGroupDraft)——没地方 直接塞按钮,也不该复活它。所以做一条与它同款(#3c464f 深色、竖排、贴右边缘、 12px 圆角、20px 图标 + 11px 白字、activate 高亮)的竖条,只放这套草稿真正用得上的 三项:书写/触摸 切换 · 清空(两步确认)· 退出。 入口用**原生那个草稿笔图标**(li.draft.tool,每道子题各有一个)——点它就打开/收起 这道题的整页草稿,和原生草稿按钮的操作感一致,不用再多一个悬浮小按钮。 */ var fpDock = null; var FP_ICO = { draw: '', touch: '', clear: '', exit: '' }; function fpDockItem(ico, name, cls) { var b = document.createElement('button'); b.type = 'button'; b.className = 'marvis-fpd-item' + (cls ? ' ' + cls : ''); b.innerHTML = ico + '' + name + ''; return b; } function fpDockEnsure() { if (fpDock && fpDock.el.isConnected) return fpDock; var old = document.getElementById(FP_DOCK_ID); if (old) old.remove(); var el = document.createElement('div'); el.id = FP_DOCK_ID; var toggle = fpDockItem(FP_ICO.draw, '当前可书写', 'fpd-toggle'); var clear = fpDockItem(FP_ICO.clear, '清空', 'fpd-clear'); var exit = fpDockItem(FP_ICO.exit, '退出', 'fpd-exit'); el.appendChild(toggle); el.appendChild(clear); el.appendChild(exit); document.body.appendChild(el); fpDock = { el: el, toggle: toggle, clear: clear, exit: exit }; toggle.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); fpDockToggle(); }); clear.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); fpDockClear(); }); exit.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); fpExit(); cvToast(fpExitLabel()); }); return fpDock; } // 竖条状态同步(会话开关 / 两态文案 / 清空二次确认 / 归属题图标高亮) function fpDockSync() { var d = fpDock; if (!d) return; var cv = fpOwner; var on = !!cv; d.el.classList.toggle('on', on); var draw = on && cv.mode === 'draw'; var name = d.toggle.querySelector('.fpd-name'); if (name) name.textContent = draw ? '当前可书写' : '当前可触摸'; d.toggle.classList.toggle('on', draw); d.toggle.title = draw ? '整页书写中(页面滚动已锁定、手指点按无效):点按切到「当前可触摸」答题' : '点按切回「当前可书写」,继续在这道题上写'; var armed = !!(on && cv.clearArmed && (Date.now() - cv.clearArmed) < 3000); var cname = d.clear.querySelector('.fpd-name'); if (cname) cname.textContent = armed ? '确认清空' : '清空'; d.clear.classList.toggle('warn', armed); d.clear.title = '清空这道题的整页草稿笔迹(两步确认)'; d.exit.title = '退出整页草稿:页面恢复普通态,笔迹已保存'; // 归属提示:会话所属那道题的草稿笔图标高亮,再点它一次即可退出 try { var lit = document.querySelectorAll('li.draft.tool.marvis-fp-pen-on'); for (var i = 0; i < lit.length; i++) lit[i].classList.remove('marvis-fp-pen-on'); var sub = on && cv.host && cv.host.closest ? cv.host.closest('app-ti.question-multiple') : null; var pen = sub ? sub.querySelector('li.draft.tool') : null; if (pen) pen.classList.add('marvis-fp-pen-on'); } catch (e) {} } function fpDockToggle() { var cv = fpOwner; if (!cv) return; cv.mode = cv.mode === 'draw' ? 'pass' : 'draw'; applyCvMode(cv); cvSave(cv.key, cv.strokes, cv.mode, cv.coordVer); cvToast(cv.mode === 'draw' ? fpDrawLabel(cv) : fpPassLabel(cv)); } function fpDockClear() { var cv = fpOwner; if (!cv) return; var now = Date.now(); if (cv.clearArmed && now - cv.clearArmed < 3000) { cv.strokes.splice(0, cv.strokes.length); // 原地清空:canvas.__marvisStrokes 引用保持有效 if (cv.canvas) penReplay(cv.canvas, cv.strokes); cvSave(cv.key, cv.strokes, cv.mode, cv.coordVer); cv.clearArmed = 0; fpDockSync(); cvToast('已清空本题整页草稿'); } else { cv.clearArmed = now; fpDockSync(); cvToast('再点一次「清空」确认(3 秒内)'); var stamp = now; setTimeout(function () { if (cv.clearArmed === stamp) { cv.clearArmed = 0; fpDockSync(); } }, 3000); } } // 原生草稿笔图标 = 整页草稿开关(仅资料分析子题;普通题照旧开粉笔自己的草稿) function fpToggleSession(cv) { if (!cv || !cv.fpEligible) return; // v2.8.1:双保险——宿主必须是一拖五资料分析组内子题,别的一概不受理 if (!fpGroupOfSub(cv.host)) return; if (fpOwner === cv) { fpExit(); cvToast(fpExitLabel()); return; } cv.mode = 'draw'; // 每次都从「可书写」进(状态不记忆) cv.__fpSess = true; applyCvMode(cv); cvSave(cv.key, cv.strokes, cv.mode, cv.coordVer); cvToast(fpDrawLabel(cv)); } // 文档捕获层:抢在粉笔自己的草稿处理器之前接管图标点击(**仅一拖五组内子题**) function fpPenEntry(e) { var t = e.target; if (!t || typeof t.closest !== 'function') return; var pen = t.closest('li.draft.tool'); if (!pen) return; var sub = pen.closest('app-ti.question-multiple'); if (!sub) return; // v2.8.1:普通单题的草稿图标 → 一律交还原生(它的组件名同样是 question-multiple) if (!fpGroupOfSub(sub)) return; var cv = sub.__marvisCv; if (!cv && typeof ensureSubCanvas === 'function') { try { cv = ensureSubCanvas(sub, true); } catch (e0) {} // 视口门控跳过时补建 } if (!cv || !cv.fpEligible) return; e.preventDefault(); e.stopPropagation(); if (e.stopImmediatePropagation) e.stopImmediatePropagation(); fpToggleSession(cv); } function fpEnsureLayer() { // 旧版(≤v2.7.8)留下的右上角胶囊:工具条已不再飞过去,见到就清掉 var oldPill = document.getElementById(FP_PILL_ID); if (oldPill) oldPill.remove(); if (fpPen && fpPen.isConnected) return fpPen; // 旧版本/重复注入可能留下同名层:先清再建,避免两层叠着 var oldLayer = document.getElementById(FP_LAYER_ID); if (oldLayer) oldLayer.remove(); var layer = document.createElement('div'); layer.id = FP_LAYER_ID; layer.style.display = 'none'; // 会话未开时收起:可书写/可触摸两态才显示笔迹层 var cv = document.createElement('canvas'); cv.className = 'marvis-pen pen'; layer.appendChild(cv); document.body.appendChild(layer); fpPen = cv; fpSizeCanvas(); window.addEventListener('resize', fpSizeCanvas); // 起笔/移动/收笔全挂 window 捕获层:不依赖 setPointerCapture 一定成功 // (捕获只是让事件重定向到 canvas;即使失败,窗口级监听照样收得到整段笔程, // iPad Safari 等环境也有兜底)。指针事件在捕获阶段最先到达 window。 window.addEventListener('pointerdown', fpOnDown, true); window.addEventListener('pointermove', fpOnMove, true); ['pointerup', 'pointercancel'].forEach(function (t) { window.addEventListener(t, fpFinish, true); }); // v2.7.9:两个态的门禁常驻(内部按 fpOwner.mode 判态,无需随开关增删) document.addEventListener('touchstart', fpTouchStart, { capture: true, passive: false }); document.addEventListener('click', fpPassClick, true); return cv; } function fpCtx(cv) { try { return cv.getContext('2d'); } catch (e) { return null; } } function fpSizeCanvas() { if (!fpPen) return; var w = window.innerWidth || 0, h = window.innerHeight || 0; if (!w || !h) return; if (fpPen.width !== w || fpPen.height !== h) { fpPen.width = w; fpPen.height = h; if (fpOwner) penReplay(fpPen, fpOwner.strokes); // 归一化坐标按新尺寸重放,笔迹不丢 } } function fpOnDown(e) { // v2.8.0:可书写态手指点按静默无效(用户不要「当前不可触摸」弹窗)—— // 一律不惊动页面。触摸态(pass)手指完全放行:答案靠点击、滚动靠手指, // 由 fpPassClick 把关只放行 ABCD/下一题/竖条。 if (e.pointerType === 'touch') { if (!fpOwner || fpOwner.mode !== 'draw') return; if (fpHitSel(e.target, FP_TOUCH_SEL)) return; // 竖条上的切换/清空/退出必须能按 e.preventDefault(); e.stopPropagation(); return; } if (!fpGate(e)) return; e.preventDefault(); e.stopPropagation(); // 抢在原生草稿/页面处理器之前,书写不惊动页面 var cv = fpEnsureLayer(); try { cv.setPointerCapture(e.pointerId); } catch (err) {} var p = penPoint(cv, e); fpDown = { pts: [[p.x, p.y]], last: p, t0: Date.now(), target: e.target, cv: cv }; penDot(fpCtx(cv), p.x, p.y); } function fpOnMove(e) { if (!fpDown) return; var cv = fpDown.cv; var p = penPoint(cv, e); var last = fpDown.last; var dx = p.x - last.x, dy = p.y - last.y; var dist = Math.sqrt(dx * dx + dy * dy); if (dist >= 0.6) { var steps = Math.max(1, Math.min(80, Math.floor(dist / 2))); for (var i = 1; i <= steps; i++) { var q = { x: last.x + dx * i / steps, y: last.y + dy * i / steps }; fpDown.pts.push([q.x, q.y]); penDot(fpCtx(cv), q.x, q.y); } fpDown.last = p; } e.preventDefault(); e.stopPropagation(); // 笔程不带给页面(防选中文字/触发页面滚动手势) } function fpFinish() { if (!fpDown) return; var d = fpDown; fpDown = null; var owner = fpOwner; if (!owner) return; var maxMove = 0; for (var i = 1; i < d.pts.length; i++) { var dx = d.pts[i][0] - d.pts[0][0], dy = d.pts[i][1] - d.pts[0][1]; var m = Math.sqrt(dx * dx + dy * dy); if (m > maxMove) maxMove = m; } var isTap = d.pts.length <= 2 && maxMove < 8 && (Date.now() - d.t0) < 400; if (isTap) { // 轻点不成笔迹:统一重放擦掉起笔那个点(否则画布上会留一个擦不掉的墨点) penReplay(d.cv, owner.strokes); // 落在按钮上(笔点答案/翻页)→ 转发一次原生点击,平板上用笔也能选答案。 // 转发目标取「落点处最深的那个元素」(等价于原生点击的 target),而不是 // closest 出来的外层按钮:粉笔的选项外层是 li,点多层标签时真正承接点击的是 // 里面的 label/input,打在外层 li 上的合成 click 不会触发选中。 if (fpIsInteractive(d.target)) { var p0 = d.pts[0]; var hit = null; try { hit = (p0 && typeof document.elementFromPoint === 'function') ? document.elementFromPoint(Math.round(p0[0]), Math.round(p0[1])) : null; } catch (e3) {} var btn = hit || d.target.closest(FP_BTN_SEL) || d.target; if (btn && btn.dispatchEvent) { try { btn.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true, view: window })); } catch (e2) {} } } return; } if (d.pts.length > 1) { var w = d.cv.width || 1, h = d.cv.height || 1; // d.pts 里是 [x, y] 数组(v2.7.7 曾误写成 p.x/p.y → NaN → 存成 null, // 刷新后笔迹全丢/全挤成一个点;v2.7.8 修正为下标读取) owner.strokes.push(d.pts.map(function (p) { return [p[0] / w, p[1] / h]; })); cvScheduleSave(owner); } } // 旧版(v2.7.6 及之前)子题笔迹存在题目框坐标系;首次展开整页层时按框的当前位置 // 重映射到视口坐标系——老笔迹不丢、不错位(框被滚出视口时原样保留归一化值)。 // v2.7.8:迁移状态随存储版本 v=2 持久化(原先是内存标记,刷新即丢,重开又迁移 // 一次 → 笔迹越迁越偏、画到视口外看不见;真机 E2E 实测)。 function fpMigrate(cv) { if (cv.__fpMigrated || !cv.strokes.length) return; var host = cv.host; if (!host || typeof host.getBoundingClientRect !== 'function') return; var r = host.getBoundingClientRect(); var vw = window.innerWidth || 1, vh = window.innerHeight || 1; // 题框量不到(还没排版/被滚出文档流)就原样保留、不标已迁移: // 标了就会把旧框坐标当视口坐标直接画,笔迹整体错位 if (!r.width || !r.height) return; cv.__fpMigrated = true; cv.coordVer = 2; cv.strokes = cv.strokes.map(function (s) { return s.map(function (p) { return [(r.left + p[0] * r.width) / vw, (r.top + p[1] * r.height) / vh]; }); }); // 立即落盘(含 v=2 标记):不等 500ms 防抖,避免「迁移完立刻刷新」又迁一遍 cvSave(cv.key, cv.strokes, cv.mode, 2); } // 开会话 / 切到「可书写」态:层显示、笔迹重放、滚动锁定、手指点按变无效 function fpOpen(cv) { // v2.8.2:板块被「部分关闭」时一律不开层(双保险,正常路径在 fpToggleSession 就挡了) if (!cv || !fpGroupOfSub(cv.host)) return; if (fpOwner !== cv) { if (fpOwner) fpClose(); // 一层制:上一个会话先整体退出(落盘 + 解滚动) var cvEl = fpEnsureLayer(); fpMigrate(cv); fpOwner = cv; cv.canvas = cvEl; fpSizeCanvas(); penReplay(cvEl, cv.strokes); } cv.__fpSess = true; var layer = document.getElementById(FP_LAYER_ID); if (layer) layer.style.display = ''; // v2.8.0:草稿竖条常驻右侧(切换/清空/退出都在里面,滚动锁了也够得着) fpDockEnsure(); fpDockSync(); fpLockScroll(); // v2.7.8:可书写态 = 纯书写,页面不许滚(笔写/手指滚动冲突) } // 会话内切到「可触摸」态:层不退(笔迹还看得见)、页面恢复滚动, // 点击由 fpPassClick 把关(只放行 ABCD/下一题/竖条/本子草稿图标) function fpEnterPass(cv) { if (fpOwner !== cv) fpOpen(cv); fpUnlockScroll(); } // 退出整个会话:落盘、解滚动、收层、竖条收起 function fpExit() { if (fpDown) fpDown = null; var owner = fpOwner; fpOwner = null; fpUnlockScroll(); // v2.7.8:恢复滚动 if (owner) { owner.__fpSess = false; owner.canvas = null; owner.mode = 'pass'; // 下次点开从「可书写」进(用户:状态不记忆) owner.clearArmed = 0; cvSave(owner.key, owner.strokes, owner.mode, owner.coordVer); // 立即落盘,不等防抖 } var layer = document.getElementById(FP_LAYER_ID); if (layer) layer.style.display = 'none'; var pill = document.getElementById(FP_PILL_ID); if (pill) pill.remove(); fpDockSync(); // 会话已空:竖条收起 + 清掉草稿图标高亮 } // 兼容旧调用点(重渲染/宿主报废一律整体退出会话)——fpClose 即 fpExit function fpClose() { fpExit(); } // 宿主被 Angular 重渲染/切题销毁 → 旧画布报废,它占用的整页层必须收掉 function fpCloseIfStale() { if (fpOwner && (!fpOwner.host || !fpOwner.host.isConnected)) fpClose(); } /* ---- v2.7.7 草稿范围淡红框提示已删(v2.7.8 用户反馈没用) ---- */ // —— 材料智能适应 v2.7.0(仅材料画布挂「适应」按钮)—— // 点一次 = 整块材料一屏完整显示、文字尽量大。三层杠杆(扰动从小到大): // L1 缩放:CSS zoom 布局级缩放(高度真实变矮、无需滚动),宽度恒 100% // (v2.7.6:zoom 坐标系里 100% 视觉宽即栏宽,补偿反而溢出被裁); // L2 宽度:缩放低于 FIT_GOOD 时收窄材料栏——图表 max-width:100% 随栏变矮,材料整体 // 变矮,从而在 100%(或更高倍率)下完整显示;正文不缩只重排。分隔条本就可手拖, // 这里是程序化代拖(直接设左栏宽度,实测 Angular 不会重置,扫描时重复断言); // L3 兜底:最窄栏 + 下限缩放,仍装不下提示滚动查看。 // 旧版 bug:真实高度锁在内层 .material-body(固定高 + overflow:auto),宿主 // scrollHeight 被内层裁成盒高 → 缩放比算成 ≥1 直接不缩。现在先解锁内层再量。 var FIT_GOOD = 0.82; // 缩放可接受线:≥此值直接用缩放,不动布局 var FIT_MIN_W = 420; // 材料栏收窄下限(px) var FIT_W_STEPS = [0.9, 0.8, 0.7, 0.6, 0.5]; // 收窄档位(相对当前栏宽) var FIT_BOX = 'data-marvis-fitbox'; // 适应临时解锁的内层盒标记 var FIT_W_ATTR = 'data-marvis-fitw'; // 适应收窄栏宽时保存的原始 style var fitGroups = {}; // key → true:该组因适应而被展平(关全局展平时也保持) // 用 CSS zoom 做布局级缩放(高度真实变矮、无需滚动),宽度恒 100%。 // v2.7.6(真机实测定论):旧版在缩放时把宽度补偿成 100/s%「让文字铺满栏宽」—— // 但 Blink 的 zoom 模型里,百分比宽度是在**被缩放元素的自身坐标系**里解析的: // 探针实测(父 600px)width:100%+zoom:0.858 → 视觉宽恰 600px(已铺满,无需补偿); // width:116.55%+zoom:0.858 → 视觉宽 699px = 600/0.858,溢出 (1/s-1)×栏宽。 // 补偿让宿主视觉宽 = 栏宽/s,右半被 .left-section 的 overflow-x:hidden 裁掉 // (真机:s=0.858 时视觉 1161px vs 栏宽 996px,右 165px 被裁——用户报 // 「材料图片还是会被裁剪」)。故补偿整体删除,zoom 时宽度恒 100%。 // 画布在宿主内绝对定位、坐标归一化存储,缩放后重放不失真。 function cvZoomApply(host, s) { if (s && s < 1) { host.style.zoom = String(s); host.style.width = '100%'; } else { host.style.zoom = ''; // v2.7.2:不能置空——吸顶展平态下宿主带 flex:0 0 auto,width 置空会按内容 // max-content 排布把整条链撑爆(文字/表格被裁)。固定回 100% 恒等栏宽。 host.style.width = '100%'; } } function cvZoomOf(host) { var z = parseFloat(host.style.zoom); return z > 0 && z < 1 ? z : 0; } // 材料里是否存在「被栏宽钳制的媒体」——图片/表格/SVG 的渲染宽≈宿主宽 // (max-width:100% 生效,或其自身 width:100%)。这类内容的渲染尺寸由栏宽决定: // 其布局宽被钳在「宿主本地宽 = 栏宽/zoom」上,视觉宽恒等于栏宽、视觉高也恒定 // (宽高比随钳制宽走)——zoom 再大也缩不动它,纯缩放档会白给(v2.7.6 实测: // 补偿删除后此结论不变,媒体仍随栏宽而非 zoom 收缩)。此时应优先收窄栏宽(L2)。 // zoom 归一(v2.7.2):zoom 会改变 rect 与 clientWidth 的坐标系(实测同一盒子 // rect 报布局宽、clientWidth 报另一口径,比值永远判不准),故测量前临时摘掉 // zoom、量完写回——探测器由此可在任何调用时机(含缩放已生效后)安全运行。 function cvHasWidthBoundMedia(host) { if (!host || !host.querySelectorAll) return false; var prevZoom = host.style.zoom; if (prevZoom) host.style.zoom = ''; try { var els = host.querySelectorAll('img, table, svg'); for (var i = 0; i < els.length; i++) { var el = els[i]; if (el.closest && el.closest('.marvis-cv-tools')) continue; // 工具条图标不算 var cs = getComputedStyle(el); if (cs.position === 'absolute' || cs.position === 'fixed') continue; // 钳制宽度 = 父元素的内容宽(max-width:100% 的参照);宿主自身可能带内边距 var p = el.parentElement; if (!p) continue; var pcs = getComputedStyle(p); var bound = p.clientWidth - (parseFloat(pcs.paddingLeft) || 0) - (parseFloat(pcs.paddingRight) || 0); if (bound <= 0) { // 父元素量不到宽度时退回宿主宽(兜底) var hr0 = host.getBoundingClientRect(); bound = hr0.width || 0; } if (bound <= 0) continue; var r = el.getBoundingClientRect(); if (r.width > 0 && r.width >= bound - 10) return true; } } catch (e) {} finally { if (prevZoom) host.style.zoom = prevZoom; } return false; } // 左栏(.nz-resizable.left)是粉笔的宽度属主;收窄它 = 代用户拖分隔条 function cvLeftCol(group) { return group ? (group.querySelector('.nz-resizable.left') || group.querySelector('.left')) : null; } function cvFitSetWidth(cv, w) { var group = cv.host && cv.host.closest ? cv.host.closest('.ti') : null; var left = cvLeftCol(group); if (!left) return; if (!left.hasAttribute(FIT_W_ATTR)) left.setAttribute(FIT_W_ATTR, left.getAttribute('style') || ''); left.style.setProperty('width', w + 'px', 'important'); } // 还原栏宽:只清理自己设过的(FIT_W_ATTR 在);没设过就一个像素都不动 // (不能 removeProperty——那会把粉笔自己写在行内的宽度也抹掉) function cvFitClearWidth(cv) { var group = cv.host && cv.host.closest ? cv.host.closest('.ti') : null; var left = cvLeftCol(group); if (!left || !left.hasAttribute(FIT_W_ATTR)) return; left.setAttribute('style', left.getAttribute(FIT_W_ATTR) || ''); left.removeAttribute(FIT_W_ATTR); } function cvFitActive(cv) { if (cvZoomOf(cv.host)) return true; var group = cv.host && cv.host.closest ? cv.host.closest('.ti') : null; var left = cvLeftCol(group); return !!(left && left.hasAttribute(FIT_W_ATTR)); } // 解锁材料内部的滚动盒:.material-body 固定高 + overflow:auto 是真实高度锁, // 不解锁它宿主 scrollHeight 永远等于盒高、量不到内容真实高度。 // 展平已解锁过的(FLAT_BOX)跳过;解锁样式存 RELAX_ATTR 供还原。 function cvUnlockInner(group) { var mat = group.querySelector('.materials-container'); if (!mat) return; var els = [mat].concat([].slice.call(mat.querySelectorAll('*'))); for (var i = 0; i < els.length; i++) { var el = els[i]; if (el.tagName === 'CANVAS') continue; if (el.hasAttribute(FLAT_BOX) || el.hasAttribute(FIT_BOX)) continue; var cs = null; try { cs = getComputedStyle(el); } catch (e) {} var oy = (cs && cs.overflowY) ? cs.overflowY : el.style.overflowY; if (oy === 'auto' || oy === 'scroll' || oy === 'hidden') { if (el.scrollHeight > el.clientHeight + 2) { if (!el.hasAttribute(RELAX_ATTR)) el.setAttribute(RELAX_ATTR, el.getAttribute('style') || ''); el.style.setProperty('overflow', 'visible', 'important'); el.style.setProperty('height', 'auto', 'important'); el.style.setProperty('max-height', 'none', 'important'); el.setAttribute(FIT_BOX, '1'); } } } } // 确保材料内容能流动:解锁内层滚动盒 +(锁态布局下)把该组高度链展平。 // 返回组元素;展平由适应触发时记 fitGroups + __fitOwned,还原时收回。 function cvEnsureFlow(cv) { var host = cv.host; var group = host && host.closest ? host.closest('.ti') : null; if (!group) return null; cvUnlockInner(group); if (group.getAttribute(FLAT_ATTR) !== '1') { try { flattenGroup(group); } catch (e) {} fitGroups[groupKeyOf(group)] = true; cv.__fitOwned = true; } return group; } // 量材料自然高度(zoom=1)与可用高度: // natural = 宿主内容高(解拉伸后)+ 内层滚动盒内容高的较大者; // avail = 内层滚动盒 → 盒高;页面滚动 → 视口高减材料顶部位移(装进视口即完整)。 // 两个坑(真机实测): // ① flex 拉伸:resizable-container 是 flex 行容器,align-items:stretch 会把左栏 // (连同材料)拉到右栏题目等高——短材料被题目高度撑爆,自然高度虚高 // (实测短文本材料被撑到 1421px,真实内容仅 229px)。量前给链上各层 // align-self:flex-start 中和,量完移除; // ② 画布 absolute 但高度是 px:stale 高度会溢出污染宿主 scrollHeight, // 量前先同步一次画布尺寸。 function cvMeasure(cv) { var host = cv.host; if (!host) return null; var prevZoom = host.style.zoom, prevWidth = host.style.width; host.style.zoom = ''; // v2.7.2:宽度不能置空——宿主带 flex:0 0 auto,置空后按内容 max-content 排布, // 文字材料整块不换行、塌成一行,natural 被量成一行高(真机实测 1196px 材料量成 // ~250px)→ 误判「装得下」不缩放。固定 100%(=栏宽)量得的自然高度才是用户 // 可见排版下的高度;L2 收窄后重量的口径也随之正确。 host.style.width = '100%'; var chain = []; var n = host; while (n) { chain.push(n); if (n.classList && n.classList.contains('resizable-container')) break; n = n.parentElement; } // 记下链上原有的行内 align-self(吸顶左栏自带 flex-start),量完原样写回—— // 早先直接 removeProperty 会把吸顶样式抹掉,左栏重新被拉伸、吸顶失效(v2.7.1) var prevAligns = []; for (var i = 0; i < chain.length; i++) { if (chain[i].style) { prevAligns.push(chain[i].style.getPropertyValue('align-self')); chain[i].style.setProperty('align-self', 'flex-start', 'important'); } else prevAligns.push(''); } try { syncCanvasSize(cv); } catch (e) {} var rect = host.getBoundingClientRect(); // natural = 子树最大 scrollHeight(宿主自身也在列——解拉伸后它就是内容高; // 不从 rect.height 取:那可能是被拉伸/固定的盒高,jsdom 桩也会误判) var natural = 0; var els = [host].concat([].slice.call(host.querySelectorAll('*'))); for (var j = 0; j < els.length; j++) { var el = els[j]; if (el.tagName === 'CANVAS') continue; var cs = null; try { cs = getComputedStyle(el); } catch (e) {} if (cs && (cs.position === 'absolute' || cs.position === 'fixed')) continue; // 覆盖层不算 try { if (el.scrollHeight > natural) natural = el.scrollHeight; } catch (e) {} } var top = rect.top || 0; var vh = window.innerHeight || 0; var sc = scrollerOf(host); var isPage = !sc || sc === document.body || sc === document.documentElement; var avail = isPage ? Math.max(0, vh - Math.max(0, top)) : (sc.clientHeight || vh); for (var k = 0; k < chain.length; k++) { if (!chain[k].style) continue; if (prevAligns[k]) chain[k].style.setProperty('align-self', prevAligns[k]); else chain[k].style.removeProperty('align-self'); } host.style.zoom = prevZoom; // 宽度写回:有 zoom 时连补偿值一起还原;无 zoom 也保证不为空(空 → max-content 撑爆) host.style.width = prevWidth || '100%'; if (!natural || avail < 120) return null; return { natural: natural, avail: avail, width: Math.round(rect.width || host.clientWidth || 0) }; } // 应用适应结果:{s: 缩放比(1=不缩), w: 栏宽px(0=还原)} + 持久化 + 按钮文案 function cvFitApply(cv, r) { var host = cv.host; cvZoomApply(host, r.s); if (r.w) cvFitSetWidth(cv, r.w); else cvFitClearWidth(cv); try { if (r.s && r.s < 1) localStorage.setItem(CV_LS_PREFIX + 'zoom-' + cv.key, String(r.s)); else localStorage.removeItem(CV_LS_PREFIX + 'zoom-' + cv.key); if (r.w) localStorage.setItem(CV_LS_PREFIX + 'fitw-' + cv.key, String(r.w)); else localStorage.removeItem(CV_LS_PREFIX + 'fitw-' + cv.key); } catch (e) {} syncCanvasSize(cv); if (cv.zoomBtn) cv.zoomBtn.textContent = (r.s && r.s < 1) || r.w ? '100%' : '适应'; } // 还原:缩放 100% + 栏宽还原 +(展平由适应触发时)收回展平 function cvFitRestore(cv) { cvFitApply(cv, { s: 1, w: 0 }); if (cv.__fitOwned) { var group = cv.host && cv.host.closest ? cv.host.closest('.ti') : null; if (group) { try { unflattenGroup(group); } catch (e) {} delete fitGroups[groupKeyOf(group)]; } cv.__fitOwned = false; } } // 智能适应主流程:返回 {s, w, msg};null = 量不出尺寸(布局未就绪) function cvSmartFit(cv) { var host = cv.host; var group = host && host.closest ? host.closest('.ti') : null; if (!group) return null; // 材料不在合理视口位置时先滚到顶:点「适应」= 要一屏看整块材料,滚动是预期行为 // (工具按钮就在材料顶部,正常点击时材料本就在视口里,不会触发滚动) try { var top0 = group.getBoundingClientRect().top || 0; if (top0 < -40 || top0 > (window.innerHeight || 0) - 120) group.scrollIntoView({ block: 'start' }); } catch (e) {} group = cvEnsureFlow(cv) || group; if (!group) return null; var m = cvMeasure(cv); if (!m) return null; // 本来就装得下:全部还原 if (m.natural <= m.avail + 2) { cvFitApply(cv, { s: 1, w: 0 }); return { s: 1, w: 0, msg: '材料已完整显示' }; } var s0 = m.avail / m.natural; // 被栏宽钳制的媒体(图片/表格):渲染尺寸由栏宽决定,zoom 缩不动它(布局宽被钳在 // 栏宽/zoom 上,视觉宽恒为栏宽)——优先走 L2 收窄,媒体随栏变矮,往往能 100% // 完整装下且不用缩放 var mediaBound = cvHasWidthBoundMedia(host); // L1:缩放就够读 → 不动布局 if (s0 >= FIT_GOOD && !(mediaBound && s0 < 1)) { var s1 = Math.round(s0 * 1000) / 1000; cvFitApply(cv, { s: s1, w: 0 }); return { s: s1, w: 0, msg: '材料已缩放至 ' + Math.round(s1 * 100) + '%,一屏完整显示' }; } // L2:宽度自适应——逐档收窄,图表随栏变矮;争取 100% 完整显示,其次更高缩放比 // 注意去重方向:档位比值递减,只有「没有变得更窄」的档(被 FIT_MIN_W 钳住) // 才跳过;写反成 w<=lastW 会把第一档之后的所有档全部跳过(真机实测:高图 // 材料只量了 840 一档就收尾,最优的 467 档永远摸不到)。 var chosen = null, best = { w: 0, s: s0 }, lastW = 0; for (var i = 0; i < FIT_W_STEPS.length; i++) { var w = Math.max(FIT_MIN_W, Math.round(m.width * FIT_W_STEPS[i])); if (lastW > 0 && w >= lastW) continue; // 档位递减去重 lastW = w; cvFitSetWidth(cv, w); var mm = cvMeasure(cv); if (!mm) break; if (mm.natural <= mm.avail + 2) { chosen = { w: w, s: 1 }; break; } // 100% 装下! var s = mm.avail / mm.natural; if (s > best.s) best = { w: w, s: s }; // 媒体钳制时不能因 s>=GOOD 就选缩放档——zoom 缩不动被钳制的媒体,白收窄;只认 100% 档 if (!mediaBound && s >= FIT_GOOD) { chosen = { w: w, s: Math.round(s * 1000) / 1000 }; break; } } if (!chosen) { if (mediaBound) { // 收窄也装不下:缩放兜底(缩得动文字部分;媒体部分随收窄后的栏宽, // 视觉尺寸已比原栏宽小,不会被裁——宽度恒 100%,无补偿溢出) chosen = { w: best.w || 0, s: Math.max(best.s, CV_ZOOM_MIN) }; } else { chosen = best; if (chosen.s < CV_ZOOM_MIN) chosen.s = CV_ZOOM_MIN; // L3:可读性下限 } chosen.s = Math.round(chosen.s * 1000) / 1000; } cvFitApply(cv, chosen); var msg = chosen.s >= 1 ? '材料栏收窄至 ' + chosen.w + 'px,材料 100% 完整显示' : (chosen.w ? '材料栏收窄至 ' + chosen.w + 'px + 缩放 ' + Math.round(chosen.s * 100) + '%,一屏完整显示' : '材料已缩放至 ' + Math.round(chosen.s * 100) + '%' + (chosen.s <= CV_ZOOM_MIN ? '(可读性下限),剩余部分滚动查看' : ',一屏完整显示')); return { s: chosen.s, w: chosen.w, msg: msg }; } // 恢复持久化的适应状态(切题重进 / Angular 重渲染后重新断言) // v2.7.5:用户要求「每个资料分析的适应按钮默认就是适应,而不是百分之百」—— // 打开资料分析题(含切题重渲染 / MutationObserver 全量重扫)一律以原始 100% 呈现, // 不再从 localStorage 恢复上次的缩放/收窄;要缩放用户自己点「适应」。 // 按钮文字只按当前 DOM 实际状态校正:已缩放态显「100%」(点了适应再被重扫时 // 文字不能错),未缩放态恒「适应」——这就是用户要的默认态。 // 顺手清掉旧版本遗留的持久化键(存在才删,避免无谓写)。 function cvFitRestoreState(cv) { if (cv.zoomBtn) cv.zoomBtn.textContent = cvFitActive(cv) ? '100%' : '适应'; try { if (localStorage.getItem(CV_LS_PREFIX + 'zoom-' + cv.key) !== null) localStorage.removeItem(CV_LS_PREFIX + 'zoom-' + cv.key); if (localStorage.getItem(CV_LS_PREFIX + 'fitw-' + cv.key) !== null) localStorage.removeItem(CV_LS_PREFIX + 'fitw-' + cv.key); } catch (e) {} } // 校准画布尺寸(布局变化 / 解析展开 / 窗宽变化时调用;幂等) // zoom 口径:宿主 zoom 会继承缩放子孙 px 长度(实测 computed 1751px 渲染成 1051px), // 故视觉高 rh 对应的 CSS 高度要除以 zoom,缩放态下画布仍满覆盖材料。 function syncCanvasSize(cv) { var host = cv.host, canvas = cv.canvas; if (!host || !canvas) return; var w = 0, rh = 0; try { var r = host.getBoundingClientRect(); // 用 rect 口径:zoom 缩放下 clientWidth 不稳 w = r.width || 0; rh = r.height || 0; } catch (e) {} if (!w) w = host.clientWidth || 0; if (!w) return; // 高度:子题用「题干+选项+48」口径;取不到时退化为宿主全高,避免画布裸奔成 300×150 var h = cv.heightFn ? (cv.heightFn(host) || rh || host.clientHeight || 0) : (rh || host.clientHeight || 0); if (!h) return; var z = cvZoomOf(host) || 1; var cssH = z < 1 ? Math.round(h / z) : h; // CSS 高度(布局口径)= 视觉高 / zoom if (canvas.width !== Math.round(w) || canvas.height !== Math.round(h)) { canvas.width = Math.round(w); canvas.height = Math.round(h); penReplay(canvas, cv.strokes); // 归一化坐标 → 按新尺寸重放,笔迹不丢 } canvas.style.width = '100%'; canvas.style.height = cssH + 'px'; } // 在宿主上挂画布 + 工具按钮(幂等:CV_ATTR+CV_VER + 元素上的引用双保险) // heightFn(host) → 画布像素高度;传 null 表示覆盖整个宿主。 // opts.fpEligible(v2.7.7,仅资料分析组内子题):不在宿主里建画布——「可书写」 // 时展开的是 body 级整页草稿层(fpOpen 把 cv.canvas 指向层内 canvas),宿主里 // 只留工具条。其他画布(材料/普通题)一律不走这条路径,行为零改动。 function ensureOverlayCanvas(host, key, heightFn, toolsPos, opts) { opts = opts || {}; var fpEligible = !!opts.fpEligible; if (!host) return null; var isMat = toolsPos === 'top'; // 材料画布(toolsPos=top):工具条是顶部通栏 band,宿主打标记让 CSS 把 // .material-body 内容压到 band 下方——按钮不再悬浮遮住材料文字(v2.7.1); // v2.7.8 起 band 里只有「适应」按钮,不再有画布 if (isMat && host.classList) host.classList.add('marvis-mat-host'); var existing = host.__marvisCv; // 不仅要属性/引用对得上,还要画布与工具仍在文档中:Angular 局部重渲染可能只销毁 // 子节点(canvas/tools 没了但宿主属性还在),此时必须自愈重建,否则用户看不到画布。 // fpEligible 画布收起态没有 canvas(整页层未展开),材料 cv(v2.7.8)本就无 canvas, // v2.8.0 起 fpEligible 也不再在宿主里挂工具条(改用右侧草稿竖条),都不能因此判定失效而反复重建。 if (existing && host.getAttribute(CV_ATTR) === CV_VER && (existing.fpEligible || (existing.tools && existing.tools.isConnected)) && (existing.canvas ? existing.canvas.isConnected : (!!existing.fpEligible || !!existing.matToolsOnly))) { if (!existing.fpEligible && !existing.matToolsOnly) syncCanvasSize(existing); return existing; } // v2.7.7:宿主被 Angular 重渲染/切题销毁 → 旧画布报废,它占用的整页层先收掉 if (fpOwner && fpOwner.host === host) fpClose(); host.setAttribute(CV_ATTR, CV_VER); // 重建前清掉残留的旧画布/旧工具(Angular 重渲染或版本升级换代), // 否则旧工具的闭包绑在已销毁的 cv 上,会出现「双工具按钮、清空写错对象」 var stale = host.querySelectorAll('canvas.marvis-pen, .marvis-cv-tools'); [].slice.call(stale).forEach(function (el) { el.remove(); }); var saved = cvLoad(key); // v2.7.8:材料区(toolsPos=top)不再建草稿画布/切换/清空(用户要求删除—— // 整页草稿已覆盖材料书写,按钮无用);「适应」是独立功能保留,工具条只剩它。 // 材料的 cv 只服务适应逻辑(zoomBtn/cvFit*),无笔迹无模式切换。 var cv = { key: key, host: host, canvas: null, tools: null, toggleBtn: null, clearBtn: null, zoomBtn: null, strokes: !isMat && saved ? cvSanitize(cvCap(saved.strokes)) : [], // 默认模式按画布类型,且不记忆(v2.7.6,用户要求):子题画布恒「当前可触摸」 // (题目区常需要滚动/点选);localStorage 里的 mode 只写不读——每次打开/刷新 // 都按类型重置。材料区 v2.7.8 起无画布无模式,此处仅占位。 mode: isMat ? 'draw' : 'pass', heightFn: heightFn, clearArmed: 0, // v2.7.7:资料分析子题标记——「可书写」展开整页草稿层而非题目框画布 fpEligible: fpEligible, // v2.7.8:坐标系版本(2=视口归一化,整页草稿层用);saved.v>=2 说明旧笔迹 // 已迁移过,不能再迁(否则每次重开都把视口坐标当框坐标再变换一次) coordVer: fpEligible ? 2 : 1, __fpMigrated: !!(fpEligible && saved && saved.v >= 2), // v2.7.8:材料 cv 无画布标记(幂等检查用) matToolsOnly: isMat }; // v2.8.0:fpEligible(资料分析子题)宿主里不再挂任何悬浮按钮——「当前可触摸 / // 当前可书写」「清空」「退出」全部收进右侧草稿竖条(#marvis-fp-dock), // 入口用题目右上角的原生草稿笔图标。材料 cv 只有「适应」。 var tools = null; if (!fpEligible) { tools = document.createElement('div'); tools.className = 'marvis-cv-tools ' + (toolsPos === 'bottom' ? 'marvis-cv-tools-bottom' : 'marvis-cv-tools-top'); } var toggle = null; if (!isMat && !fpEligible) { toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'marvis-cvbtn'; toggle.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); var idx = CV_MODES.indexOf(cv.mode); cv.mode = CV_MODES[(idx < 0 ? 0 : idx + 1) % CV_MODES.length]; applyCvMode(cv); cvSave(cv.key, cv.strokes, cv.mode, cv.coordVer); if (cv.mode === 'draw') cvToast(drawLabel(cv)); else cvToast(passLabel(cv)); }); } var clear = null; if (!isMat && !fpEligible) { clear = document.createElement('button'); clear.type = 'button'; clear.className = 'marvis-cvbtn'; clear.textContent = '清空'; clear.title = '清空本画布笔迹(两步确认)'; clear.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); var now = Date.now(); if (now - cv.clearArmed < 3000) { cv.strokes.splice(0, cv.strokes.length); // 原地清空:canvas.__marvisStrokes 引用保持有效 if (cv.canvas) penReplay(cv.canvas, cv.strokes); // fp 收起态 canvas=null,跳过重放 cvSave(cv.key, cv.strokes, cv.mode, cv.coordVer); cv.clearArmed = 0; clear.textContent = '清空'; clear.classList.remove('marvis-cvbtn-warn'); } else { cv.clearArmed = now; clear.textContent = '确认清空'; clear.classList.add('marvis-cvbtn-warn'); setTimeout(function () { if (cv.clearArmed === now) { cv.clearArmed = 0; clear.textContent = '清空'; clear.classList.remove('marvis-cvbtn-warn'); } }, 3000); } }); } // 材料(toolsPos=top)挂「适应」:一键智能适应(缩放/收窄栏宽),再点还原。 // v2.7.8 起这是材料工具条的唯二内容(另一个是容器本身)——材料区再无草稿功能 var zoomBtn = null; if (toolsPos === 'top') { zoomBtn = document.createElement('button'); zoomBtn.type = 'button'; zoomBtn.className = 'marvis-cvbtn'; zoomBtn.textContent = '适应'; zoomBtn.title = '材料智能适应:整块材料一屏完整显示——优先缩放,不够时自动收窄材料栏(图表随栏变矮);再点一次还原 100%'; zoomBtn.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); if (cvFitActive(cv)) { cvFitRestore(cv); cvToast('材料已还原 100%'); } else { var r = cvSmartFit(cv); if (!r) return; // 量不出尺寸(布局未就绪),不动 if (r.msg) cvToast(r.msg); } }); cv.zoomBtn = zoomBtn; // v2.7.5:默认就是「适应」——不恢复上次的缩放/收窄(用户要求打开即原始 100%) cvFitRestoreState(cv); } if (tools && toggle) tools.appendChild(toggle); if (tools && zoomBtn) tools.appendChild(zoomBtn); if (tools && clear) tools.appendChild(clear); if (tools) host.appendChild(tools); cv.tools = tools; cv.toggleBtn = toggle; cv.clearBtn = clear; host.__marvisCv = cv; cvRegistry.push(cv); if (fpEligible) { // v2.7.7:子题画布 = 整页草稿层(展开时 cv.canvas 才指向层内 canvas), // 宿主里不建 canvas、不挂框内笔事件;默认「可触摸」即收起态。 applyCvMode(cv); return cv; } if (isMat) { // v2.7.8:材料区无画布无笔事件——cv 只服务「适应」按钮 return cv; } var canvas = document.createElement('canvas'); canvas.className = 'marvis-pen pen'; // pen → ⑤笔刷 hook 生效 canvas.setAttribute('data-marvis-cvkey', key); canvas.__marvisStrokes = cv.strokes; // 测试/排障挂钩 host.appendChild(canvas); cv.canvas = canvas; syncCanvasSize(cv); applyCvMode(cv); attachPen(canvas, cv); penReplay(canvas, cv.strokes); // 宿主尺寸变化(材料栏拖拽 / 窗口缩放 / 解析展开)时按归一化坐标重放 if (typeof window.ResizeObserver === 'function') { try { var ro = new window.ResizeObserver(function () { syncCanvasSize(cv); }); ro.observe(host); cv.ro = ro; } catch (e) {} } return cv; } function ensureMaterialCanvas(group) { var host = group.querySelector('.materials-container'); if (!host) return null; var cv = ensureOverlayCanvas(host, 'mat-' + groupKeyOf(group), null, 'top'); // 画布已存在(重渲染/切题重扫)时也校正按钮文字:v2.7.5 起默认不恢复持久化 // 缩放(用户要求每个资料分析组默认就是「适应」),只按 DOM 实际状态校正文字 if (cv) cvFitRestoreState(cv); return cv; } // fpEligible:仅资料分析组内子题传 true(v2.7.7,用户明确要求别的题型不变)—— // 普通题走粉笔原生草稿、材料画布本就通栏,都不碰整页层。 function ensureSubCanvas(sub, fpEligible) { // v2.8.1:整页草稿只给「一拖五」组内子题;其它宿主一律不建(防止 v2.8.0 那种 // 把普通题当子题的误建,普通题继续走粉笔原生草稿) if (fpEligible && !fpGroupOfSub(sub)) return null; var qkey = sub.getAttribute('data-question-key'); var key = qkey ? 'sub-' + qkey : 'sub-h' + hashText((sub.querySelector('.title-index') || { textContent: '' }).textContent || ''); // 高度口径与普通题一致:题干+选项区 + 48;工具按钮放下方留白区,避开题目工具条 return ensureOverlayCanvas(sub, key, draftTargetHeight, 'bottom', { fpEligible: fpEligible }); } /* ---------- 单题 / 组处理 ---------- */ // opts.force:用户主动触发(下一题 / 强制全开),无视视口门控; // opts.sync:是否同步回填;缺省时随视口门控(滚动扫描只同步视口附近的题)。 function processTi(ti, opts) { opts = opts || {}; var near = opts.force || nearViewport(ti); var doSync = opts.sync === undefined ? near : opts.sync; if (isGroupTi(ti)) { // v2.8.2:右下角胶囊面板可「部分关闭」——资料分析(1拖5)板块整体不介入, // 完全交还粉笔原生:不隐藏组级草稿、不展平、不加字母盒、不建材料画布、 // 没有整页草稿与草稿竖条。普通题照常。 if (!groupEnabled()) return; neutralizeGroupDraft(ti); // 组级原生草稿:覆盖全组、吃掉指针事件,持续压制 try { // 展平门控:全局开 或 该组有进行中的适应(适应会自己展平锁态组) if (flattenEnabled() || fitGroups[groupKeyOf(ti)]) flattenGroup(ti); else unflattenGroup(ti); } catch (e) { // 展开失败不静默:控制台给原因(胶囊已改为脚本总开关,不再承载展开状态) if (window.console && console.warn) console.warn('[marvis] 展开失败:', e && e.message); } var subs = subsOf(ti); subs.forEach(function (sub) { buildBox(sub); if (doSync) syncFeedback(sub); }); if (near) { ensureMaterialCanvas(ti); // 解锁盒子后材料仍可见(整宽排在题目上方),共享画布保留 // v2.7.7:组内子题 = 资料分析专属整页草稿(fpEligible);材料画布与 // 普通题的原生草稿都不走这条路径 subs.forEach(function (s) { ensureSubCanvas(s, true); }); } return; } buildBox(ti); if (doSync) syncFeedback(ti); // 页面重进 / 元素复用后按既有作答状态回填按钮颜色 if (near) { autoOpenDraft(ti); lockDraft(ti); } } /* ---------- v2.7.4 答题卡折叠把手透明化 ---------- */ // 页面左下角「答题卡 13/130」小白卡(.answer-card-tag)挡题面文字,用户要求全透明。 // 三个事实决定实现方式:① 白卡是 SVG path 的 fill/stroke(不是背景色),只能 CSS 褪; // ② 折叠/展开是 fixed 容器的 transform 位移——不增删节点、不挂类, // MutationObserver 完全无声,且三态容器类名一模一样、没有状态类可钩; // ③ 展开态下这个 tag 就是答题卡面板的头部,一概全透会让面板头部穿帮。 // 故按容器位置判定(rect.top >= 视口高 = 已缩到视口外 = 折叠态)打 .marvis-ac-clear 类, // 只透折叠态。scan() 内调一次(DOM 变化即同步)+ 400ms 轮询兜底(位移无事件)。 // 该把手是全页唯一答题卡入口:只褪色、不隐藏、不拦点击。 var AC_CLEAR_CLS = 'marvis-ac-clear'; function acWatch() { // 整体 try/catch:这是装饰性同步,任何环境缺桩(如单测的极简 document)都只能静默跳过, // 绝不能把 scan() 带崩(brush-test 的 document 桩就没有 querySelector) try { var c = document.querySelector('.answer-card-container'); if (!c || !c.classList) return; var collapsed = c.getBoundingClientRect().top >= ((window && window.innerHeight) || 0) - 2; if (c.classList.contains(AC_CLEAR_CLS) !== collapsed) { c.classList.toggle(AC_CLEAR_CLS, collapsed); } } catch (e) {} } /* ---------- 全量扫描 ---------- */ function scan(force) { ensureBrushPanel(); placeBrushPanel(); acWatch(); fpCloseIfStale(); // v2.7.7:切题/重渲染后旧画布报废,它占的整页层随之收回 cvPurgeStrayFp(); // v2.8.1:清掉误建在普通题上的整页草稿画布(旧版本残留) // v2.7.9:只有「可书写」态才需要补锁滚动;可触摸态是真·滚动状态,不能锁 if (fpOwner && fpOwner.mode === 'draw') fpLockScroll(); var list = tis(); for (var i = 0; i < list.length; i++) processTi(list[i], force ? { force: true, sync: true } : null); } /* ---------- SPA 增量渲染适配 ---------- */ // 除 childList 外必须监听属性级变化:粉笔重进恢复作答时可能只写 checked 属性 / 切 class, // 不增删节点,childList-only 观察者完全无声(做题模式 ABCD 按钮不回填的根因之一)。 var MO_ATTRS = ['checked', 'class', 'aria-checked', 'aria-selected', 'aria-pressed']; var pending = false; var mo = new MutationObserver(function (records) { if (pending) return; pending = true; requestAnimationFrame(function () { pending = false; // 属性变化只影响按钮回填:按 mutation 目标向上找 .ti 精准同步,避免全量重扫; // 目标不在任何 .ti 内(如题目列表容器新增子节点)说明结构变了,走完整 scan。 var targets = []; var needFull = false; for (var i = 0; i < records.length; i++) { var t = records[i].target; var ti = t && typeof t.closest === 'function' ? t.closest('.ti') : null; if (ti) { if (targets.indexOf(ti) === -1) targets.push(ti); } else { needFull = true; } } if (needFull || !targets.length) { scan(); } else { // 属性变化只影响按钮回填:按 mutation 目标向上找 .ti 精准同步(组会展开到子题), // 避免全量重扫;sync 显式开启,不随视口门控(交互刚发生,必须立即上色)。 for (var j = 0; j < targets.length; j++) processTi(targets[j], { sync: true }); } }); }); function startMO() { mo.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: MO_ATTRS }); } // ⑨ 滚动即按需补开视口附近的草稿/画布(rAF 节流;passive 不拦滚动) var scrollPending = false; window.addEventListener('scroll', function () { if (scrollPending) return; scrollPending = true; requestAnimationFrame(function () { scrollPending = false; scan(); }); }, { passive: true }); // v2.8.0:资料分析子题的原生草稿笔图标 = 整页草稿开关。 // 文档捕获层注册得比 fpPassClick 早 → 锁定态下也能先被我们接管(不会被白名单以外吃掉) document.addEventListener('click', fpPenEntry, true); if (document.body) { scan(); startMO(); } else { document.addEventListener('DOMContentLoaded', function () { scan(); startMO(); }); } // 草稿层动态插入后补偿锁定一次 setTimeout(function () { scan(); }, 800); // v2.7.4:答题卡折叠/展开是 transform 位移(无 MutationObserver 事件可搭), // 轮询兜底同步 .marvis-ac-clear 类;容器不存在时每次只是一次 querySelector,可忽略 setInterval(acWatch, 400); window.__MARVIS_FENBI_SCAN__ = scan; // 逃生门:强制全开所有题的草稿与画布(视口门控只影响「自动」打开) window.__MARVIS_FENBI_OPEN_ALL__ = function () { scan(true); }; // 一拖五展开开关:__MARVIS_FENBI_FLATTEN__() 无参切换;传 true/false 显式设置;返回新状态 window.__MARVIS_FENBI_FLATTEN__ = function (on) { if (on === undefined) on = !flattenEnabled(); try { localStorage.setItem(FLAT_LS, on ? '1' : '0'); localStorage.removeItem(FLAT_LS_OLD); // 清掉旧 key,避免旧状态继续误导 } catch (e) {} scan(true); return on; }; // 结构自检:返回每组的层级/内联样式/计算样式/滚动尺寸,排查「没生效」时先跑这个 window.__MARVIS_FENBI_DIAG__ = function () { var groups = tis().filter(isGroupTi); var out = { version: SCRIPT_VER, flattenEnabled: flattenEnabled(), groups: groups.length, detail: [] }; groups.forEach(function (g) { var rc = g.querySelector('.resizable-container'); var left = g.querySelector('.nz-resizable.left'); var qc = g.querySelector('.questions-container'); var cs = function (el, k) { try { return el ? getComputedStyle(el)[k] : null; } catch (e) { return null; } }; // 祖先滚动壳链(排查「没延伸」先看这里:谁在 overflow auto/scroll/hidden 且内容超高) var ancestors = []; var anc = g.parentElement; while (anc && anc !== document.body && anc !== document.documentElement) { ancestors.push({ el: anc.tagName.toLowerCase() + (anc.className && typeof anc.className === 'string' ? '.' + anc.className.split(' ').slice(0, 2).join('.') : ''), pos: cs(anc, 'position'), oy: cs(anc, 'overflowY'), h: cs(anc, 'height'), sh: anc.scrollHeight, ch: anc.clientHeight }); anc = anc.parentElement; } out.detail.push({ flat: g.getAttribute(FLAT_ATTR), rcInlineH: rc ? rc.style.height : null, rcComputedH: cs(rc, 'height'), leftInlineW: left ? left.style.width : null, leftComputedW: cs(left, 'width'), qcInlineOverflow: qc ? qc.style.overflow : null, qcComputedOverflowY: cs(qc, 'overflowY'), qcScrollH: qc ? qc.scrollHeight : null, qcClientH: qc ? qc.clientHeight : null, subs: g.querySelectorAll('app-ti.question-multiple').length, ancestors: ancestors }); }); if (window.console && console.log) console.log('[marvis] diag', out); return out; }; })();