From c2ee25ba3d46dbf6ec05001d933f9ca050d86ce3 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 30 May 2026 02:57:34 +0000 Subject: [PATCH] Fix synced row deadzone behavior and isolate cast scroll metrics --- frontend/src/components/MediaDetail.vue | 4 +- frontend/src/components/MediaRow.vue | 1 + .../src/composables/useKeyboardNavigation.ts | 91 ++++++++++++++----- 3 files changed, 72 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/MediaDetail.vue b/frontend/src/components/MediaDetail.vue index ab18f2b..531aa10 100644 --- a/frontend/src/components/MediaDetail.vue +++ b/frontend/src/components/MediaDetail.vue @@ -110,6 +110,7 @@ v-if="limitedMovieCast.length > 0" class="cast-list" data-sync-scroll-row="true" + data-sync-scroll-group="cast" data-nav-cast-row="true" > { left: calc(-50vw + 50%); width: 100vw; margin: 0; - padding: 4px calc(var(--cast-safe-end) + var(--sync-row-tail)) 8px - calc(var(--cast-safe-start) + var(--sync-row-tail)); + padding: 4px calc(var(--cast-safe-end) + var(--sync-row-tail)) 8px var(--cast-safe-start); z-index: 0; display: flex; flex-wrap: nowrap; diff --git a/frontend/src/components/MediaRow.vue b/frontend/src/components/MediaRow.vue index 0178571..690a4af 100644 --- a/frontend/src/components/MediaRow.vue +++ b/frontend/src/components/MediaRow.vue @@ -3,6 +3,7 @@ class="media-row" :class="{ 'media-row-wrap': wrap }" :data-sync-scroll-row="!wrap && rowIndex !== undefined ? 'true' : undefined" + :data-sync-scroll-group="!wrap && rowIndex !== undefined ? 'browse' : undefined" > () -function invalidateMetrics() { - gMetrics = null +function getRowSyncGroup(row: HTMLElement): string { + return row.getAttribute(SYNC_SCROLL_GROUP_ATTR) || DEFAULT_SYNC_SCROLL_GROUP } -function measureGlobalMetrics(): boolean { - const rows = getSyncedRows() +function getSyncRowsByGroup(group: string): HTMLElement[] { + return getSyncedRows().filter((row) => getRowSyncGroup(row) === group) +} + +function invalidateMetrics(group?: string) { + if (group) { + gMetricsByGroup.delete(group) + return + } + gMetricsByGroup.clear() +} + +function measureGlobalMetrics(group: string): boolean { + const rows = getSyncRowsByGroup(group) for (const row of rows) { const cards = row.querySelectorAll(`.media-card[${FOCUSABLE_ATTR}]`) if (cards.length < 2) continue @@ -81,29 +97,42 @@ function measureGlobalMetrics(): boolean { const paddingLeft = parseFloat(rowStyle.paddingLeft || "0") const viewportWidth = row.clientWidth const deadzoneInset = Math.max(paddingLeft, (viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO) + const leftDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_LEFT_DEADZONE_VAR).trim() + const leftDeadzone = Number.isFinite(parseFloat(leftDeadzoneRaw)) + ? Math.max(0, parseFloat(leftDeadzoneRaw)) + : deadzoneInset const rightDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_RIGHT_DEADZONE_VAR).trim() const rightDeadzone = Number.isFinite(parseFloat(rightDeadzoneRaw)) ? Math.max(0, parseFloat(rightDeadzoneRaw)) : deadzoneInset - gMetrics = { cardWidth, stride, paddingLeft, viewportWidth, rightDeadzone } + gMetricsByGroup.set(group, { + cardWidth, + stride, + paddingLeft, + viewportWidth, + leftDeadzone, + rightDeadzone, + }) return true } return false } -function getMetrics(): ScrollMetrics | null { - if (!gMetrics) { - measureGlobalMetrics() +function getMetrics(group: string): ScrollMetrics | null { + let metrics = gMetricsByGroup.get(group) || null + if (!metrics) { + measureGlobalMetrics(group) + metrics = gMetricsByGroup.get(group) || null } - return gMetrics + return metrics } // On first navigation into a view, snap the global virtual offset to whatever // the first synced row is already scrolled to. This avoids animating from 0 // every time the view is entered. -function initCurrentOffsetFromDOM() { +function initCurrentOffsetFromDOM(group: string) { if (syncedRowsCurrentOffset !== 0) return - const rows = getSyncedRows() + const rows = getSyncRowsByGroup(group) for (const row of rows) { const sl = row.scrollLeft if (sl > 0) { @@ -126,7 +155,7 @@ function getRowItemCount(row: HTMLElement): number { // last item at the right edge of the safe zone. Computed purely from global // metrics and item count — no DOM queries. function getRowMaxScroll(row: HTMLElement): number { - const m = getMetrics() + const m = getMetrics(getRowSyncGroup(row)) if (!m) return 0 const n = getRowItemCount(row) if (n === 0) return 0 @@ -221,23 +250,35 @@ function animateSyncedRows(now: number) { } function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null = null) { - const rows = getSyncedRows() + const group = anchorRow ? getRowSyncGroup(anchorRow) : DEFAULT_SYNC_SCROLL_GROUP + const rows = getSyncRowsByGroup(group) if (rows.length === 0) return - initCurrentOffsetFromDOM() + initCurrentOffsetFromDOM(group) - const m = getMetrics() + const m = getMetrics(group) if (!m) return const itemLeft = m.paddingLeft + anchorCol * m.stride - const maxVisibleLeft = Math.max( + const leftVisibleLimit = Math.max( + m.paddingLeft, + m.leftDeadzone, + ) + const rightVisibleLimit = Math.max( m.paddingLeft, m.viewportWidth - m.cardWidth - m.rightDeadzone, ) - // Ideal unclamped offset: position the anchor column at the right edge of - // the safe zone, or 0 if it fits without scrolling. - let desiredOffset = Math.max(0, itemLeft - maxVisibleLeft) + // Keep focus inside the deadzone: no scroll while the focused item remains + // between left and right limits. + const itemViewportLeft = itemLeft - syncedRowsCurrentOffset + let desiredOffset = syncedRowsCurrentOffset + if (itemViewportLeft > rightVisibleLimit) { + desiredOffset = itemLeft - rightVisibleLimit + } else if (itemViewportLeft < leftVisibleLimit) { + desiredOffset = itemLeft - leftVisibleLimit + } + desiredOffset = Math.max(0, desiredOffset) // Clamp to the anchor row's own limit so the global target is always // reachable by the row that drove the navigation. @@ -332,7 +373,12 @@ function syncRowsToElement(element: HTMLElement) { } function handleSyncedRowResize() { - invalidateMetrics() + const activeRow = focusedElement.value?.closest(`[${SYNC_SCROLL_ROW_ATTR}="true"]`) + if (activeRow) { + invalidateMetrics(getRowSyncGroup(activeRow)) + } else { + invalidateMetrics() + } if (lastSyncedAnchorCol === null) { resetSyncedRows(true) return @@ -608,7 +654,8 @@ function findNextElement( return entryTarget?.element || null } - const m = getMetrics() + const activeRow = current.closest(`[${SYNC_SCROLL_ROW_ATTR}="true"]`) + const m = activeRow ? getMetrics(getRowSyncGroup(activeRow)) : null const currentRect = current.getBoundingClientRect() const actualCurrentCenterX = currentRect.left + currentRect.width / 2 const closestByViewport = findElementClosestToLogicalViewportX(