Compare commits

...

1 Commits

Author SHA1 Message Date
Claude 435cac1fcb fix(teleprompter): make follow-lock tolerant and its state legible
Two problems were feeding the confusion:

- Any wheel, touch, or pointerdown event broke the follow, with no tolerance.
  A stray touch or trackpad momentum after a deliberate gesture disengaged it
  as readily as a real scroll away from the read position, with no way to
  tell the two apart from the outside. The operator view solved the same
  problem with a distance check against the loaded item's expected position,
  which this now mirrors: it takes a real scroll (past 1.5 lines) to count as
  taking over, in a new hasBrokenFollow(), unit tested like the rest of the
  scroll maths.

- The runtime flag and the view option shared adjacent, opposite-valence
  names (followLocked / followLoaded) and were ANDed together in a third
  place to get the value the button actually needed. Renamed the runtime
  flag to autoScrollLocked, matching the operator's own lockAutoScroll for
  the same concept, and moved the AND into the hook so it exposes one signal
  the view no longer has to assemble itself.

Also fixes the threshold check reading a stale position: a burst of wheel
events can fire faster than the animation frame that keeps the scroll ref in
sync, so it now reads the element's scrollTop directly rather than the ref a
frame behind it. Caught by testing the tolerance in a live browser rather
than trusting the unit tests alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
2026-08-22 15:09:01 +00:00
6 changed files with 95 additions and 14 deletions
@@ -74,7 +74,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
controller,
isRunning,
speed,
followLocked,
canReengageFollow,
atEnd,
} = useTeleprompterScroll({
initialSpeed: options.speed,
@@ -161,7 +161,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
<ControlOverlay
isRunning={isRunning}
speed={speed}
canReengageFollow={followLocked && options.followLoaded}
canReengageFollow={canReengageFollow}
atEnd={atEnd}
controller={controller}
onToggleHelp={handleToggleHelp}
@@ -2,7 +2,9 @@ import {
advance,
clampSpeed,
easeCatchUp,
FOLLOW_BREAK_LINES,
frameDeltaSeconds,
hasBrokenFollow,
linesPerMinuteToPxPerSecond,
MAX_FONT_SIZE,
MAX_FRAME_DELTA_MS,
@@ -94,6 +96,32 @@ describe('advance()', () => {
});
});
describe('hasBrokenFollow()', () => {
const lineHeight = 40;
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);
});
test('counts a deliberate scroll past the threshold, from 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);
});
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);
});
});
describe('easeCatchUp()', () => {
test('approaches the target monotonically from either side', () => {
let fromAbove = 500;
@@ -100,9 +100,7 @@ export default function ControlOverlay({
</Tooltip>
<Tooltip
text={
canReengageFollow ? 'Jump back to the loaded event and follow it again (L)' : 'Following the loaded event'
}
text={canReengageFollow ? 'Resume following the loaded event (L)' : 'Following the loaded event'}
render={
<IconButton
variant={canReengageFollow ? 'primary' : 'subtle-white'}
@@ -51,3 +51,12 @@ export function easeCatchUp(current: number, target: number, deltaSeconds: numbe
const next = target + (current - target) * Math.exp(-CATCH_UP_RATE * deltaSeconds);
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. */
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 {
if (lineHeightPx <= 0) return false;
return Math.abs(position - followTarget) > lineHeightPx * FOLLOW_BREAK_LINES;
}
@@ -6,6 +6,7 @@ import {
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
hasBrokenFollow,
linesPerMinuteToPxPerSecond,
} from './teleprompter.scroll';
import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
@@ -73,10 +74,15 @@ 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
const followTargetRef = useRef(0);
const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(initialSpeed);
const [followLocked, setFollowLocked] = useState(false);
// mirrors the operator view's lockAutoScroll: true once the reader has taken
// the scroll over by hand, false while following is doing the driving
const [autoScrollLocked, setAutoScrollLocked] = useState(false);
const [atEnd, setAtEnd] = useState(false);
const tick = useCallback((timestamp: number) => {
@@ -195,7 +201,7 @@ export function useTeleprompterScroll({
const hasSelectedBlock = selectedEventId !== null && blocks.some((block) => block.id === selectedEventId);
useEffect(() => {
if (!followLoaded || followLocked || !selectedEventId) return;
if (!followLoaded || autoScrollLocked || !selectedEventId) return;
const scroller = scrollerRef.current;
const target = blockRefs.current.get(selectedEventId);
@@ -203,16 +209,30 @@ export function useTeleprompterScroll({
const offset = (scroller.clientHeight * readingLinePos) / 100;
const top = getLayoutTop(target) - getLayoutTop(scroller) - offset;
const clamped = clamp(top, 0, maxScrollRef.current);
catchUpTargetRef.current = clamp(top, 0, maxScrollRef.current);
followTargetRef.current = clamped;
catchUpTargetRef.current = clamped;
setAtEnd(false);
}, [selectedEventId, followLoaded, followLocked, readingLinePos, hasSelectedBlock]);
}, [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) {
setFollowLocked(true);
if (!followLoaded || autoScrollLocked) return;
const position = scrollerRef.current?.scrollTop;
if (position === undefined) return;
if (hasBrokenFollow(position, followTargetRef.current, lineHeightRef.current)) {
setAutoScrollLocked(true);
}
}, [followLoaded]);
}, [followLoaded, autoScrollLocked]);
const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
if (element) {
@@ -264,7 +284,7 @@ export function useTeleprompterScroll({
setAtEnd(true);
}
},
reengageFollow: () => setFollowLocked(false),
reengageFollow: () => setAutoScrollLocked(false),
};
}, []);
@@ -276,7 +296,9 @@ export function useTeleprompterScroll({
controller,
isRunning,
speed,
followLocked,
// folds followLoaded in, so callers get one ready-to-use signal instead of
// a runtime flag they must remember to AND with the option themselves
canReengageFollow: followLoaded && autoScrollLocked,
atEnd,
};
}
@@ -46,3 +46,27 @@ test('teleprompter renders and responds to its primary controls', async ({ page,
await expect(page).toHaveURL(/flipH=true/);
await expect(view).toHaveCSS('transform', /^matrix\(-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);
const loadResponse = await request.get('/api/load/index/5');
expect(loadResponse.ok()).toBe(true);
await page.goto('/teleprompter?script=note');
const scroller = page.getByTestId('teleprompter-scroller');
const follow = page.getByTestId('teleprompter-follow');
await expect(scroller).toBeVisible();
await expect(follow).toBeDisabled();
await page.mouse.move(960, 500);
await page.mouse.wheel(0, 15);
await expect(follow).toBeDisabled();
await page.mouse.wheel(0, 400);
await expect(follow).toBeEnabled();
await page.keyboard.press('l');
await expect(follow).toBeDisabled();
});