fix(embed): scroll freeze — pointer pause leaked when release happened outside window

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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 16:57:04 +08:00
parent ae17776be6
commit cd8a3696e2
2 changed files with 44 additions and 13 deletions
+20 -13
View File
@@ -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);
@@ -219,6 +219,14 @@ function onPointerDown(event: PointerEvent): void {
const anchor = (event.target as HTMLElement).closest<HTMLElement>('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);