// ==UserScript== // @name 粉笔自动全开草稿 // @namespace marvis.fenbi.enhance // @version 2.8.15 // @description 页面加载后自动打开全部题的草稿;每题注入 ABCD 字母选择盒(D 后带「下一题」),草稿画布锁定为题干+选项高度;新增「笔刷设置」面板(黑/蓝/红笔色切换 + 粗细无级调节);MutationObserver 适配 SPA 增量渲染,按题目作用域去重;同步 canvas#draft 绘图缓冲区尺寸为容器显示尺寸,修复退出草稿重开后笔迹被垂直压缩;新增 ABCD 点击按钮本身变色(背题对错 / 做题选中),修复判分重建选项节点后 ABCD 按钮不变色;退出重进后按页面既有作答状态自动回填 ABCD 按钮颜色(做题选中深蓝 / 背题对错绿红);修复做题模式退出重进后 ABCD 按钮空白不回填:选中态改多信号识别(checked 属性 / aria / class),MutationObserver 增补属性级监听并按题精准同步,定时补偿改为前 30 秒持续扫描 + pageshow/visibilitychange/focus 兜底;新增资料分析「一拖五」支持:每个子题独立注入字母盒与草稿画布(tab 切换即就位),「下一题」优先推进组内子题,材料区配独立草稿画布(可完全打开、五题共享、笔迹存 localStorage 退出重进重放),隐藏会吞掉全组指针事件的组级原生草稿;新增性能护栏:草稿与画布只在题目接近视口时才自动创建,低配环境不再 110 个画布全开卡死;平板 Apple Pencil 笔迹改 Catmull-Rom 样条消除折线,原生草稿路径加合并采样还原/防切笔/收笔兜底;新增局域网自动更新地址;v2.8.11 修平板笔迹「一条笔画成两条交错的线」:Apple Pencil 会同时产生 pen 与 touch 两路指针事件,此前两路被一起喂进同一点序导致来回重描,现按 pointerId/pointerType 锁定本笔指针并对同一采样坐标去重。v2.8.12 笔迹管线去浏览器化:跨 world 实例守卫(DOM 属性标记,防重复注入);会话期间原生草稿画布静音(画者独占,治「两个画者各画一遍」);归属判定不再依赖 pointerType(避免第三方 WebView 壳里把笔画整段切掉);采样去重放宽到 0.5px 并新增回访检测(治重复/乱序投递导致的重描);自适应「触控笔被报成 touch」的设备(把触摸当笔接管);新增胶囊面板「笔迹自检」(采集指针流分布/采样密度/原生画布像素变化,跨 world 可读)。v2.8.13 依据真机自检数据(Via 报 pen 但采样时没开书写会话)重做笔迹稳健性:原生补密改为幂等(按已投递坐标+区间投影判定,杜绝同一物理采样被补投两遍导致粉笔画出两条交错的线);归属判定允许浏览器在一笔之内更换 pointerId(不再把笔画截断成直线);采样去重改为 1px 距离判定;原生画布静音改为只在「可书写」态生效(修 pass 态手指写不了原生草稿的回归);自检新增指针生命周期台账/事件时间线/自绘笔台账/重复投递嫌疑判定与 captureValid 标志。v2.8.14 依据 Via 真机「有效采样」自检数据修掉「一条笔画成两条交错的线」的真正成因:原生补密的已投递坐标表在每次事件后重建时被丢弃,幂等防线只在单个事件内生效、跨帧完全失效,于是浏览器已经独立投递过的同一批真实采样又被补投一遍(铁证:同一批 14 个采样在相隔 1ms 的两个时间戳里各出现一次;补投 441 点而幂等只挡下 57 点);现 sent 随笔画继承并把保留上限提到 800,同时补记原生路径的合并采样密度、时间线区分合成事件与原始事件、自检新增「补投/采样」比值指标。v2.8.15:胶囊面板里的「笔迹自检」入口默认隐藏(功能代码全部保留,需要时把 PENDIAG_ROW_VISIBLE 改回 true,或直接调用 __MARVIS_FENBI_PEN__())。 // @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.15'; /* v2.8.15:胶囊面板里「笔迹自检」那一行的显示开关。 平时=false(面板里看不到这一行),但功能代码一行没删: · 控制台/地址栏调 __MARVIS_FENBI_PEN__() 照样能出诊断 JSON · 需要重新露出来时,把这里改成 true 即可(不用动别处) */ var PENDIAG_ROW_VISIBLE = false; /* ---------- v2.8.12 跨世界实例守卫 ---------- 问题的来源:Via 这类「自带脚本注入器」的浏览器(iOS 版 = WKWebView 壳), 注入语义和 Tampermonkey 不同: · 可能把用户脚本注入到**独立的 content world**(JS 全局不与页面共享, 但 DOM 共享)→ 我们在 window 上装的捕获监听与页面里的粉笔原生草稿 **互不可见**,stopPropagation 也拦不住对方 → 同一次书写被两个画者 各画一遍 → 屏幕上就是「两条交错的线」; · 也可能在同一次页面加载里**重复注入**两次 → 两个实例各建一层画布、 各抢各的 fpPen → 笔画被拆到不同画布上。 修法:守卫标记写在 **DOM 属性**上(DOM 跨 content world 共享,JS 全局不共享), 同版本重复注入直接退出,保证「页面上永远只有一个实例、一个画者」。 */ var INST_ATTR = 'data-marvis-fb-inst'; try { var _instRoot = document.documentElement; if (_instRoot) { if (_instRoot.getAttribute(INST_ATTR) === SCRIPT_VER) { return; // 同版本已有实例在跑 → 本次注入直接退出 } _instRoot.setAttribute(INST_ATTR, SCRIPT_VER); } } catch (e) {} 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 === 'pendiag') { // v2.8.12:笔迹自检(不改任何设置,只弹诊断面板) try { penDiagOverlay(); } catch (e) { /* 诊断失败不惊动页面 */ } return; } 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 FPGRP_ATTR = 'data-marvis-fpgroup'; // 一拖五组(整页草稿会话的作用域标记) /* ---------- 样式 ---------- */ 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(); /* ---------- v2.8.9 平板笔迹「合并还原 + 补密 + 防切笔」(治「平板用笔只能画折线」) ---------- 背景(全部逆向自粉笔前端 chunk-Q3SELT5A.js 的 Draft 组件源码,逐字核对,非猜测): ① 粉笔画笔迹 = 一串采样点,每个采样点画一个半径 1px 的圆(arc), 点数 ≥4 时每 4 点一组做基数样条(cardinal spline)细分后连成曲线; 点数 <4 时走 drawLine()(moveTo + 逐点 lineTo)= **纯直线**。 ② 全站 chunk 搜不到 pointerType:粉笔完全不区分笔 / 手, 二者唯一差别是【采样密度】+【有没有隐式指针捕获】。 ③ 触摸指针有隐式捕获(Pointer Events 规范)→ 全程不触发 pointerout → 笔画不会被切; 笔没有隐式捕获 → 一旦掠过画布上的覆盖元素(脚本注入的 ABCD 盒、粉笔工具条), pointerout 会冒泡到 app-draft → finishCurStroke() → 这一笔被【切成短段】, 每段点数 <4 → 整段只剩直线(这才是「只会画直线」)。 修法(全部在 window 捕获阶段做,不改粉笔的画法;笔迹仍由粉笔自己画, 橡皮 / 撤销 / 笔色 / 落笔缓存全部照旧): A. 合并还原:pointermove 里取 e.getCoalescedEvents()——浏览器因主线程繁忙而 「合并」掉的中间采样全在里面。页面越重(一页成卷把版面撑大)合并越多, 这一步直接把采样还原回手指级别,不用猜。 B. 切线补密:没有合并事件(或合并后仍稀疏)时,沿行笔切线用二次贝塞尔补中间点, 间距 ≈3 缓冲像素(粉笔圆点半径 1px),把「折线段」还原成「曲线」。 C. 防切笔:笔在草稿范围内的 pointerout 一律拦下,笔画不再被切成短段; 笔在草稿外抬笔时补发一个合成 pointerup,避免粉笔 pointerEventId 卡死。 只处理 pen(鼠标采样本来就密;手指有隐式捕获,本来就不受影响)。 */ var PEN_SYNTH = '__marvisSynth'; var penTrack = null; // {id, x, y, px, py, hasP, cv, n}:上一笔采样位置与方向 var penStrokeCv = null; // 当前笔画所在的原生草稿画布(防切笔 / 收笔兜底用) var penSynthN = 0; // 本笔已补点数(总量护栏) var PEN_SYNTH_MAX = 40; // 单个事件最多补多少点 var PEN_STROKE_MAX = 800; // 单笔最多补多少点(极端快速书写时防爆量) /* ---------- v2.8.12 笔迹事实采集(诊断用) ---------- 教训:同一份代码在 Safari 正常、在 Via 异常,说明**不能再靠猜浏览器怎么上报 指针事件**。于是把事实采下来: · 每一路指针流(pointerType + pointerId)的 down/move/up 与采样点数 —— 直接回答「到底有几路流同时在动、我们吃的是哪一路」 · 合并采样密度(getCoalescedEvents 每次能拿回多少真实采样) —— 直接回答「这一笔到底采到几个点」 · 各类丢弃计数(归属不符 / 重复点 / 回访点) · **原生草稿画布在我们书写期间像素有没有变** —— 直接回答 「是不是两个画者同时在画」(像素跨 content world 共享,一定测得到) · 环境指纹:UA / maxTouchPoints / pointer 媒体查询 / 有没有见过 pen 事件 出口有三处,保证在 Via 里也拿得到: ① window.__MARVIS_FENBI_PEN__()(脚本与页面同 world 时) ② (DOM 属性,跨 world 必定可读) ③ 胶囊面板「笔迹自检」浮层(用户点一下截图即可) */ var penStat = { streams: {}, // 'type:id' -> {type, id, down, move, up, pts, ev} coalescedMax: 0, coalescedSum: 0, coalescedN: 0, rejected: 0, dup: 0, retrace: 0, pts: 0, strokes: 0, penSeen: false, mouseSeen: false, touchSeen: false, nativeChanged: null, nativeCanvases: 0, instMark: null, layerCount: 0, worldGuess: null, /* v2.8.13 新增(都是为了把「浏览器到底怎么投递指针事件」变成可读数据): life —— 指针生命周期台账:每次 pointerdown→pointerup 记一条 {id, type, t0, t1, n, x0,y0, x1,y1} → 两段生命周期若起止点都几乎重合 = 同一物理笔画被投递了两次 (这是「一条笔画画成两条交错的线」的头号嫌疑) timeline —— 最近若干个指针事件的原始投递顺序(type/id/事件类型/坐标/时间戳) downIds —— 已经 down 过的 pointerId 集合(用于判断「浏览器中途换了 pointerId」) synth / synthSkip —— 原生补密:实际补投了几个 / 因重复或陈旧被跳过几个 fpLedger —— 我们自己画的每一笔的台账(点数、起止、时长) */ life: [], timeline: [], downIds: {}, fpLedger: [], synth: 0, synthSkip: 0, tickSkip: 0, tailSkip: 0 // v2.8.14:tickSkip = 因「浏览器正在逐点投递」不补;tailSkip = 因「合并列表末尾不是当前事件」整批不补 }; var PEN_LIFE_MAX = 24; // 生命周期台账条数 var PEN_TIMELINE_MAX = 260; // 时间线事件条数 var PEN_LEDGER_MAX = 16; // 自绘笔台账条数 function penStatStream(e) { if (!e) return null; var t = e.pointerType || 'unknown'; var id = (e.pointerId === undefined || e.pointerId === null) ? 'x' : e.pointerId; var k = t + ':' + id; var s = penStat.streams[k]; if (!s) { s = penStat.streams[k] = { type: t, id: id, down: 0, move: 0, up: 0, pts: 0, ev: 0 }; } if (t === 'pen') penStat.penSeen = true; else if (t === 'mouse') penStat.mouseSeen = true; else if (t === 'touch') penStat.touchSeen = true; return s; } // 原生草稿画布的像素指纹(24×24 降采样哈希)。 // 跨 content world 也能测——像素是共享的,JS 全局不是。 function penNativeFingerprint() { var out = {}; var cvs = []; try { cvs = Array.prototype.slice.call(document.querySelectorAll('app-draft canvas')); } catch (e) { return out; } var tmp = null; for (var i = 0; i < cvs.length; i++) { var cv = cvs[i]; if (cv.classList && cv.classList.contains('marvis-pen')) continue; var r = null; try { r = cv.getBoundingClientRect(); } catch (e2) {} if (!r || r.width < 2 || r.height < 2) continue; if (r.bottom < 0 || r.top > (window.innerHeight || 0) || r.right < 0 || r.left > (window.innerWidth || 0)) continue; // 只看视口内可见的 var h = 0; try { if (!tmp) tmp = document.createElement('canvas'); tmp.width = 24; tmp.height = 24; var tc = tmp.getContext('2d'); if (!tc) continue; tc.clearRect(0, 0, 24, 24); tc.drawImage(cv, 0, 0, 24, 24); var d = tc.getImageData(0, 0, 24, 24).data; for (var k = 0; k < d.length; k += 4) { h = (h * 31 + d[k] + d[k + 1] * 3 + d[k + 2] * 7 + d[k + 3] * 11) | 0; } } catch (e3) { continue; } out[(cv.id || 'cv') + '#' + i] = h; } penStat.nativeCanvases = Object.keys(out).length; return out; } function penNativeDelta(a, b) { var changed = []; for (var k in b) { if (a[k] !== undefined && a[k] !== b[k]) changed.push(k); } for (var k2 in a) { if (b[k2] === undefined) changed.push(k2 + '(gone)'); } return changed; } // 只认粉笔自己的原生草稿画布;我们自建的 canvas.marvis-pen 不在其列(它自己走贝塞尔平滑) function isNativeDraftCanvas(el) { if (!el || el.tagName !== 'CANVAS') return false; if (el.classList && el.classList.contains('marvis-pen')) return false; if (el.id === 'draft') return true; try { return !!(el.closest && (el.closest('app-draft') || el.closest('.draft-container'))); } catch (e) { return false; } } // 组级原生草稿(一拖五)被脚本隐藏 → 那块由我们自建画布接管,不参与原生补密 function draftCanvasUsable(cv) { if (!cv || cv.tagName !== 'CANVAS') return false; if (cv.classList && cv.classList.contains('marvis-pen')) return false; try { var dc = cv.closest ? cv.closest('.draft-container') : null; if (dc && dc.getAttribute(HIDE_ATTR) === '1') return false; } catch (e) {} return true; } // 事件目标 → 粉笔原生草稿画布。 // 目标可能是画布本身,也可能是压在画布上的覆盖元素(我们注入的 ABCD 盒、粉笔工具条): // 那种情况下 pointermove 仍然冒泡到 app-draft,粉笔照常画线(坐标按 canvas 换算), // 所以补密也必须继续做,否则一跨过覆盖元素就又变回采样稀疏的折线。 function resolveDraftCanvas(el) { if (!el || el.nodeType !== 1) return null; var cv = null; if (isNativeDraftCanvas(el)) { cv = el; } else { var host = null; try { host = el.closest ? el.closest('app-draft') : null; } catch (e) {} if (!host) return null; try { cv = host.querySelector('canvas#draft') || host.querySelector('canvas'); } catch (e2) {} } return draftCanvasUsable(cv) ? cv : null; } // 指针是否还落在画布范围内(用来判断「只是掠过覆盖元素」还是「真的离开草稿区」) function pointerInCanvas(cv, e) { try { var r = cv.getBoundingClientRect(); if (!r || !r.width || !r.height) return false; return e.clientX >= r.left - 1 && e.clientX <= r.right + 1 && e.clientY >= r.top - 1 && e.clientY <= r.bottom + 1; } catch (err) { return false; } } // 合成一个指针事件(优先用原生 PointerEvent;无则退回普通 Event 补属性) function mkSynthPointer(type, opts) { var ev = null; try { if (typeof window.PointerEvent === 'function') { ev = new window.PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: opts.pointerId, pointerType: opts.pointerType, clientX: opts.clientX, clientY: opts.clientY, screenX: opts.screenX, screenY: opts.screenY, pressure: opts.pressure, tiltX: opts.tiltX, tiltY: opts.tiltY, isPrimary: opts.isPrimary }); } } catch (e) { ev = null; } if (!ev) { try { ev = document.createEvent('Event'); ev.initEvent(type, true, true); } catch (e2) { return null; } for (var k in opts) { try { ev[k] = opts[k]; } catch (e3) {} } } try { ev[PEN_SYNTH] = true; } catch (e4) {} return ev; } // 把合成事件派发回原生草稿画布(冒泡到 app-draft → 粉笔的 onPointerMove 收到) function sendSynth(cv, type, e, x, y, pressure) { var ev = mkSynthPointer(type, { pointerId: e.pointerId, pointerType: 'pen', clientX: x, clientY: y, screenX: e.screenX, screenY: e.screenY, pressure: pressure, tiltX: e.tiltX, tiltY: e.tiltY, isPrimary: e.isPrimary === undefined ? true : e.isPrimary }); if (!ev) return false; try { cv.dispatchEvent(ev); } catch (e2) { return false; } return true; } function penOnDown(e) { if (!e || e.pointerType !== 'pen') return; if (e[PEN_SYNTH]) return; var cv = resolveDraftCanvas(e.target); if (!cv) return; penTrack = { id: e.pointerId, x: e.clientX, y: e.clientY, px: 0, py: 0, hasP: false, cv: cv, n: 0, sent: [], // v2.8.13:这一笔里「原生画布已经收到过的坐标」——补密幂等的依据 t: (typeof Date !== 'undefined' && Date.now) ? Date.now() : 0 // v2.8.14:逐点投递判据 }; penSentAdd(penTrack, e.clientX, e.clientY); // 起笔点原生会自己收到 penSynthN = 0; penStrokeCv = cv; } /* ---------- v2.8.13 原生补密幂等化 ---------- 旧写法(v2.8.7 起)假定「getCoalescedEvents() 返回的最后一个元素就是当前事件本身」, 于是只补投 co[0..len-2]。这条假定只对 Safari / Chrome 成立。 第三方 WebView 壳(Via = iOS 上的 WKWebView 壳)返回的列表可能是 · 包含**已经作为独立事件投递过**的样本(旧版 WebKit 的已知行为),或 · 顺序与 Safari 不同(当前事件不在末尾) → 我们把同一批物理采样**再投一遍**给原生画布 → 粉笔的 stroke.points 变成 A,A,B,B,C,C → paint() 的 4 点滑动窗口沿原路折返 → 屏幕上就是 「一条笔画画成两条几乎平行、局部交叉的线」。 修法:**不再依赖任何浏览器语义**。只补「还没投过、且确实落在上一次事件与本次 事件之间」的样本: ① 当前事件的位置先登记为「已投递」(原生会自己收到它); ② 候选样本若与已投递坐标过近 → 跳过(已经投过了); ③ 候选样本若不落在「上次位置→本次位置」的区间内 → 跳过(陈旧或超前,绝不乱序)。 这样无论浏览器怎么返回合并列表,原生画布都只会收到每个物理采样**一次**。 */ var PEN_SENT_MAX = 800; // 一笔内保留多少个「已投递坐标」:必须覆盖整笔(一笔可到 400+ 点) var PEN_SENT_SCAN = 240; // 去重只回看最近这么多条:重复必然发生在最近,避免每帧 O(n) 全扫 var PEN_TICK_GAP = 5; // v2.8.14:相邻两个原始事件间隔小于 5ms = 浏览器正在把这一帧的 // 采样**逐个**投递(Via 实测:14 个采样挤在 1ms 内)→ 这些点 // 原生马上会自己收到,此时绝不能再补投,否则就是「投两遍」。 function penSentAdd(tr, x, y) { if (!tr) return; if (!tr.sent) tr.sent = []; tr.sent.push({ x: x, y: y }); if (tr.sent.length > PEN_SENT_MAX) tr.sent.shift(); } function penSentHas(tr, x, y, r) { if (!tr || !tr.sent) return false; var rr = (r || 1) * (r || 1); var n = tr.sent.length; var from = n > PEN_SENT_SCAN ? n - PEN_SENT_SCAN : 0; for (var i = n - 1; i >= from; i--) { // 从最近的开始回看 var q = tr.sent[i]; var dx = x - q.x, dy = y - q.y; if (dx * dx + dy * dy < rr) return true; } return false; } /* v2.8.14 再收紧:判定三道闸(与浏览器怎么返回列表无关): ① 已经投递过的坐标 → 跳过(否则原生会把同一条轨迹画两遍) ② 不落在「上次位置→本次位置」区间内 → 跳过(陈旧/超前,绝不乱序) ③ pointerId 不符 → 跳过(别的指针的采样) ④ 列表末尾必须就是当前事件本身 → 否则整批不补(见下) 返回 { list: 可补投的样本, skip: 被跳过的个数, tail: 是否因 ④ 整批放弃 } ④ 的来历(Via 真机自检):Via 会把同一帧的 14 个采样**逐个作为独立 pointermove 投递**,而每个事件的 getCoalescedEvents() 又把这一帧 14 个**原样返回**。于是帧内 第一个事件(P1)的列表末尾是 P14 而不是 P1 —— 列表里全是「浏览器随后会自己送到」 的未来采样;照补就是把这一帧画两遍。Safari/Chrome 的正常形态则是 「列表末尾 = 当前事件」(中间点在前、当前点在末),此时补前面的点才安全。 宁可少补(采样稀一点,cardinal 样条照样平滑),绝不能补出重复(那是两条交错的线)。 */ function penPickCoalesced(tr, co, ax, ay, bx, by, pid) { var list = [], skip = 0; if (!co || !co.length) return { list: list, skip: skip }; var tail = co[co.length - 1]; if (tail && isFinite(tail.clientX) && isFinite(tail.clientY)) { var ddx = tail.clientX - bx, ddy = tail.clientY - by; if (ddx * ddx + ddy * ddy > 1) return { list: list, skip: co.length, tail: true }; // ④ } var pdx = bx - ax, pdy = by - ay; var plen2 = pdx * pdx + pdy * pdy; for (var i = 0; i < co.length; i++) { var ce = co[i]; if (!ce) { skip++; continue; } if (ce.pointerId !== undefined && pid !== undefined && ce.pointerId !== pid) { skip++; continue; } var gx = ce.clientX, gy = ce.clientY; if (!isFinite(gx) || !isFinite(gy)) { skip++; continue; } if (penSentHas(tr, gx, gy, 1)) { skip++; continue; } // ① 已投递过 if (plen2 > 0.01) { // ② 必须落在区间内 var tt = ((gx - ax) * pdx + (gy - ay) * pdy) / plen2; if (tt < 0.02 || tt > 1.05) { skip++; continue; } } list.push({ x: gx, y: gy, pressure: ce.pressure }); } return { list: list, skip: skip }; } // 收笔:这一笔结束,清空跟踪状态(pointerout 不在此列——它可能只是掠过覆盖元素) function penOnEnd(e) { if (!e) return; if (penTrack && penTrack.id === e.pointerId) penTrack = null; } /* 粉笔把 pointerout 也绑到了 finishCurStroke:笔只要掠过画布上的任何覆盖元素 (我们注入的 ABCD 字母盒 / 粉笔工具条…)就会把这一笔【切成两段】, 每段累计点数不足 4 → paint() 走 drawLine() → 只画直线(就是「画不了弧线」)。 手指没这问题:触摸指针有隐式捕获,全程不触发 pointerout。 修法:笔还在草稿范围内时的 pointerout 一律拦下(不传给粉笔),笔画保持为一整笔。 兜底:笔在画布外抬笔时(真实 pointerup 落不到 app-draft 上),补发一个合成 pointerup 给画布,否则粉笔的 pointerEventId 会一直占着、下笔直接被忽略(画布卡死)。 */ function penOnOut(e) { if (!e || e.pointerType !== 'pen') return; if (e[PEN_SYNTH]) return; var cv = resolveDraftCanvas(e.target); if (!cv) { // 目标已经是覆盖元素了(上一跳就被我们放行过一次)→ 仍按当前笔画的画布判断 cv = (penTrack && penTrack.cv) || penStrokeCv; if (!cv) return; } penStrokeCv = cv; if (pointerInCanvas(cv, e)) { try { e.stopPropagation(); } catch (err) {} // 只掠过覆盖元素 → 不切笔 } else { penTrack = null; // 真的离开草稿区 → 这一笔到此为止 } } function penOnUp(e) { if (!e || e.pointerType !== 'pen') return; if (e[PEN_SYNTH]) return; var cv = penStrokeCv; penStrokeCv = null; if (!cv) return; if (e.target === cv) return; // 抬笔就在画布上 → 粉笔自己会收到 var host = null; try { host = cv.closest ? cv.closest('app-draft') : null; } catch (e1) {} if (host && e.target && e.target.nodeType === 1) { try { if (host.contains(e.target)) return; } catch (e2) {} // 仍在草稿内 → 粉笔自己会收到 } // 抬笔在草稿之外 → 补发合成 pointerup,防止粉笔 pointerEventId 卡住 if (!sendSynth(cv, 'pointerup', e, e.clientX, e.clientY, 0)) return; } function penDensify(e) { if (!e || e.pointerType !== 'pen') return; if (e[PEN_SYNTH]) return; var cv = null; // 指针可能压在覆盖元素上(此时 target 不是画布),但只要还在画布范围内就继续补密 if (penTrack && penTrack.id === e.pointerId && penTrack.cv && pointerInCanvas(penTrack.cv, e)) { cv = penTrack.cv; } if (!cv) cv = resolveDraftCanvas(e.target); if (!cv) return; var last = penTrack; var now = (typeof Date !== 'undefined' && Date.now) ? Date.now() : 0; if (!last || last.id !== e.pointerId || last.cv !== cv) { penTrack = { id: e.pointerId, x: e.clientX, y: e.clientY, px: 0, py: 0, hasP: false, cv: cv, n: 0, sent: [], t: now }; penSentAdd(penTrack, e.clientX, e.clientY); // 新的一笔:当前点原生会自己收到 penSynthN = 0; return; } var ax = last.x, ay = last.y; var bx = e.clientX, by = e.clientY; /* v2.8.14 第二道闸 —— 「逐点投递」识别: Via 真机自检的时间线显示,同一帧的 14 个真实采样会被浏览器**逐个作为独立 pointermove 投递**(14 条挤在同一个毫秒里),而每个事件的 getCoalescedEvents() 又把这 14 个**原样返回**。于是第一个事件刚把 P2..P14 补投出去,浏览器紧接着就把 P2..P14 自己投递一遍 → 原生画布每个点收到两次 → 两条交错的线。 判据与浏览器语义无关:相邻两个原始事件间隔 < 5ms = 同一帧内的连续投递 → 这些点原生马上会自己收到,一律不补。Safari/Chrome 的正常形态是「每帧一个事件」 (间隔 16~60ms),不受影响。 */ // hasP=false 表示上一个是 pointerdown(落笔瞬间与第一个 move 可能同一毫秒), // 那种情况下不做「逐点投递」判定,否则会把落笔后的第一次补密也误杀。 var gap = (last.hasP && last.t && now) ? (now - last.t) : 999; var ticking = gap < PEN_TICK_GAP; // 浏览器正在逐点投递 /* ① 合并还原(v2.8.13 幂等版):浏览器因主线程繁忙,把中间的采样合并进这一个 pointermove,getCoalescedEvents() 能把它们全部拿回来。 【v2.8.13 的关键修正】旧写法假定「列表最后一个 = 当前事件本身」,只补 co[0..len-2]——这条假定只对 Safari/Chrome 成立。第三方 WebView 壳(Via) 返回的列表可能把**已经投递过的样本**也包含进来(或顺序不同)→ 我们再投一遍 → 粉笔的 stroke.points 变成 A,A,B,B,C,C → paint() 的 4 点窗口沿原路折返 → 屏幕上就是「一条笔画画成两条几乎平行、局部交叉的线」。 现在只补「还没投过、且确实落在上次位置与本次位置之间」的样本: ① 当前事件的位置先登记为「已投递」(原生会自己收到它); ② 与已投递坐标过近 → 跳过; ③ 不落在区间内 → 跳过(陈旧/超前,绝不乱序投递)。 */ var co = null; try { co = (typeof e.getCoalescedEvents === 'function') ? e.getCoalescedEvents() : null; } catch (err) {} // v2.8.14:原生路径也要记合并密度。以前只在整页草稿路径(fpOnMove)记,于是 // Via 的诊断里 coalesced 恒为 {max:0,avg:0,events:0} —— 差点据此误判「Via 没有合并采样」。 if (co && co.length) { penStat.coalescedN++; penStat.coalescedSum += co.length; if (co.length > penStat.coalescedMax) penStat.coalescedMax = co.length; } penSentAdd(last, bx, by); // 当前事件原生会自己收到 → 先登记 var pick = { list: [], skip: 0 }; if (ticking) { penStat.tickSkip++; // 浏览器正在逐点投递 → 一个都不补(补了就是投两遍) } else { pick = penPickCoalesced(last, co, ax, ay, bx, by, e.pointerId); if (pick.tail) penStat.tailSkip++; // ④ 列表末尾不是当前事件 → 整批放弃 penStat.synthSkip += pick.skip; for (var pi = 0; pi < pick.list.length; pi++) { if (penSynthN >= PEN_STROKE_MAX) break; var ps = pick.list[pi]; if (!sendSynth(cv, 'pointermove', e, ps.x, ps.y, ps.pressure)) break; penSynthN++; penStat.synth++; penSentAdd(last, ps.x, ps.y); ax = ps.x; ay = ps.y; } } /* ② 粉笔自己的画法(cardinal spline,4 点窗口)本身就是平滑曲线: 采样再稀也只是「形状粗一点」,不会变成折线——所以这里【不再做插值重建】 (切线重建反而会在采样点处留下折角,见 tests/fenbi-kink-sim.js 的量化对比)。 只有浏览器给出的真实合并采样(①)才值得补回去:那是笔的真实轨迹。 */ /* v2.8.14 关键修复 —— 这一行漏掉 sent 就是 Via「两条交错线」的直接成因: v2.8.13 在这里重建 penTrack 时**没有把 last.sent 带过来**,于是「已投递坐标」 每个事件都从空数组重新开始 —— 幂等防线只在「单个事件内部」生效,跨事件/跨帧 完全失效。Via 实测(v2.8.13 自检 JSON)的铁证: · timeline 里同一批 14 个采样在相隔 1ms 的两个时间戳里各出现一次 (t=...611025 与 t=...611026 的坐标序列完全相同)→ 真实采样被投了两遍; · synth = {sent:441, skip:57} → 补投了 441 个、幂等只挡下 57 个。 浏览器(Via)既把这一帧的采样**作为独立 pointermove 投递**、又在 getCoalescedEvents() 里**原样返回同一批**,于是我们把已经画过的点又补投一遍 → 粉笔 stroke.points 变成 A,A,B,B,C,C → paint() 的 4 点窗口沿原路折返 → 屏幕上两条交错的线。 sent 继承后:浏览器独立投递过的坐标已登记在案 → coalesced 里再出现就被 ① 挡掉 → 「已经独立投递过就不补」与「跨帧不重复补」两个问题一并解决。 */ penTrack = { id: e.pointerId, x: bx, y: by, px: ax, py: ay, hasP: true, cv: cv, n: last.n + 1, sent: last.sent, t: now }; } function installPenDensifier() { if (typeof window.addEventListener !== 'function') return; try { window.addEventListener('pointerdown', penOnDown, true); window.addEventListener('pointermove', penDensify, true); window.addEventListener('pointerout', penOnOut, true); window.addEventListener('pointerup', penOnUp, true); window.addEventListener('pointerup', penOnEnd, true); window.addEventListener('pointercancel', penOnEnd, true); // v2.8.12:全局旁观所有指针事件(不分类型)——用来回答「这台设备到底会不会 // 报 pointerType=pen」。Via(iOS WKWebView 壳)很可能把触控笔降级成 touch, // 那时我们就必须自己认得出「这是笔」而不是靠 pointerType。 window.addEventListener('pointerdown', penStatWatch, true); window.addEventListener('pointermove', penStatWatch, true); window.addEventListener('pointerup', penStatWatch, true); // v2.8.13:收笔也要计数 window.addEventListener('pointercancel', penStatWatch, true); } catch (e) { /* 装不上不影响原有功能 */ } } /* v2.8.13:全局旁观所有指针事件——这是「浏览器到底怎么投递指针事件」的唯一事实来源。 v2.8.12 只挂 pointerdown/pointermove,且 down/move/up 三个计数只在我们的 fp 路径里 自增 → 上一版诊断里出现过「ev=349 但 down/move/up 全是 0」的矛盾(因为那次采样时 整页草稿会话没开,我们的路径根本没跑)。现在三个计数一律由旁观器自己记, 与我们的落笔路径无关,任何时候都能读出浏览器的真实投递形态。 */ function penStatWatch(e) { if (!e) return; var s = penStatStream(e); if (!s) return; s.ev = (s.ev || 0) + 1; var ty = e.type || ''; var now = Date.now(); var x = Math.round(e.clientX || 0), y = Math.round(e.clientY || 0); if (ty === 'pointerdown') { s.down = (s.down || 0) + 1; try { penStat.downIds[e.pointerId] = true; } catch (e2) {} // 开一条生命周期台账 penStat.life.push({ id: e.pointerId, type: e.pointerType || 'unknown', t0: now, t1: 0, n: 0, x0: x, y0: y, x1: x, y1: y, open: true }); if (penStat.life.length > PEN_LIFE_MAX) penStat.life.shift(); } else if (ty === 'pointermove') { s.move = (s.move || 0) + 1; } else if (ty === 'pointerup' || ty === 'pointercancel') { s.up = (s.up || 0) + 1; } // 生命周期收尾:给该指针最后一条未闭合的台账补上终点 if (ty === 'pointerup' || ty === 'pointercancel') { for (var i = penStat.life.length - 1; i >= 0; i--) { var L = penStat.life[i]; if (L.open && L.id === e.pointerId) { L.open = false; L.t1 = now; L.x1 = x; L.y1 = y; L.n = s.ev || 0; break; } } } // 原始投递顺序(最近 N 条)——跨 world 也能从 DOM 属性里读到 try { // v2.8.14:syn 标记区分「浏览器投的」与「我们补投的合成事件」——上一版诊断里 // 两者混在一条时间线上,只能靠时间戳差 1ms 去猜哪批是我们投的(Via 实测正是如此) penStat.timeline.push({ t: now, ty: ty, id: e.pointerId, pt: e.pointerType || '', x: x, y: y, syn: e[PEN_SYNTH] ? 1 : 0 }); if (penStat.timeline.length > PEN_TIMELINE_MAX) penStat.timeline.shift(); } catch (e3) {} } installPenDensifier(); /* ---------- ⑤ 笔刷设置面板(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) { // v2.8.12:标记「这一组由我们的整页草稿接管」——会话期间据此让组内原生草稿 // 画布彻底收不到指针事件(见 fpQuietOn)。 try { group.setAttribute(FPGRP_ATTR, '1'); } catch (e) {} [].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 }; } // 平板 / 触控笔快速书写时,浏览器会把一次笔程里的多个点合并进同一个 pointermove // 事件(coalesced events)。若只取 e.clientX/Y 就会丢掉中间点 → 采样过稀 → // 曲线被近似成折线、圆圈变成多边形(典型「画不了弧线」症状)。展开被合并的 // 中间点,笔迹才平滑。无该 API 的环境(旧 Safari 等)回退到单点,行为不变。 function penCoalesce(e) { try { if (typeof e.getCoalescedEvents === 'function') { var list = e.getCoalescedEvents(); if (list && list.length) return list; } } catch (err) {} return [e]; } 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]; if (!s || !s.length) continue; var px = []; for (var j = 0; j < s.length; j++) px.push({ x: s[j][0] * w, y: s[j][1] * h }); penStrokePath(ctx, px); } } // 笔迹统一笔刷样式:色走笔刷 hook(已在 Fenbi 草稿 canvas 上强制为当前笔色、保留 alpha), // 线宽 = 4×笔宽(与旧点阵直径一致:旧点半径 2、hook 再乘笔宽 → 直径 4×笔宽)。 function penStrokeStyle(ctx) { if (!ctx) return; ctx.strokeStyle = brushColor; ctx.lineWidth = 4 * brushWidth; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; } /* 笔迹绘制:Catmull-Rom 样条(与粉笔原生画笔同族:cardinal spline,tension 0.5) 转三次贝塞尔逐段描。 为什么不用「中点二次贝塞尔」:那条曲线虽然平滑,但**不经过采样点**—— 采样一稀(平板主线程卡、pointermove 被浏览器合并)形状就走样成折线; Catmull-Rom 从前后邻点取切线,既没有尖角、又贴着笔的真实轨迹, 视觉上与粉笔原生草稿画出来的完全一致。 done:已画到第几段(增量调用时只补画新段,不全量重绘)。 */ function penAdvance(ctx, pts, done) { if (!ctx) return done || 0; var n = pts.length, d = done || 0; while (d + 2 <= n - 1) { // 需要 pts[d+2] 才能定右切线,缺就等下一个点 var p0 = d > 0 ? pts[d - 1] : pts[d]; var p1 = pts[d], p2 = pts[d + 1]; var p3 = d + 2 <= n - 1 ? pts[d + 2] : pts[d + 1]; var c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6; var c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2.x, p2.y); ctx.stroke(); d++; } return d; } // 收尾:补最后一段(末端用沿末段方向的虚拟点,曲线不留尖角);2 点笔画则直接连线。 function penFinalize(ctx, pts) { if (!ctx) return; var n = pts.length; if (n < 2) return; if (n === 2) { ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); ctx.lineTo(pts[1].x, pts[1].y); ctx.stroke(); return; } var p0 = pts[n - 3], p1 = pts[n - 2], p2 = pts[n - 1]; var p3 = { x: p2.x + (p2.x - p1.x), y: p2.y + (p2.y - p1.y) }; var c1x = p1.x + (p2.x - p0.x) / 6, c1y = p1.y + (p2.y - p0.y) / 6; var c2x = p2.x - (p3.x - p1.x) / 6, c2y = p2.y - (p3.y - p1.y) / 6; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.bezierCurveTo(c1x, c1y, c2x, c2y, p2.x, p2.y); ctx.stroke(); } // 整笔(用于重放):单点画圆点,多点点阵平滑成曲线。 function penStrokePath(ctx, pts) { if (!ctx || !pts || !pts.length) return; if (pts.length === 1) { penDot(ctx, pts[0].x, pts[0].y); return; } penStrokeStyle(ctx); penAdvance(ctx, pts, 0); penFinalize(ctx, pts); } // 找元素向上最近的「真滚动容器」(内容超高且 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, done = 0; var stroke = null; // v2.8.11:本笔所属指针 + 已收坐标(防两路指针流把笔画画两遍) cv.__drawing = false; cv.__abort = function () { // 模式切换/重建时中断进行中的书写 drawing = false; pts = []; last = null; done = 0; cv.__drawing = false; stroke = null; }; 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]; done = 0; stroke = { pointerId: e.pointerId, pointerType: e.pointerType, seen: {}, seenKeys: [], // v2.8.13:与 fp 路径同一套归属语义(允许中途换 id,只拒绝自己 down 过的指针) ids: (function () { var m = {}; m[e.pointerId] = true; return m; })(), downIds: penStat.downIds }; fpStrokeDup(stroke, last); penDot(ctx, last.x, last.y); penStrokeStyle(ctx); e.preventDefault(); }); canvas.addEventListener('pointermove', function (e) { if (!drawing) return; // v2.8.11:只收本笔所属指针的事件;同一物理坐标不收两遍 if (!fpStrokeOwns(stroke, e)) return; var evs = penCoalesce(e); for (var k = 0; k < evs.length; k++) { var p = penPoint(canvas, evs[k]); var dx = p.x - last.x, dy = p.y - last.y; var dist = Math.sqrt(dx * dx + dy * dy); if (dist >= 0.35) { // 只收有意义的位移;阈值调细(一页成卷下主线程卡、采样稀),贝塞尔曲线更顺 if (fpStrokeDup(stroke, p)) continue; pts.push(p); last = p; } } done = penAdvance(ctx, pts, done); // 增量补画新段,不全量重绘 e.preventDefault(); }); ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(function (t) { canvas.addEventListener(t, function (e) { if (!drawing) return; // v2.8.11/v2.8.13:别的指针收笔不动本笔(只认本笔认领过的指针) if (e && !fpStrokeEnds(stroke, e)) return; drawing = false; cv.__drawing = false; if (pts.length >= 2) penFinalize(ctx, pts); 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; done = 0; stroke = 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 fpQuietSync(); // v2.8.13:静音跟着模式走(可触摸态要能用手写原生草稿) 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'; // 书写态手指唯一可点的东西:草稿竖条(切换/清空/退出都在里面,必须够得着) // v2.8.12:加 #marvis-pen-diag-box —— 笔迹自检浮层必须能点(复制/关闭) var FP_TOUCH_SEL = '#marvis-fp-dock, #marvis-flat-chip, .marvis-brush-panel, #marvis-cv-toast, #marvis-pen-diag-box'; // 触摸态唯一可点的东西:上面这些 + 本题字母盒(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); } // 指针分流总闸(纯函数,单测直接打表):这一笔到底抓不抓 // 笔(pen)的走向: // - 普通单题(fpOwner 为空)→ 此处直接 false,笔与手指一样落粉笔原生草稿(原生画曲线) // - 一拖五/一页成卷组(fpOwner 存在)→ 原生组级草稿被 neutralizeGroupDraft 隐藏 // (它本是盖住整组的透明层、会吃掉所有点击,不能复活),故笔被脚本接管、走自建 // 整页草稿层;该层自 v2.8.5 起已用二次贝塞尔平滑,v2.8.6 又把采样阈值调细,曲线更顺。 // 结论:两种情况笔都不再画折线;若仍见折线,几乎必然是 Tampermonkey 仍是旧版未更新。 function fpGate(e) { if (!fpOwner || fpOwner.mode !== 'draw') return false; if (e.pointerType === 'touch') return false; // 手指不接管 → 落原生草稿(画曲线) 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); // 归一化坐标按新尺寸重放,笔迹不丢 } } /* ---------- v2.8.11 一笔之内的收点归属(治「一条笔画变成两条交错的线」) ---------- iPad 上 Apple Pencil 的接触会同时产生【两路指针事件】: · pointerType='pen' 的指针流(带 getCoalescedEvents 的真实采样) · pointerType='touch' 的指针流(iOS 的触摸兼容流,同一批物理采样、不同的 pointerId) v2.8.10 的 fpOnMove 挂在 window 捕获层、且对来者不拒 → 两路事件被一起喂进同一个 点序,点序变成 A,B,A,B… → 样条沿同一条轨迹来回重描;而每次重描时 Catmull-Rom 的 切线邻域(p0/p3)已被重复点污染 → 两次描出的曲线形状略有差异 → 屏幕上就是「一条笔画画成两条几乎平行、局部交叉的线」。 修法(两道闸): 1) fpStrokeOwns:一笔只认一个指针(pointerId + pointerType 都要匹配),另一路丢弃; 2) fpStrokeDup:同一物理坐标只收一次 —— 防 Safari 把同一批 coalesced 采样点重复投递。 两者都是纯函数,tests/pen-dual-stream-sim.js 直接抽取本文件的实现做离线仿真。 */ /* v2.8.12:归属判定去掉 pointerType 硬排除。 之前用「pointerType 必须一致」来隔开 Apple Pencil 的双路指针流,出发点没问题, 但它隐含假设「同一路事件在整笔里 pointerType 不变」——这条假设在第三方 WebView 壳里不成立:pointerdown 可能报 'pen'、后续 move 报 'touch'(或反过来)。 一旦被判成「不是本笔」,整段事件被丢弃 → 笔画被切成几截,每截只剩 1~2 个点 → 我们只能画直线 → 又是「折线」。 现在只认 pointerId(指针身份的唯一硬指标);类型差异交给「采样去重 + 回访检测」 处理——那两路流报的是同一批物理采样,去重层会天然把重复的那一路吃掉。 */ /* v2.8.13:归属判定再放宽一步——**允许浏览器在一笔之内更换 pointerId**。 v2.8.12 只认 pointerId,一旦浏览器中途换了 id(第三方 WebView 壳常见:同一支笔 的接触被拆成两段不同 id 的生命周期),后续事件全部被判「不是本笔」→ 笔画被截断成 起笔点那一两个点 → 只能画直线(折线)。 新规则: · 本笔已绑定的 id → 收; · 另一个 id,但**它没有自己 down 过**(浏览器中途换 id)→ 收,并改绑到它; · 另一个 id,且**它自己 down 过**(真·第二指针:手掌 / 另一支笔)→ 拒绝。 down 过哪些 id 由全局旁观器记录(penStat.downIds),与我们的落笔路径无关。 */ function fpStrokeOwns(stroke, e) { if (!stroke) return false; if (!e) return true; var id = e.pointerId; if (id === undefined || id === null) return true; if (!stroke.ids) stroke.ids = {}; if (stroke.ids[id]) return true; // 本笔已认领的指针 if (stroke.downIds && stroke.downIds[id]) return false; // 自己 down 过的第二指针 → 拒绝 stroke.ids[id] = true; // 中途换 id → 认领并改绑 stroke.pointerId = id; return true; } /* v2.8.13:收笔判定与「移动」判定分开。 移动:允许浏览器中途换 pointerId(见 fpStrokeOwns)——不收的话笔画会被截断成直线。 收笔:**只认本笔已认领的指针**。别的指针抬笔(触摸兼容流先抬笔、手掌离开) 不能把本笔提前收掉。 */ function fpStrokeEnds(stroke, e) { if (!stroke) return false; if (!e) return true; var id = e.pointerId; if (id === undefined || id === null) return true; if (!stroke.ids) return id === stroke.pointerId; return !!stroke.ids[id]; } // 同一物理采样点是否已经收过(v2.8.13:量化去重只当快速路径,真正的判定改成距离) // 亚像素抖动(重复投递时坐标常有 0.2~1px 偏差)抓不住 → 去重失效 → 样条沿同一条 // 轨迹来回重描 → 一条笔画分成两条交错的线。故:距离 1px 内一律判重(只比最近 256 点)。 function fpStrokeDup(stroke, p) { if (!stroke || !p) return false; if (!stroke.seen) { stroke.seen = {}; stroke.seenKeys = []; } var k = Math.round(p.x * 4) + ',' + Math.round(p.y * 4); if (stroke.seen[k]) return true; var n = stroke.pts ? stroke.pts.length : 0; var from = n - 256; if (from < 0) from = 0; for (var i = from; i < n; i++) { var q = stroke.pts[i]; var dx = p.x - q.x, dy = p.y - q.y; if (dx * dx + dy * dy < 1) { stroke.seen[k] = 1; return true; } // 1px 内 = 同一个物理点 } stroke.seen[k] = 1; stroke.seenKeys.push(k); if (stroke.seenKeys.length > 512) { try { delete stroke.seen[stroke.seenKeys.shift()]; } catch (e) {} } return false; } /* v2.8.12 回访检测:新采样点若离「已经走过的某个旧点」很近,说明这是被 重复/乱序投递的旧采样,而不是笔真的又走了一遍。放行它 = 样条沿原路折返 → 屏幕上就是一条笔画分叉成两条。 两档判定(量化去重是 O(1) 快速路径,但抓不住亚像素抖动,这里用距离兜底): · 离**当前位置** < 0.6px → 只是抖动重复,丢掉; · 离**更早的点** < 1.2px → 回访,丢掉(只比最近 64 个点,且跳过紧邻的上一点, 避免误杀正常的回头路与打勾)。 */ function fpStrokeRetrace(stroke, p) { if (!stroke || !p || !stroke.pts || !stroke.pts.length) return false; var n = stroke.pts.length; var last = stroke.pts[n - 1]; var ldx = p.x - last.x, ldy = p.y - last.y; if (ldx * ldx + ldy * ldy < 0.36) return true; // 0.6px:当前位置的抖动重复 var from = n - 64; if (from < 0) from = 0; for (var i = from; i < n - 1; i++) { var q = stroke.pts[i]; var dx = p.x - q.x, dy = p.y - q.y; if (dx * dx + dy * dy < 1.44) return true; // 1.2px:回访旧点 } return false; } function fpOnDown(e) { // v2.8.0:可书写态手指点按静默无效(用户不要「当前不可触摸」弹窗)—— // 一律不惊动页面。触摸态(pass)手指完全放行:答案靠点击、滚动靠手指, // 由 fpPassClick 把关只放行 ABCD/下一题/竖条。 var touchAsPen = false; if (e.pointerType === 'touch') { if (!fpOwner || fpOwner.mode !== 'draw') return; if (fpHitSel(e.target, FP_TOUCH_SEL)) return; // 竖条上的切换/清空/退出必须能按 if (penStat.penSeen) { // 这台设备会报 pointerType=pen → 这次的 touch 就是手指/手掌,挡掉(防误触/防掌心) e.preventDefault(); e.stopPropagation(); return; } // v2.8.12 自适应:本会话从没见过 pointerType='pen' —— 说明这台浏览器根本不把 // 触控笔报成 pen(Via 的 WKWebView 壳就是这种),于是「触控笔」会以 touch 到达。 // 这时把 touch 当笔接管(= 用户说的「统一当成是手写的」),否则笔根本写不出字。 touchAsPen = true; } if (!touchAsPen && !fpGate(e)) return; e.preventDefault(); e.stopPropagation(); // 抢在原生草稿/页面处理器之前,书写不惊动页面 var cv = fpEnsureLayer(); try { cv.setPointerCapture(e.pointerId); } catch (err) {} var p = penPoint(cv, e); fpDown = { pts: [{ x: p.x, y: p.y }], last: p, done: 0, t0: Date.now(), target: e.target, cv: cv, // v2.8.11:记下本笔所属指针 —— 另一路(触摸兼容流)的事件一律不收 pointerId: e.pointerId, pointerType: e.pointerType, // v2.8.13:ids = 本笔认领过的指针集合(允许浏览器中途换 id 时改绑); // downIds = 全局记录「哪些 id 自己 down 过」(用来分辨真·第二指针 vs 中途换 id) ids: (function () { var m = {}; m[e.pointerId] = true; return m; })(), downIds: penStat.downIds, seen: {}, seenKeys: [], rejected: 0, // v2.8.12:起笔瞬间给原生草稿画布拍个像素指纹,收笔时再拍一次对比 —— // 若期间有像素变化,说明**原生也在画**,即「两个画者」同时落笔(跨 world 也能测) fp0: penNativeFingerprint() }; var st0 = penStatStream(e); if (st0) { st0.pts++; } // down 计数交给全局旁观器(与本路径无关) fpStrokeDup(fpDown, p); // 起笔点登记进「已收坐标」 penDot(fpCtx(cv), p.x, p.y); penStrokeStyle(fpCtx(cv)); } function fpOnMove(e) { if (!fpDown) return; if (e && e.__marvisSynth) return; // v2.8.7:原生草稿补密用的合成事件,别二次落笔 // v2.8.11/v2.8.12:只收本笔所属指针的事件(Apple Pencil 会同时冒出 touch 指针流) var st = penStatStream(e); if (!fpStrokeOwns(fpDown, e)) { fpDown.rejected++; penStat.rejected++; return; } var cv = fpDown.cv; var ctx = fpCtx(cv); var evs = penCoalesce(e); penStat.coalescedN++; penStat.coalescedSum += evs.length; if (evs.length > penStat.coalescedMax) penStat.coalescedMax = evs.length; for (var k = 0; k < evs.length; k++) { var p = penPoint(cv, evs[k]); 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.35) { // 只收有意义的位移;阈值调细(一页成卷下主线程卡、采样稀),贝塞尔曲线更顺,无需直线插值 if (fpStrokeDup(fpDown, p)) { fpDown.rejected++; penStat.dup++; continue; } // v2.8.11:同一采样点不收两遍 // v2.8.12:回访点(重复/乱序投递的旧采样)也不收——放行它会让样条沿原路折返 if (fpStrokeRetrace(fpDown, p)) { fpDown.rejected++; penStat.retrace++; continue; } fpDown.pts.push({ x: p.x, y: p.y }); fpDown.last = p; penStat.pts++; if (st) st.pts++; } } fpDown.done = penAdvance(ctx, fpDown.pts, fpDown.done); // 增量补画新段 e.preventDefault(); e.stopPropagation(); // 笔程不带给页面(防选中文字/触发页面滚动手势) } function fpFinish(e) { if (!fpDown) return; // v2.8.11/v2.8.13:别的指针收笔不动本笔(触摸兼容流先抬笔时不能把笔的笔画提前收掉) if (e && !fpStrokeEnds(fpDown, e)) return; var d = fpDown; fpDown = null; var owner = fpOwner; if (!owner) return; // v2.8.13:up 计数交给全局旁观器(与本路径无关,任何时候都能读到浏览器的真实投递) // v2.8.12:收笔时再拍一次原生草稿画布指纹 → 有变化 = 原生也在画(= 两个画者) if (d.fp0) { var fp1 = penNativeFingerprint(); var delta = penNativeDelta(d.fp0, fp1); penStat.nativeChanged = delta; } var maxMove = 0; for (var i = 1; i < d.pts.length; i++) { var dx = d.pts[i].x - d.pts[0].x, dy = d.pts[i].y - d.pts[0].y; 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.x), Math.round(p0.y)) : 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) { if (d.pts.length >= 2) penFinalize(fpCtx(d.cv), d.pts); var w = d.cv.width || 1, h = d.cv.height || 1; // d.pts 里是 {x, y} 对象(笔迹按 Catmull-Rom 样条平滑渲染,存储仍是归一化坐标点序列) owner.strokes.push(d.pts.map(function (p) { return [p.x / w, p.y / h]; })); cvScheduleSave(owner); penStat.strokes++; // v2.8.13:自绘笔台账(点数/起止/时长/被拒事件数)——用来核对「一笔到底收了多少点」 penStat.fpLedger.push({ id: d.pointerId, type: d.pointerType, pts: d.pts.length, x0: Math.round(d.pts[0].x), y0: Math.round(d.pts[0].y), x1: Math.round(d.pts[d.pts.length - 1].x), y1: Math.round(d.pts[d.pts.length - 1].y), dur: Date.now() - d.t0, rejected: d.rejected }); if (penStat.fpLedger.length > PEN_LEDGER_MAX) penStat.fpLedger.shift(); } } // 旧版(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); } // 开会话 / 切到「可书写」态:层显示、笔迹重放、滚动锁定、手指点按变无效 /* ---------- v2.8.12 画者独占:会话期间让原生草稿「闭嘴」 ---------- 为什么必须做这一步:我们的捕获监听 + stopPropagation 只能拦住**同一个 JS world** 里的监听器。Via 这类自带脚本注入器的浏览器(iOS 版 = WKWebView 壳)可能把用户 脚本注入到独立 content world —— 此时我们与页面里的粉笔原生草稿互不可见, 同一笔会被「我们」和「原生」各画一遍:两套不同的平滑算法(Catmull-Rom vs 粉笔的 cardinal spline)画同一段轨迹,形状略有差异 → 屏幕上就是两条交错的线。 结构性解法:**会话期间让原生草稿画布收不到任何指针事件**(pointer-events:none), 这样不论几个 world、几个实例,都只有一个画者。 作用域只限一拖五组([data-marvis-fpgroup]),普通单题的原生草稿(用户还要用 手指在上面写)完全不受影响。 */ var QUIET_ID = 'marvis-fp-quiet'; function fpQuietOn() { try { if (document.getElementById(QUIET_ID)) return; var st = document.createElement('style'); st.id = QUIET_ID; st.textContent = '[' + FPGRP_ATTR + '] app-draft canvas{pointer-events:none !important;}'; (document.head || document.documentElement).appendChild(st); } catch (e) {} } function fpQuietOff() { try { var st = document.getElementById(QUIET_ID); if (st) st.remove(); } catch (e) {} } /* v2.8.13:静音只在「可书写(draw)」态生效。 v2.8.12 把静音挂在**会话开关**上(会话一开就静默、退出才恢复),于是会话内切到 「可触摸(pass)」后,组内原生草稿仍然收不到指针事件 → 用户用手指在原生草稿上 写字时**根本写不了**(v2.8.12 引入的回归)。 正确语义:可书写态 = 整页层接管书写,原生必须闭嘴(否则两个画者各画一遍); 可触摸态 = 层让开,原生草稿要能正常用手写 → 必须放开。 */ function fpQuietSync() { try { if (fpOwner && fpOwner.mode === 'draw') fpQuietOn(); else fpQuietOff(); } catch (e) {} } 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 = ''; fpQuietSync(); // v2.8.13:只有「可书写」态才让原生草稿闭嘴 // 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'; fpQuietOff(); // v2.8.12:退出会话 → 恢复原生草稿的正常交互 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; }; /* ---------- v2.8.12 笔迹自检 ---------- 同一份代码在 Safari 正常、在 Via 异常 → 说明不能再靠猜浏览器怎么上报指针事件, 必须把事实采下来。这里把事实打包,并**直接给出结论判定**,三个出口保证拿得到: ① window.__MARVIS_FENBI_PEN__() ② ③ 面板浮层 */ window.__MARVIS_FENBI_PEN__ = function () { var streams = penStat.streams; var types = {}; var nStream = 0; // 真正落过笔的流(有采样点) var nAll = 0; // 页面里出现过的所有流 for (var k in streams) { nAll++; if (streams[k].pts > 0) { nStream++; types[streams[k].type] = (types[streams[k].type] || 0) + streams[k].pts; } } /* v2.8.13:生命周期台账 → 「同一物理笔画被投递两次」的嫌疑检测。 浏览器把一次落笔拆成两段不同 pointerId 的生命周期时,两段的起点与终点都会 几乎重合 —— 这正是「一条笔画画成两条交错的线」的头号嫌疑。 */ var life = penStat.life || []; var dupLife = []; for (var li = 1; li < life.length; li++) { var LA = life[li - 1], LB = life[li]; if (!LA || !LB || LA.type !== LB.type) continue; var d0 = Math.sqrt((LA.x0 - LB.x0) * (LA.x0 - LB.x0) + (LA.y0 - LB.y0) * (LA.y0 - LB.y0)); var d1 = Math.sqrt((LA.x1 - LB.x1) * (LA.x1 - LB.x1) + (LA.y1 - LB.y1) * (LA.y1 - LB.y1)); if (d0 < 10 && d1 < 14) { dupLife.push({ from: LA.id, to: LB.id, type: LB.type, dStart: Math.round(d0), dEnd: Math.round(d1), gapMs: LB.t0 - LA.t0 }); } } // 是否真的采到了一次书写(pen 指针留下明显位移) var wrote = false; for (var wi = 0; wi < life.length; wi++) { var LW = life[wi]; if (!LW || LW.type !== 'pen') continue; var dw = Math.sqrt((LW.x1 - LW.x0) * (LW.x1 - LW.x0) + (LW.y1 - LW.y0) * (LW.y1 - LW.y0)); if (dw > 20) { wrote = true; break; } } var findings = []; if (penStat.nativeChanged && penStat.nativeChanged.length) { findings.push('原生草稿画布在书写期间像素发生变化 → 存在第二个画者(原生与我们同时落笔)'); } if (dupLife.length) { findings.push('疑似同一物理笔画被投递了 ' + dupLife.length + ' 次(相邻两段生命周期起止点几乎重合)→ ' + JSON.stringify(dupLife)); } if (nStream > 1) { findings.push('本次会话出现过 ' + nStream + ' 路指针流(' + JSON.stringify(types) + ')'); } if (penStat.rejected) findings.push('归属不符被丢弃 ' + penStat.rejected + ' 个事件'); if (penStat.dup) findings.push('重复采样点被丢弃 ' + penStat.dup + ' 个'); if (penStat.retrace) findings.push('回访(重复/乱序投递)点被丢弃 ' + penStat.retrace + ' 个'); if (!penStat.penSeen) findings.push('本会话从未出现 pointerType=pen —— 该浏览器可能不把触控笔报告为 pen'); if (penStat.synthSkip) findings.push('原生补密跳过 ' + penStat.synthSkip + ' 个重复/陈旧样本(幂等防线生效)'); // v2.8.14:补投/采样比值 —— 唯一能直接判定「有没有重复投递」的指标。 // 正常情况每个合并采样最多被补投一次(当前点还会被 ① 挡掉)→ 比值应明显 < 1; // 比值接近或超过 1 就说明同一批采样被投了不止一遍(v2.8.13 在 Via 上就是这个状态)。 if (penStat.tickSkip) { findings.push('浏览器正在把同一帧的采样逐个投递(' + penStat.tickSkip + ' 个事件间隔 <5ms)→ 这些点已整体跳过补密(补了就是投两遍)'); } if (penStat.tailSkip) { findings.push('有 ' + penStat.tailSkip + ' 个事件的合并列表末尾不是当前事件(浏览器随后会自己把这些采样送到)→ 整批放弃补密'); } if (penStat.synth && penStat.coalescedSum) { var sr = penStat.synth / penStat.coalescedSum; findings.push('原生补密:补投 ' + penStat.synth + ' 点 / 合并采样 ' + penStat.coalescedSum + ' 点 → 比值 ' + sr.toFixed(2) + (sr > 0.95 ? '(⚠ 偏高,疑似重复投递)' : '(正常)')); } if (!wrote && !(penStat.fpLedger && penStat.fpLedger.length)) { findings.push('⚠ 本次采样没有采到任何书写(没有 pen 指针留下位移)——这份数据说明不了笔迹问题;' + '请先在草稿区用笔写一笔,再点本按钮重新自检'); } var layers = 0, pens = 0, chips = 0; try { layers = document.querySelectorAll('#marvis-fp-layer').length; pens = document.querySelectorAll('canvas.marvis-pen').length; chips = document.querySelectorAll('#marvis-flat-chip').length; } catch (e) {} if (layers > 1 || chips > 1) findings.push('检测到重复注入(层 ' + layers + ' 个 / 胶囊 ' + chips + ' 个)'); var out = { version: SCRIPT_VER, captureValid: !!(wrote || (penStat.fpLedger && penStat.fpLedger.length)), findings: findings, ua: navigator.userAgent, maxTouchPoints: navigator.maxTouchPoints || 0, pointerFine: !!(window.matchMedia && matchMedia('(pointer: fine)').matches), pointerCoarse: !!(window.matchMedia && matchMedia('(pointer: coarse)').matches), hasPointerEvent: typeof PointerEvent !== 'undefined', hasCoalesced: typeof PointerEvent !== 'undefined' && !!PointerEvent.prototype.getCoalescedEvents, penSeen: penStat.penSeen, mouseSeen: penStat.mouseSeen, touchSeen: penStat.touchSeen, streams: streams, streamCount: nStream, streamCountAll: nAll, coalesced: { max: penStat.coalescedMax, avg: penStat.coalescedN ? +(penStat.coalescedSum / penStat.coalescedN).toFixed(2) : 0, events: penStat.coalescedN }, dropped: { rejected: penStat.rejected, dup: penStat.dup, retrace: penStat.retrace }, // v2.8.13:原生补密幂等统计(sent=实际补投数,skip=因重复/陈旧被跳过数) // v2.8.14:tick = 因「浏览器正在逐点投递」而整体不补密的事件数 synth: { sent: penStat.synth, skip: penStat.synthSkip, tick: penStat.tickSkip }, // v2.8.13:三个取证台账 life: life, // 指针生命周期(down→up):起止点/时长/类型 // 原始事件投递顺序(只带最近 120 条:够看清形态,又不至于让 JSON 大到没法粘贴) timeline: penStat.timeline.slice(Math.max(0, penStat.timeline.length - 120)), fpLedger: penStat.fpLedger, // 我们自己画的每一笔(点数/起止/时长/被拒数) dupLife: dupLife, // 疑似重复投递的相邻生命周期 pts: penStat.pts, strokes: penStat.strokes, nativeCanvases: penStat.nativeCanvases, nativeChanged: penStat.nativeChanged, layerOpen: !!fpOwner, mode: fpOwner ? fpOwner.mode : null, down: !!fpDown, curPointerId: fpDown ? fpDown.pointerId : null, curPointerType: fpDown ? fpDown.pointerType : null, curPts: fpDown ? fpDown.pts.length : 0, layers: layers, penCanvases: pens, chips: chips, instMark: (document.documentElement && document.documentElement.getAttribute(INST_ATTR)) || null, now: new Date().toString() }; var json = JSON.stringify(out, null, 1); try { document.documentElement.setAttribute('data-marvis-pen-diag', json); } catch (e) {} if (window.console && console.log) console.log('[marvis] pen', out); return out; }; // 面板浮层:用户点一下「笔迹自检」就能看到并复制 JSON(不需要能开开发者工具) function penDiagOverlay() { var out = window.__MARVIS_FENBI_PEN__(); var json = JSON.stringify(out, null, 1); var old = document.getElementById('marvis-pen-diag-box'); if (old) old.remove(); var box = document.createElement('div'); box.id = 'marvis-pen-diag-box'; box.style.cssText = 'position:fixed;left:8px;right:8px;top:8px;bottom:8px;z-index:2147483100;' + 'background:#fff;border:1px solid #E4E6EB;border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.18);' + 'display:flex;flex-direction:column;color:#2B2F36;' + 'font-family:-apple-system,BlinkMacSystemFont,"PingFang SC",monospace;'; var bar = document.createElement('div'); bar.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid #F1F3F5;'; var title = document.createElement('span'); title.textContent = '笔迹自检 · v' + SCRIPT_VER; title.style.cssText = 'font-weight:700;font-size:12px;'; var copy = document.createElement('button'); copy.textContent = '复制 JSON'; copy.style.cssText = 'font:inherit;font-size:12px;padding:3px 8px;border:1px solid #E4E6EB;border-radius:5px;background:#F8F9FA;'; copy.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(json); copy.textContent = '已复制'; } else { var ta = document.createElement('textarea'); ta.value = json; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e2) {} ta.remove(); copy.textContent = '已复制'; } } catch (e3) { copy.textContent = '复制失败'; } }); var close = document.createElement('button'); close.textContent = '关闭'; close.style.cssText = 'font:inherit;font-size:12px;padding:3px 8px;border:1px solid #E4E6EB;border-radius:5px;background:#F8F9FA;margin-left:auto;'; close.addEventListener('click', function (ev) { ev.preventDefault(); ev.stopPropagation(); box.remove(); }); bar.appendChild(title); bar.appendChild(copy); bar.appendChild(close); var pre = document.createElement('pre'); pre.textContent = json; pre.style.cssText = 'flex:1;overflow:auto;margin:0;padding:8px;white-space:pre-wrap;word-break:break-all;' + 'font:11px/1.5 ui-monospace,Menlo,Consolas,monospace;'; box.appendChild(bar); box.appendChild(pre); document.body.appendChild(box); return out; } window.__MARVIS_FENBI_PEN_OVERLAY__ = penDiagOverlay; })();