autodiff-dev / frontends /react /src /TimelineControls.tsx
nhamid289's picture
Deploy autodiff ff35e9c
b9499ab verified
Raw History Blame Contribute Delete
6.3 kB
import { Pause, Play, RotateCcw, StepBack, StepForward } from "lucide-react";
import { useSyncExternalStore, type ReactNode } from "react";
import type { Timeline, TimelineSnapshot } from "./Timeline";
// Styled to match @elvis/ui's StepControls, which works in whole iterations
const thumbClass =
"[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white [&::-webkit-slider-thumb]:bg-ink " +
"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white [&::-moz-range-thumb]:bg-ink";
// How far an arrow key moves the playhead
const KEY_MOVE_MS = 250;
// The slider's thumb stops this far from each end, so marks on the track are inset to line up with it
const THUMB_RADIUS = 8;
const iconButtonClass =
"flex size-11 shrink-0 items-center justify-center rounded border-[1.5px] border-ink bg-white text-ink enabled:cursor-pointer enabled:hover:bg-marker-soft disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink";
// The slider along the whole run, with a tick for each step and the two passes marked
function TimelineBar({ timeline, snapshot }: { timeline: Timeline; snapshot: TimelineSnapshot }) {
const time = useSyncExternalStore(timeline.subscribeTime, timeline.getTime);
const { ticks, backwardAt, iteration, lastIteration } = snapshot;
const length = ticks[ticks.length - 1];
const at = (t: number) => `${length > 0 ? (t / length) * 100 : 0}%`;
return (
<div className="flex flex-col gap-1">
<div className="relative h-6">
{/* Inset so positions here match the thumb's centre */}
<div className="pointer-events-none absolute inset-y-0" style={{ left: THUMB_RADIUS, right: THUMB_RADIUS }}>
<div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 bg-grid" />
<div className="absolute left-0 top-1/2 h-1 -translate-y-1/2 bg-ink" style={{ width: at(time) }} />
{ticks.map((tick) => (
<div key={tick} data-tick className="absolute top-1/2 h-2.5 w-px -translate-y-1/2 bg-ink-muted" style={{ left: at(tick) }} />
))}
{backwardAt !== null && (
<div data-pass-marker className="absolute top-0 h-full w-0.5 -translate-x-1/2 bg-ink" style={{ left: at(backwardAt) }} />
)}
</div>
<input
type="range"
aria-label="Position in the run"
aria-valuetext={`iteration ${iteration}${lastIteration !== null ? ` of ${lastIteration}` : ""}`}
min={0}
max={length}
step={1}
value={time}
disabled={length === 0}
onChange={(e) => timeline.seek(Number(e.target.value))}
onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
timeline.seek(time + (e.key === "ArrowLeft" ? -KEY_MOVE_MS : KEY_MOVE_MS));
}
}}
className={`absolute inset-0 m-0 w-full cursor-pointer appearance-none bg-transparent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink disabled:cursor-not-allowed ${thumbClass}`}
/>
</div>
{backwardAt !== null && (
<div className="relative h-4 text-xs text-ink-muted" style={{ marginLeft: THUMB_RADIUS, marginRight: THUMB_RADIUS }}>
<span className="absolute left-0">Forward pass</span>
<span className="absolute" style={{ left: at(backwardAt) }}>Backward pass</span>
</div>
)}
</div>
);
}
export default function TimelineControls({
timeline,
snapshot,
animated,
onAnimatedChange,
}: {
timeline: Timeline;
snapshot: TimelineSnapshot;
animated: boolean;
onAnimatedChange: (animated: boolean) => void;
}) {
const { iteration, lastIteration, atStart, playing } = snapshot;
const atEnd = lastIteration === null || iteration >= lastIteration;
return (
<section className="flex flex-col gap-3">
<TimelineBar timeline={timeline} snapshot={snapshot} />
<div className="flex gap-2">
<IconButton label="Reset to iteration 0" onClick={timeline.reset} disabled={atStart}>
<RotateCcw size={18} />
</IconButton>
<IconButton label="Step back" onClick={timeline.stepBack} disabled={atStart || playing}>
<StepBack size={18} />
</IconButton>
<button
type="button"
onClick={playing ? timeline.pause : timeline.play}
disabled={!playing && atEnd}
className="flex h-11 flex-1 items-center justify-center gap-2 rounded border-2 border-ink bg-ink text-sm font-semibold text-white shadow-[3px_3px_0_var(--color-marker)] enabled:cursor-pointer disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
>
<span aria-hidden>{playing ? <Pause size={16} /> : <Play size={16} />}</span>
{playing ? "Pause" : "Play"}
</button>
<IconButton label="Step forward" onClick={timeline.stepForward} disabled={atEnd || playing}>
<StepForward size={18} />
</IconButton>
</div>
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-sm text-ink-muted" aria-live="polite">
iteration {iteration}{lastIteration !== null ? ` / ${lastIteration}` : ""}
</p>
<label className="flex cursor-pointer items-center gap-2 text-sm text-gray-700">
<input type="checkbox" checked={animated} onChange={(e) => onAnimatedChange(e.target.checked)} className="size-4 accent-ink" />
Animate steps
</label>
</div>
</section>
);
}
function IconButton({ label, onClick, disabled, children }: { label: string; onClick: () => void; disabled: boolean; children: ReactNode }) {
return (
<button type="button" aria-label={label} title={label} onClick={onClick} disabled={disabled} className={iconButtonClass}>
<span aria-hidden>{children}</span>
</button>
);
}