feat(web): rework deck carousel navigation
Replace the counter with a sliding-dot pagination: dots slide and fade as the active card changes, a counter above the bar shows the current index, and round first/last and prev/next buttons flank the strip. Scrolling over the dots steps through the deck. Also fix the camera fit so it frames the active card front-on instead of the carousel center.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject, WheelEvent } from 'react';
|
||||||
import { useFrame } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { TTSObject } from '@tts/shared';
|
import type { TTSObject } from '@tts/shared';
|
||||||
@@ -223,7 +223,14 @@ function arcRadius(cardWidth: number | null): number {
|
|||||||
return Math.max(ARC_RADIUS, (cardWidth / 2 + ARC_PADDING) / Math.sin(ARC_STEP));
|
return Math.max(ARC_RADIUS, (cardWidth / 2 + ARC_PADDING) / Math.sin(ARC_STEP));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prev/next controls and a counter, rendered as the scene overlay. */
|
/**
|
||||||
|
* Prev/next controls and a sliding-dot pagination, rendered as the scene
|
||||||
|
* overlay. All dots live in a fixed-width track that slides so the active dot
|
||||||
|
* stays centered; dots fade out as they leave the window. A counter above the
|
||||||
|
* bar shows the current index (`1 / 51`). Round first/last and prev/next
|
||||||
|
* buttons flank the dots. Scrolling over the pagination steps through the
|
||||||
|
* deck.
|
||||||
|
*/
|
||||||
function CarouselControls({
|
function CarouselControls({
|
||||||
active,
|
active,
|
||||||
count,
|
count,
|
||||||
@@ -235,25 +242,95 @@ function CarouselControls({
|
|||||||
}) {
|
}) {
|
||||||
const prev = () => onStep(-1);
|
const prev = () => onStep(-1);
|
||||||
const next = () => onStep(1);
|
const next = () => onStep(1);
|
||||||
|
const first = () => onStep(-active);
|
||||||
|
const last = () => onStep(count - 1 - active);
|
||||||
|
|
||||||
|
// Dots visible on each side of the active dot.
|
||||||
|
const DOT_WINDOW = 4;
|
||||||
|
const DOT_SIZE = 8;
|
||||||
|
const ACTIVE_WIDTH = 26;
|
||||||
|
const GAP = 6;
|
||||||
|
const SLOTS = DOT_WINDOW * 2 + 1;
|
||||||
|
const slotW = DOT_SIZE + GAP;
|
||||||
|
// Extra width the active dot (plus its gap) pushes beyond a normal slot.
|
||||||
|
const extra = ACTIVE_WIDTH + GAP - slotW;
|
||||||
|
const trackW = SLOTS * slotW + extra;
|
||||||
|
// Left offset of a dot at slot `s` (0 = leftmost). Dots after the active
|
||||||
|
// slot shift right by `extra` to make room for the wider active dot.
|
||||||
|
const slotLeft = (s: number) => s * slotW + (s > DOT_WINDOW ? extra : 0);
|
||||||
|
const dots = Array.from({ length: count }, (_, i) => i);
|
||||||
|
|
||||||
|
const onWheel = (e: React.WheelEvent) => {
|
||||||
|
// Scroll up/left goes back, scroll down/right goes forward.
|
||||||
|
onStep(e.deltaY < 0 ? -1 : 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const roundBtn =
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-zinc-900/80 disabled:hover:text-zinc-300';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-x-0 bottom-2 z-10 flex items-center justify-center gap-3">
|
<div className="absolute inset-x-0 bottom-2 z-10 flex flex-col items-center gap-2">
|
||||||
<button
|
<span className="rounded-md bg-zinc-900/80 px-2 py-0.5 font-mono text-xs text-zinc-300 backdrop-blur">
|
||||||
onClick={prev}
|
|
||||||
aria-label="Previous card"
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</button>
|
|
||||||
<span className="rounded-md bg-zinc-900/80 px-3 py-1 font-mono text-xs text-zinc-300 backdrop-blur">
|
|
||||||
{active + 1} / {count}
|
{active + 1} / {count}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={next}
|
<button
|
||||||
aria-label="Next card"
|
onClick={first}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/80 text-zinc-300 backdrop-blur transition hover:bg-zinc-800 hover:text-zinc-100"
|
disabled={active === 0}
|
||||||
>
|
aria-label="First card"
|
||||||
›
|
className={roundBtn}
|
||||||
</button>
|
>
|
||||||
|
«
|
||||||
|
</button>
|
||||||
|
<button onClick={prev} aria-label="Previous card" className={roundBtn}>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
onWheel={onWheel}
|
||||||
|
role="group"
|
||||||
|
aria-label="Browse cards"
|
||||||
|
className="flex items-center rounded-full bg-zinc-900/80 px-2 py-1.5 backdrop-blur"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
style={{ width: trackW, height: DOT_SIZE }}
|
||||||
|
>
|
||||||
|
{dots.map((i) => {
|
||||||
|
const isActive = i === active;
|
||||||
|
const slot = DOT_WINDOW + (i - active);
|
||||||
|
const inWindow = slot >= 0 && slot < SLOTS;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onStep(i - active)}
|
||||||
|
aria-label={`Go to card ${i + 1}`}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
className="absolute top-0 flex items-center justify-center rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
left: slotLeft(slot),
|
||||||
|
width: isActive ? ACTIVE_WIDTH : DOT_SIZE,
|
||||||
|
height: DOT_SIZE,
|
||||||
|
opacity: inWindow ? 1 : 0,
|
||||||
|
pointerEvents: inWindow ? 'auto' : 'none',
|
||||||
|
backgroundColor: isActive ? '#f4f4f5' : '#71717a',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={next} aria-label="Next card" className={roundBtn}>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={last}
|
||||||
|
disabled={active === count - 1}
|
||||||
|
aria-label="Last card"
|
||||||
|
className={roundBtn}
|
||||||
|
>
|
||||||
|
»
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user