Preserve browse scroll positions exactly when returning from details

Skip ensure-visible and synced-row animations on focus restore, and
snapshot/restore panel scrollTop plus synced-row scrollLeft around
detail navigation so the browse page comes back pixel-identical.
This commit is contained in:
2026-09-09 04:51:10 +00:00
parent 8f462b9e1d
commit 36e2fdd5ff
2 changed files with 74 additions and 9 deletions
+36 -4
View File
@@ -211,6 +211,7 @@ import {
} from "./api"
import { useSettings } from "./composables/useSettings"
import { useKeyboardNavigation, setActiveNavigationScope } from "./composables/useKeyboardNavigation"
import type { SyncedRowScrollSnapshot } from "./composables/useKeyboardNavigation"
import { useMediaWebSocket } from "./composables/useMediaWebSocket"
import Header from "./components/Header.vue"
import CollageHero from "./components/CollageHero.vue"
@@ -219,7 +220,13 @@ import MediaDetail from "./components/MediaDetail.vue"
import type { SearchResultItem, SearchResponseMessage } from "./search-worker"
// Initialize keyboard navigation
const { getFocusState, restoreFocusState, focusElement } = useKeyboardNavigation()
const {
getFocusState,
restoreFocusState,
focusElement,
snapshotSyncedRowScroll,
restoreSyncedRowScroll,
} = useKeyboardNavigation()
const router = useRouter()
const route = useRoute()
@@ -674,6 +681,23 @@ const searchCategories = ref<{ name: string; items: MediaItem[] }[]>([])
const focusStateMap = new Map<string, { row: number; col: number }>()
// Track the last viewed item ID to restore focus to the right card
const lastViewedItemId = ref<string | null>(null)
let browseScrollSnapshot: { panelTop: number; rows: SyncedRowScrollSnapshot } | null = null
function captureBrowseScrollSnapshot() {
browseScrollSnapshot = {
panelTop: browsePanelRef.value?.scrollTop ?? 0,
rows: snapshotSyncedRowScroll(),
}
}
function restoreBrowseScrollSnapshot() {
if (!browseScrollSnapshot) return
if (browsePanelRef.value) {
browsePanelRef.value.scrollTop = browseScrollSnapshot.panelTop
}
restoreSyncedRowScroll(browseScrollSnapshot.rows)
browseScrollSnapshot = null
}
// Save current focus state for a page
function saveFocusForPage(page: string) {
@@ -693,22 +717,24 @@ function restoreFocusForPage(page: string) {
if (lastViewedItemId.value) {
// Use nextTick + timeout to ensure DOM is updated after navigation
setTimeout(() => {
restoreBrowseScrollSnapshot()
const itemId = lastViewedItemId.value
// Find the element with matching item id
const element = document.querySelector(`[data-item-id="${itemId}"]`) as HTMLElement | null
if (element) {
focusElement(element)
focusElement(element, { preserveScroll: true })
lastViewedItemId.value = null
return
}
// Fallback to saved focus state
const state = focusStateMap.get(page)
restoreFocusState(state || null)
restoreFocusState(state || null, { preserveScroll: true })
lastViewedItemId.value = null
}, 100)
} else {
restoreBrowseScrollSnapshot()
const state = focusStateMap.get(page)
restoreFocusState(state || null)
restoreFocusState(state || null, { preserveScroll: true })
}
}
@@ -1025,6 +1051,12 @@ function showDetail(item: MediaItem) {
const currentPage = route.path === "/series" ? "series" : "movies"
saveFocusForPage(currentPage)
// Capture the exact browse scroll positions to restore on return,
// but only when leaving the browse page (not for detail-to-detail hops)
if (!isDetailOpen.value) {
captureBrowseScrollSnapshot()
}
// Check if there are matched episodes to focus on
if (item.type === "series" && item.searchMatchInfo?.matchedEpisodes?.length) {
const firstMatch = item.searchMatchInfo.matchedEpisodes[0]
@@ -702,7 +702,7 @@ function findNextElement(
}
}
function focusElement(element: HTMLElement | null) {
function focusElement(element: HTMLElement | null, options?: { preserveScroll?: boolean }) {
if (!element) return
if (!isElementInActiveScope(element)) return
@@ -714,8 +714,10 @@ function focusElement(element: HTMLElement | null) {
element.classList.add("nav-focused")
element.focus({ preventScroll: true })
ensureElementVisibleVertically(element)
syncRowsToElement(element)
if (!options?.preserveScroll) {
ensureElementVisibleVertically(element)
syncRowsToElement(element)
}
focusedElement.value = element
}
@@ -727,13 +729,16 @@ function getFocusState(): { row: number; col: number } | null {
return { row, col }
}
function restoreFocusState(state: { row: number; col: number } | null) {
function restoreFocusState(
state: { row: number; col: number } | null,
options?: { preserveScroll?: boolean },
) {
if (!state) return
const target = findElementAt(state.row, state.col)
if (target) {
setTimeout(() => {
focusElement(target.element)
focusElement(target.element, options)
}, 50)
}
}
@@ -893,6 +898,32 @@ export function installKeyboardNavigation() {
})
}
export interface SyncedRowScrollSnapshot {
rows: [HTMLElement, number][]
offset: number
targetOffset: number
}
function snapshotSyncedRowScroll(): SyncedRowScrollSnapshot {
return {
rows: getSyncedRows().map((row) => [row, row.scrollLeft]),
offset: syncedRowsCurrentOffset,
targetOffset: syncedRowsTargetOffset,
}
}
function restoreSyncedRowScroll(snapshot: SyncedRowScrollSnapshot) {
stopSyncedRowAnimation()
for (const [row, scrollLeft] of snapshot.rows) {
if (row.isConnected) {
row.scrollLeft = scrollLeft
}
}
syncedRowsCurrentOffset = snapshot.offset
syncedRowsTargetOffset = snapshot.targetOffset
lastSyncedRowsAnimationAt = null
}
export function useKeyboardNavigation() {
return {
focusedElement,
@@ -901,6 +932,8 @@ export function useKeyboardNavigation() {
focusAt,
getFocusState,
restoreFocusState,
snapshotSyncedRowScroll,
restoreSyncedRowScroll,
}
}