feat(teleprompter): anchor the read position and step between events

The scroll position was a pixel offset into a document that changes while
it is being read. Growing an event above the reader left scrollTop where it
was, so the text under the reading line slid: measured going from "Lucas
Bennett" to "Lunch" on an unchanged offset of 900.

Hold the position as a place in the script instead — the block under the
reading line plus an offset into it — and re-resolve it to pixels whenever
the document is measured. This follows how professional prompters cue a
story plus an offset rather than a scroll offset, so a reorder carries the
reader with the block and an edit above them changes nothing. A deleted
block falls back to the end of the nearest surviving event before it.

Add event-to-event stepping on Shift with the vertical arrows, matching
Shift being the coarser step on the horizontal ones, and take the step from
where the scroll is headed so pressing again mid-ease moves on rather than
re-aiming at the same event.

Free scrolling and the existing nudge and page keys are unchanged, but they
now hand over the scroll by one rule: how far the reader moved the script
themselves, accumulated. Distance from the follow target could not tell a
catch-up still running from a reader who had moved, so touching the wheel
while the prompter eased towards a newly loaded event stopped it following.
Accumulating also measures a wheel gesture the browser spreads over many
frames, which the old per-event check sampled only once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
This commit is contained in:
Claude
2026-08-24 19:35:12 +00:00
parent 435cac1fcb
commit a8be8ee97c
10 changed files with 483 additions and 123 deletions
@@ -66,23 +66,14 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
showGroups: options.showGroups,
});
const {
scrollerRef,
contentRef,
registerBlock,
handleUserScroll,
controller,
isRunning,
speed,
canReengageFollow,
atEnd,
} = useTeleprompterScroll({
initialSpeed: options.speed,
followLoaded: options.followLoaded,
selectedEventId,
readingLinePos: options.readingLinePos,
blocks,
});
const { scrollerRef, contentRef, registerBlock, controller, isRunning, speed, canReengageFollow, atEnd } =
useTeleprompterScroll({
initialSpeed: options.speed,
followLoaded: options.followLoaded,
selectedEventId,
readingLinePos: options.readingLinePos,
blocks,
});
const handleFlip = (axis: 'h' | 'v') =>
setLive((current) => {
@@ -136,14 +127,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
<EmptyPage text='There is no script text in the selected field' />
) : (
<>
<div
className='teleprompter__scroller'
data-testid='teleprompter-scroller'
ref={scrollerRef}
onWheel={handleUserScroll}
onTouchMove={handleUserScroll}
onPointerDown={handleUserScroll}
>
<div className='teleprompter__scroller' data-testid='teleprompter-scroller' ref={scrollerRef}>
<div className='teleprompter__content' ref={contentRef}>
{blocks.map((block) => (
<ScriptBlockView key={block.id} block={block} registerRef={registerBlock} />
@@ -35,6 +35,17 @@ describe('resolveTeleprompterAction()', () => {
});
});
test('shift makes the vertical arrows jump a whole event', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown', shiftKey: true }))).toEqual({
type: 'jumpEvent',
direction: 1,
});
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowUp', shiftKey: true }))).toEqual({
type: 'jumpEvent',
direction: -1,
});
});
test('page keys jump a screen', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'PageDown' }))).toEqual({ type: 'page', direction: 1 });
expect(resolveTeleprompterAction(makeEvent({ code: 'PageUp' }))).toEqual({ type: 'page', direction: -1 });
@@ -1,16 +1,20 @@
import {
advance,
anchorAtReadPoint,
type BlockGeometry,
clampSpeed,
easeCatchUp,
FOLLOW_BREAK_LINES,
frameDeltaSeconds,
hasBrokenFollow,
indexAtReadPoint,
linesPerMinuteToPxPerSecond,
MAX_FONT_SIZE,
MAX_FRAME_DELTA_MS,
MAX_SPEED,
MIN_FONT_SIZE,
MIN_SPEED,
readPointForAnchor,
stepFontSize,
} from '../teleprompter.scroll';
@@ -101,24 +105,100 @@ describe('hasBrokenFollow()', () => {
test('tolerates drift under the threshold, so momentum or a stray touch does not break it', () => {
const underThreshold = lineHeight * FOLLOW_BREAK_LINES - 1;
expect(hasBrokenFollow(underThreshold, 0, lineHeight)).toBe(false);
expect(hasBrokenFollow(-underThreshold, 0, lineHeight)).toBe(false);
expect(hasBrokenFollow(underThreshold, lineHeight)).toBe(false);
expect(hasBrokenFollow(-underThreshold, lineHeight)).toBe(false);
});
test('counts a deliberate scroll past the threshold, from either direction', () => {
test('counts a deliberate scroll past the threshold, in either direction', () => {
const overThreshold = lineHeight * FOLLOW_BREAK_LINES + 1;
expect(hasBrokenFollow(overThreshold, 0, lineHeight)).toBe(true);
expect(hasBrokenFollow(-overThreshold, 0, lineHeight)).toBe(true);
});
test('measures from the follow target, not from zero', () => {
expect(hasBrokenFollow(1000, 1000, lineHeight)).toBe(false);
expect(hasBrokenFollow(1000 + lineHeight * FOLLOW_BREAK_LINES + 1, 1000, lineHeight)).toBe(true);
expect(hasBrokenFollow(overThreshold, lineHeight)).toBe(true);
expect(hasBrokenFollow(-overThreshold, lineHeight)).toBe(true);
});
test('never breaks follow before the document has been measured', () => {
// an unmeasured line height would make any distance look like a break
expect(hasBrokenFollow(10_000, 0, 0)).toBe(false);
expect(hasBrokenFollow(10_000, 0)).toBe(false);
});
});
describe('the read anchor', () => {
const script: BlockGeometry[] = [
{ id: 'welcome', top: 0, height: 100 },
{ id: 'keynote', top: 100, height: 300 },
{ id: 'lunch', top: 400, height: 100 },
];
const ids = script.map((block) => block.id);
describe('indexAtReadPoint()', () => {
test('finds the block the reading line is over', () => {
expect(indexAtReadPoint(150, script)).toBe(1);
expect(indexAtReadPoint(400, script)).toBe(2);
});
test('clamps past either end, so a jump from there still lands on a block', () => {
expect(indexAtReadPoint(-50, script)).toBe(0);
expect(indexAtReadPoint(10_000, script)).toBe(2);
});
test('reports no block for an empty script', () => {
expect(indexAtReadPoint(0, [])).toBe(-1);
});
});
describe('readPointForAnchor()', () => {
test('round trips an unchanged document', () => {
const anchor = anchorAtReadPoint(250, script);
expect(anchor).toEqual({ blockId: 'keynote', offset: 150 });
expect(readPointForAnchor(anchor!, script, ids)).toBe(250);
});
test('holds the same words under the reading line when an event above grows', () => {
// the whole point: the rundown is edited while it is being read, and an
// edit above the reader moves every pixel below it
const anchor = anchorAtReadPoint(250, script);
const grown: BlockGeometry[] = [
{ id: 'welcome', top: 0, height: 180 },
{ id: 'keynote', top: 180, height: 300 },
{ id: 'lunch', top: 480, height: 100 },
];
expect(readPointForAnchor(anchor!, grown, ids)).toBe(330);
});
test('follows the anchored event when the rundown is reordered', () => {
const anchor = anchorAtReadPoint(250, script);
const reordered: BlockGeometry[] = [
{ id: 'lunch', top: 0, height: 100 },
{ id: 'welcome', top: 100, height: 100 },
{ id: 'keynote', top: 200, height: 300 },
];
expect(readPointForAnchor(anchor!, reordered, ids)).toBe(350);
});
test('stays inside an event which was edited shorter than the read offset', () => {
const anchor = anchorAtReadPoint(250, script);
const trimmed: BlockGeometry[] = [
{ id: 'welcome', top: 0, height: 100 },
{ id: 'keynote', top: 100, height: 40 },
{ id: 'lunch', top: 140, height: 100 },
];
expect(readPointForAnchor(anchor!, trimmed, ids)).toBe(140);
});
test('falls back to the end of the nearest surviving event when the anchored one is deleted', () => {
// where the deleted text used to begin, rather than wherever its pixels
// now happen to point
const anchor = anchorAtReadPoint(250, script);
const deleted: BlockGeometry[] = [
{ id: 'welcome', top: 0, height: 100 },
{ id: 'lunch', top: 100, height: 100 },
];
expect(readPointForAnchor(anchor!, deleted, ids)).toBe(100);
});
test('gives up rather than guessing when nothing before the anchor survives', () => {
const anchor = anchorAtReadPoint(250, script);
expect(readPointForAnchor(anchor!, [{ id: 'lunch', top: 0, height: 100 }], ids)).toBeNull();
});
});
});
@@ -63,6 +63,11 @@ export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
<Separator />
<Combo keys={['PgDn']} />
</Shortcut>
<Shortcut label='Jump to previous / next event'>
<Combo keys={['Shift', '↑']} />
<Separator />
<Combo keys={['Shift', '↓']} />
</Shortcut>
<Shortcut label='Jump to top / end'>
<Combo keys={['Home']} />
<Separator />
@@ -19,10 +19,12 @@ export function resolveTeleprompterAction(event: TeleprompterKeyEvent): Teleprom
switch (event.code) {
case 'Space':
return event.repeat ? null : { type: 'togglePlay' };
// Shift is the coarser step on both axes: a bigger speed change sideways,
// a whole event rather than a line vertically.
case 'ArrowDown':
return { type: 'nudge', lines: 1 };
return event.shiftKey ? { type: 'jumpEvent', direction: 1 } : { type: 'nudge', lines: 1 };
case 'ArrowUp':
return { type: 'nudge', lines: -1 };
return event.shiftKey ? { type: 'jumpEvent', direction: -1 } : { type: 'nudge', lines: -1 };
case 'PageDown':
return { type: 'page', direction: 1 };
case 'PageUp':
@@ -52,11 +52,84 @@ export function easeCatchUp(current: number, target: number, deltaSeconds: numbe
return Math.abs(next - target) < CATCH_UP_EPSILON ? target : next;
}
/** How far, in lines, the reader may drift from the follow target before it counts as taking over. */
/** Where a block sits inside the scrolled content, in layout pixels, ordered top to bottom. */
export type BlockGeometry = { id: string; top: number; height: number };
/**
* The read position expressed as a place in the script rather than an offset
* into the document.
*
* A rundown is edited while it is being read, and an edit above the reader
* moves every pixel below it. Professional prompters cue a story plus an
* offset into it for exactly this reason: the text under the reading line is
* the position, the scroll offset is only how it is currently drawn.
*/
export type ScrollAnchor = { blockId: string; offset: number };
/**
* Index of the block the read point falls in, clamped to the ends of the script.
*
* Scans rather than bisects: this runs once a frame over a show's worth of
* events, and a scan makes no assumption the caller has to keep true.
*/
export function indexAtReadPoint(readPoint: number, blocks: BlockGeometry[]): number {
if (blocks.length === 0) return -1;
// starts on the first block, which is where a read point above the script lands
let index = 0;
for (let i = 1; i < blocks.length; i += 1) {
if (blocks[i].top > readPoint) break;
index = i;
}
return index;
}
export function anchorAtReadPoint(readPoint: number, blocks: BlockGeometry[]): ScrollAnchor | null {
const index = indexAtReadPoint(readPoint, blocks);
if (index === -1) return null;
return { blockId: blocks[index].id, offset: readPoint - blocks[index].top };
}
/**
* The read point which puts an anchored place in the script back under the
* reading line, or null when it cannot be found at all.
*/
export function readPointForAnchor(
anchor: ScrollAnchor,
blocks: BlockGeometry[],
previousOrder: string[],
): number | null {
const match = blocks.find((block) => block.id === anchor.blockId);
if (match) {
// The block may have been edited shorter than the offset into it.
return match.top + Math.min(anchor.offset, match.height);
}
// The anchored event was deleted mid-read. Land on the end of the nearest
// event which preceded it and survives, which is where the deleted text
// used to begin, rather than wherever its pixels now happen to point.
const previousIndex = previousOrder.indexOf(anchor.blockId);
for (let i = previousIndex - 1; i >= 0; i -= 1) {
const survivor = blocks.find((block) => block.id === previousOrder[i]);
if (survivor) return survivor.top + survivor.height;
}
return null;
}
/** How far, in lines, the reader may move the script themselves before it counts as taking over. */
export const FOLLOW_BREAK_LINES = 1.5;
/** Distinguishes a deliberate scroll away from the read position from momentum or a stray touch. */
export function hasBrokenFollow(position: number, followTarget: number, lineHeightPx: number): boolean {
/**
* Distinguishes a deliberate scroll away from the read position from momentum
* or a stray touch.
*
* Takes what the reader moved rather than where the script ended up: while
* following eases towards a newly loaded event, and while playback carries the
* script along, the distance to the target is the prompter's own doing.
*/
export function hasBrokenFollow(readerDriftPx: number, lineHeightPx: number): boolean {
if (lineHeightPx <= 0) return false;
return Math.abs(position - followTarget) > lineHeightPx * FOLLOW_BREAK_LINES;
return Math.abs(readerDriftPx) > lineHeightPx * FOLLOW_BREAK_LINES;
}
@@ -31,6 +31,7 @@ export type TeleprompterAction =
| { type: 'togglePlay' }
| { type: 'nudge'; lines: number }
| { type: 'page'; direction: 1 | -1 }
| { type: 'jumpEvent'; direction: 1 | -1 }
| { type: 'speed'; delta: number }
| { type: 'rewind' }
| { type: 'rewindAndPause' }
@@ -45,6 +46,7 @@ export type TeleprompterController = {
togglePlay: () => void;
nudge: (lines: number) => void;
page: (direction: 1 | -1) => void;
jumpEvent: (direction: 1 | -1) => void;
changeSpeed: (delta: number) => void;
rewind: (alsoPause?: boolean) => void;
jumpToEnd: () => void;
@@ -31,6 +31,8 @@ export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
return controller.nudge(action.lines);
case 'page':
return controller.page(action.direction);
case 'jumpEvent':
return controller.jumpEvent(action.direction);
case 'speed':
return controller.changeSpeed(action.delta);
case 'rewind':
@@ -2,17 +2,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
advance,
anchorAtReadPoint,
type BlockGeometry,
clamp,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
hasBrokenFollow,
indexAtReadPoint,
linesPerMinuteToPxPerSecond,
readPointForAnchor,
type ScrollAnchor,
} from './teleprompter.scroll';
import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
const PAGE_FRACTION = 0.85;
const EXTERNAL_SCROLL_EPSILON = 2;
/** Below this the correction would be invisible and only add jitter. */
const ANCHOR_CORRECTION_EPSILON = 1;
/**
* Uses layout coordinates because client rects include the teleprompter's CSS
@@ -74,9 +81,17 @@ export function useTeleprompterScroll({
const maxScrollRef = useRef(0);
const catchUpTargetRef = useRef<number | null>(null);
const pendingDeltaRef = useRef(0);
// where following last put (or is easing towards putting) the reader, so a
// user scroll can be measured against it rather than breaking on any input
// where following last put (or is easing towards putting) the reader
const followTargetRef = useRef(0);
// how far the reader has moved the script themselves since following last placed it
const readerDriftRef = useRef(0);
// distance from the top of the viewport to the reading line
const readingOffsetRef = useRef(0);
const readingLinePosRef = useRef(readingLinePos);
const selectedEventIdRef = useRef(selectedEventId);
// the script's layout as of the last measure, and the reader's place in it
const geometryRef = useRef<BlockGeometry[]>([]);
const anchorRef = useRef<ScrollAnchor | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(initialSpeed);
@@ -85,50 +100,85 @@ export function useTeleprompterScroll({
const [autoScrollLocked, setAutoScrollLocked] = useState(false);
const [atEnd, setAtEnd] = useState(false);
const tick = useCallback((timestamp: number) => {
const el = scrollerRef.current;
if (!el) return;
const isFollowingRef = useRef(false);
useEffect(() => {
isFollowingRef.current = followLoaded && !autoScrollLocked;
}, [followLoaded, autoScrollLocked]);
// Adopt wheel, touch, scrollbar, browser-clamp and find-in-page changes.
if (Math.abs(el.scrollTop - posRef.current) > EXTERNAL_SCROLL_EPSILON) {
posRef.current = el.scrollTop;
catchUpTargetRef.current = null;
}
const deltaSeconds = frameDeltaSeconds(timestamp - lastTsRef.current);
lastTsRef.current = timestamp;
let next = posRef.current;
if (pendingDeltaRef.current !== 0) {
next += pendingDeltaRef.current;
pendingDeltaRef.current = 0;
catchUpTargetRef.current = null;
}
if (catchUpTargetRef.current !== null) {
next = easeCatchUp(next, catchUpTargetRef.current, deltaSeconds);
if (next === catchUpTargetRef.current) {
catchUpTargetRef.current = null;
}
} else if (runningRef.current) {
const pxPerSecond = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
const result = advance(next, pxPerSecond, deltaSeconds, maxScrollRef.current);
next = result.position;
if (result.atEnd) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
}
}
const clamped = clamp(next, 0, maxScrollRef.current);
if (clamped !== posRef.current) {
posRef.current = clamped;
el.scrollTop = clamped;
/**
* One rule for every input, wheel and keyboard alike: once the reader has
* moved the script far enough themselves, they are driving.
*
* Accumulates, so a gesture the browser spreads over many frames adds up to
* the move it was, and so does a slow drag. Scrolling back where you came
* from cancels out, which is what keeps momentum and a stray touch from
* taking the scroll over.
*/
const addReaderDrift = useCallback((delta: number) => {
if (!isFollowingRef.current) return;
readerDriftRef.current += delta;
if (hasBrokenFollow(readerDriftRef.current, lineHeightRef.current)) {
setAutoScrollLocked(true);
}
}, []);
const tick = useCallback(
(timestamp: number) => {
const el = scrollerRef.current;
if (!el) return;
// Adopt wheel, touch, scrollbar, browser-clamp and find-in-page changes.
// Whatever the position is that the frame loop did not put there is the
// reader's own doing, which makes this the one place a scroll by hand can
// be measured, however the browser chose to deliver it.
const external = el.scrollTop - posRef.current;
if (Math.abs(external) > EXTERNAL_SCROLL_EPSILON) {
addReaderDrift(external);
posRef.current = el.scrollTop;
catchUpTargetRef.current = null;
}
const deltaSeconds = frameDeltaSeconds(timestamp - lastTsRef.current);
lastTsRef.current = timestamp;
let next = posRef.current;
if (pendingDeltaRef.current !== 0) {
next += pendingDeltaRef.current;
pendingDeltaRef.current = 0;
catchUpTargetRef.current = null;
}
if (catchUpTargetRef.current !== null) {
next = easeCatchUp(next, catchUpTargetRef.current, deltaSeconds);
if (next === catchUpTargetRef.current) {
catchUpTargetRef.current = null;
}
} else if (runningRef.current) {
const pxPerSecond = linesPerMinuteToPxPerSecond(speedRef.current, lineHeightRef.current);
const result = advance(next, pxPerSecond, deltaSeconds, maxScrollRef.current);
next = result.position;
if (result.atEnd) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
}
}
const clamped = clamp(next, 0, maxScrollRef.current);
if (clamped !== posRef.current) {
posRef.current = clamped;
el.scrollTop = clamped;
}
// Remember the reader's place in the script, not just in the document, so
// the next measure can put them back if the rundown changed underneath
// them. Reads no layout: the geometry is the one taken at that measure.
anchorRef.current = anchorAtReadPoint(clamped + readingOffsetRef.current, geometryRef.current);
},
[addReaderDrift],
);
useEffect(() => {
lastTsRef.current = performance.now();
let frame = requestAnimationFrame(function loop(timestamp) {
@@ -138,12 +188,20 @@ export function useTeleprompterScroll({
return () => cancelAnimationFrame(frame);
}, [tick]);
/** Where following would put the reader for a block, against the last measure. */
const scrollTargetFor = useCallback((blockId: string): number | null => {
const block = geometryRef.current.find((entry) => entry.id === blockId);
if (!block) return null;
return clamp(block.top - readingOffsetRef.current, 0, maxScrollRef.current);
}, []);
const measure = useCallback(() => {
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) return;
maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
readingOffsetRef.current = (scroller.clientHeight * readingLinePosRef.current) / 100;
const computed = getComputedStyle(content);
const parsedLineHeight = Number.parseFloat(computed.lineHeight);
@@ -153,7 +211,58 @@ export function useTeleprompterScroll({
const parsedFontSize = Number.parseFloat(computed.fontSize);
lineHeightRef.current = Number.isFinite(parsedFontSize) ? parsedFontSize * 1.2 : 0;
}
}, []);
const wasEasingToLoadedEvent = catchUpTargetRef.current === followTargetRef.current;
const previous = geometryRef.current;
const scrollerTop = getLayoutTop(scroller);
const geometry: BlockGeometry[] = [];
blockRefs.current.forEach((element, id) => {
geometry.push({ id, top: getLayoutTop(element) - scrollerTop, height: element.offsetHeight });
});
// registration follows mounting rather than rundown order, so trust layout
geometry.sort((a, b) => a.top - b.top);
geometryRef.current = geometry;
// Put the reader back on the words they were on. Nothing to restore on the
// first measure, when there is no earlier document to have moved.
const anchor = anchorRef.current;
if (anchor && previous.length > 0) {
const readPoint = readPointForAnchor(
anchor,
geometry,
previous.map((block) => block.id),
);
if (readPoint !== null) {
const target = clamp(readPoint - readingOffsetRef.current, 0, maxScrollRef.current);
const delta = target - posRef.current;
if (Math.abs(delta) > ANCHOR_CORRECTION_EPSILON) {
posRef.current = target;
scroller.scrollTop = target;
if (catchUpTargetRef.current !== null) {
catchUpTargetRef.current = clamp(catchUpTargetRef.current + delta, 0, maxScrollRef.current);
}
}
}
}
// The follow target is a position in a document which may have just moved,
// so take it from the new geometry rather than ageing the old value.
const selectedId = selectedEventIdRef.current;
if (selectedId !== null) {
const followTarget = scrollTargetFor(selectedId);
if (followTarget !== null) {
followTargetRef.current = followTarget;
}
}
// Shifting an in-flight ease by the correction is only an estimate. When it
// was heading for the loaded event we know better: send it to where that
// event is now, so following still lands exactly on the reading line.
if (wasEasingToLoadedEvent) {
catchUpTargetRef.current = followTargetRef.current;
}
}, [scrollTargetFor]);
useEffect(() => {
speedRef.current = speed;
@@ -189,6 +298,21 @@ export function useTeleprompterScroll({
};
}, [measure]);
useEffect(() => {
readingLinePosRef.current = readingLinePos;
measure();
}, [readingLinePos, measure]);
/**
* A resize observer sees the document get taller or shorter, but not a
* reorder or a rewrite which happens to leave the height alone, and both
* move the reader's place in the script.
*/
const contentSignature = blocks.map((block) => `${block.id}:${block.text.length}`).join();
useEffect(() => {
measure();
}, [contentSignature, measure]);
useEffect(() => {
const onVisibilityChange = () => {
lastTsRef.current = performance.now();
@@ -197,42 +321,24 @@ export function useTeleprompterScroll({
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, []);
useEffect(() => {
selectedEventIdRef.current = selectedEventId;
}, [selectedEventId]);
// Avoid re-following when a new blocks array contains the same selected event.
const hasSelectedBlock = selectedEventId !== null && blocks.some((block) => block.id === selectedEventId);
useEffect(() => {
if (!followLoaded || autoScrollLocked || !selectedEventId) return;
const scroller = scrollerRef.current;
const target = blockRefs.current.get(selectedEventId);
if (!scroller || !target) return;
const target = scrollTargetFor(selectedEventId);
if (target === null) return;
const offset = (scroller.clientHeight * readingLinePos) / 100;
const top = getLayoutTop(target) - getLayoutTop(scroller) - offset;
const clamped = clamp(top, 0, maxScrollRef.current);
followTargetRef.current = clamped;
catchUpTargetRef.current = clamped;
followTargetRef.current = target;
catchUpTargetRef.current = target;
readerDriftRef.current = 0;
setAtEnd(false);
}, [selectedEventId, followLoaded, autoScrollLocked, readingLinePos, hasSelectedBlock]);
/**
* Only a real scroll away from the follow target takes over: momentum after a
* deliberate gesture, or a stray touch, would otherwise break it on any input,
* which is what made the operator view move to a distance check instead.
*
* Reads scrollTop from the element rather than posRef: a burst of wheel
* events can fire faster than the animation frame that keeps posRef in sync,
* so posRef here can still be reporting where the gesture started.
*/
const handleUserScroll = useCallback(() => {
if (!followLoaded || autoScrollLocked) return;
const position = scrollerRef.current?.scrollTop;
if (position === undefined) return;
if (hasBrokenFollow(position, followTargetRef.current, lineHeightRef.current)) {
setAutoScrollLocked(true);
}
}, [followLoaded, autoScrollLocked]);
}, [selectedEventId, followLoaded, autoScrollLocked, readingLinePos, hasSelectedBlock, scrollTargetFor]);
const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
if (element) {
@@ -257,26 +363,49 @@ export function useTeleprompterScroll({
setIsRunning(false);
};
/**
* Where the scroll is headed rather than where it currently sits, so
* pressing a step key again before the ease settles moves on by another
* step instead of re-aiming at the one already in flight.
*/
const destination = () => catchUpTargetRef.current ?? posRef.current;
/** Eases to a position the reader asked for, by hand. */
const goTo = (position: number) => {
const target = clamp(position, 0, maxScrollRef.current);
addReaderDrift(target - destination());
catchUpTargetRef.current = target;
setAtEnd(false);
};
return {
togglePlay: () => (runningRef.current ? pause() : play()),
nudge: (lines: number) => {
pendingDeltaRef.current += lines * lineHeightRef.current;
const distance = lines * lineHeightRef.current;
pendingDeltaRef.current += distance;
addReaderDrift(distance);
setAtEnd(false);
},
page: (direction: 1 | -1) => {
const scroller = scrollerRef.current;
if (!scroller) return;
const distance = scroller.clientHeight * PAGE_FRACTION * direction;
catchUpTargetRef.current = clamp(posRef.current + distance, 0, maxScrollRef.current);
setAtEnd(false);
goTo(destination() + scroller.clientHeight * PAGE_FRACTION * direction);
},
jumpEvent: (direction: 1 | -1) => {
const geometry = geometryRef.current;
const current = indexAtReadPoint(destination() + readingOffsetRef.current, geometry);
if (current === -1) return;
const next = clamp(current + direction, 0, geometry.length - 1);
goTo(geometry[next].top - readingOffsetRef.current);
},
changeSpeed: (delta: number) => setSpeed((current) => clampSpeed(current + delta)),
rewind: (alsoPause = false) => {
catchUpTargetRef.current = 0;
goTo(0);
if (alsoPause) pause();
setAtEnd(false);
},
jumpToEnd: () => {
addReaderDrift(maxScrollRef.current - destination());
catchUpTargetRef.current = maxScrollRef.current;
if (maxScrollRef.current > 0) {
runningRef.current = false;
@@ -284,15 +413,17 @@ export function useTeleprompterScroll({
setAtEnd(true);
}
},
reengageFollow: () => setAutoScrollLocked(false),
reengageFollow: () => {
readerDriftRef.current = 0;
setAutoScrollLocked(false);
},
};
}, []);
}, [addReaderDrift]);
return {
scrollerRef: attachScroller,
contentRef: attachContent,
registerBlock,
handleUserScroll,
controller,
isRunning,
speed,
+71 -1
View File
@@ -1,4 +1,14 @@
import { expect, test } from '@playwright/test';
import { expect, type Page, test } from '@playwright/test';
/** The heading of the event the reading line is currently over. */
function eventUnderReadingLine(page: Page) {
return page.evaluate(() => {
const line = document.querySelector('.teleprompter__reading-line')?.getBoundingClientRect();
if (!line) throw new Error('Reading line not found');
const element = document.elementFromPoint(window.innerWidth / 2, line.top + line.height / 2);
return element?.closest('.teleprompter__block')?.querySelector('.teleprompter__heading')?.textContent ?? null;
});
}
test('teleprompter renders and responds to its primary controls', async ({ page, request }) => {
// Earlier feature specs edit the loaded rundown. Restore the real demo project
@@ -47,6 +57,66 @@ test('teleprompter renders and responds to its primary controls', async ({ page,
await expect(view).toHaveCSS('transform', /^matrix\(-1/);
});
test('an edit above the reader leaves the same text under the reading line', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
const loadResponse = await request.get('/api/load/index/5');
expect(loadResponse.ok()).toBe(true);
// following moves the reader for its own reasons; this is about the document
// changing underneath a position the reader chose
await page.goto('/teleprompter?script=note&followLoaded=false');
const scroller = page.getByTestId('teleprompter-scroller');
await expect(scroller).toBeVisible();
// park the reading line inside a block rather than at a fraction of the
// document, whose tail is a screen of padding below the last event
await scroller.evaluate((element) => {
const blocks = element.querySelectorAll<HTMLElement>('.teleprompter__block');
const target = blocks[Math.floor(blocks.length / 2)];
element.scrollTop = target.offsetTop + 10 - element.clientHeight * 0.25;
});
const before = await eventUnderReadingLine(page);
expect(before).not.toBeNull();
const scrollBefore = await scroller.evaluate((element) => element.scrollTop);
// grow the first event's script, which sits above wherever we scrolled to
const edit = await request.put('/data/rundowns/default/entry', {
data: { id: '9bf60f', note: `Music plays, holding slide on screens\n${'Another line of script. '.repeat(120)}` },
});
expect(edit.ok()).toBe(true);
// the document grew, so holding position means the offset had to change
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(scrollBefore);
expect(await eventUnderReadingLine(page)).toBe(before);
});
test('shift and the vertical arrows walk the reader event by event', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);
const loadResponse = await request.get('/api/load/index/5');
expect(loadResponse.ok()).toBe(true);
await page.goto('/teleprompter?script=note&followLoaded=false');
const scroller = page.getByTestId('teleprompter-scroller');
await expect(scroller).toBeVisible();
const headings = await page.locator('.teleprompter__heading').allTextContents();
expect(headings.length).toBeGreaterThan(2);
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[0]);
await page.keyboard.press('Shift+ArrowDown');
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]);
await page.keyboard.press('Shift+ArrowDown');
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[2]);
await page.keyboard.press('Shift+ArrowUp');
await expect.poll(() => eventUnderReadingLine(page)).toBe(headings[1]);
});
test('follow tolerates a small scroll and breaks on a real one, like the operator view', async ({ page, request }) => {
const response = await request.post('/data/db/demo');
expect(response.ok()).toBe(true);