fix(viewer): 松开关闭切图手势的「闩锁」——这解释了安卓上「纹丝不动 / 大多数切图失败」

真因(真机打点 518 条 + 源码逐行核对 + jsdom 复现三方对齐):

  node_modules/viewerjs/src/js/others.js:216-223  change() 里

      case ACTION_SWITCH: {
        this.action = 'switched';                                   // ← 先上闩
        const absoluteOffsetX = Math.abs(offsetX);
        if (absoluteOffsetX > 1 && absoluteOffsetX > Math.abs(offsetY)) {
          … this.view(index);                                       // 过了判定才真切图
        }

  第一次 pointermove 进来就把 this.action 改成字符串 'switched',而外层 switch 没有这个分支
  ⇒ 之后每一次 pointermove 全部落空:不切图、不平移、不放大 = 用户看到的「纹丝不动」。
  判定只看开头那次采样,而真手指刚落下天然带纵向抖动(dy ≥ dx 很常见)⇒ 大多数手势一开始
  就被闩死,偶尔开头偏横向才成功 = 「大多数失败、少数成功、无规律」。iOS 上因为触摸路径不同
  不触发,官方演示页也没有列表页那套前置条件,所以都正常。

修法(不改 viewer.min.js —— 压缩产物改了没法维护):从外面把闩松开。
手势期间用 document 捕获阶段监听 pointermove,发现 viewer.action 变成了那个闩值,
就恢复成手势开始时 Viewer 自己算出的动作值;后续采样继续参与方向判定(判据看的是最近一次
位移,所以横滑终究会切到)。首次遇到闩值时自动学出「切图动作」的真值,无需硬编码常量。

验证(用 Claude 提供的 jsdom 复现脚本,加/不加本补丁对比):
  修复前:starts diagonal dy>dx, then horizontal → no switch        ✗
  修复后:starts diagonal dy>dx, then horizontal → SWITCHED         ✓
  对照组:vertical drag (should NOT switch)      → 仍 no switch     ✓(未被破坏)
  其余 7 个用例修复前后都正常。jsdom 9 套 409 项全绿。
This commit is contained in:
mole404 committed 2026-10-03 01:34:09 +08:00
1 parent 36245c583b
commit 5f3603658b
4 files changed
+82 -3

No files matched your search

+2 -2
View File
@@ -165,7 +165,7 @@ RUN php -r "file_exists('.env') || copy('.env.example', '.env');" \
RUN printf '%s\n' \
'c0e513ec8e93fd81b34b3c6de5cf5eb8 ./public/js/context-js/context-js.js' \
'c0e513ec8e93fd81b34b3c6de5cf5eb8 ./resources/js/context-js.js' \
'b40ba5e968e70e7f6d5c9e00c03e25e1 ./resources/views/user/images.blade.php' \
'e0b5b669408e8a5f3dee7f332ad21e97 ./resources/views/user/images.blade.php' \
| md5sum -c - \
&& grep -q "assetVersion('js/context-js/context-js.js')" ./resources/views/user/images.blade.php \
&& grep -q 'isIOSWebKit' ./public/js/context-js/context-js.js \
@@ -208,7 +208,7 @@ RUN APP_SRC_MD5=$(find app config routes -type f -print0 2>/dev/null | sort -z |
"fork_sha=${FORK_SHA}" \
"lsky_commit=${LSKY_COMMIT}" \
"context_js_md5=c0e513ec8e93fd81b34b3c6de5cf5eb8" \
"images_blade_md5=b40ba5e968e70e7f6d5c9e00c03e25e1" \
"images_blade_md5=e0b5b669408e8a5f3dee7f332ad21e97" \
"app_src_md5=${APP_SRC_MD5}" \
"app_js_md5=${APP_JS_MD5}" \
"app_css_md5=${APP_CSS_MD5}" \
+1 -1
View File
@@ -52,7 +52,7 @@
# 三个文件各自的 md5(与 Dockerfile「自证 2」里的期望值一致):
# c0e513ec8e93fd81b34b3c6de5cf5eb8 src/resources/js/context-js.js
# c0e513ec8e93fd81b34b3c6de5cf5eb8 src/public/js/context-js/context-js.js
# b40ba5e968e70e7f6d5c9e00c03e25e1 src/resources/views/user/images.blade.php
# e0b5b669408e8a5f3dee7f332ad21e97 src/resources/views/user/images.blade.php
#
# 上游原文件 md5(改动前,供比对):
# c8e57f6232848ca8277341ddf1a3a7a6 resources/js/context-js.js
+36
View File
@@ -535,6 +535,42 @@ class="relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-brand
};
const viewer = new Viewer(document.getElementById('images-grid'), {url: 'data-original'});
/* 修 Viewer.js 的「闩锁」bug(真机打点 + 源码逐行核对 + jsdom 复现确认):
* others.js:216-223 change() 里
* case ACTION_SWITCH: {
* this.action = 'switched'; // ← 第一次 pointermove 就先上闩
* if (|offsetX| > 1 && |offsetX| > |offsetY|) { … this.view(index) }
* 上闩之后 action 成了字符串 'switched',而外层 switch 没有这个分支 ⇒ 之后
* 每一次 pointermove 全部落空:不切图、不平移、不放大 =「纹丝不动」。
* 判据又只看开头那次采样,真手指刚落下天然带纵向抖动(dy ≥ dx)⇒ 大多数手势
* 一开始就被闩死,偶尔开头偏横向才成功 —— 这就是「大多数失败、少数成功、无规律」。
* 从外面把闩松开:手势期间发现 action 变成那个闩值,就恢复成刚才那个动作值,
* 后续采样继续参与判定(切图判据看的是"最近一次位移",所以横滑一定能切到)。
* 不改 viewer.min.js(压缩产物,改了没法维护)。 */
(function () {
let inViewer = false;
let lastAction = null;
let switchAction = null;
const isViewer = (el) => el instanceof Element && !!el.closest('.viewer-container');
document.addEventListener('pointerdown', (e) => {
inViewer = isViewer(e.target);
lastAction = null;
}, true);
document.addEventListener('pointermove', (e) => {
if (!inViewer) return;
const a = viewer.action;
if (a === 'switched') {
// 学到切图动作的真值后,把闩松开
if (!switchAction && lastAction) switchAction = lastAction;
if (switchAction && viewer.action !== switchAction) viewer.action = switchAction;
} else if (a) {
lastAction = a; // 手势开始时 Viewer 刚算出来的动作值
}
}, true);
document.addEventListener('pointerup', () => { inViewer = false; }, true);
document.addEventListener('pointercancel', () => { inViewer = false; }, true);
})();
/* 安卓 Chromium:看图控件自己有"模拟双击"逻辑(viewerjs handlers.js:424-451)——
* 两次落在**图片上**的抬手间隔 <500ms,它就 setTimeout 后自己派发一个 dblclick,
* 于是放大。真机打点证实:老师连续滑图时凭空出现 dblclick×23 ⇒ 一放大,图片就不再
+43
View File
@@ -0,0 +1,43 @@
const {JSDOM}=require('jsdom');
const fs=require('fs');
function run(file, moves, label){
const dom=new JSDOM('<!doctype html><body><ul id=g><li><img src="a.jpg" data-original="a.jpg"></li><li><img src="b.jpg" data-original="b.jpg"></li><li><img src="c.jpg" data-original="c.jpg"></li></ul></body>',{runScripts:'outside-only',pretendToBeVisual:true});
const w=dom.window;
w.ontouchstart=null;
if(!w.PointerEvent){ w.PointerEvent=class extends w.MouseEvent{constructor(t,i={}){super(t,i);this.pointerId=i.pointerId||1;this.pointerType=i.pointerType||'touch';}}; }
w.eval(fs.readFileSync(file,'utf8'));
const v=new w.Viewer(w.document.getElementById('g'),{transition:false});
v.show();
// fake a loaded, fitted image
v.viewed=true; v.viewing=false; v.showing=false;
v.imageData={x:0,y:0,width:300,height:200}; v.viewerData={width:360,height:640};
const idx0=v.index;
const ev=(t,x,y)=>new w.PointerEvent(t,{bubbles:true,cancelable:true,pointerId:7,pointerType:'touch',clientX:x,clientY:y,view:w});
const mk=(t,x,y)=>{const e=ev(t,x,y);Object.defineProperty(e,'pageX',{value:x});Object.defineProperty(e,'pageY',{value:y});return e;};
const canvas=v.canvas;
// ==== 阿罗娜的"松闩"补丁(从外面松开 Viewer 的 'switched' 闩锁)====
let inV=false,lastA=null,swA=null;
w.document.addEventListener('pointerdown',(e)=>{ inV=!!e.target.closest('.viewer-container'); lastA=null; },true);
w.document.addEventListener('pointermove',()=>{
if(!inV) return;
const a=v.action;
if(a==='switched'){ if(!swA&&lastA) swA=lastA; if(swA&&v.action!==swA) v.action=swA; }
else if(a){ lastA=a; }
},true);
w.document.addEventListener('pointerup',()=>{ inV=false; },true);
// ==== 补丁结束 ====
canvas.dispatchEvent(mk('pointerdown',300,400));
for(const [x,y] of moves) w.document.dispatchEvent(mk('pointermove',x,y));
w.document.dispatchEvent(mk('pointerup',moves[moves.length-1][0],moves[moves.length-1][1]));
console.log(label.padEnd(58),'index',idx0,'->',v.index, v.index!==idx0?'SWITCHED':'no switch');
}
const f=process.argv[2];
run(f,[[250,402],[200,405],[150,410]],'first move big & horizontal');
run(f,[[300,401],[250,402],[200,405],[150,410]],'first move dx=0,dy=1 (tiny, vertical noise)');
run(f,[[299,402],[250,402],[200,405],[150,410]],'first move dx=-1,dy=2');
run(f,[[298,403],[250,402],[200,405],[150,410]],'first move dx=-2,dy=3 (dy>dx)');
run(f,[[296,401],[250,402],[200,405],[150,410]],'first move dx=-4,dy=1 (ok)');
run(f,[[300,401],[300,402],[299,402],[250,402],[150,410]],'jitter 3 samples then swipe');
run(f,[[300,420],[300,450],[301,500]],'vertical drag (should NOT switch)');
run(f,[[299,405],[298,412],[250,415],[150,418]],'starts diagonal dy>dx, then horizontal');
run(f,[[330,401],[360,403],[400,405]],'swipe right (should go prev -> stays 0 at index 0 / no loop)');