From 17d510ccd5e4b1b057cd2a3f24ff831b8d2c0bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 23 Sep 2026 18:59:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(embed):=20scroll=20freeze=20final=20layer?= =?UTF-8?q?=20=E2=80=94=20Chrome=20fires=20a=20queued=20pointerenter=20aft?= =?UTF-8?q?er=20pointercancel=20when=20dragging=20outside,=20permanently?= =?UTF-8?q?=20trapping=20hover=20pause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequence: drag past iframe edge -> pointercancel (clears press state) -> Chrome still delivers one pre-queued pointerenter (re-sets hover) -> no further boundary events ever reach the iframe -> hover pause stuck forever. Fix: ignore pointerenter within 500ms after a pointercancel (and while pressed); re-set hover on genuine in-frame release so post-click hover behavior is preserved. Co-Authored-By: Claude Code --- apps/viewer/src/pages/embed/EmbedStream.vue | 48 ++++++++++++++------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/apps/viewer/src/pages/embed/EmbedStream.vue b/apps/viewer/src/pages/embed/EmbedStream.vue index 6f3d8f8..0d82400 100644 --- a/apps/viewer/src/pages/embed/EmbedStream.vue +++ b/apps/viewer/src/pages/embed/EmbedStream.vue @@ -155,10 +155,6 @@ function simulateBurst(photoId: string, delta: number): void { } defineExpose({ simulateBurst }); -// 临时探针(卡死排查,随后移除) -if (typeof window !== 'undefined') { - (window as unknown as Record).__embedDebug = { reasons: pauseReasons, userPaused }; -} // 收起/隐藏/换绑/减少动效 → 清粒子(§5.3)。 watch(activeRef, (value) => { @@ -205,6 +201,8 @@ let pointerMoved = 0; let lockedTarget: string | null = null; /** 拖动抑制一次 click。 */ let suppressClick = false; +/** 最近 pointercancel 时刻(enter 噪音过滤)。 */ +let lastPointerCancelTs = 0; /** 按住看门狗(滚动卡死修复):up 丢失(窗外释放)时 2s 强制恢复滚动。 */ const POINTER_WATCHDOG_MS = 2000; let pointerWatchdog: ReturnType | null = null; @@ -273,19 +271,32 @@ function onPointerUp(event: PointerEvent): void { // 滚动停死到刷新;滚轮/键盘路径各有 500ms 定时清除,拖动漏了)。 setReason('manual', false); setReason('pointer', false); - // 窗外结束时(拖出窗外 Chrome 派发 pointercancel 而非 pointerleave——leave - // 不会再来)hover 原因同样会永久残留,这里按坐标判定一并清除;窗内正常 - // 释放不清(点击后悬停继续暂停是合理行为)。 - const rect = viewportEl.value?.getBoundingClientRect(); - if ( - rect !== undefined && - (event.clientX < rect.left || - event.clientX > rect.right || - event.clientY < rect.top || - event.clientY > rect.bottom) - ) { + // 窗外结束时 hover 原因同样会永久残留(拖出窗外 Chrome 派发 pointercancel + // 而非 pointerleave;其合成坐标恒为 0,0——恰落在铺满 iframe 的 viewport + // rect 内,坐标判定不可用)。pointercancel 本身即手势被打断(拖出/系统 + // 抢占),鼠标大概率已不在窗上:无条件清 hover。真实 pointerup 坐标可信, + // 窗内释放不清(点击后悬停继续暂停是合理行为)。 + if (event.type === 'pointercancel') { + lastPointerCancelTs = Date.now(); hoverPointerIsMouse = false; setReason('hover', false); + } else { + const rect = viewportEl.value?.getBoundingClientRect(); + const outside = + rect !== undefined && + (event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom); + if (outside) { + hoverPointerIsMouse = false; + setReason('hover', false); + } else if (event.pointerType === 'mouse') { + // 窗内真实释放:按住期间的 enter 被忽略后在此补设悬停(释放后鼠标 + // 仍在窗上 → 悬停暂停继续成立,与直接移入行为一致)。 + hoverPointerIsMouse = true; + setReason('hover', true); + } } scheduleResume(); } @@ -351,6 +362,13 @@ function onVisibilityChange(): void { } function onPointerEnter(event: PointerEvent): void { // 悬停仅鼠标(效果示例 e.pointerType==='mouse'——触摸不粘滞)。 + // 按住期间 / pointercancel 后短窗内的 enter 是边界移交噪音(滚动卡死末层 + // 根因 2026-09-23:拖出窗外时 Chrome 先派发 pointercancel 清掉按住标志, + // 再补发一个 cancel 前排队的 pointerenter——此后 iframe 不再收到任何 + // boundary 事件,此 enter 设置的 hover 会永久卡住滚动)。真实悬停意图只在 + // 无按住且非 cancel 余波时成立;释放后鼠标仍在窗上由 onPointerUp 补设。 + if (pointerDownY !== null) return; + if (Date.now() - lastPointerCancelTs < 500) return; if (event.pointerType === 'mouse') { hoverPointerIsMouse = true; if (resumeTimer !== null) {