From cd8a3696e2d1b2108924eea573dc25976d8e5c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 23 Sep 2026 16:57:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(embed):=20scroll=20freeze=20=E2=80=94=20poi?= =?UTF-8?q?nter=20pause=20leaked=20when=20release=20happened=20outside=20w?= =?UTF-8?q?indow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: pointerup/pointercancel bound only on the viewport element; press on a photo then release outside the 195x280 window (or gesture interruption) never cleared the 'pointer' pause reason -> auto-scroll stopped permanently until page refresh. - pointerdown now setPointerCapture (release guaranteed to return) - window-level pointerup/pointercancel + blur fallbacks clear the reason - rAF frame wrapped in try/catch so a single bad frame can no longer break the animation chain Co-Authored-By: Claude Code --- .../src/embed/useVerticalPhotoStream.ts | 33 +++++++++++-------- apps/viewer/src/pages/embed/EmbedStream.vue | 24 ++++++++++++++ 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/apps/viewer/src/embed/useVerticalPhotoStream.ts b/apps/viewer/src/embed/useVerticalPhotoStream.ts index d94f597..8200062 100644 --- a/apps/viewer/src/embed/useVerticalPhotoStream.ts +++ b/apps/viewer/src/embed/useVerticalPhotoStream.ts @@ -98,21 +98,28 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) { function frame(now: number): void { rafId = null; - const s = state.value; - if (s === null || !scrollable()) return; - if (lastTime === null) { - lastTime = now; - } else { - const dt = now - lastTime; - lastTime = now; - if (dt > 0) { - const result = advanceStream(s, dt, speedPxPerSec.value, innerWidth, viewportHeight); - if (result.retired) { - items.value = s.items.map((item) => ({ ...item })); + // 异常保护(滚动卡死修复 2026-09-23):帧回调内任何异常若任其冒泡, + // 本帧不会排下一帧 → rAF 链永久断裂(表现为滚动停死、刷新才恢复)。 + // 逐帧 try-catch,异常帧跳过推进、继续排帧(写 transform 的 DOM 部分同护)。 + try { + const s = state.value; + if (s === null || !scrollable()) return; + if (lastTime === null) { + lastTime = now; + } else { + const dt = now - lastTime; + lastTime = now; + if (dt > 0) { + const result = advanceStream(s, dt, speedPxPerSec.value, innerWidth, viewportHeight); + if (result.retired) { + items.value = s.items.map((item) => ({ ...item })); + } + offsetView.value = s.offset; + writeTransform(); } - offsetView.value = s.offset; - writeTransform(); } + } catch { + /* 异常帧:跳过本次推进,链继续(数据边缘态由下次 reanchor 修复) */ } if (running.value) { rafId = requestAnimationFrame(frame); diff --git a/apps/viewer/src/pages/embed/EmbedStream.vue b/apps/viewer/src/pages/embed/EmbedStream.vue index 1ba7e9a..60f6dab 100644 --- a/apps/viewer/src/pages/embed/EmbedStream.vue +++ b/apps/viewer/src/pages/embed/EmbedStream.vue @@ -219,6 +219,14 @@ function onPointerDown(event: PointerEvent): void { const anchor = (event.target as HTMLElement).closest('a.stream-photo'); lockedTarget = anchor === null ? null : (anchor.dataset.photoId ?? null); setReason('pointer', true); + // 指针捕获(滚动卡死修复 2026-09-23):按下后拖出窗外松开时 viewport 收不到 + // pointerup,'pointer' 暂停原因会永久残留 → 滚动停死。捕获后 up/cancel 必回 + // 本元素;window 级兜底监听(onMounted)双保险。 + try { + viewportEl.value?.setPointerCapture(event.pointerId); + } catch { + /* 指针已释放等:忽略 */ + } } function onPointerMove(event: PointerEvent): void { @@ -336,8 +344,21 @@ function onFocusOut(): void { }, RESUME_DELAY_MS); } +// window 级兜底(滚动卡死修复 2026-09-23):pointerup 落在窗外/别的元素、 +// 或整个 iframe 失焦(弹框抢焦点)时也要清 pointer 原因,避免暂停态永久卡住。 +function onWindowPointerEnd(): void { + if (pointerDownY !== null) onPointerUp(); +} +function onWindowBlur(): void { + pointerDownY = null; + setReason('pointer', false); +} + onMounted(() => { document.addEventListener('visibilitychange', onVisibilityChange); + window.addEventListener('pointerup', onWindowPointerEnd); + window.addEventListener('pointercancel', onWindowPointerEnd); + window.addEventListener('blur', onWindowBlur); onVisibilityChange(); if (typeof IntersectionObserver !== 'undefined' && viewportEl.value !== null) { visibilityObserver = new IntersectionObserver( @@ -354,6 +375,9 @@ onBeforeUnmount(() => { setReason('destroyed', true); layerEl.value?.clear(); document.removeEventListener('visibilitychange', onVisibilityChange); + window.removeEventListener('pointerup', onWindowPointerEnd); + window.removeEventListener('pointercancel', onWindowPointerEnd); + window.removeEventListener('blur', onWindowBlur); visibilityObserver?.disconnect(); if (resumeTimer !== null) clearTimeout(resumeTimer); if (manualClearTimer !== null) clearTimeout(manualClearTimer);