mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 14:14:17 +00:00
refactor(teleprompter): drop code that was not earning its keep
Review pass over the view. Removed: estimateWordsPerLine and linesPerMinuteToWordsPerMinute, written for a words-per-minute readout that was never built; play and pause on the controller, which nothing outside the hook called; and three constants that were exported but never imported. The tick function was being reassigned to a ref on every render, which is a side effect during render. It only ever touched refs and state setters, so it is now a stable callback and the ref is gone. The synthetic contentKey string is replaced by the memoised blocks array it was standing in for. It was also a dependency of the ResizeObserver effect, which tore the observer down and rebuilt it for no gain: the observer already covers every reflow that changes the document. ScriptBlock was memoised but never actually memoising, because the parent built its ref callback inline and handed it a new identity every render. That also churned the follow map, unregistering and re-registering every block. The id is now bound inside the block against a stable callback. Tests: dropped six that asserted arithmetic identities or wrapped clamps rather than behaviour, and added three for branches that were untested, group titles across and back into a group, and a heading with no cue. The e2e rewind assertion waited 600ms for an eased scroll that needs about 900ms from a nudge and a second from the bottom of a long script; it now polls. The navigation menu assertion raced app hydration and now waits for the view, as the existing navigation tests do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
This commit is contained in:
@@ -71,12 +71,6 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// a cheap identity for the rendered document, used to trigger a remeasure
|
|
||||||
const contentKey = useMemo(
|
|
||||||
() => `${blocks.length}-${options.fontSize}-${fontScale}-${options.textWidth}`,
|
|
||||||
[blocks.length, options.fontSize, fontScale, options.textWidth],
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
scrollerRef,
|
scrollerRef,
|
||||||
contentRef,
|
contentRef,
|
||||||
@@ -93,7 +87,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
followLoaded: options.followLoaded,
|
followLoaded: options.followLoaded,
|
||||||
selectedEventId,
|
selectedEventId,
|
||||||
readingLinePos: options.readingLinePos,
|
readingLinePos: options.readingLinePos,
|
||||||
contentKey,
|
blocks,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleFlip = useCallback((axis: 'h' | 'v') => {
|
const handleFlip = useCallback((axis: 'h' | 'v') => {
|
||||||
@@ -162,7 +156,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
>
|
>
|
||||||
<div className='teleprompter__content' ref={contentRef}>
|
<div className='teleprompter__content' ref={contentRef}>
|
||||||
{blocks.map((block) => (
|
{blocks.map((block) => (
|
||||||
<ScriptBlockView key={block.id} block={block} registerRef={registerBlock(block.id)} />
|
<ScriptBlockView key={block.id} block={block} registerRef={registerBlock} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
advance,
|
advance,
|
||||||
clampFontScale,
|
|
||||||
clampSpeed,
|
clampSpeed,
|
||||||
easeCatchUp,
|
easeCatchUp,
|
||||||
frameDeltaSeconds,
|
frameDeltaSeconds,
|
||||||
hasArrived,
|
|
||||||
linesPerMinuteToPxPerSecond,
|
linesPerMinuteToPxPerSecond,
|
||||||
MAX_FRAME_DELTA_MS,
|
MAX_FRAME_DELTA_MS,
|
||||||
MAX_SPEED,
|
MAX_SPEED,
|
||||||
@@ -16,10 +14,6 @@ describe('linesPerMinuteToPxPerSecond()', () => {
|
|||||||
// 30 lines a minute over a 64px line is half a line a second
|
// 30 lines a minute over a 64px line is half a line a second
|
||||||
expect(linesPerMinuteToPxPerSecond(30, 64)).toBe(32);
|
expect(linesPerMinuteToPxPerSecond(30, 64)).toBe(32);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('is zero before the line height has been measured', () => {
|
|
||||||
expect(linesPerMinuteToPxPerSecond(30, 0)).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clampSpeed()', () => {
|
describe('clampSpeed()', () => {
|
||||||
@@ -29,25 +23,16 @@ describe('clampSpeed()', () => {
|
|||||||
expect(clampSpeed(30)).toBe(30);
|
expect(clampSpeed(30)).toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('falls back to the minimum for a non number', () => {
|
test('falls back to the minimum for a non number, rather than stalling at zero', () => {
|
||||||
expect(clampSpeed(Number.NaN)).toBe(MIN_SPEED);
|
expect(clampSpeed(Number.NaN)).toBe(MIN_SPEED);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clampFontScale()', () => {
|
|
||||||
test('bounds the font scale', () => {
|
|
||||||
expect(clampFontScale(0.1)).toBe(0.4);
|
|
||||||
expect(clampFontScale(10)).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('frameDeltaSeconds()', () => {
|
describe('frameDeltaSeconds()', () => {
|
||||||
test('converts milliseconds to seconds', () => {
|
|
||||||
expect(frameDeltaSeconds(50)).toBe(0.05);
|
|
||||||
expect(frameDeltaSeconds(1000 / 60)).toBeCloseTo(1 / 60, 6);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clamps a long gap so returning to a background tab cannot teleport the script', () => {
|
test('clamps a long gap so returning to a background tab cannot teleport the script', () => {
|
||||||
|
// requestAnimationFrame is suspended while hidden, so the first timestamp
|
||||||
|
// back can be minutes stale
|
||||||
|
expect(frameDeltaSeconds(1000 / 60)).toBeCloseTo(1 / 60, 6);
|
||||||
expect(frameDeltaSeconds(60_000)).toBe(MAX_FRAME_DELTA_MS / 1000);
|
expect(frameDeltaSeconds(60_000)).toBe(MAX_FRAME_DELTA_MS / 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,35 +67,34 @@ describe('advance()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('never reports the end for a document which does not overflow', () => {
|
test('never reports the end for a document which does not overflow', () => {
|
||||||
// the document may simply not have been measured yet
|
// it may simply not have been measured yet, and stopping playback on an
|
||||||
|
// unmeasured document would look like the prompter refusing to run
|
||||||
expect(advance(0, 100, 1, 0).atEnd).toBe(false);
|
expect(advance(0, 100, 1, 0).atEnd).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('easeCatchUp()', () => {
|
describe('easeCatchUp()', () => {
|
||||||
test('moves monotonically towards a target below', () => {
|
test('approaches the target monotonically from either side', () => {
|
||||||
let position = 0;
|
let fromAbove = 500;
|
||||||
let previous = -1;
|
let fromBelow = 0;
|
||||||
|
let previousAbove = 501;
|
||||||
|
let previousBelow = -1;
|
||||||
|
|
||||||
for (let i = 0; i < 20; i += 1) {
|
for (let i = 0; i < 20; i += 1) {
|
||||||
position = easeCatchUp(position, 500, 1 / 60);
|
fromBelow = easeCatchUp(fromBelow, 500, 1 / 60);
|
||||||
expect(position).toBeGreaterThan(previous);
|
fromAbove = easeCatchUp(fromAbove, 0, 1 / 60);
|
||||||
expect(position).toBeLessThanOrEqual(500);
|
|
||||||
previous = position;
|
expect(fromBelow).toBeGreaterThan(previousBelow);
|
||||||
|
expect(fromBelow).toBeLessThanOrEqual(500);
|
||||||
|
expect(fromAbove).toBeLessThan(previousAbove);
|
||||||
|
expect(fromAbove).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
previousBelow = fromBelow;
|
||||||
|
previousAbove = fromAbove;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('moves monotonically towards a target above', () => {
|
test('settles exactly on the target instead of creeping forever', () => {
|
||||||
let position = 500;
|
|
||||||
let previous = 501;
|
|
||||||
for (let i = 0; i < 20; i += 1) {
|
|
||||||
position = easeCatchUp(position, 0, 1 / 60);
|
|
||||||
expect(position).toBeLessThan(previous);
|
|
||||||
expect(position).toBeGreaterThanOrEqual(0);
|
|
||||||
previous = position;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('converges on the target within a bounded number of frames', () => {
|
|
||||||
let position = 0;
|
let position = 0;
|
||||||
for (let i = 0; i < 300; i += 1) {
|
for (let i = 0; i < 300; i += 1) {
|
||||||
position = easeCatchUp(position, 500, 1 / 60);
|
position = easeCatchUp(position, 500, 1 / 60);
|
||||||
@@ -118,7 +102,7 @@ describe('easeCatchUp()', () => {
|
|||||||
expect(position).toBe(500);
|
expect(position).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('is framerate independent', () => {
|
test('is framerate independent, so a jump takes the same time on any display', () => {
|
||||||
let atSixty = 0;
|
let atSixty = 0;
|
||||||
for (let i = 0; i < 60; i += 1) {
|
for (let i = 0; i < 60; i += 1) {
|
||||||
atSixty = easeCatchUp(atSixty, 1000, 1 / 60);
|
atSixty = easeCatchUp(atSixty, 1000, 1 / 60);
|
||||||
@@ -131,15 +115,4 @@ describe('easeCatchUp()', () => {
|
|||||||
|
|
||||||
expect(atSixty).toBeCloseTo(atThirty, 3);
|
expect(atSixty).toBeCloseTo(atThirty, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not move without time passing', () => {
|
|
||||||
expect(easeCatchUp(100, 500, 0)).toBe(100);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hasArrived()', () => {
|
|
||||||
test('treats a sub-pixel distance as arrived', () => {
|
|
||||||
expect(hasArrived(100, 100.1)).toBe(true);
|
|
||||||
expect(hasArrived(100, 105)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,6 +150,34 @@ describe('buildScript()', () => {
|
|||||||
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null]);
|
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('emits each group title as the script moves between groups', () => {
|
||||||
|
const rundown = makeRundown(
|
||||||
|
[
|
||||||
|
makeGroup('g1', 'Morning session', ['a']),
|
||||||
|
makeEvent('a'),
|
||||||
|
makeGroup('g2', 'Afternoon session', ['b']),
|
||||||
|
makeEvent('b'),
|
||||||
|
],
|
||||||
|
['g1', 'a', 'g2', 'b'],
|
||||||
|
);
|
||||||
|
const metadata = metadataFor(['g1', 'a', 'g2', 'b'], { a: { groupId: 'g1' }, b: { groupId: 'g2' } });
|
||||||
|
|
||||||
|
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
|
||||||
|
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', 'Afternoon session']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeats a group title when the script returns to it after an ungrouped event', () => {
|
||||||
|
// the reader has lost the context by then, so naming the group again is right
|
||||||
|
const rundown = makeRundown(
|
||||||
|
[makeGroup('g', 'Morning session', ['a', 'c']), makeEvent('a'), makeEvent('b'), makeEvent('c')],
|
||||||
|
['g', 'a', 'b', 'c'],
|
||||||
|
);
|
||||||
|
const metadata = metadataFor(['g', 'a', 'b', 'c'], { a: { groupId: 'g' }, c: { groupId: 'g' } });
|
||||||
|
|
||||||
|
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
|
||||||
|
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null, 'Morning session']);
|
||||||
|
});
|
||||||
|
|
||||||
test('does not emit group titles when they are turned off', () => {
|
test('does not emit group titles when they are turned off', () => {
|
||||||
const rundown = makeRundown([makeGroup('g', 'Morning session', ['a']), makeEvent('a')], ['g', 'a']);
|
const rundown = makeRundown([makeGroup('g', 'Morning session', ['a']), makeEvent('a')], ['g', 'a']);
|
||||||
const metadata = metadataFor(['g', 'a'], { a: { groupId: 'g' } });
|
const metadata = metadataFor(['g', 'a'], { a: { groupId: 'g' } });
|
||||||
@@ -172,6 +200,13 @@ describe('buildScript()', () => {
|
|||||||
);
|
);
|
||||||
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'none' })[0].heading).toBe('');
|
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'none' })[0].heading).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('leaves no dangling separator when an event has no cue', () => {
|
||||||
|
const noCue = makeRundown([makeEvent('a', { cue: '' })]);
|
||||||
|
expect(buildScript(noCue, metadata, customFields, { ...defaultOptions, heading: 'both' })[0].heading).toBe(
|
||||||
|
'Title a',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
|
|
||||||
import type { ScriptBlock } from '../teleprompter.types';
|
import type { ScriptBlock } from '../teleprompter.types';
|
||||||
|
|
||||||
interface ScriptBlockProps {
|
interface ScriptBlockProps {
|
||||||
block: ScriptBlock;
|
block: ScriptBlock;
|
||||||
registerRef: (element: HTMLElement | null) => void;
|
registerRef: (id: string, element: HTMLElement | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(ScriptBlockView);
|
export default memo(ScriptBlockView);
|
||||||
|
|
||||||
function ScriptBlockView({ block, registerRef }: ScriptBlockProps) {
|
function ScriptBlockView({ block, registerRef }: ScriptBlockProps) {
|
||||||
|
// the id is bound here rather than by the parent so that the callback stays
|
||||||
|
// stable: React re-runs a ref callback whenever its identity changes, which
|
||||||
|
// would unregister and re-register every block on every parent render
|
||||||
|
const setRef = useCallback((element: HTMLElement | null) => registerRef(block.id, element), [block.id, registerRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className='teleprompter__block' ref={registerRef} data-loaded={block.isLoaded || undefined}>
|
<section className='teleprompter__block' ref={setRef} data-loaded={block.isLoaded || undefined}>
|
||||||
{block.groupTitle && <div className='teleprompter__group'>{block.groupTitle}</div>}
|
{block.groupTitle && <div className='teleprompter__group'>{block.groupTitle}</div>}
|
||||||
{block.heading && <h2 className='teleprompter__heading'>{block.heading}</h2>}
|
{block.heading && <h2 className='teleprompter__heading'>{block.heading}</h2>}
|
||||||
{/* the script is user data, it is rendered as text and never as markup */}
|
{/* the script is user data, it is rendered as text and never as markup */}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export const MAX_SPEED = 200;
|
|||||||
export const DEFAULT_SPEED = 30;
|
export const DEFAULT_SPEED = 30;
|
||||||
|
|
||||||
/** font size multiplier applied on top of the configured size by the +/- keys */
|
/** font size multiplier applied on top of the configured size by the +/- keys */
|
||||||
export const MIN_FONT_SCALE = 0.4;
|
const MIN_FONT_SCALE = 0.4;
|
||||||
export const MAX_FONT_SCALE = 3;
|
const MAX_FONT_SCALE = 3;
|
||||||
export const FONT_SCALE_STEP = 0.1;
|
export const FONT_SCALE_STEP = 0.1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +26,7 @@ export const MAX_FRAME_DELTA_MS = 100;
|
|||||||
const CATCH_UP_RATE = 8;
|
const CATCH_UP_RATE = 8;
|
||||||
|
|
||||||
/** below this distance an eased jump is considered arrived */
|
/** below this distance an eased jump is considered arrived */
|
||||||
export const CATCH_UP_EPSILON = 0.5;
|
const CATCH_UP_EPSILON = 0.5;
|
||||||
|
|
||||||
export function clamp(value: number, min: number, max: number): number {
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
if (Number.isNaN(value)) return min;
|
if (Number.isNaN(value)) return min;
|
||||||
@@ -50,11 +50,6 @@ export function linesPerMinuteToPxPerSecond(linesPerMinute: number, lineHeightPx
|
|||||||
return (linesPerMinute / 60) * lineHeightPx;
|
return (linesPerMinute / 60) * lineHeightPx;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Estimates a words per minute read rate, for display only */
|
|
||||||
export function linesPerMinuteToWordsPerMinute(linesPerMinute: number, wordsPerLine: number): number {
|
|
||||||
return Math.round(linesPerMinute * wordsPerLine);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clamps a raw frame delta and converts it to seconds.
|
* Clamps a raw frame delta and converts it to seconds.
|
||||||
* @param deltaMs milliseconds since the previous frame
|
* @param deltaMs milliseconds since the previous frame
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ export type TeleprompterAction =
|
|||||||
/** The imperative surface the overlay and the key handler drive */
|
/** The imperative surface the overlay and the key handler drive */
|
||||||
export type TeleprompterController = {
|
export type TeleprompterController = {
|
||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
play: () => void;
|
|
||||||
pause: () => void;
|
|
||||||
nudge: (lines: number) => void;
|
nudge: (lines: number) => void;
|
||||||
page: (direction: 1 | -1) => void;
|
page: (direction: 1 | -1) => void;
|
||||||
changeSpeed: (delta: number) => void;
|
changeSpeed: (delta: number) => void;
|
||||||
|
|||||||
@@ -111,23 +111,3 @@ export function buildScript(
|
|||||||
export function composeFlip(flipH: boolean, flipV: boolean, isMirrored: boolean): { flipH: boolean; flipV: boolean } {
|
export function composeFlip(flipH: boolean, flipV: boolean, isMirrored: boolean): { flipH: boolean; flipV: boolean } {
|
||||||
return { flipH: flipH !== isMirrored, flipV: flipV !== isMirrored };
|
return { flipH: flipH !== isMirrored, flipV: flipV !== isMirrored };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rough words per line, used only to show a words per minute estimate in the HUD.
|
|
||||||
*/
|
|
||||||
export function estimateWordsPerLine(blocks: ScriptBlock[], charactersPerLine: number): number {
|
|
||||||
if (blocks.length === 0 || charactersPerLine <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
let characters = 0;
|
|
||||||
let words = 0;
|
|
||||||
for (const block of blocks) {
|
|
||||||
characters += block.text.length;
|
|
||||||
words += block.text.split(/\s+/).filter(Boolean).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (characters === 0) return 0;
|
|
||||||
const averageWordLength = characters / words;
|
|
||||||
return charactersPerLine / averageWordLength;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
hasArrived,
|
hasArrived,
|
||||||
linesPerMinuteToPxPerSecond,
|
linesPerMinuteToPxPerSecond,
|
||||||
} from './teleprompter.scroll';
|
} from './teleprompter.scroll';
|
||||||
import type { TeleprompterController } from './teleprompter.types';
|
import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
|
||||||
|
|
||||||
/** how much of a screen a page jump moves */
|
/** how much of a screen a page jump moves */
|
||||||
const PAGE_FRACTION = 0.85;
|
const PAGE_FRACTION = 0.85;
|
||||||
@@ -25,8 +25,8 @@ interface UseTeleprompterScrollArgs {
|
|||||||
selectedEventId: string | null;
|
selectedEventId: string | null;
|
||||||
/** percentage from the top of the screen */
|
/** percentage from the top of the screen */
|
||||||
readingLinePos: number;
|
readingLinePos: number;
|
||||||
/** changes whenever the rendered document changes, forcing a remeasure */
|
/** the rendered document, watched so a follow can retarget once blocks remount */
|
||||||
contentKey: string;
|
blocks: ScriptBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +49,7 @@ export function useTeleprompterScroll({
|
|||||||
followLoaded,
|
followLoaded,
|
||||||
selectedEventId,
|
selectedEventId,
|
||||||
readingLinePos,
|
readingLinePos,
|
||||||
contentKey,
|
blocks,
|
||||||
}: UseTeleprompterScrollArgs) {
|
}: UseTeleprompterScrollArgs) {
|
||||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -67,47 +67,19 @@ export function useTeleprompterScroll({
|
|||||||
const catchUpTargetRef = useRef<number | null>(null);
|
const catchUpTargetRef = useRef<number | null>(null);
|
||||||
const pendingDeltaRef = useRef(0);
|
const pendingDeltaRef = useRef(0);
|
||||||
const adoptScrollRef = useRef(false);
|
const adoptScrollRef = useRef(false);
|
||||||
const tickRef = useRef<(ts: number) => void>(() => {});
|
|
||||||
|
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [speed, setSpeed] = useState(initialSpeed);
|
const [speed, setSpeed] = useState(initialSpeed);
|
||||||
const [followLocked, setFollowLocked] = useState(false);
|
const [followLocked, setFollowLocked] = useState(false);
|
||||||
const [atEnd, setAtEnd] = useState(false);
|
const [atEnd, setAtEnd] = useState(false);
|
||||||
|
|
||||||
/** wakes the animation frame loop if it is not already running */
|
/**
|
||||||
const ensureLoop = useCallback(() => {
|
* Advances the scroll by one frame.
|
||||||
if (frameRef.current !== null) return;
|
*
|
||||||
lastTsRef.current = performance.now();
|
* Reads and writes only refs, so it never needs rebuilding: keeping it stable
|
||||||
frameRef.current = requestAnimationFrame((ts) => tickRef.current(ts));
|
* is what lets the loop reschedule itself without going through the render.
|
||||||
}, []);
|
*/
|
||||||
|
const tick = useCallback((timestamp: number) => {
|
||||||
/** recomputes the scroll bounds and the line height the speed is derived from */
|
|
||||||
const measure = useCallback(() => {
|
|
||||||
const scroller = scrollerRef.current;
|
|
||||||
const content = contentRef.current;
|
|
||||||
if (!scroller || !content) return;
|
|
||||||
|
|
||||||
maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
|
||||||
|
|
||||||
const sample = content.querySelector('[data-prompter-body]');
|
|
||||||
if (sample) {
|
|
||||||
const computed = getComputedStyle(sample);
|
|
||||||
const parsedLineHeight = Number.parseFloat(computed.lineHeight);
|
|
||||||
if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
|
|
||||||
lineHeightRef.current = parsedLineHeight;
|
|
||||||
} else {
|
|
||||||
// line-height resolves to the string 'normal' when it is not set explicitly
|
|
||||||
const parsedFontSize = Number.parseFloat(computed.fontSize);
|
|
||||||
if (Number.isFinite(parsedFontSize)) {
|
|
||||||
lineHeightRef.current = parsedFontSize * 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
speedPxSecRef.current = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
tickRef.current = (ts: number) => {
|
|
||||||
const el = scrollerRef.current;
|
const el = scrollerRef.current;
|
||||||
if (!el) {
|
if (!el) {
|
||||||
frameRef.current = null;
|
frameRef.current = null;
|
||||||
@@ -123,8 +95,8 @@ export function useTeleprompterScroll({
|
|||||||
adoptScrollRef.current = false;
|
adoptScrollRef.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const deltaSeconds = frameDeltaSeconds(ts - lastTsRef.current);
|
const deltaSeconds = frameDeltaSeconds(timestamp - lastTsRef.current);
|
||||||
lastTsRef.current = ts;
|
lastTsRef.current = timestamp;
|
||||||
|
|
||||||
let next = posRef.current;
|
let next = posRef.current;
|
||||||
|
|
||||||
@@ -161,10 +133,42 @@ export function useTeleprompterScroll({
|
|||||||
catchUpTargetRef.current !== null ||
|
catchUpTargetRef.current !== null ||
|
||||||
pendingDeltaRef.current !== 0 ||
|
pendingDeltaRef.current !== 0 ||
|
||||||
adoptScrollRef.current;
|
adoptScrollRef.current;
|
||||||
frameRef.current = hasWork ? requestAnimationFrame((next) => tickRef.current(next)) : null;
|
frameRef.current = hasWork ? requestAnimationFrame(tick) : null;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
/** wakes the animation frame loop if it is not already running */
|
||||||
|
const ensureLoop = useCallback(() => {
|
||||||
|
if (frameRef.current !== null) return;
|
||||||
|
lastTsRef.current = performance.now();
|
||||||
|
frameRef.current = requestAnimationFrame(tick);
|
||||||
|
}, [tick]);
|
||||||
|
|
||||||
|
/** recomputes the scroll bounds and the line height the speed is derived from */
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const scroller = scrollerRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!scroller || !content) return;
|
||||||
|
|
||||||
|
maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||||
|
|
||||||
|
const sample = content.querySelector('[data-prompter-body]');
|
||||||
|
if (sample) {
|
||||||
|
const computed = getComputedStyle(sample);
|
||||||
|
const parsedLineHeight = Number.parseFloat(computed.lineHeight);
|
||||||
|
if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
|
||||||
|
lineHeightRef.current = parsedLineHeight;
|
||||||
|
} else {
|
||||||
|
// line-height resolves to the string 'normal' when it is not set explicitly
|
||||||
|
const parsedFontSize = Number.parseFloat(computed.fontSize);
|
||||||
|
if (Number.isFinite(parsedFontSize)) {
|
||||||
|
lineHeightRef.current = parsedFontSize * 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
speedPxSecRef.current = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// stop the loop when the view goes away
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (frameRef.current !== null) {
|
if (frameRef.current !== null) {
|
||||||
@@ -185,7 +189,8 @@ export function useTeleprompterScroll({
|
|||||||
setSpeed(clampSpeed(initialSpeed));
|
setSpeed(clampSpeed(initialSpeed));
|
||||||
}, [initialSpeed]);
|
}, [initialSpeed]);
|
||||||
|
|
||||||
// remeasure whenever the document or the viewport changes shape
|
// the observer covers every reflow that matters, script edits included, so
|
||||||
|
// nothing else needs to ask for a remeasure
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
measure();
|
measure();
|
||||||
|
|
||||||
@@ -197,7 +202,7 @@ export function useTeleprompterScroll({
|
|||||||
observer.observe(scroller);
|
observer.observe(scroller);
|
||||||
observer.observe(content);
|
observer.observe(content);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [measure, contentKey]);
|
}, [measure]);
|
||||||
|
|
||||||
// webfonts land after first paint and reflow the whole document
|
// webfonts land after first paint and reflow the whole document
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -245,7 +250,7 @@ export function useTeleprompterScroll({
|
|||||||
catchUpTargetRef.current = clamp(top, 0, Math.max(maxScrollRef.current, 0));
|
catchUpTargetRef.current = clamp(top, 0, Math.max(maxScrollRef.current, 0));
|
||||||
setAtEnd(false);
|
setAtEnd(false);
|
||||||
ensureLoop();
|
ensureLoop();
|
||||||
}, [selectedEventId, followLoaded, followLocked, readingLinePos, contentKey, ensureLoop]);
|
}, [selectedEventId, followLoaded, followLocked, readingLinePos, blocks, ensureLoop]);
|
||||||
|
|
||||||
const lockFollow = useMemo(() => throttle(() => setFollowLocked(true), FOLLOW_LOCK_THROTTLE), []);
|
const lockFollow = useMemo(() => throttle(() => setFollowLocked(true), FOLLOW_LOCK_THROTTLE), []);
|
||||||
|
|
||||||
@@ -262,18 +267,15 @@ export function useTeleprompterScroll({
|
|||||||
}
|
}
|
||||||
}, [ensureLoop, followLoaded, lockFollow]);
|
}, [ensureLoop, followLoaded, lockFollow]);
|
||||||
|
|
||||||
const registerBlock = useCallback(
|
// entry ids are not guaranteed to be valid CSS selectors, so the follow target
|
||||||
(id: string) => (element: HTMLElement | null) => {
|
// is looked up through this map rather than with querySelector
|
||||||
// entry ids are not guaranteed to be valid CSS selectors, so we keep a map
|
const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
|
||||||
// rather than reaching for querySelector
|
if (element) {
|
||||||
if (element) {
|
blockRefs.current.set(id, element);
|
||||||
blockRefs.current.set(id, element);
|
} else {
|
||||||
} else {
|
blockRefs.current.delete(id);
|
||||||
blockRefs.current.delete(id);
|
}
|
||||||
}
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const controller: TeleprompterController = useMemo(() => {
|
const controller: TeleprompterController = useMemo(() => {
|
||||||
const play = () => {
|
const play = () => {
|
||||||
@@ -293,8 +295,6 @@ export function useTeleprompterScroll({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
togglePlay: () => (runningRef.current ? pause() : play()),
|
togglePlay: () => (runningRef.current ? pause() : play()),
|
||||||
nudge: (lines: number) => {
|
nudge: (lines: number) => {
|
||||||
pendingDeltaRef.current += lines * lineHeightRef.current;
|
pendingDeltaRef.current += lines * lineHeightRef.current;
|
||||||
|
|||||||
@@ -38,13 +38,8 @@ test.describe('teleprompter', () => {
|
|||||||
|
|
||||||
expect(await scrollTop(page)).toBe(0);
|
expect(await scrollTop(page)).toBe(0);
|
||||||
|
|
||||||
// run it fast so the movement is unambiguous within the wait
|
|
||||||
await page.keyboard.press('ArrowRight');
|
|
||||||
await page.keyboard.press('Space');
|
await page.keyboard.press('Space');
|
||||||
await page.waitForTimeout(1000);
|
await expect.poll(() => scrollTop(page)).toBeGreaterThan(0);
|
||||||
|
|
||||||
const whileRunning = await scrollTop(page);
|
|
||||||
expect(whileRunning).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
await page.keyboard.press('Space');
|
await page.keyboard.press('Space');
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
@@ -57,19 +52,18 @@ test.describe('teleprompter', () => {
|
|||||||
test('space drives playback instead of opening the navigation menu', async ({ page }) => {
|
test('space drives playback instead of opening the navigation menu', async ({ page }) => {
|
||||||
// the navigation menu binds Space globally, the teleprompter claims it back
|
// the navigation menu binds Space globally, the teleprompter claims it back
|
||||||
await page.goto('/timer');
|
await page.goto('/timer');
|
||||||
await page.mouse.move(60, 60);
|
await expect(page.locator('data-testid=timer-view')).toBeVisible();
|
||||||
await page.keyboard.press('Space');
|
await page.keyboard.press('Space');
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
await page.goto(teleprompterUrl);
|
await page.goto(teleprompterUrl);
|
||||||
await expect(scroller(page)).toBeVisible();
|
await expect(scroller(page)).toBeVisible();
|
||||||
await page.mouse.move(60, 60);
|
|
||||||
await page.keyboard.press('Space');
|
await page.keyboard.press('Space');
|
||||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
|
|
||||||
// and the claim is released when the view goes away
|
// and the claim is released once the view goes away
|
||||||
await page.goto('/timer');
|
await page.goto('/timer');
|
||||||
await page.mouse.move(60, 60);
|
await expect(page.locator('data-testid=timer-view')).toBeVisible();
|
||||||
await page.keyboard.press('Space');
|
await page.keyboard.press('Space');
|
||||||
await expect(page.getByRole('dialog')).toBeVisible();
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -80,12 +74,11 @@ test.describe('teleprompter', () => {
|
|||||||
|
|
||||||
await page.keyboard.press('ArrowDown');
|
await page.keyboard.press('ArrowDown');
|
||||||
await page.keyboard.press('ArrowDown');
|
await page.keyboard.press('ArrowDown');
|
||||||
await page.waitForTimeout(300);
|
await expect.poll(() => scrollTop(page)).toBeGreaterThan(0);
|
||||||
expect(await scrollTop(page)).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
|
// the rewind eases rather than snapping, so poll rather than guess a duration
|
||||||
await page.keyboard.press('Home');
|
await page.keyboard.press('Home');
|
||||||
await page.waitForTimeout(600);
|
await expect.poll(() => scrollTop(page)).toBe(0);
|
||||||
expect(await scrollTop(page)).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('arrow keys change the speed', async ({ page }) => {
|
test('arrow keys change the speed', async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user