diff --git a/apps/client/src/views/teleprompter/Teleprompter.tsx b/apps/client/src/views/teleprompter/Teleprompter.tsx index 19368c3f1..e33085968 100644 --- a/apps/client/src/views/teleprompter/Teleprompter.tsx +++ b/apps/client/src/views/teleprompter/Teleprompter.tsx @@ -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 ) : ( <> -
+
{blocks.map((block) => ( diff --git a/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts b/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts index f633749b5..ca575a03c 100644 --- a/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.keymap.test.ts @@ -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 }); diff --git a/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts b/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts index 75febb590..70e31ff71 100644 --- a/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts +++ b/apps/client/src/views/teleprompter/__tests__/teleprompter.scroll.test.ts @@ -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(); + }); }); }); diff --git a/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx b/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx index 01e494817..af0dcfd58 100644 --- a/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx +++ b/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx @@ -63,6 +63,11 @@ export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) { + + + + + diff --git a/apps/client/src/views/teleprompter/teleprompter.keymap.ts b/apps/client/src/views/teleprompter/teleprompter.keymap.ts index 92bbdb813..1936150c5 100644 --- a/apps/client/src/views/teleprompter/teleprompter.keymap.ts +++ b/apps/client/src/views/teleprompter/teleprompter.keymap.ts @@ -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': diff --git a/apps/client/src/views/teleprompter/teleprompter.scroll.ts b/apps/client/src/views/teleprompter/teleprompter.scroll.ts index 52c459cc8..20fe07fb2 100644 --- a/apps/client/src/views/teleprompter/teleprompter.scroll.ts +++ b/apps/client/src/views/teleprompter/teleprompter.scroll.ts @@ -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; } diff --git a/apps/client/src/views/teleprompter/teleprompter.types.ts b/apps/client/src/views/teleprompter/teleprompter.types.ts index e0a14628c..205cc6caa 100644 --- a/apps/client/src/views/teleprompter/teleprompter.types.ts +++ b/apps/client/src/views/teleprompter/teleprompter.types.ts @@ -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; diff --git a/apps/client/src/views/teleprompter/useTeleprompterControls.ts b/apps/client/src/views/teleprompter/useTeleprompterControls.ts index dcb1e6282..8a62d3bc5 100644 --- a/apps/client/src/views/teleprompter/useTeleprompterControls.ts +++ b/apps/client/src/views/teleprompter/useTeleprompterControls.ts @@ -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': diff --git a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts index 693918c07..04e55b451 100644 --- a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts +++ b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts @@ -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(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([]); + const anchorRef = useRef(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, diff --git a/e2e/tests/features/215-teleprompter.spec.ts b/e2e/tests/features/215-teleprompter.spec.ts index 80bc6006f..1ae10bcf7 100644 --- a/e2e/tests/features/215-teleprompter.spec.ts +++ b/e2e/tests/features/215-teleprompter.spec.ts @@ -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('.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);