/** * Pure scroll arithmetic for the teleprompter. * * Everything here is side effect free so it can be unit tested without a DOM. * The hook that owns the requestAnimationFrame loop is the only caller. */ /** * Lines per minute. * * The default is calibrated against the reading rate rather than picked for * feel: broadcast presenters read at 140-160 words per minute and conference * talent slower still, and at the default column width a line carries a dozen * or so words. Twelve lines per minute lands in that band. The ceiling is set * where the text stops being readable at all, not at the fastest the loop can * physically scroll, so the arrow keys stay useful across their whole range. */ export const MIN_SPEED = 1; export const MAX_SPEED = 40; export const DEFAULT_SPEED = 12; /** how much one speed adjustment moves, shared by the keymap and the overlay */ export const SPEED_STEP = 1; export const SPEED_STEP_COARSE = 5; /** font size multiplier applied on top of the configured size by the +/- keys */ const MIN_FONT_SCALE = 0.4; const MAX_FONT_SCALE = 3; export const FONT_SCALE_STEP = 0.1; /** * requestAnimationFrame is suspended in background tabs, so the timestamp can * jump by minutes when the view becomes visible again. * We clamp the frame delta so a resume can never teleport the script. */ export const MAX_FRAME_DELTA_MS = 100; /** how aggressively an eased jump converges on its target, per second */ const CATCH_UP_RATE = 8; /** below this distance an eased jump is considered arrived */ const CATCH_UP_EPSILON = 0.5; export function clamp(value: number, min: number, max: number): number { if (Number.isNaN(value)) return min; return Math.min(Math.max(value, min), max); } export function clampSpeed(value: number): number { return clamp(value, MIN_SPEED, MAX_SPEED); } export function clampFontScale(value: number): number { return clamp(value, MIN_FONT_SCALE, MAX_FONT_SCALE); } /** * Converts a speed in lines per minute into pixels per second. * Lines per minute is the unit prompter operators think in, and it is * independent of font size, which is why it is what we persist. */ export function linesPerMinuteToPxPerSecond(linesPerMinute: number, lineHeightPx: number): number { return (linesPerMinute / 60) * lineHeightPx; } /** * Clamps a raw frame delta and converts it to seconds. * @param deltaMs milliseconds since the previous frame */ export function frameDeltaSeconds(deltaMs: number): number { if (!Number.isFinite(deltaMs) || deltaMs < 0) return 0; return Math.min(deltaMs, MAX_FRAME_DELTA_MS) / 1000; } /** * Advances the scroll position at a constant rate. * * The position is kept as a float by the caller: at readable prompter speeds the * per frame delta is well under a pixel, so rounding on every frame would stall * the scroll entirely. We return the exact float and let the DOM round on write. */ export function advance( position: number, pxPerSecond: number, deltaSeconds: number, maxScroll: number, ): { position: number; atEnd: boolean } { const next = clamp(position + pxPerSecond * deltaSeconds, 0, Math.max(maxScroll, 0)); // a document which does not overflow is never "at the end": it may simply not // have been measured yet, and stopping playback on that would be wrong return { position: next, atEnd: maxScroll > 0 && next >= maxScroll }; } /** * Moves `current` towards `target` with an exponential ease. * * Framerate independent: the same wall clock duration produces the same curve * regardless of how many frames it was sampled over. */ export function easeCatchUp(current: number, target: number, deltaSeconds: number): number { if (deltaSeconds <= 0) return current; const next = target + (current - target) * Math.exp(-CATCH_UP_RATE * deltaSeconds); return Math.abs(next - target) < CATCH_UP_EPSILON ? target : next; } export function hasArrived(current: number, target: number): boolean { return Math.abs(current - target) < CATCH_UP_EPSILON; }