From 408564269522acda731c7592244841cf2b52a0b0 Mon Sep 17 00:00:00 2001
From: mole404 <248403041+mole404@users.noreply.github.com>
Date: Sat, 3 Oct 2026 14:49:22 +0800
Subject: [PATCH] =?UTF-8?q?fix(viewer):=20=E5=88=87=E5=9B=BE=E5=8A=A8?=
=?UTF-8?q?=E7=94=BB=E8=B5=B7=E7=82=B9=E5=9B=BA=E5=AE=9A=E4=B8=BA=E5=B1=8F?=
=?UTF-8?q?=E5=B9=95=E4=B8=AD=E5=BF=83=EF=BC=88=E5=BC=BA=E5=88=B6=E5=9B=9E?=
=?UTF-8?q?=E6=B5=81=EF=BC=89=EF=BC=8C=E5=B9=B6=E5=88=A0=E6=8E=89=E4=B8=8A?=
=?UTF-8?q?=E4=B8=80=E7=89=88=E6=97=A0=E6=95=88=E7=9A=84=E6=91=98=E7=B1=BB?=
=?UTF-8?q?=E8=A1=A5=E4=B8=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
老师反馈上一版没修好(附真机截图)。按 Claude 的静态分析复核后确认,根因是 view() 的时序竞态:
· view() 每次都新建一个
:先写成"屏幕中心的点"(宽高 0 + margin 居中),读自然尺寸后
再加 viewer-transition 写最终尺寸,靠 CSS 过渡补间。
· CSS 过渡的起点 = 浏览器"上一次真正渲染过的样式"。第 1 步默认样式是"满宽贴顶",第 2 步是
"中心点",第 3 步是最终值。哪一帧被渲染过决定动画从哪开始:第 2、3 步之间有帧 ⇒ 从中心
长出来;只渲染到第 1 步 ⇒ 从屏幕上方飞入;一帧都没渲染 ⇒ 无动画直接出现。取决于图片是否
缓存、网络快慢、主线程是否空闲 —— 这就是"没规律"的来源。
· 上一版在 pointerup 里 viewer.image.classList.remove('viewer-transition') 无效:view() 新建
的是另一个
,且 load() 会按 options.transition 给新图重新加类。已删除该段及错误注释
(原注释"从上一张图 margin 滑过来"也不成立)。
修法(Claude 方案 A):包一层 viewer.initImage,在它读自然尺寸之前强制回流一次
(void viewer.image.offsetWidth),把"中心点"提交为已渲染样式 ⇒ 起点恒为屏幕中心。
覆盖手指滑动、缩略图条、键盘、底部按钮等所有切图路径(都在 view() 内)。
本机实测(延迟图片响应 + 限速,模拟"未缓存的大图",即 Claude 给的复现法):
切换瞬间 top 与稳定后 top 的差值:A 组 0px / 0px,C 组重跑 0px / 0px ✓
对照组(剥掉补丁)未取得有效数据(生成对照版本时脚本把页面切坏),"无补丁会飞"留待真机确认。
本机 9 套 jsdom 测试全绿。
---
Dockerfile | 4 ++--
patches/ios-longpress.patch | 2 +-
src/resources/views/user/images.blade.php | 25 ++++++++++++++++-------
3 files changed, 21 insertions(+), 10 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 1eb71b4..5d2c7d4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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' \
- '69e2e947c24af2795bee2e6323723145 ./resources/views/user/images.blade.php' \
+ '15878ee87c65d46137249dde8e40931f ./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=69e2e947c24af2795bee2e6323723145" \
+ "images_blade_md5=15878ee87c65d46137249dde8e40931f" \
"app_src_md5=${APP_SRC_MD5}" \
"app_js_md5=${APP_JS_MD5}" \
"app_css_md5=${APP_CSS_MD5}" \
diff --git a/patches/ios-longpress.patch b/patches/ios-longpress.patch
index 604ff46..32158a2 100644
--- a/patches/ios-longpress.patch
+++ b/patches/ios-longpress.patch
@@ -52,7 +52,7 @@
# 三个文件各自的 md5(与 Dockerfile「自证 2」里的期望值一致):
# c0e513ec8e93fd81b34b3c6de5cf5eb8 src/resources/js/context-js.js
# c0e513ec8e93fd81b34b3c6de5cf5eb8 src/public/js/context-js/context-js.js
-# 69e2e947c24af2795bee2e6323723145 src/resources/views/user/images.blade.php
+# 15878ee87c65d46137249dde8e40931f src/resources/views/user/images.blade.php
#
# 上游原文件 md5(改动前,供比对):
# c8e57f6232848ca8277341ddf1a3a7a6 resources/js/context-js.js
diff --git a/src/resources/views/user/images.blade.php b/src/resources/views/user/images.blade.php
index e70156b..bca9ad6 100644
--- a/src/resources/views/user/images.blade.php
+++ b/src/resources/views/user/images.blade.php
@@ -644,7 +644,24 @@ class="relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-brand
document.addEventListener('touchcancel', () => { navDown = false; }, true);
})();
- /* 触摸端切图判定(接管 slideOnTouch 后的配套逻辑):
+ /* 切图动画起点固定为"屏幕中心"(Claude 方案 A)。
+ * 库每切一张都新建
:先写成屏幕中心的点(宽高 0 + margin 居中),读完自然尺寸
+ * 再加 viewer-transition 写最终宽高与 margin,靠 CSS 过渡补间。CSS 过渡的起点是
+ * 浏览器"上一次真正渲染过的样式" —— 中间那帧有没有渲染出来取决于图片缓存/网速/
+ * 主线程忙不忙,于是表现为"有时从中间长出来、有时从屏幕上方飞进来"。
+ * 这里在 initImage 之前强制回流一次,把"中心点"提交为已渲染样式 ⇒ 起点永远是中心。
+ * 覆盖手指滑动、缩略图条、键盘与底部按钮等所有切图路径(都在 view() 里)。
+ * 注:早先那版在 pointerup 里摘旧图的 viewer-transition 是无效的 —— view() 新建的是
+ * 另一个
,且 load() 会按 options.transition 给新图重新加类。 */
+ if (viewer && typeof viewer.initImage === 'function') {
+ const rawInitImage = viewer.initImage.bind(viewer);
+ viewer.initImage = function (cb) {
+ if (viewer.image) void viewer.image.offsetWidth; // 强制回流,提交"中心点"
+ return rawInitImage(cb);
+ };
+ }
+
+ /* 触摸端切图判定(接管 slideOnTouch 后的配套逻辑):
* 库里 slideOnTouch:false ⇒ 单指动作永远是 'move',库自己的平移就是跟手拖动,
* 不再出现 'switch' 动作 ⇒ 1px 判据与"闩锁"都不存在。切图还是回弹由松手时决定。
* 为什么不拦事件:库用 HAS_POINTER_EVENT ? 'pointermove' : 'touchmove' 选通道,
@@ -680,12 +697,6 @@ class="relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-brand
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) {
- /* 切图不要"飞入":库的过渡是"从上一张图的 margin 滑到新图的 margin",
- * 两张图高度不同(横图↔竖图)时,新图就会从偏上/偏下的位置滑进来。
- * 这一段临时摘掉过渡类 ⇒ 新图直接出现在它该在的位置(= "从中间出来")。
- * 过渡类是库自己在管的(action 为 move/zoom 时它自己也会摘),不属于
- * 那个碰不得的 action 状态机,且同一帧即由 renderImage 恢复。 */
- viewer.image.classList.remove('viewer-transition');
if (dx < 0) viewer.next(false); else viewer.prev(false);
guardClick = Date.now(); // 标记:刚刚切过图
} else if (dx !== 0 || dy !== 0) {