Fix synced row deadzone behavior and isolate cast scroll metrics
This commit is contained in:
@@ -110,6 +110,7 @@
|
|||||||
v-if="limitedMovieCast.length > 0"
|
v-if="limitedMovieCast.length > 0"
|
||||||
class="cast-list"
|
class="cast-list"
|
||||||
data-sync-scroll-row="true"
|
data-sync-scroll-row="true"
|
||||||
|
data-sync-scroll-group="cast"
|
||||||
data-nav-cast-row="true"
|
data-nav-cast-row="true"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@@ -1098,8 +1099,7 @@ onUnmounted(() => {
|
|||||||
left: calc(-50vw + 50%);
|
left: calc(-50vw + 50%);
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 4px calc(var(--cast-safe-end) + var(--sync-row-tail)) 8px
|
padding: 4px calc(var(--cast-safe-end) + var(--sync-row-tail)) 8px var(--cast-safe-start);
|
||||||
calc(var(--cast-safe-start) + var(--sync-row-tail));
|
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
class="media-row"
|
class="media-row"
|
||||||
:class="{ 'media-row-wrap': wrap }"
|
:class="{ 'media-row-wrap': wrap }"
|
||||||
:data-sync-scroll-row="!wrap && rowIndex !== undefined ? 'true' : undefined"
|
:data-sync-scroll-row="!wrap && rowIndex !== undefined ? 'true' : undefined"
|
||||||
|
:data-sync-scroll-group="!wrap && rowIndex !== undefined ? 'browse' : undefined"
|
||||||
>
|
>
|
||||||
<MediaCard
|
<MediaCard
|
||||||
v-for="(item, index) in items"
|
v-for="(item, index) in items"
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ const ROW_ATTR = "data-nav-row"
|
|||||||
const COL_ATTR = "data-nav-col"
|
const COL_ATTR = "data-nav-col"
|
||||||
const ENTRY_COL_ATTR = "data-nav-entry-col"
|
const ENTRY_COL_ATTR = "data-nav-entry-col"
|
||||||
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
|
const SYNC_SCROLL_ROW_ATTR = "data-sync-scroll-row"
|
||||||
|
const SYNC_SCROLL_GROUP_ATTR = "data-sync-scroll-group"
|
||||||
|
const DEFAULT_SYNC_SCROLL_GROUP = "browse"
|
||||||
|
|
||||||
const SYNC_SCROLL_FIRST_CONTENT_ROW = 2
|
const SYNC_SCROLL_FIRST_CONTENT_ROW = 2
|
||||||
const SYNC_SCROLL_DEADZONE_RATIO = 0.18
|
const SYNC_SCROLL_DEADZONE_RATIO = 0.18
|
||||||
const SYNC_SCROLL_EASING_MS = 220
|
const SYNC_SCROLL_EASING_MS = 220
|
||||||
const SYNC_SCROLL_TAIL_VAR = "--sync-row-tail"
|
const SYNC_SCROLL_TAIL_VAR = "--sync-row-tail"
|
||||||
|
const SYNC_SCROLL_LEFT_DEADZONE_VAR = "--sync-row-left-deadzone"
|
||||||
const SYNC_SCROLL_RIGHT_DEADZONE_VAR = "--sync-row-right-deadzone"
|
const SYNC_SCROLL_RIGHT_DEADZONE_VAR = "--sync-row-right-deadzone"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -59,16 +62,29 @@ interface ScrollMetrics {
|
|||||||
stride: number // cardWidth + gap
|
stride: number // cardWidth + gap
|
||||||
paddingLeft: number
|
paddingLeft: number
|
||||||
viewportWidth: number
|
viewportWidth: number
|
||||||
|
leftDeadzone: number
|
||||||
rightDeadzone: number
|
rightDeadzone: number
|
||||||
}
|
}
|
||||||
let gMetrics: ScrollMetrics | null = null
|
const gMetricsByGroup = new Map<string, ScrollMetrics>()
|
||||||
|
|
||||||
function invalidateMetrics() {
|
function getRowSyncGroup(row: HTMLElement): string {
|
||||||
gMetrics = null
|
return row.getAttribute(SYNC_SCROLL_GROUP_ATTR) || DEFAULT_SYNC_SCROLL_GROUP
|
||||||
}
|
}
|
||||||
|
|
||||||
function measureGlobalMetrics(): boolean {
|
function getSyncRowsByGroup(group: string): HTMLElement[] {
|
||||||
const rows = getSyncedRows()
|
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) {
|
for (const row of rows) {
|
||||||
const cards = row.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`)
|
const cards = row.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`)
|
||||||
if (cards.length < 2) continue
|
if (cards.length < 2) continue
|
||||||
@@ -81,29 +97,42 @@ function measureGlobalMetrics(): boolean {
|
|||||||
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
|
const paddingLeft = parseFloat(rowStyle.paddingLeft || "0")
|
||||||
const viewportWidth = row.clientWidth
|
const viewportWidth = row.clientWidth
|
||||||
const deadzoneInset = Math.max(paddingLeft, (viewportWidth - cardWidth) * SYNC_SCROLL_DEADZONE_RATIO)
|
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 rightDeadzoneRaw = rowStyle.getPropertyValue(SYNC_SCROLL_RIGHT_DEADZONE_VAR).trim()
|
||||||
const rightDeadzone = Number.isFinite(parseFloat(rightDeadzoneRaw))
|
const rightDeadzone = Number.isFinite(parseFloat(rightDeadzoneRaw))
|
||||||
? Math.max(0, parseFloat(rightDeadzoneRaw))
|
? Math.max(0, parseFloat(rightDeadzoneRaw))
|
||||||
: deadzoneInset
|
: deadzoneInset
|
||||||
gMetrics = { cardWidth, stride, paddingLeft, viewportWidth, rightDeadzone }
|
gMetricsByGroup.set(group, {
|
||||||
|
cardWidth,
|
||||||
|
stride,
|
||||||
|
paddingLeft,
|
||||||
|
viewportWidth,
|
||||||
|
leftDeadzone,
|
||||||
|
rightDeadzone,
|
||||||
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMetrics(): ScrollMetrics | null {
|
function getMetrics(group: string): ScrollMetrics | null {
|
||||||
if (!gMetrics) {
|
let metrics = gMetricsByGroup.get(group) || null
|
||||||
measureGlobalMetrics()
|
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
|
// 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
|
// the first synced row is already scrolled to. This avoids animating from 0
|
||||||
// every time the view is entered.
|
// every time the view is entered.
|
||||||
function initCurrentOffsetFromDOM() {
|
function initCurrentOffsetFromDOM(group: string) {
|
||||||
if (syncedRowsCurrentOffset !== 0) return
|
if (syncedRowsCurrentOffset !== 0) return
|
||||||
const rows = getSyncedRows()
|
const rows = getSyncRowsByGroup(group)
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const sl = row.scrollLeft
|
const sl = row.scrollLeft
|
||||||
if (sl > 0) {
|
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
|
// last item at the right edge of the safe zone. Computed purely from global
|
||||||
// metrics and item count — no DOM queries.
|
// metrics and item count — no DOM queries.
|
||||||
function getRowMaxScroll(row: HTMLElement): number {
|
function getRowMaxScroll(row: HTMLElement): number {
|
||||||
const m = getMetrics()
|
const m = getMetrics(getRowSyncGroup(row))
|
||||||
if (!m) return 0
|
if (!m) return 0
|
||||||
const n = getRowItemCount(row)
|
const n = getRowItemCount(row)
|
||||||
if (n === 0) return 0
|
if (n === 0) return 0
|
||||||
@@ -221,23 +250,35 @@ function animateSyncedRows(now: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSyncedRowTarget(anchorCol: number, anchorRow: HTMLElement | null = null) {
|
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
|
if (rows.length === 0) return
|
||||||
|
|
||||||
initCurrentOffsetFromDOM()
|
initCurrentOffsetFromDOM(group)
|
||||||
|
|
||||||
const m = getMetrics()
|
const m = getMetrics(group)
|
||||||
if (!m) return
|
if (!m) return
|
||||||
|
|
||||||
const itemLeft = m.paddingLeft + anchorCol * m.stride
|
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.paddingLeft,
|
||||||
m.viewportWidth - m.cardWidth - m.rightDeadzone,
|
m.viewportWidth - m.cardWidth - m.rightDeadzone,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ideal unclamped offset: position the anchor column at the right edge of
|
// Keep focus inside the deadzone: no scroll while the focused item remains
|
||||||
// the safe zone, or 0 if it fits without scrolling.
|
// between left and right limits.
|
||||||
let desiredOffset = Math.max(0, itemLeft - maxVisibleLeft)
|
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
|
// Clamp to the anchor row's own limit so the global target is always
|
||||||
// reachable by the row that drove the navigation.
|
// reachable by the row that drove the navigation.
|
||||||
@@ -332,7 +373,12 @@ function syncRowsToElement(element: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSyncedRowResize() {
|
function handleSyncedRowResize() {
|
||||||
invalidateMetrics()
|
const activeRow = focusedElement.value?.closest<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`)
|
||||||
|
if (activeRow) {
|
||||||
|
invalidateMetrics(getRowSyncGroup(activeRow))
|
||||||
|
} else {
|
||||||
|
invalidateMetrics()
|
||||||
|
}
|
||||||
if (lastSyncedAnchorCol === null) {
|
if (lastSyncedAnchorCol === null) {
|
||||||
resetSyncedRows(true)
|
resetSyncedRows(true)
|
||||||
return
|
return
|
||||||
@@ -608,7 +654,8 @@ function findNextElement(
|
|||||||
return entryTarget?.element || null
|
return entryTarget?.element || null
|
||||||
}
|
}
|
||||||
|
|
||||||
const m = getMetrics()
|
const activeRow = current.closest<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`)
|
||||||
|
const m = activeRow ? getMetrics(getRowSyncGroup(activeRow)) : null
|
||||||
const currentRect = current.getBoundingClientRect()
|
const currentRect = current.getBoundingClientRect()
|
||||||
const actualCurrentCenterX = currentRect.left + currentRect.width / 2
|
const actualCurrentCenterX = currentRect.left + currentRect.width / 2
|
||||||
const closestByViewport = findElementClosestToLogicalViewportX(
|
const closestByViewport = findElementClosestToLogicalViewportX(
|
||||||
|
|||||||
Reference in New Issue
Block a user