feat(viewer): 关掉库自带触摸切图,改由松手判定 —— 未放大也能拖动、不再一碰就切

采用 Claude 提出的方案(第二步),替换此前所有"阈值接管"尝试。

为什么之前的尝试都不行:
  · 第 1 版在 pointerup 后 view() 弹回 —— 拖动中途库早已按 1px 判据翻页,事后补救无效。
  · 第 2 版从外面改写 viewer.action —— 库的捏合/双击/过渡全靠这个状态机,外部改写导
    致双指捏合失效、拖动带过渡延迟、切图错乱(老师真机实测四条全中)。
  · 第 3 版在 document 捕获阶段 stopPropagation 拦 pointermove —— 库用
    HAS_POINTER_EVENT ? 'pointermove' : 'touchmove' 选通道,真机可能走 touchmove,
    拦截整段失效;且监听器注册在库之后,同一节点同一阶段抢不到前面。真机实测无效。

本次做法(读源码 + 真机特征共同指向的最小改动):
  · new Viewer(...) 加 slideOnTouch: false —— 单指动作永远是 'move',库自带的平移就是
    跟手拖动;不再产生 'switch' 动作 ⇒ 1px 判据与 others.js 的 'switched' 闩锁都不存在。
  · 松手时自己判定:够远(> max(56px, 视口宽×0.18))或够快(> 0.5px/ms 且 > 30px),
    且横向占优(|dx| > |dy|*1.5)⇒ viewer.next(false)/prev(false);否则 viewer.reset()
    带动画回弹。放大态或第二指(捏合)出现时一律放权给库。
  · 全程不写库的内部状态,也不拦事件 ⇒ 不依赖 pointer/touch 通道,捏合/双击/过渡不受影响。

本机实测(390x844 手机模拟 + CDP 真触摸)全绿:
  【未放大可拖动】按下 left=20 → 中途 left=-20 → 松手回弹 left=20 ⇒ 跟手 ✓ 回弹 ✓
  横划 20px / 竖划 80px → 不切 ✓    横划 120px → 切 ✓    拖到 30px 中途 → 不翻页 ✓
  双指捏合 w 351→1615 ✓            双击放大 w 1615→2751 ✓
  注:放大态下本方案不接管切图(一律放权给库做平移),与上一版"放大后 120px 也切"不同。

