mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 15:39:11 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 435cac1fcb |
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user