a medium's time as rows under one ruler that zooms: chapters, stills, claims
npx shadcn add @ag/timelineAlready installed? Preview an update
npx shadcn add @ag/timeline --dry-runYour installed source stays unchanged until you update it. How updates work
- Its frame is
Panel's, plain or glass. - A folded row still says which row it is: its name stays, in the outline rows' quiet voice (smaller, mono, fainter), and the folded row is a little taller to hold it (16 px). Before, a folded row showed only its arrow.
also called video editor
shelf motion
comes with timeline-view
uses clip, timebase, playback, button, kbd, keys, key-router, material, panel
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
// #region interview-media
import INTERVIEW_FACES from "@/public/apollo-11/cronkite-aldrin.faces.json"
// #endregion
// #region speech-media
import MOON_FACES from "@/public/kennedy/moon.faces.json"
import MOON_SAID from "@/public/kennedy/moon.transcript.json"
import INTERVIEW_LOUDNESS from "@/public/timeline/cronkite-aldrin.loudness.json"
import MOON_LOUDNESS from "@/public/timeline/moon.loudness.json"
// #endregion
import type { Chapter, Clip } from "@/registry/base-nova/lib/clip"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Playback } from "@/registry/base-nova/ui/playback"
import { PlayerChrome, PlayerTransport } from "@/registry/base-nova/ui/player"
import {
type CurvePoint,
Timeline,
type TimelineAnswer,
type TimelineClaim,
} from "@/registry/base-nova/ui/timeline"
import {
VideoPicture,
VideoPlayback,
} from "@/registry/base-nova/ui/video-playback"
// #region claims
/** A face found every half second (`[from, to, x, y, r]`, perceive's samples) as the
* spans the face is on screen: samples that touch join into one claim, the machine's. */
function onScreen(who: string, samples: number[][]): TimelineClaim[] {
const spans: { from: number; to: number }[] = []
for (const [from, to] of samples) {
if (from === undefined || to === undefined)
throw new Error(`${who}: a face sample is short`)
const last = spans[spans.length - 1]
if (last && from - last.to <= 500) last.to = to
else spans.push({ from, to })
}
return spans.map((when, i) => ({
id: `${who}-${i}`,
when,
answer: who,
by: "machine",
}))
}
/** A loudness file's `[ms, value]` pairs as a curve. */
const curveOf = (pairs: number[][]): CurvePoint[] =>
pairs.map(([at, value]) => ({ at: at as number, value: value as number }))
// #endregion
// #region interview
// Walter Cronkite questions Buzz Aldrin, 5 July 1969 (NASA, public domain): the camera
// cuts between them; perceive found their faces, the sound is the clip's own.
const INTERVIEW_FPS = 30000 / 1001
const TURNS: Chapter[] = [
{ start: 0, title: "is the public overconfident?" },
{ start: 33_000, title: "the reaction we hoped for" },
{ start: 60_800, title: "if, or when?" },
{ start: 76_500, title: "when we land, not if" },
]
const CRONKITE = onScreen("cronkite", INTERVIEW_FACES.cronkite)
const ALDRIN = onScreen("aldrin", INTERVIEW_FACES.aldrin)
/** Where the picture cuts to the other man: every on-screen span's start but the
* first, an instant each. */
const CUTS: TimelineClaim[] = [...CRONKITE, ...ALDRIN]
.map((c) => ("from" in c.when ? c.when.from : c.when.at))
.filter((at) => at > 0)
.sort((a, b) => a - b)
.map((at) => ({ id: `cut-${at}`, when: { at }, by: "machine" }))
/** Who speaks, as a person said it so far: the first two turns. */
const SAID_SO_FAR: TimelineClaim[] = [
{
id: "said-0",
when: { from: 640, to: 32_000 },
answer: "cronkite",
by: "person",
},
{
id: "said-1",
when: { from: 33_220, to: 60_000 },
answer: "aldrin",
by: "person",
},
]
// #endregion
function Interview() {
const [said, setSaid] = React.useState(SAID_SO_FAR)
// A person's answer becomes their claim: kept here, handed back in the row.
const answer = ({ when, answer }: TimelineAnswer) =>
setSaid((was) => [
...was,
{ id: `said-${was.length}`, when, answer, by: "person" },
])
return (
<VideoPlayback
src="/apollo-11/cronkite-aldrin.mp4"
poster="/apollo-11/cronkite-aldrin.jpg"
label="Cronkite questions Aldrin, July 1969"
fps={INTERVIEW_FPS}
chapters={TURNS}
>
<div className="flex flex-col gap-3">
<VideoPicture
aspect="626 / 476"
className="mx-auto w-full max-w-sm rounded-xl"
>
<PlayerChrome>
<PlayerTransport only={["play"]} />
</PlayerChrome>
</VideoPicture>
<Timeline
label="Cronkite questions Aldrin"
filmstrip={{
still: (ms) =>
`/gallery-stills/cronkite-aldrin/${Math.min(108, Math.floor(ms / 1000))}.jpg`,
every: 1000,
aspect: 626 / 476,
}}
rows={[
{
id: "speaks",
kind: "claims",
label: "who speaks",
claims: said,
answers: ["cronkite", "aldrin"],
},
{
id: "cronkite",
kind: "claims",
label: "cronkite on screen",
claims: CRONKITE,
},
{
id: "aldrin",
kind: "claims",
label: "aldrin on screen",
claims: ALDRIN,
},
{ id: "cuts", kind: "claims", label: "cuts", claims: CUTS },
{
id: "loud",
kind: "curve",
label: "loudness",
points: curveOf(INTERVIEW_LOUDNESS),
},
]}
onAnswer={answer}
/>
</div>
</VideoPlayback>
)
}
// #region speech
// John F. Kennedy at Rice University, 12 September 1962: his words as listen timed
// them, his face where perceive found it (the camera leaves him for the crowd).
const MOON_FPS = 24000 / 1001
const MOON_PARTS: Chapter[] = [
{ start: 0, title: "why climb the highest mountain" },
{ start: 9_060, title: "we choose to go to the moon" },
{ start: 19_860, title: "because they are hard" },
{ start: 39_720, title: "and we intend to win" },
]
const WORDS: TimelineClaim[] = MOON_SAID.map((u, i) => ({
id: `said-${i}`,
when: u.span,
answer: u.text,
}))
const KENNEDY = onScreen("kennedy", MOON_FACES.kennedy)
// #endregion
function Speech() {
return (
<VideoPlayback
src="/kennedy/moon.mp4"
poster="/kennedy/moon.jpg"
label="John F. Kennedy, September 1962"
fps={MOON_FPS}
chapters={MOON_PARTS}
>
<div className="flex flex-col gap-3">
<VideoPicture
aspect="640 / 480"
className="mx-auto w-full max-w-xs rounded-xl"
>
<PlayerChrome>
<PlayerTransport only={["play"]} />
</PlayerChrome>
</VideoPicture>
<Timeline
label="We choose to go to the Moon"
filmstrip={{
still: (ms) =>
`/timeline/moon/${Math.min(49, Math.floor(ms / 1000))}.jpg`,
every: 1000,
aspect: 640 / 480,
}}
rows={[
{ id: "said", kind: "claims", label: "said", claims: WORDS },
{
id: "kennedy",
kind: "claims",
label: "kennedy on screen",
claims: KENNEDY,
},
{
id: "loud",
kind: "curve",
label: "loudness",
points: curveOf(MOON_LOUDNESS),
},
]}
/>
</div>
</VideoPlayback>
)
}
// #region many
// An hour of drawn time with ten thousand machine claims and a value every 100 ms:
// what a long recording's detections look like. Seeded, so every visit is the same.
const HOUR: Clip = {
duration: 3_600_000,
chapters: [0, 1, 2, 3].map((q) => ({
start: q * 900_000,
title: `quarter ${q + 1}`,
})),
}
const seeded = (seed: number) => () => {
seed = (seed * 1_103_515_245 + 12_345) % 2 ** 31
return seed / 2 ** 31
}
const random = seeded(11)
const GUESSES: TimelineClaim[] = Array.from({ length: 10_000 }, (_, i) => {
const from = Math.floor(random() * 3_590_000)
return {
id: `guess-${i}`,
when: { from, to: from + 500 + Math.floor(random() * 8_000) },
answer: "a ball",
by: "machine",
}
})
// #endregion
function Many() {
return (
<Playback clip={HOUR} autoPlay={false}>
<Timeline
label="an hour of detections"
rows={[
{ id: "ball", kind: "claims", label: "ball seen", claims: GUESSES },
]}
/>
</Playback>
)
}
export default function TimelineDemo() {
return (
<div className="space-y-8">
<Sample
name="interview"
title="an interview, every row"
description="Click in the timeline and the keys are an editor's: J K L shuttle, ← → a frame, ⌥ ← → the next edge, I and O set the selection, 1 and 2 say who speaks over it, R repeats it, B goes back to where you were. A pinch zooms at the hand down to the frame; a drag selects, snapping to every edge."
with="interview-media claims interview"
>
<Interview />
</Sample>
<Sample
name="speech"
title="a speech, in glass"
with="speech-media claims speech"
>
<MaterialProvider material="glass">
<div className="rounded-2xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-4 sm:p-8">
<Speech />
</div>
</MaterialProvider>
</Sample>
<Sample
name="many"
glance="wide"
title="ten thousand claims"
description="The whole hour paints the row; zoom in and it becomes claims you can point at, each drawn only while it is in view."
with="many"
>
<Many />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// THE TIMELINE: a medium's time laid out as rows under one ruler, to look at what is
// known about it and to say more. It follows the timebase around it (VideoPlayback,
// AudioPlayback, Playback: one owner, this is a follower) and stores nothing about the
// medium: the consumer hands its claims in, and labelling hands new ones back.
//
// the view the window of time the width shows (lib/timeline-view). A pinch or
// ⌘-wheel zooms at the hand, from the whole medium down to a dozen
// frames; a sideways swipe pans; both stop at the ends, nothing bands,
// and every delta a trackpad sends moves it at full weight (no momentum
// of our own, none projected: the lightbox's lessons). + - 0 at the
// playhead. The ruler's labels never touch (frames and timecode close
// in), and an overview under it shows where the view is in the whole.
// the rows the outline (each level a row of its divisions), a filmstrip of
// stills along the view, then the consumer's rows: claims drawn as
// bands (spans) and pins (instants), a person's solid, a machine's
// outlined, overlapping ones stacked; curves drawn as ridges, every
// peak kept at any width. A row collapses to a line. Thousands of claims
// cost what the view shows (lib/timeline-rows); past a few hundred in
// view a row is painted on a canvas instead of drawn as nodes.
// the hand resting on the rows shows that instant in the picture (skimming,
// the preview every timeline here makes); a press seeks; a drag
// selects a span, its ends snapping to every claim's edge, the outline,
// the playhead (⌥ held, free); a press on a claim selects it, found
// by the row's index under the hand, painted or drawn as nodes.
// the keys an editor's table, not the watching player's (lib/timeline-shuttle,
// EDIT_KEYS): J K L shuttle, ← → a frame, ⌥ the next edge, ⇧ the
// selection's end, ↑ ↓ the row, I O in and out, X the claim under the
// playhead, R repeats the selection (the timebase's loop), B back to
// where you were (a big move leaves the way back on the ruler), 1-9
// answer the row's question. One table drives the handler, a legend
// and `aria-keyshortcuts` alike. Its own while focused: the player's
// keys never hear them, and a control inside it keeps its own.
// labelling a selection (or the playhead), a row (its question) and a number
// key (its answer) become a claim, handed to `onAnswer`; the consumer
// keeps it and hands it back in the row, by a person.
// heard one focusable widget (`application`, named by `label`), its active
// descendant the row in hand or the claim chosen in it, each claim
// named by its row's question, answer, when and whose; an answer, an
// in, an out, a repeat and the way back said in a polite live region.
//
// Both materials (ui/material): plain on the page's surfaces, glass over a picture;
// `scheme` the colour scheme it stands in.
import { ChevronDown, ChevronRight, Maximize2, Minus, Plus } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
divisionSpans,
type Level,
type Span,
} from "@/registry/base-nova/lib/clip"
import { capOf } from "@/registry/base-nova/lib/keys"
import { timeLabel } from "@/registry/base-nova/lib/timebase"
import {
type ClaimIndex,
type CurvePoint,
claimAt,
countIn,
decimate,
edgesOf,
indexClaims,
isInstant,
nextEdge,
type Painting,
pack,
repaintOf,
spanOf,
type TimelineClaim,
visibleIn,
type When,
} from "@/registry/base-nova/lib/timeline-rows"
import {
EDIT_KEYS,
EDIT_SHORTCUTS,
type EditAction,
goBack,
isJump,
resolveEdit,
shuttle,
} from "@/registry/base-nova/lib/timeline-shuttle"
import {
clampView,
instantAt,
minSpanOf,
msPerPx,
onFrame,
panBy,
snapTo,
ticksOf,
type View,
viewAround,
viewFollowing,
wholeView,
xOf,
zoomAt,
} from "@/registry/base-nova/lib/timeline-view"
import { Button } from "@/registry/base-nova/ui/button"
import { Kbd } from "@/registry/base-nova/ui/kbd"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import {
useHoverIntent,
useOutline,
useTimebase,
useTimebaseState,
} from "@/registry/base-nova/ui/playback"
export type { CurvePoint, TimelineClaim, When }
export { EDIT_KEYS }
/** A row of claims: one question about the medium (its label), the claims made about
* it, and, when a person may answer it here, the answers the number keys give. */
export interface TimelineClaimRow {
id: string
label: string
kind: "claims"
claims: readonly TimelineClaim[]
/** The answers to its question, in key order: 1 the first. */
answers?: readonly string[]
}
/** A row of a curve: values 0..1 over time (how loud, how sure). */
export interface TimelineCurveRow {
id: string
label: string
kind: "curve"
points: readonly CurvePoint[]
}
export type TimelineRow = TimelineClaimRow | TimelineCurveRow
/** A person's answer: the row (its question), when, and what. The consumer makes it a
* claim, by a person, and hands it back in the row. */
export interface TimelineAnswer {
row: string
when: When
answer: string
}
/** The stills along the view: the one at an instant, a source's address. */
export interface TimelineFilmstrip {
still: (ms: number) => string
/** How often the source has a still, ms: no two tiles ask for one between. */
every?: number
/** The medium's width over its height. */
aspect: number
}
export interface TimelineProps {
/** What it is the timeline of: its accessible name. */
label: string
rows: readonly TimelineRow[]
/** Levels finer than the timebase's own outline (scenes, shots), coarse to fine. */
levels?: readonly Level[]
filmstrip?: TimelineFilmstrip
/** A person answered a row's question (1-9) for the selection or the playhead. */
onAnswer?: (answer: TimelineAnswer) => void
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
className?: string
}
/** A span selected, and the claim it is when a claim was chosen (by its id, never
* read back from what the page drew). */
type Selection = Span & { claim?: { row: string; id: string } }
/** A claim row as laid out: its claims indexed and stacked. */
type Packed = {
index: ClaimIndex
line: ReadonlyMap<string, number>
lines: number
}
/** When, in words: timecode when the medium has frames, a clock when not. */
const whenWords = (when: When, fps: number | null) => {
const form = fps ? "timecode" : "clock"
const s = spanOf(when)
return isInstant(when) || s.to === s.from
? `at ${timeLabel(s.from, form, fps)}`
: `${timeLabel(s.from, form, fps)} to ${timeLabel(s.to, form, fps)}`
}
/** A claim's name for assistive tech: the row's question, its answer, when, by whom. */
const claimWords = (
row: TimelineClaimRow,
claim: TimelineClaim,
fps: number | null,
) =>
[
`${row.label}: ${claim.answer ?? "no answer"}`,
whenWords(claim.when, fps),
claim.by && `by a ${claim.by}`,
]
.filter(Boolean)
.join(", ")
/** The element id of a chosen claim, the timeline's active descendant: one per claim,
* so choosing another is heard as a move. */
const chosenIdOf = (uid: string, row: string, claim: string) =>
`${uid}-claim-${encodeURIComponent(row)}-${encodeURIComponent(claim)}`
// ── sizes, px ────────────────────────────────────────────────────────────────────
const RULER_H = 28
const OVERVIEW_H = 14
const LINE_H = 22
const MAX_LINES = 4
/** A folded row: one line of its name in the quiet voice of the outline's rows. */
const COLLAPSED_H = 16
const CURVE_H = 40
const OUTLINE_H = 22
const FILM_H = 44
/** Past this many claims in view, a row is painted, not drawn as nodes. */
const MAX_NODES = 300
/** How near (px) a drag's end comes to an edge before it is pulled onto it. */
const SNAP_PX = 7
/** How near (px) a press comes to a claim to land on it: a pin is a hair wide. */
const HIT_PX = 4
/** A press that moves less than this (px) is a press, not a drag. */
const DRAG_PX = 4
/** After the hand moves the view, how long playing leaves it alone, ms. */
const HANDS_OFF_MS = 1800
/** The ruler's type: tabular mono at 10 px, its advance a share of the em. */
const RULER_CHAR_PX = 6.2
/** What a wheel's line is in px, when a mouse reports lines. */
const LINE_PX = 16
/** The zoom a pinch's delta buys: exp(-delta × this). */
const ZOOM_PER_PX = 0.01
const FACE = {
lane: { plain: "bg-foreground/[0.035]", glass: "bg-foreground/[0.07]" },
} as const
type RowItem =
| { kind: "outline"; key: string; level: Level; height: number }
| { kind: "film"; key: string; height: number }
| {
kind: "claims"
key: string
row: TimelineClaimRow
height: number
collapsed: boolean
}
| {
kind: "curve"
key: string
row: TimelineCurveRow
height: number
collapsed: boolean
}
export function Timeline({
label,
rows,
levels,
filmstrip,
onAnswer,
material,
scheme,
className,
}: TimelineProps) {
const m = useMaterial(material)
const timebase = useTimebase()
const duration = useTimebaseState((s) => Math.max(1, s.duration))
const fps = useTimebaseState((s) => s.fps)
const range = useTimebaseState((s) => s.range)
// The instant is never read up here: it changes every frame while it plays, and
// only the playhead (written straight to its element), the ruler's value, the
// overview's line and the readout follow it, each on its own.
const outline = useOutline()
const uid = React.useId()
const minSpan = minSpanOf(fps)
// ── the view, and the width it is drawn at ──
const root = React.useRef<HTMLDivElement>(null)
// Over a picture it is read by the part of it under the timeline.
const [sch, here] = useSurfaceScheme(scheme, root)
const lanes = React.useRef<HTMLDivElement>(null)
const [width, setWidth] = React.useState(0)
React.useEffect(() => {
const el = lanes.current
if (!el) return
const observer = new ResizeObserver(() => setWidth(el.clientWidth))
observer.observe(el)
setWidth(el.clientWidth)
return () => observer.disconnect()
}, [])
const [viewState, setViewState] = React.useState<View | null>(null)
// One object per view, so a render that moved nothing (a drag's selection) hands
// the lanes the same view and they draw nothing again.
const view = React.useMemo(
() => clampView(viewState ?? wholeView(duration), duration, minSpan),
[viewState, duration, minSpan],
)
const live = React.useRef({ view, width, duration, minSpan, fps })
live.current = { view, width, duration, minSpan, fps }
const handsAt = React.useRef(0)
const moveView = React.useCallback(
(next: (v: View) => View, byHand = true) => {
if (byHand) handsAt.current = performance.now()
setViewState((was) => {
const { duration: d, minSpan: min } = live.current
return clampView(next(clampView(was ?? wholeView(d), d, min)), d, min)
})
},
[],
)
// ── session state: the selection, the row in hand, the way back, the shuttle ──
const [selection, setSelection] = React.useState<Selection | null>(null)
// What the last key did, said to a screen reader (a polite live region): an answer
// given, an in or an out set, the way back taken.
const [said, setSaid] = React.useState("")
// The ink a painted row draws in: the timeline's own text colour, read once per
// scheme (its own, or the page's theme class changing under it), never per paint.
// Only the root's class is watched: a style written on it per frame (a scroll lock,
// a variable) must not cost a style read each time.
const [ink, setInk] = React.useState("")
// biome-ignore lint/correctness/useExhaustiveDependencies: read again when the scheme or the material changes the ink, not only on mount
React.useLayoutEffect(() => {
const el = root.current
if (!el) return
const read = () => setInk(getComputedStyle(el).color)
read()
const theme = new MutationObserver(read)
theme.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
})
return () => theme.disconnect()
}, [sch, m])
const [rowAt, setRowAt] = React.useState(0)
const [back, setBack] = React.useState<number | null>(null)
const [rate, setRate] = React.useState(0)
const [collapsed, setCollapsed] = React.useState<ReadonlySet<string>>(
() => new Set(),
)
// ── the rows, as laid out ──
const shownLevels = React.useMemo(
() =>
[...outline, ...(levels ?? [])].filter(
(l) => l.divisions.length > 1 || !!l.divisions[0]?.title,
),
[outline, levels],
)
const packed = React.useMemo(
() =>
new Map<string, Packed>(
rows
.filter((r): r is TimelineClaimRow => r.kind === "claims")
.map((r) => [
r.id,
{ index: indexClaims(r.claims), ...pack(r.claims) },
]),
),
[rows],
)
const items: RowItem[] = React.useMemo(
() => [
...shownLevels.map(
(level): RowItem => ({
kind: "outline",
key: `outline:${level.name}`,
level,
height: OUTLINE_H,
}),
),
...(filmstrip
? [{ kind: "film", key: "film", height: FILM_H } as RowItem]
: []),
...rows.map((row): RowItem => {
const folded = collapsed.has(row.id)
if (row.kind === "curve")
return {
kind: "curve",
key: row.id,
row,
collapsed: folded,
height: folded ? COLLAPSED_H : CURVE_H,
}
const lines = packed.get(row.id)?.lines ?? 1
return {
kind: "claims",
key: row.id,
row,
collapsed: folded,
height: folded ? COLLAPSED_H : Math.min(MAX_LINES, lines) * LINE_H,
}
}),
],
[shownLevels, filmstrip, rows, collapsed, packed],
)
const focusRow = rows[Math.min(rowAt, rows.length - 1)]
// How many of each claim row's claims the view holds: said with the row's name.
const inViewCount = React.useMemo(
() => new Map([...packed].map(([id, p]) => [id, countIn(p.index, view)])),
[packed, view],
)
// Every instant a hand aims for: claims' edges, the outline's divisions.
const edges = React.useMemo(() => {
const all = [
...rows.flatMap((r) => (r.kind === "claims" ? edgesOf(r.claims) : [])),
...shownLevels.flatMap((l) => l.divisions.map((d) => d.start)),
0,
duration,
]
return [...new Set(all)].sort((a, b) => a - b)
}, [rows, shownLevels, duration])
const rowEdges = React.useMemo(
() => (focusRow?.kind === "claims" ? edgesOf(focusRow.claims) : edges),
[focusRow, edges],
)
// ── the playhead, the hand's line and the way back: written, never re-rendered ──
const playhead = React.useRef<HTMLDivElement>(null)
const shuttling = React.useRef(false)
React.useEffect(() => {
let last = { at: timebase.get().at, wall: performance.now() }
const place = () => {
const s = timebase.get()
const { view: v, width: w, duration: d } = live.current
const el = playhead.current
if (el && w > 0) {
const x = xOf(v, w, s.at)
el.style.transform = `translateX(${x}px)`
el.style.opacity = x < -1 || x > w + 1 ? "0" : "1"
}
const now = performance.now()
// A big move leaves a way back: the instant it left.
if (
s.preview === null &&
!shuttling.current &&
isJump(last.at, s.at, now - last.wall, s.playing ? s.rate : 0)
)
setBack(last.at)
if (s.preview === null) last = { at: s.at, wall: now }
// Playing on past the view's edge turns the page, unless a hand just moved it.
if (
s.playing &&
now - handsAt.current > HANDS_OFF_MS &&
(s.at > v.to || s.at < v.from)
)
moveView((view) => viewFollowing(view, s.at, d), false)
}
place()
return timebase.subscribe(place)
}, [timebase, moveView])
// The playhead follows a view or width change too.
// biome-ignore lint/correctness/useExhaustiveDependencies: placed again on each view
React.useLayoutEffect(() => {
const el = playhead.current
if (!el || width === 0) return
const x = xOf(view, width, timebase.get().at)
el.style.transform = `translateX(${x}px)`
el.style.opacity = x < -1 || x > width + 1 ? "0" : "1"
}, [view.from, view.to, width, timebase])
// ── the shuttle: forwards through the timebase's own speed, backwards by seeking ──
React.useEffect(() => {
const { controls } = timebase
shuttling.current = rate !== 0
if (rate === 0) return
if (rate > 0 && controls.setRate) {
controls.setRate(rate, { remember: false, pitch: "follows" })
controls.play()
return () => controls.setRate?.(1, { remember: false })
}
controls.pause()
let frame = 0
let was = performance.now()
// Its own position, moved by the clock and sought to: never read back from the
// timebase, whose presented frame lands at or before a seek's instant (a decoder
// shows what it has), so reading it back would run away backwards.
let pos = timebase.get().at
const tick = (now: number) => {
const { duration: end } = timebase.get()
pos += Math.max(0, now - was) * rate
was = now
if (pos <= 0 || pos >= end) {
controls.seek(Math.min(Math.max(0, pos), end))
setRate(0)
return
}
controls.seek(pos)
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [rate, timebase])
// A pause from anywhere else (the bar, space) ends a forward shuttle: a fall from
// playing to paused, never "not playing yet" (the play a shuttle asks for lands a
// frame or more after it).
React.useEffect(() => {
if (rate <= 0) return
let was = timebase.get().playing
return timebase.subscribe(() => {
const now = timebase.get().playing
if (was && !now) setRate(0)
was = now
})
}, [rate, timebase])
// ── the hand on the rows ──
const xToMs = (clientX: number) => {
const r = lanes.current?.getBoundingClientRect()
if (!r) return 0
return instantAt(view, Math.max(1, r.width), clientX - r.left)
}
const snap = (ms: number, free: boolean) => {
if (free) return onFrame(ms, fps)
const candidates = [...edges, timebase.get().at].sort((a, b) => a - b)
const snapped = snapTo(ms, candidates, view, Math.max(1, width), SNAP_PX)
return snapped.snapped ? snapped.at : onFrame(snapped.at, fps)
}
// Skimming: a hand resting on the rows shows that instant, while it is paused; a
// preview holds the clock, and playing on under a resting hand is what was asked.
const intent = useHoverIntent(
(p) => {
if (!timebase.get().playing) timebase.controls.preview(xToMs(p.clientX))
},
() => timebase.controls.endPreview(),
)
// What a press lands on, from the layout the timeline itself made (the ruler, then
// each row's height, each claim row's lines) and the rows' own index, never from
// the element under the hand: a row painted on a canvas answers as one of nodes. An
// instant (x), a row (y), then the index's binary search for the claims near it.
const hitAt = (clientX: number, clientY: number): Selection | null => {
const r = lanes.current?.getBoundingClientRect()
if (!r) return null
const ms = xToMs(clientX)
const y = clientY - r.top
let top = RULER_H
for (const item of items) {
if (y < top) return null
if (y >= top + item.height) {
top += item.height
continue
}
if (item.kind === "outline") {
const d = divisionSpans(item.level, duration).find(
(d) => d.start <= ms && ms < d.end,
)
return d ? { from: d.start, to: d.end } : null
}
if (item.kind !== "claims") return null
const p = packed.get(item.row.id)
if (!p) throw new Error(`timeline: row ${item.row.id} was never packed`)
const lines = item.collapsed ? 1 : p.lines
const claim = claimAt(
p.index,
ms,
HIT_PX * msPerPx(view, Math.max(1, width)),
item.collapsed
? undefined
: {
of: (id) => p.line.get(id) ?? 0,
at: Math.min(
lines - 1,
Math.floor((y - top) / (item.height / lines)),
),
},
)
return claim
? { ...spanOf(claim.when), claim: { row: item.row.id, id: claim.id } }
: null
}
return null
}
const drag = React.useRef<{
x: number
from: number
moved: boolean
claim: Selection | null
scrub: boolean
} | null>(null)
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return
intent.leave()
// A press in the timeline gives it the keys: the timeline itself holds the focus
// (nothing inside the lanes can take it).
root.current?.focus({ preventScroll: true })
const r = lanes.current?.getBoundingClientRect()
const onRuler = !!r && e.clientY - r.top < RULER_H
e.currentTarget.setPointerCapture(e.pointerId)
drag.current = {
x: e.clientX,
from: snap(xToMs(e.clientX), e.altKey),
moved: false,
claim: onRuler ? null : hitAt(e.clientX, e.clientY),
scrub: onRuler,
}
if (onRuler) timebase.controls.seek(onFrame(xToMs(e.clientX), fps))
}
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current
if (!d) return intent.move(e)
if (d.scrub) return timebase.controls.seek(onFrame(xToMs(e.clientX), fps))
if (!d.moved && Math.abs(e.clientX - d.x) < DRAG_PX) return
d.moved = true
const to = snap(xToMs(e.clientX), e.altKey)
setSelection({ from: Math.min(d.from, to), to: Math.max(d.from, to) })
}
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
const d = drag.current
drag.current = null
if (!d || d.scrub) return
e.currentTarget.releasePointerCapture(e.pointerId)
if (d.moved) return
// A press: on a claim selects it and goes to its start (an outline's division as
// a span, a claim by its id, a pin too); elsewhere goes there.
if (d.claim) {
choose(d.claim)
timebase.controls.seek(d.claim.from)
} else timebase.controls.seek(d.from)
}
// A claim (or a division) chosen, by a press or by X: selected, and its row the row
// in hand, so the claim is the timeline's active descendant and is heard by name.
const choose = (chosen: Selection) => {
setSelection(chosen.claim || chosen.to > chosen.from ? chosen : null)
const row = chosen.claim?.row
if (row !== undefined) setRowAt(rows.findIndex((r) => r.id === row))
}
// ── the wheel: a pinch zooms at the hand, a sideways swipe pans ──
React.useEffect(() => {
const el = lanes.current
if (!el) return
const onWheel = (e: WheelEvent) => {
const unit = e.deltaMode === 1 ? LINE_PX : 1
const dx = e.deltaX * unit
const dy = e.deltaY * unit
const { view: v, width: w } = live.current
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const r = el.getBoundingClientRect()
const anchor = instantAt(v, Math.max(1, r.width), e.clientX - r.left)
moveView((view) =>
zoomAt(
view,
anchor,
Math.exp(-dy * ZOOM_PER_PX),
live.current.duration,
live.current.minSpan,
),
)
return
}
const sideways = e.shiftKey
? dy || dx
: Math.abs(dx) > Math.abs(dy)
? dx
: 0
// Up and down stay the page's.
if (!sideways) return
e.preventDefault()
moveView((view) =>
panBy(view, sideways * msPerPx(v, w), live.current.duration),
)
}
el.addEventListener("wheel", onWheel, { passive: false })
return () => el.removeEventListener("wheel", onWheel)
}, [moveView])
// ── the keys: an editor's, while the timeline has focus ──
const kHeld = React.useRef(false)
const answer = (row: TimelineRow | undefined, n: number) => {
if (row?.kind !== "claims" || !row.answers || !onAnswer) return false
const text = row.answers[n - 1]
if (!text) return false
const when: When =
selection && selection.to > selection.from
? { from: selection.from, to: selection.to }
: { at: onFrame(timebase.get().at, fps) }
onAnswer({ row: row.id, when, answer: text })
setSaid(`${row.label}: ${text}, ${whenWords(when, fps)}`)
return true
}
// One table drives it (lib/timeline-shuttle's EDIT_KEYS, `resolveEdit`): a key not
// in it is never the timeline's, and a press a control inside it owns (a zoom
// button's Space, a field's letters, a claim's own button) is that control's
// (key-router's `ownedHere`, counted up to the timeline itself, which holds the
// focus).
const onKey = React.useEffectEvent((e: KeyboardEvent) => {
if (ownedHere(e, root.current)) return false
const hit = resolveEdit(e, kHeld.current)
if (!hit) return false
// Held, a key that keeps going goes on (the arrows, the zoom, a frame step under
// K); any other is one press: a shuttle speeds up by pressing again, never by
// holding.
if (e.repeat && !hit.row.repeats) return true
const { controls } = timebase
const s = timebase.get()
const frame = fps ? 1000 / fps : 100
const seekTo = (ms: number) =>
controls.seek(Math.min(duration, Math.max(0, onFrame(ms, fps))))
const dirOf = (back: EditAction) => (hit.action === back ? -1 : 1)
switch (hit.action) {
case "shuttle-back":
case "shuttle-forward":
setRate((r) => shuttle(r, hit.action === "shuttle-back" ? "j" : "l"))
return true
case "held-back":
case "held-forward": {
const dir = dirOf("held-back")
if (fps) controls.step(dir)
else controls.jump(dir * 100)
return true
}
case "stop":
kHeld.current = true
setRate(0)
controls.pause()
return true
case "toggle":
setRate(0)
controls.toggle()
return true
case "extend-back":
case "extend-forward": {
const dir = dirOf("extend-back")
const anchor = selection ? selection.from : s.at
const end = selection ? selection.to + dir * frame : s.at + dir * frame
setSelection({ from: Math.min(anchor, end), to: Math.max(anchor, end) })
return true
}
case "edge-back":
case "edge-forward": {
const edge = nextEdge(rowEdges, s.at, dirOf("edge-back"))
if (edge !== null) seekTo(edge)
return true
}
case "frame-back":
case "frame-forward": {
const dir = dirOf("frame-back")
if (fps && !s.playing) controls.step(dir)
else seekTo(s.at + dir * frame)
return true
}
case "row-up":
case "row-down":
setRowAt((i) =>
Math.min(
Math.max(0, rows.length - 1),
Math.max(0, i + dirOf("row-up")),
),
)
return true
case "in":
case "out": {
const here = onFrame(s.at, fps)
const from = hit.action === "in" ? here : (selection?.from ?? 0)
const to = hit.action === "out" ? here : (selection?.to ?? duration)
const next = { from: Math.min(from, to), to: Math.max(from, to) }
setSelection(next)
setSaid(`${hit.action}: ${whenWords(next, fps)}`)
return true
}
case "select-claim": {
// The claim under the playhead, found by the index (the row in hand's, else
// any claim row's), and chosen by its id: it becomes the active descendant,
// heard by its name.
const pool =
focusRow?.kind === "claims"
? [focusRow]
: rows.filter((r): r is TimelineClaimRow => r.kind === "claims")
for (const r of pool) {
const p = packed.get(r.id)
const under = p && claimAt(p.index, s.at, 0)
if (under && !isInstant(under.when)) {
choose({
...spanOf(under.when),
claim: { row: r.id, id: under.id },
})
return true
}
}
return true
}
case "repeat":
if (s.range) {
controls.setRange(null)
setSaid("repeat off")
} else if (selection && selection.to > selection.from) {
controls.setRange({ from: selection.from, to: selection.to })
controls.seek(selection.from, { play: true })
setSaid(`repeating ${whenWords(selection, fps)}`)
}
return true
case "go-back": {
const way = goBack(back, s.at)
if (way) {
setBack(way.back)
controls.seek(way.to)
moveView((v) => viewFollowing(v, way.to, duration), false)
setSaid(`back ${whenWords({ at: way.to }, fps)}`)
}
return true
}
case "zoom-in":
case "zoom-out":
moveView((v) =>
zoomAt(
v,
s.at,
hit.action === "zoom-out" ? 0.5 : 2,
duration,
minSpan,
),
)
return true
case "whole":
moveView(() =>
selection && selection.to > selection.from
? viewAround(selection, duration, minSpan)
: wholeView(duration),
)
return true
case "clear":
if (!selection && !s.range) return false
setSelection(null)
if (s.range) controls.setRange(null)
setSaid("selection cleared")
return true
case "answer":
return answer(focusRow, Number(capOf(e)))
}
})
React.useEffect(() => {
const el = root.current
if (!el) return
// On the timeline itself, so a press handled here never reaches the page's keys
// (the watching player's, on the key router).
const down = (e: KeyboardEvent) => {
if (onKey(e)) {
e.preventDefault()
e.stopPropagation()
}
}
// K held makes J and L frame steps until it is let go, or until the window loses
// the keyboard (⌘Tab away with K down never sends its keyup here).
const up = (e: KeyboardEvent) => {
if (capOf(e).toLowerCase() === "k") kHeld.current = false
}
const lost = () => {
kHeld.current = false
}
el.addEventListener("keydown", down)
el.addEventListener("keyup", up)
window.addEventListener("blur", lost)
return () => {
el.removeEventListener("keydown", down)
el.removeEventListener("keyup", up)
window.removeEventListener("blur", lost)
}
}, [])
const toggle = (id: string) =>
setCollapsed((was) => {
const next = new Set(was)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
const lane = FACE.lane[m]
const repeating =
!!range &&
!!selection &&
range.from === selection.from &&
range.to === selection.to
const chosen = selection?.claim
// The active descendant: the claim chosen in the row in hand, else that row.
const active =
chosen && chosen.row === focusRow?.id
? chosenIdOf(uid, chosen.row, chosen.id)
: focusRow
? `${uid}-row-${rows.indexOf(focusRow)}`
: undefined
return (
// THE TIMELINE IS ONE WIDGET with its own keys (J K L, I O, the numbers), so it
// is the focus itself (`application`: a screen reader hands it the keys), not a
// ruler posing as a slider. Its active descendant is the row in hand, said by
// name with its claims and answers, or the claim chosen in it (a press, or X),
// said by its row's question, answer, when and whose; each row's claims are a
// named list (a painted row says how many it holds and to zoom in). What a key did
// is said in a polite live region.
// Its frame is the one container's (Panel), plain or glass.
<Panel
render={<div />}
ref={here}
material={m}
scheme={sch}
role="application"
aria-roledescription="timeline"
aria-label={label}
aria-describedby={`${uid}-hint`}
aria-keyshortcuts={EDIT_SHORTCUTS}
aria-activedescendant={active}
tabIndex={0}
data-slot="timeline"
className={cn(
"relative isolate flex flex-col gap-2 p-3 text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
className,
)}
>
<p id={`${uid}-hint`} className="sr-only">
an editor: J K L shuttle, arrows step a frame, up and down change the
row, I and O set in and out, X chooses the claim under the playhead, and
a number answers the row in hand.
</p>
<p aria-live="polite" className="sr-only">
{said}
</p>
<div className="flex">
{/* The labels: one per row, the same heights as the rows beside them. */}
<div className="flex w-36 shrink-0 flex-col pr-3">
<div
className="flex items-center gap-0.5"
style={{ height: RULER_H }}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="zoom out"
title="zoom out (-)"
onClick={() =>
moveView((v) =>
zoomAt(v, timebase.get().at, 0.5, duration, minSpan),
)
}
>
<Minus />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="zoom in"
title="zoom in (+)"
onClick={() =>
moveView((v) =>
zoomAt(v, timebase.get().at, 2, duration, minSpan),
)
}
>
<Plus />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="the whole medium"
title="the whole medium (0)"
onClick={() => moveView(() => wholeView(duration))}
>
<Maximize2 />
</Button>
</div>
{items.map((item) => (
<RowLabel
key={item.key}
item={item}
id={
item.kind === "claims" || item.kind === "curve"
? `${uid}-row-${rows.indexOf(item.row)}`
: undefined
}
rows={rows}
inView={
item.kind === "claims"
? (inViewCount.get(item.row.id) ?? 0)
: null
}
focused={
(item.kind === "claims" || item.kind === "curve") &&
item.row.id === focusRow?.id
}
onToggle={toggle}
onFocusRow={(id) => setRowAt(rows.findIndex((r) => r.id === id))}
/>
))}
</div>
{/* The lanes: the ruler, every row, and over them the selection, the
playhead and the hand. */}
<div
ref={lanes}
className="relative min-w-0 flex-1 cursor-text touch-none select-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={() => {
drag.current = null
}}
onPointerLeave={() => {
if (!drag.current) intent.leave()
}}
>
<Ruler view={view} width={width} fps={fps} back={back} />
{width > 0 &&
items.map((item) => (
<Lane
key={item.key}
item={item}
view={view}
width={width}
lane={lane}
picked={
item.kind === "claims" && chosen?.row === item.row.id
? chosen.id
: null
}
uid={uid}
fps={fps}
ink={ink}
filmstrip={filmstrip}
duration={duration}
packed={
item.kind === "claims" ? packed.get(item.row.id) : undefined
}
/>
))}
{selection && width > 0 && (
<div
aria-hidden
data-slot="timeline-selection"
className={cn(
"pointer-events-none absolute bottom-0 border-x border-foreground/70",
repeating ? "bg-foreground/15" : "bg-foreground/[0.08]",
)}
style={{
top: RULER_H,
left: xOf(view, width, selection.from),
width: Math.max(
1,
xOf(view, width, selection.to) -
xOf(view, width, selection.from),
),
}}
/>
)}
<div
ref={playhead}
aria-hidden
data-slot="timeline-playhead"
className="pointer-events-none absolute top-0 bottom-0 left-0 w-px bg-foreground"
>
<span className="absolute -top-0.5 -left-[3.5px] size-2 rotate-45 rounded-[1px] bg-foreground" />
</div>
</div>
</div>
<Overview
view={view}
duration={duration}
onView={(next) => moveView(() => next)}
className="ml-36"
/>
<Readout
fps={fps}
selection={selection}
rate={rate}
repeating={!!range}
row={focusRow}
canAnswer={!!onAnswer}
/>
</Panel>
)
}
/** A row's name and its fold; the answers its number keys give are in the readout
* while it is the row in hand. */
function RowLabel({
item,
id,
rows,
inView,
focused,
onToggle,
onFocusRow,
}: {
item: RowItem
/** Its id as the timeline's active descendant (a row of the consumer's). */
id?: string
/** The consumer's rows, for this one's place among them. */
rows: readonly TimelineRow[]
/** How many of its claims the view holds (a claim row). */
inView: number | null
focused: boolean
onToggle: (id: string) => void
onFocusRow: (id: string) => void
}) {
if (item.kind === "outline" || item.kind === "film")
return (
<div
className="flex items-center truncate font-mono text-[11px] text-foreground/45 lowercase"
style={{ height: item.height }}
>
{item.kind === "film" ? "stills" : item.level.name}
</div>
)
const { row } = item
const count = row.kind === "claims" ? row.claims.length : null
const place = rows.indexOf(row) + 1
// What a screen reader says when this row is the one in hand.
const said = [
`${row.label}, row ${place} of ${rows.length}`,
row.kind === "claims" ? `${inView} of ${count} claims in view` : "a curve",
row.kind === "claims" && row.answers
? `answers: ${row.answers
.slice(0, 9)
.map((a, i) => `${i + 1} ${a}`)
.join(", ")}`
: null,
]
.filter(Boolean)
.join("; ")
return (
<fieldset
id={id}
aria-label={said}
className={cn(
"-ml-1.5 m-0 flex min-w-0 gap-1 rounded-md border-0 p-0 pl-0.5 transition-colors",
item.collapsed ? "items-center text-[11px]" : "items-start text-[13px]",
focused ? "bg-foreground/[0.06] text-foreground" : "text-foreground/70",
)}
style={{ height: item.height }}
>
<button
type="button"
aria-expanded={!item.collapsed}
aria-label={`${item.collapsed ? "open" : "fold"} ${row.label}`}
onClick={() => onToggle(row.id)}
className={cn(
"grid shrink-0 cursor-pointer place-items-center rounded text-foreground/50 hover:bg-foreground/8 hover:text-foreground",
item.collapsed
? "size-4 [&_svg]:size-3"
: "mt-px size-5 [&_svg]:size-3.5",
)}
>
{item.collapsed ? <ChevronRight /> : <ChevronDown />}
</button>
{/* Folded, the row still says which it is, in the outline rows' quiet voice */}
<button
type="button"
onClick={() => onFocusRow(row.id)}
className={cn(
"flex min-w-0 flex-1 cursor-pointer flex-col items-start text-left",
!item.collapsed && "pt-0.5",
)}
>
<span
className={cn(
"w-full truncate lowercase",
item.collapsed && "font-mono text-foreground/45",
)}
title={row.label}
>
{row.label}
{count !== null && (
<span className="ml-1.5 font-mono text-[10px] text-foreground/40">
{count}
</span>
)}
</span>
</button>
</fieldset>
)
}
/** The ruler: the view's divisions, labels that never touch, and where the way back
* is. A drawing: the time is said by the readout under it, the keys are the
* timeline's own. */
function Ruler({
view,
width,
fps,
back,
}: {
view: View
width: number
fps: number | null
back: number | null
}) {
const ticks = React.useMemo(
() =>
width > 0 ? ticksOf(view, width, { fps, charPx: RULER_CHAR_PX }) : [],
[view, width, fps],
)
return (
<div
aria-hidden
data-slot="timeline-ruler"
className="relative cursor-ew-resize overflow-hidden rounded-md"
style={{ height: RULER_H }}
>
{ticks.map((t) => (
<span
key={t.at}
className="absolute bottom-0"
style={{ left: xOf(view, width, t.at) }}
>
<span
className={cn(
"absolute bottom-0 w-px",
t.major ? "h-2.5 bg-foreground/50" : "h-1.5 bg-foreground/25",
)}
/>
{t.label && (
<span className="absolute bottom-3 left-1 whitespace-nowrap font-mono text-[10px] text-foreground/55 tabular-nums">
{t.label}
</span>
)}
</span>
))}
{back !== null && (
<span
title="back to where you were (B)"
className="absolute top-0.5 -ml-[5px] size-0 border-x-[5px] border-x-transparent border-t-[7px] border-t-foreground/55"
style={{ left: xOf(view, width, back) }}
/>
)}
</div>
)
}
/** One row's strip. */
function Lane({
item,
view,
width,
lane,
picked,
uid,
fps,
ink,
filmstrip,
duration,
packed,
}: {
item: RowItem
view: View
width: number
lane: string
/** The claim chosen in this row, by id. */
picked: string | null
/** The timeline's id, the prefix of its active descendant's. */
uid: string
fps: number | null
/** The colour a painted row draws in. */
ink: string
filmstrip?: TimelineFilmstrip
duration: number
packed?: Packed
}) {
const box = "relative overflow-hidden rounded-md"
if (item.kind === "outline")
return (
<div
data-slot="timeline-outline"
className={box}
style={{ height: item.height }}
>
{divisionSpans(item.level, duration).map((d, i) => {
const left = xOf(view, width, d.start)
const right = xOf(view, width, d.end)
if (right < 0 || left > width) return null
return (
<span
key={`${d.start}`}
title={d.title}
className={cn(
"absolute inset-y-0.5 flex items-center overflow-hidden rounded-[4px] px-1.5 text-[11px] text-foreground/75",
i % 2 ? "bg-foreground/[0.10]" : "bg-foreground/[0.06]",
)}
style={{ left: left + 1, width: Math.max(1, right - left - 2) }}
>
<span className="truncate">{d.title}</span>
</span>
)
})}
</div>
)
if (item.kind === "film" && filmstrip)
return (
<Filmstrip
filmstrip={filmstrip}
view={view}
width={width}
duration={duration}
/>
)
if (item.kind === "curve")
return (
<div
data-slot="timeline-curve"
className={cn(box, lane)}
style={{ height: item.height }}
>
<Ridge
points={item.row.points}
view={view}
width={width}
height={item.height}
/>
</div>
)
if (item.kind !== "claims" || !packed) return null
return (
<ClaimLane
row={item.row}
view={view}
width={width}
height={item.height}
collapsed={item.collapsed}
packed={packed}
picked={picked}
uid={uid}
fps={fps}
ink={ink}
className={cn(box, lane)}
/>
)
}
/** Claims as bands and pins, stacked in the lines `pack` gave them; nodes while the
* view holds a few hundred, a painting past that. Memoized, and painted only when
* `repaintOf` says what it shows changed (the claims, the view, its size, its lines,
* the ink of its scheme): a selection dragged over it, a hand skimming it and the
* playhead change none of them, and the claim chosen is drawn over the painting,
* never by painting again. A press on a claim is found by the index (the timeline's
* hit test), so a painted claim is as pressable as a node. For assistive tech the row
* is a named list: its claims while they are nodes, the chosen one alone while it is
* painted, each named by its row's question, its answer, when and whose, the chosen
* one carrying the id the timeline names as its active descendant. */
const ClaimLane = React.memo(function ClaimLane({
row,
view,
width,
height,
collapsed,
packed,
picked,
uid,
fps,
ink,
className,
}: {
row: TimelineClaimRow
view: View
width: number
height: number
collapsed: boolean
packed: Packed
picked: string | null
uid: string
fps: number | null
ink: string
className: string
}) {
const shown = React.useMemo(
() => visibleIn(packed.index, view, msPerPx(view, width) * 4),
[packed.index, view, width],
)
const lineH = collapsed ? height : height / Math.max(1, packed.lines)
const lineOf = (id: string) => (collapsed ? 0 : (packed.line.get(id) ?? 0))
const canvas = React.useRef<HTMLCanvasElement>(null)
const painting = React.useRef<Painting | null>(null)
const painted = shown.length > MAX_NODES
React.useEffect(() => {
const c = canvas.current
if (!painted || !c || !ink) {
painting.current = null
return
}
const next: Painting = {
index: packed.index,
view,
width,
height,
dpr: window.devicePixelRatio || 1,
line: collapsed ? null : packed.line,
lines: collapsed ? 1 : packed.lines,
ink,
}
const todo = repaintOf(painting.current, next)
if (!todo.paint) return
// Setting a canvas's size reallocates (and clears) it: only when it changed.
if (todo.resize) {
c.width = Math.round(width * next.dpr)
c.height = Math.round(height * next.dpr)
}
const ctx = c.getContext("2d")
if (!ctx) throw new Error("timeline: no 2d canvas")
ctx.setTransform(next.dpr, 0, 0, next.dpr, 0, 0)
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = ink
const h = height / next.lines
for (const claim of shown) {
const s = spanOf(claim.when)
const x = xOf(view, width, s.from)
const cw = Math.max(1, xOf(view, width, s.to) - x)
const y = (next.line?.get(claim.id) ?? 0) * h + 1
ctx.globalAlpha = claim.by === "person" ? 0.75 : 0.35
ctx.fillRect(x, y, cw, Math.max(1, h - 2))
}
painting.current = next
}, [painted, packed, view, width, height, collapsed, ink, shown])
const chosen = picked ? packed.index.byId.get(picked) : undefined
// The nodes: what the view holds, or only the chosen claim over a painting; the
// chosen claim always, in view or not, so the active descendant is always there.
const nodes = painted
? chosen
? [chosen]
: []
: chosen && !shown.includes(chosen)
? [...shown, chosen]
: shown
return (
<div data-slot="timeline-claims" className={className} style={{ height }}>
{painted && (
<canvas
ref={canvas}
aria-hidden
className="absolute inset-0 size-full"
/>
)}
<ul
aria-label={`${row.label}: ${shown.length} of ${row.claims.length} claims in view${painted ? ", too many to list; zoom in to list them" : ""}`}
className="absolute inset-0 m-0 list-none p-0"
>
{nodes.map((claim) => {
const s = spanOf(claim.when)
const left = xOf(view, width, s.from)
const top = lineOf(claim.id) * lineH
const words = claimWords(row, claim, fps)
const here = claim === chosen
const named = {
id: here ? chosenIdOf(uid, row.id, claim.id) : undefined,
"aria-label": words,
"aria-current": here || undefined,
title: words,
}
if (painted)
return (
<li
key={claim.id}
{...named}
data-slot="timeline-claim-chosen"
className="pointer-events-none absolute rounded-[3px] ring-2 ring-foreground ring-inset"
style={{
left: left - 1,
width: Math.max(3, xOf(view, width, s.to) - left + 2),
top,
height: Math.max(3, lineH),
}}
/>
)
if (isInstant(claim.when))
return (
<li
key={claim.id}
{...named}
className={cn(
"absolute w-3 -translate-x-1/2 cursor-pointer rounded-[3px]",
here && "ring-2 ring-foreground ring-inset",
)}
style={{ left, top, height: lineH }}
>
<span
className={cn(
"absolute inset-y-0.5 left-1/2 w-0.5 -translate-x-1/2 rounded-full",
claim.by === "person"
? "bg-foreground"
: "bg-foreground/55",
)}
/>
{!collapsed && (
<span
className={cn(
"absolute top-0.5 left-1/2 size-2 -translate-x-1/2 rounded-full",
claim.by === "person"
? "bg-foreground"
: "border border-foreground/70 bg-background",
)}
/>
)}
</li>
)
const w = Math.max(2, xOf(view, width, s.to) - left)
return (
<li
key={claim.id}
{...named}
className={cn(
"absolute flex cursor-pointer items-center overflow-hidden rounded-[4px] px-1.5 text-[11px] leading-none",
claim.by === "person"
? "bg-foreground/75 text-background"
: claim.by === "machine"
? "border border-foreground/55 text-foreground/80"
: "bg-foreground/20 text-foreground/85",
here && "ring-2 ring-foreground ring-inset",
)}
style={{
left,
width: w,
top: top + 1,
height: Math.max(2, lineH - 2),
}}
>
{!collapsed && w > 36 && lineH >= 14 && (
<span aria-hidden className="truncate">
{claim.answer}
</span>
)}
</li>
)
})}
</ul>
</div>
)
})
/** A curve as a ridge: each px column's greatest value, so no peak is lost. */
function Ridge({
points,
view,
width,
height,
}: {
points: readonly CurvePoint[]
view: View
width: number
height: number
}) {
const d = React.useMemo(() => {
const columns = Math.max(1, Math.floor(width))
const cols = decimate(points, view, columns)
if (cols.length === 0) return ""
const y = (v: number) => height - Math.min(1, Math.max(0, v)) * (height - 2)
const top = cols.map((c) => `L ${(c.x / columns) * width} ${y(c.high)}`)
const first = cols[0] as (typeof cols)[number]
const last = cols[cols.length - 1] as (typeof cols)[number]
return `M ${(first.x / columns) * width} ${height} ${top.join(" ")} L ${(last.x / columns) * width} ${height} Z`
}, [points, view, width, height])
return (
<svg
aria-hidden
width={width}
height={height}
className="absolute inset-0 text-foreground"
>
<path d={d} fill="currentColor" fillOpacity={0.4} />
</svg>
)
}
/** Stills along the view, anchored to time: each tile an instant's still, as wide as
* the medium's shape at the row's height or wider, never asking for two stills the
* source cannot tell apart. */
function Filmstrip({
filmstrip,
view,
width,
duration,
}: {
filmstrip: TimelineFilmstrip
view: View
width: number
duration: number
}) {
const every = filmstrip.every ?? 1000
const tilePx = FILM_H * filmstrip.aspect
const per = msPerPx(view, width)
const tileMs = Math.max(every, Math.ceil((tilePx * per) / every) * every)
const first = Math.floor(view.from / tileMs)
const last = Math.ceil(view.to / tileMs)
const tiles: number[] = []
for (let k = first; k <= last; k++)
if (k * tileMs < duration) tiles.push(k * tileMs)
return (
<div
aria-hidden
data-slot="timeline-filmstrip"
className="relative overflow-hidden rounded-md bg-foreground/[0.06]"
style={{ height: FILM_H }}
>
{tiles.map((t) => {
const left = xOf(view, width, t)
const w =
Math.min(
xOf(view, width, Math.min(duration, t + tileMs)),
width + tilePx,
) - left
return (
// biome-ignore lint/performance/noImgElement: a still from the consumer's source, many at once, sized by the strip
<img
key={t}
src={filmstrip.still(t)}
alt=""
draggable={false}
loading="lazy"
decoding="async"
className="absolute inset-y-0 h-full border-background/60 border-r object-cover"
style={{ left, width: Math.max(1, w) }}
/>
)
})}
</div>
)
}
/** The whole medium in small, the view a window on it: drag the window to pan, press
* elsewhere to go there. */
function Overview({
view,
duration,
onView,
className,
}: {
view: View
duration: number
onView: (view: View) => void
className?: string
}) {
const bar = React.useRef<HTMLDivElement>(null)
const grab = React.useRef<{ x: number; from: number } | null>(null)
const span = view.to - view.from
const whole = span >= duration - 1
const msAt = (clientX: number) => {
const r = bar.current?.getBoundingClientRect()
if (!r) return 0
return ((clientX - r.left) / Math.max(1, r.width)) * duration
}
return (
<div
ref={bar}
aria-hidden
data-slot="timeline-overview"
className={cn(
"relative touch-none rounded-full bg-foreground/[0.06]",
whole && "opacity-40",
className,
)}
style={{ height: OVERVIEW_H }}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId)
const ms = msAt(e.clientX)
const inside = ms >= view.from && ms <= view.to
const from = inside ? view.from : ms - span / 2
grab.current = { x: e.clientX, from }
if (!inside) onView({ from, to: from + span })
}}
onPointerMove={(e) => {
const g = grab.current
if (!g) return
const r = bar.current?.getBoundingClientRect()
if (!r) return
const from = g.from + ((e.clientX - g.x) / r.width) * duration
onView({ from, to: from + span })
}}
onPointerUp={() => {
grab.current = null
}}
>
<span
className="absolute inset-y-0 cursor-grab rounded-full border border-foreground/40 bg-foreground/15"
style={{
left: `${(view.from / duration) * 100}%`,
width: `${Math.max(1, (span / duration) * 100)}%`,
}}
/>
<OverviewPlayhead duration={duration} />
</div>
)
}
/** The playhead in the overview: its own follower, the only part redrawn each frame. */
function OverviewPlayhead({ duration }: { duration: number }) {
const at = useTimebaseState((s) => s.at)
return (
<span
className="absolute inset-y-0 w-px bg-foreground"
style={{ left: `${(at / duration) * 100}%` }}
/>
)
}
/** Under it all, in metadata's voice: where the playhead is, the selection, the
* shuttle's speed, and whether it repeats. */
function Readout({
fps,
selection,
rate,
repeating,
row,
canAnswer,
}: {
fps: number | null
selection: Span | null
rate: number
repeating: boolean
row: TimelineRow | undefined
canAnswer: boolean
}) {
const at = useTimebaseState((s) => s.at)
const form = fps ? "timecode" : "clock"
return (
<div
data-slot="timeline-readout"
className="flex flex-wrap items-center gap-x-4 gap-y-1 pl-36 font-mono text-[11px] text-foreground/55 tabular-nums"
>
<span className="text-foreground/80">{timeLabel(at, form, fps)}</span>
{selection && selection.to > selection.from && (
<span>
{timeLabel(selection.from, form, fps)} to{" "}
{timeLabel(selection.to, form, fps)} (
{((selection.to - selection.from) / 1000).toFixed(2)} s)
</span>
)}
{repeating && <span>repeating</span>}
{rate !== 0 && (
<span>
{rate < 0 ? "◀" : "▶"} {Math.abs(rate)}×
</span>
)}
{canAnswer && row?.kind === "claims" && row.answers && (
// The answers the number keys give, here where there is room for every one:
// under the row's name they spilled into the rows below.
<span className="ml-auto flex flex-wrap items-center gap-x-2.5 gap-y-1 text-foreground/45">
<span className="lowercase">{row.label}</span>
{row.answers.slice(0, 9).map((a, i) => (
<span
key={a}
className="flex items-center gap-1 text-foreground/60"
>
<Kbd keys={String(i + 1)} className="text-[10px]" />
{a}
</span>
))}
</span>
)}
</div>
)
}// A timeline's ROWS as data: claims about a medium laid out to be drawn, and the
// arithmetic that keeps thousands of them cheap. Framework-free and pure.
//
// A claim (ui/CLAUDE.md "Time") is a fact about the medium: what is asked (its row's
// question), when (an instant or a span, in the medium's own time), the answer, and
// who said it. `ui` stores none of them: a consumer hands its claims in, and a person
// labelling hands a new one back. How a claim is DRAWN is the timeline's business
// (an instant a pin, a span a band, a curve a ridge); those drawing words are never
// the data's.
//
// visibleIn the claims a view shows, found by binary search over claims kept in
// order of start, one list per length class (each class's longest at
// most twice its shortest), so a long claim begun before the view is
// never missed, one as long as the medium costs one claim and not a
// walk from the first, and a view over 10 000 claims reads only the
// handful it shows.
// pack overlapping spans into stacked lines, each claim the first line free
// at its start: no two bands in one line ever overlap.
// decimate a curve down to the width it is drawn at: each px column's least and
// greatest value, so no peak is lost however many samples it had.
// edges the instants a hand aims for in a row: every claim's ends, sorted,
// what the playhead and a selection snap to.
// claimAt the claim a hand lands on, read from the index (by instant and line),
// never from what the page drew: a row painted on a canvas has no nodes
// to ask, and its claims are pressable all the same.
// repaintOf what a canvas showing a row must do for the next painting: reallocate
// only when its device size changed, paint only when something it shows
// changed (the claims, the view, the size, the lines, the ink).
import type { Span } from "@/registry/base-nova/lib/clip"
import type { View } from "@/registry/base-nova/lib/timeline-view"
function assert(ok: unknown, why: string): asserts ok {
if (!ok) throw new Error(`timeline-rows: ${why}`)
}
/** When a claim holds: an instant (`at`) or a span (`from`, `to`), ms. A claim about
* the whole medium is its whole span. */
export type When = { at: number } | Span
export const isInstant = (when: When): when is { at: number } => "at" in when
/** The span a `when` covers: an instant's is itself. */
export const spanOf = (when: When): Span =>
isInstant(when) ? { from: when.at, to: when.at } : when
/** One fact about the medium on a row: when, its answer in words (what a band or a
* pin is called), and whose it is: a person's drawn solid, a machine's outlined. */
export interface TimelineClaim {
id: string
when: When
answer?: string
by?: "person" | "machine"
}
/** Claims of one length class, in order of start: the longest at most twice the
* shortest (instants and claims under 2 ms are one class). */
interface LengthClass {
longest: number
claims: readonly TimelineClaim[]
starts: readonly number[]
}
/** A row's claims in the order the arithmetic needs. Built once per row, never per
* frame. */
export interface ClaimIndex {
/** Every claim, in order of start. */
claims: readonly TimelineClaim[]
/** The claims by how long they hold, each class in order of start. No claim of a
* class starts more than its `longest` before an instant and still reaches it, so a
* view begins each class by binary search and walks past only claims that end
* within one class length before it. */
classes: readonly LengthClass[]
/** Each claim by its id: a selection names its claim, never its span. */
byId: ReadonlyMap<string, TimelineClaim>
}
const lengthClass = (ms: number) => (ms < 2 ? 0 : Math.floor(Math.log2(ms)))
export function indexClaims(claims: readonly TimelineClaim[]): ClaimIndex {
const sorted = [...claims].sort(
(a, b) => spanOf(a.when).from - spanOf(b.when).from,
)
const byClass = new Map<number, TimelineClaim[]>()
for (const c of sorted) {
const s = spanOf(c.when)
assert(s.to >= s.from, `${c.id} ends before it starts`)
const k = lengthClass(s.to - s.from)
const members = byClass.get(k)
if (members) members.push(c)
else byClass.set(k, [c])
}
const byId = new Map(sorted.map((c) => [c.id, c]))
assert(byId.size === sorted.length, "two claims share an id")
return {
claims: sorted,
classes: [...byClass.values()].map((members) => ({
longest: members.reduce((most, c) => {
const s = spanOf(c.when)
return Math.max(most, s.to - s.from)
}, 0),
claims: members,
starts: members.map((c) => spanOf(c.when).from),
})),
byId,
}
}
/** Every claim touching `from`..`to`, class by class (in order of start within one). */
function eachIn(
index: ClaimIndex,
from: number,
to: number,
visit: (claim: TimelineClaim) => void,
): void {
for (const k of index.classes) {
// The first claim that could reach `from`: any before it ended before.
const earliest = from - k.longest
let lo = 0
let hi = k.starts.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if ((k.starts[mid] as number) < earliest) lo = mid + 1
else hi = mid
}
for (let i = lo; i < k.claims.length; i++) {
if ((k.starts[i] as number) > to) break
const c = k.claims[i] as TimelineClaim
if (spanOf(c.when).to >= from) visit(c)
}
}
}
/** The claim a hand at `ms` lands on: of those within `slopMs` of it (a pin is a hair
* wide, so a hand needs room either side), on line `line` when the row is stacked
* (`line.of` says each claim's), the most specific: a span holding `ms` before
* anything only near it, the shortest such span first; else the nearest by its nearer
* end. Read from the index, so a row drawn as a painting answers as one drawn as
* nodes, and a press reads only the claims near it. Null when no claim is near. */
export function claimAt(
index: ClaimIndex,
ms: number,
slopMs: number,
line?: { of: (id: string) => number; at: number },
): TimelineClaim | null {
assert(slopMs >= 0, `slop ${slopMs}`)
let best: TimelineClaim | null = null
let bestScore = Number.POSITIVE_INFINITY
eachIn(index, ms - slopMs, ms + slopMs, (c) => {
if (line && line.of(c.id) !== line.at) return
const s = spanOf(c.when)
const holds = !isInstant(c.when) && s.from <= ms && ms <= s.to
// Holding ranks below every near miss: a duration is finite, so the offset keeps
// any holding span ahead of any claim merely near.
const score = holds
? s.to - s.from
: Number.MAX_SAFE_INTEGER / 2 +
Math.min(Math.abs(ms - s.from), Math.abs(ms - s.to))
if (score < bestScore) {
best = c
bestScore = score
}
})
return best
}
/** The claims touching the view (with `marginMs` either side), in order of start. */
export function visibleIn(
index: ClaimIndex,
view: View,
marginMs = 0,
): TimelineClaim[] {
const out: TimelineClaim[] = []
eachIn(index, view.from - marginMs, view.to + marginMs, (c) => out.push(c))
return out.sort((a, b) => spanOf(a.when).from - spanOf(b.when).from)
}
/** How many claims touch the view: `visibleIn`'s length, nothing gathered. */
export function countIn(index: ClaimIndex, view: View): number {
let n = 0
eachIn(index, view.from, view.to, () => {
n++
})
return n
}
/** What a row painted on a canvas shows, all of it: two equal paintings are the same
* pixels. `line` is null when the row is folded to one line. */
export interface Painting {
index: ClaimIndex
view: View
/** css px. */
width: number
height: number
/** Device px per css px. */
dpr: number
line: ReadonlyMap<string, number> | null
lines: number
/** The colour it is painted in, as the page computes it. */
ink: string
}
/** What a canvas showing `was` must do to show `next`: `resize` (reallocate, which
* also clears it) only when its device size changed, `paint` only when anything it
* shows changed. A selection, a hover or a playhead changes none of it. */
export function repaintOf(
was: Painting | null,
next: Painting,
): { resize: boolean; paint: boolean } {
const device = (p: Painting) =>
`${Math.round(p.width * p.dpr)}x${Math.round(p.height * p.dpr)}`
const resize = !was || device(was) !== device(next)
const paint =
resize ||
was.index !== next.index ||
was.view.from !== next.view.from ||
was.view.to !== next.view.to ||
was.width !== next.width ||
was.height !== next.height ||
was.line !== next.line ||
was.lines !== next.lines ||
was.ink !== next.ink
return { resize, paint }
}
/** Overlapping spans stacked into lines: each claim in the first line free at its
* start (instants take no room and share line 0's). Returns each claim's line and how
* many lines the row needs. Order of the input decides nothing: it is laid by start. */
export function pack(claims: readonly TimelineClaim[]): {
line: ReadonlyMap<string, number>
lines: number
} {
const line = new Map<string, number>()
const ends: number[] = []
const sorted = [...claims].sort(
(a, b) => spanOf(a.when).from - spanOf(b.when).from,
)
for (const c of sorted) {
if (isInstant(c.when)) {
line.set(c.id, 0)
continue
}
const s = spanOf(c.when)
let i = ends.findIndex((end) => end <= s.from)
if (i === -1) {
i = ends.length
ends.push(s.to)
} else ends[i] = s.to
line.set(c.id, i)
}
return { line, lines: Math.max(1, ends.length) }
}
/** Every claim's ends in a row, sorted and without repeats: where a hand snaps. */
export function edgesOf(claims: readonly TimelineClaim[]): number[] {
const all = claims.flatMap((c) => {
const s = spanOf(c.when)
return s.from === s.to ? [s.from] : [s.from, s.to]
})
return [...new Set(all)].sort((a, b) => a - b)
}
/** The edge after (`dir` 1) or before (-1) `ms`, or null past the last. */
export function nextEdge(
edges: readonly number[],
ms: number,
dir: 1 | -1,
): number | null {
if (dir === 1) return edges.find((e) => e > ms + 0.5) ?? null
return edges.findLast((e) => e < ms - 0.5) ?? null
}
// ── curves ───────────────────────────────────────────────────────────────────────
/** A curve's sample: its value 0..1 at an instant. */
export interface CurvePoint {
at: number
value: number
}
/** The curve as drawn at `columns` px across the view: each column's least and
* greatest value (`low`, `high`), so a peak one sample wide still reaches its height;
* columns with no sample carry the line across from their neighbours. */
export function decimate(
points: readonly CurvePoint[],
view: View,
columns: number,
): { x: number; low: number; high: number }[] {
assert(columns > 0, `${columns} columns`)
const span = view.to - view.from
const cols: { low: number; high: number }[] = Array.from(
{ length: columns },
() => ({ low: Number.POSITIVE_INFINITY, high: Number.NEGATIVE_INFINITY }),
)
// The samples in view and one either side, so the line runs to both edges.
let lo = 0
let hi = points.length
while (lo < hi) {
const mid = (lo + hi) >> 1
if ((points[mid] as CurvePoint).at < view.from) lo = mid + 1
else hi = mid
}
const first = Math.max(0, lo - 1)
for (let i = first; i < points.length; i++) {
const p = points[i] as CurvePoint
const c = Math.min(
columns - 1,
Math.max(0, Math.floor(((p.at - view.from) / span) * columns)),
)
const col = cols[c] as { low: number; high: number }
col.low = Math.min(col.low, p.value)
col.high = Math.max(col.high, p.value)
if (p.at > view.to) break
}
const out: { x: number; low: number; high: number }[] = []
let last: { low: number; high: number } | null = null
for (let c = 0; c < columns; c++) {
const col = cols[c] as { low: number; high: number }
if (col.high >= col.low) last = col
if (last) out.push({ x: c + 0.5, low: last.low, high: last.high })
}
return out
}
/** The curve's value at `ms`, straight between the samples either side (null outside
* the curve): what a readout under the hand says. */
export function valueAt(
points: readonly CurvePoint[],
ms: number,
): number | null {
if (points.length === 0) return null
const first = points[0] as CurvePoint
const last = points[points.length - 1] as CurvePoint
if (ms < first.at || ms > last.at) return null
let lo = 0
let hi = points.length - 1
while (hi - lo > 1) {
const mid = (lo + hi) >> 1
if ((points[mid] as CurvePoint).at <= ms) lo = mid
else hi = mid
}
const a = points[lo] as CurvePoint
const b = points[hi] as CurvePoint
if (b.at === a.at) return a.value
return a.value + ((ms - a.at) / (b.at - a.at)) * (b.value - a.value)
}
/** Spans where a curve stays at or over `threshold`, shorter gaps than `bridgeMs`
* closed: a curve read as the claims it implies (where the face is, where it is
* loud), named after the curve it came from by the caller. */
export function spansOver(
points: readonly CurvePoint[],
threshold: number,
bridgeMs = 0,
): Span[] {
const out: Span[] = []
let open: number | null = null
let prev: CurvePoint | null = null
for (const p of points) {
if (p.value >= threshold && open === null) open = p.at
if (p.value < threshold && open !== null && prev) {
out.push({ from: open, to: prev.at })
open = null
}
prev = p
}
if (open !== null && prev) out.push({ from: open, to: prev.at })
return out.reduce<Span[]>((acc, s) => {
const before = acc[acc.length - 1]
if (before && s.from - before.to <= bridgeMs) before.to = s.to
else acc.push({ ...s })
return acc
}, [])
}// The EDITING keys of a timeline, as data and pure steps: a separate table from the
// watching player's (ui/player PlayerKeys, where J and L jump), because an editor's J
// K L are a SHUTTLE, the trained keys of every editing suite:
//
// J plays backwards; pressed again, faster (1, 2, 4, 8 times)
// K stops; held with J or L, one frame back or forward per press
// L plays forwards; pressed again, faster
//
// A J while going forwards turns round at the slowest speed, and an L while going
// backwards too: the hand reverses, it never jumps from 8× one way to 8× the other.
//
// And BACK TO WHERE YOU WERE: a big move (a click far along the axis, a jump to a
// claim, a seek from anywhere) leaves the instant it left as `back`, one slot of
// session state, never a claim. Going back swaps: the instant you left becomes the
// place to come back to, so the key toggles between the two. A move counts as big
// past `BIG_MOVE_MS` beyond what playing would have travelled in the same time.
// Framework-free.
import {
ariaKeyshortcuts,
type Press,
pressed,
} from "@/registry/base-nova/lib/keys"
/** The shuttle's speeds, slowest first. */
export const SHUTTLE = [1, 2, 4, 8] as const
/** The rate a press leaves: negative backwards, 0 stopped. */
export function shuttle(rate: number, key: "j" | "k" | "l"): number {
if (key === "k") return 0
const dir = key === "l" ? 1 : -1
const speed = Math.abs(rate)
// Turning round, or starting: the slowest speed in the new direction.
if (rate === 0 || Math.sign(rate) !== dir) return dir * (SHUTTLE[0] as number)
const next = SHUTTLE.find((s) => s > speed) ?? SHUTTLE[SHUTTLE.length - 1]
return dir * (next as number)
}
/** How far past ordinary travel a move must go to leave a way back, ms. */
export const BIG_MOVE_MS = 1500
/** Whether going from `from` to `to` in `elapsedMs` at `rate` is a jump (a seek, not
* playing): further than the clock could have gone, by more than BIG_MOVE_MS. */
export function isJump(
from: number,
to: number,
elapsedMs: number,
rate: number,
): boolean {
const travelled = Math.abs(rate) * Math.max(0, elapsedMs)
return Math.abs(to - from) > travelled + BIG_MOVE_MS
}
/** Going back: where to go, and the new way back (the instant being left). */
export function goBack(
back: number | null,
at: number,
): { to: number; back: number } | null {
return back === null ? null : { to: back, back: at }
}
/** What an editing key asks the timeline to do. */
export type EditAction =
| "shuttle-back"
| "stop"
| "shuttle-forward"
| "held-back"
| "held-forward"
| "toggle"
| "frame-back"
| "frame-forward"
| "edge-back"
| "edge-forward"
| "extend-back"
| "extend-forward"
| "row-up"
| "row-down"
| "in"
| "out"
| "select-claim"
| "repeat"
| "answer"
| "go-back"
| "zoom-in"
| "zoom-out"
| "whole"
| "clear"
/** A row of the editing table: its keys (lib/keys notation, each pressed for the
* action beside it), what they do in words, and how they behave. */
export interface EditKey {
keys: readonly string[]
actions: readonly EditAction[]
does: string
/** Pressed with K held (an editor's frame step). */
held?: "k"
/** Held down, it keeps going (the arrows, the zoom); else a held key is one press. */
repeats?: boolean
/** How a legend writes the keys, where listing them all would not help ("1-9"). */
shown?: string
}
const DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
/** Every editing key, in the order a legend lists them. ONE table: the handler
* resolves through it (`resolveEdit`), a legend reads it, `aria-keyshortcuts` is
* derived from it, so none of them can disagree. */
export const EDIT_KEYS: readonly EditKey[] = [
{
keys: ["j", "k", "l"],
actions: ["shuttle-back", "stop", "shuttle-forward"],
does: "shuttle: back, stop, forward; again, faster",
},
{
keys: ["j", "l"],
actions: ["held-back", "held-forward"],
does: "a frame back, forward (K held)",
held: "k",
repeats: true,
shown: "K J, K L",
},
{ keys: ["space"], actions: ["toggle"], does: "play, pause" },
{
keys: ["left", "right"],
actions: ["frame-back", "frame-forward"],
does: "a frame back, forward (a tenth of a second without frames)",
repeats: true,
},
{
keys: ["alt+left", "alt+right"],
actions: ["edge-back", "edge-forward"],
does: "the previous, next edge in the row",
},
{
keys: ["shift+left", "shift+right"],
actions: ["extend-back", "extend-forward"],
does: "the selection's end a step back, forward",
repeats: true,
},
{
keys: ["up", "down"],
actions: ["row-up", "row-down"],
does: "the row above, below",
},
{
keys: ["i", "o"],
actions: ["in", "out"],
does: "the selection starts, ends here",
},
{
keys: ["x"],
actions: ["select-claim"],
does: "select the claim under the playhead",
},
{
keys: ["r"],
actions: ["repeat"],
does: "repeat the selection, again to stop",
},
{
keys: DIGITS,
actions: DIGITS.map(() => "answer" as const),
does: "answer the row's question for the selection",
shown: "1-9",
},
{ keys: ["b"], actions: ["go-back"], does: "back to where you were" },
{
keys: ["+", "=", "-"],
actions: ["zoom-in", "zoom-in", "zoom-out"],
does: "zoom in, out at the playhead",
repeats: true,
shown: "+ -",
},
{
keys: ["0"],
actions: ["whole"],
does: "the whole medium; with a selection, the selection",
},
{ keys: ["esc"], actions: ["clear"], does: "clear the selection" },
]
for (const row of EDIT_KEYS)
if (row.keys.length !== row.actions.length)
throw new Error(`EDIT_KEYS: ${row.does} has a key without its action`)
/** The editing row and action a press asks for, with K held or not: a row made for K
* held answers only then, and wins then. Null when no editing key is pressed. */
export function resolveEdit(
press: Press,
kHeld: boolean,
): { action: EditAction; row: EditKey } | null {
const rows = kHeld
? [
...EDIT_KEYS.filter((r) => r.held === "k"),
...EDIT_KEYS.filter((r) => !r.held),
]
: EDIT_KEYS.filter((r) => !r.held)
for (const row of rows) {
const i = row.keys.findIndex((k) => pressed(k, press))
if (i >= 0) return { action: row.actions[i] as EditAction, row }
}
return null
}
/** The editing keys as `aria-keyshortcuts` names them (K-held steps are a chord no
* attribute can say, and are left to the legend). */
export const EDIT_SHORTCUTS = ariaKeyshortcuts(
EDIT_KEYS.filter((r) => !r.held).flatMap((r) => r.keys),
)
