File size: 6,297 Bytes
b9499ab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
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>
  );
}