本机 9 套 jsdom 测试全绿。
This commit is contained in:
mole404 committed 2026-10-03 13:28:21 +08:00
1 parent f535c8c6fb
commit a3f5c82287
3 files changed
+39 -43

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' \
'dad60266ca53a1af4b52567dac2d0dbb ./resources/views/user/images.blade.php' \
'b88fe8302a60b4ca5cdaaf69d7ac01b8 ./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=dad60266ca53a1af4b52567dac2d0dbb" \
"images_blade_md5=b88fe8302a60b4ca5cdaaf69d7ac01b8" \
"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
# dad60266ca53a1af4b52567dac2d0dbb src/resources/views/user/images.blade.php
# b88fe8302a60b4ca5cdaaf69d7ac01b8 src/resources/views/user/images.blade.php
#
# 上游原文件 md5(改动前,供比对):
# c8e57f6232848ca8277341ddf1a3a7a6 resources/js/context-js.js
+36 -40
View File
@@ -535,6 +535,8 @@ 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',
slideOnTouch: false, // 关掉库自带的触摸切图:单指动作永远是 move ⇒ 库自己的平移就是跟手拖动
// 到头不再绕回(老师明确不要循环:第一张向右滑会绕到最后一张,且那一下会让
// 控件重建画布/缩略图条 ⇒ iOS 整个界面左偏、安卓缩略图全空,见后续修复记录)
loop: false,
@@ -642,52 +644,46 @@ class="relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-brand
document.addEventListener('touchcancel', () => { navDown = false; }, true);
})();
/* 触摸端切图阈值接管(最小侵入版):只拦事件,绝不写库的内部状态。
* 为什么必须这样 —— 血的教训:库的捏合、双击、移动过渡全靠它自己的
* this.action 状态机(handlers.js:367-375 在 pointerdown 时一次性定值,
* 且移动时若 action 为 'move' 会摘掉过渡类)。从外面改写这个值 ⇒
* 双指捏合被覆盖失效、拖动带上 CSS 过渡延迟、切图行为也乱 —— 全崩。
* 所以这里只做一件事:单指触摸、且位移还没到阈值时,在捕获阶段
* stopPropagation,让库收不到这次移动(它就不会切图);一旦划够阈值就
* 立刻放行,之后全部交给库原样处理(跟手切图本来就是它自带的)。
* 多指(捏合)一根手指都不干预;库的内部状态自始至终不碰。 */
/* 触摸端切图判定(接管 slideOnTouch 后的配套逻辑):
* 库里 slideOnTouch:false ⇒ 单指动作永远是 'move',库自己的平移就是跟手拖动,
* 不再出现 'switch' 动作 ⇒ 1px 判据与"闩锁"都不存在。切图还是回弹由松手时决定。
* 为什么不拦事件:库用 HAS_POINTER_EVENT ? 'pointermove' : 'touchmove' 选通道,
* 拦 pointermove 在真机上可能整段失效(踩过)。这里只读 viewer.action 判定是否放权,
* 一个字都不写库的内部状态 —— 捏合/双击/过渡因此完全不受影响。 */
(function () {
const SWIPE_NOT_ZOOMED = 60; // 未放大:划够这么多才切图
const SWIPE_ZOOMED = 120; // 放大后:更钝,避免和拖动打架
const DIRECTION_RATIO = 1.2; // |dx| 要明显大于 |dy| 才算横划
let active = 0; // 当前按下的手指数
let downX = 0;
let downY = 0;
let zoomed = false;
let qualified = true; // 默认放行(非画布/多指都走放行)
const FAR_RATIO = 0.18; // 够远:视口宽的这个比例
const FAR_MIN = 56; // 或至少这么多 px
const FAST_SPEED = 0.5; // 够快:px/ms
let g = null;
const inCanvas = (el) => el instanceof Element && !!el.closest('.viewer-canvas');
const isZoomed = () => {
const im = document.querySelector('.viewer-canvas img');
const c = document.querySelector('.viewer-canvas');
if (!im || !c) return false;
return im.getBoundingClientRect().width > c.getBoundingClientRect().width + 2;
const isFit = () => {
const d = viewer.imageData;
const v = viewer.viewerData;
if (!d || !v) return false;
return d.x >= 0 && d.y >= 0 && d.width <= v.width && d.height <= v.height;
};
document.addEventListener('pointerdown', (e) => {
active += 1;
if (active > 1 || !inCanvas(e.target)) { qualified = true; return; }
downX = e.clientX;
downY = e.clientY;
zoomed = isZoomed();
qualified = false;
if (!e.isPrimary) { if (g) g.multi = true; return; } // 第二指 ⇒ 捏合,放权给库
g = (e.pointerType !== 'mouse' && inCanvas(e.target))
? { x: e.clientX, y: e.clientY, t: e.timeStamp, fit: isFit(), multi: false }
: null;
}, true);
document.addEventListener('pointermove', (e) => {
if (active > 1 || qualified) return; // 捏合中 / 已放行:不干预
const dx = e.clientX - downX;
const dy = e.clientY - downY;
const need = zoomed ? SWIPE_ZOOMED : SWIPE_NOT_ZOOMED;
if (Math.abs(dx) >= need && Math.abs(dx) > Math.abs(dy) * DIRECTION_RATIO) {
qualified = true; // 划够了:从这一下起放行
return;
document.addEventListener('pointerup', (e) => {
if (!g || !e.isPrimary) return;
const s = g;
g = null;
if (!s.fit || s.multi) return; // 放大态 / 捏合:一律交给库
const dx = e.clientX - s.x;
const dy = e.clientY - s.y;
const dt = Math.max(1, e.timeStamp - s.t);
const far = Math.abs(dx) > Math.max(FAR_MIN, window.innerWidth * FAR_RATIO);
const fast = Math.abs(dx) / dt > FAST_SPEED && Math.abs(dx) > 30;
if ((far || fast) && Math.abs(dx) > Math.abs(dy) * 1.5) {
if (dx < 0) viewer.next(false); else viewer.prev(false);
} else if (dx !== 0 || dy !== 0) {
viewer.reset(); // 带动画的回弹
}
e.stopPropagation(); // 没划够:库收不到 ⇒ 不会切图
}, true);
document.addEventListener('pointerup', () => { active = Math.max(0, active - 1); }, true);
document.addEventListener('pointercancel', () => { active = Math.max(0, active - 1); }, true);
}, false);
})();
/* 双击放大:库里的「模拟双击」(handlers.js:424-451)只看两次抬手的间隔(硬编码 500ms),