mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 00:29:41 +00:00
fix(teleprompter): address review findings and the e2e ordering failure
The e2e run failed because 214-rundown-switch-edit loads a fresh rundown, leaving the teleprompter spec with nothing to read. The spec now seeds its own script entry, idempotently, so it no longer depends on which rundown a previous spec happened to leave loaded. Review findings: - PresetView rendered outside ViewLoader, so a view reached through a preset never got the project's CSS override stylesheet. - The help overlay was a bare div. It is now a Dialog, so focus moves into it and back out, Escape closes it instead of rewinding the script, and the prompter keymap stands down while it is open. - Space is left unbound rather than made a no-op when a view claims it. useHotkeys calls preventDefault before reaching the handler, so an early return still swallowed the key and stopped Space activating a focused button. - The scroll observer attached on mount only, so a scroller which mounted later (the empty state resolving into a script) was never measured and could not play. Callback refs attach it whenever the elements appear. - jumpToEnd marked the end state before the document had been measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
This commit is contained in:
@@ -225,12 +225,19 @@ function PresetView() {
|
|||||||
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
|
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
|
||||||
return (
|
return (
|
||||||
<PresetContext value={preset}>
|
<PresetContext value={preset}>
|
||||||
<ViewNavigationMenu
|
{/*
|
||||||
isNavigationLocked={getIsNavigationLocked()}
|
Presets render the same views as the direct routes and need the same
|
||||||
suppressSettings
|
wrapper: ViewLoader is what injects the project's CSS override
|
||||||
suppressSpaceHotkey={preset.target === OntimeView.Teleprompter}
|
stylesheet. Without it a preset silently ignores configured styling.
|
||||||
/>
|
*/}
|
||||||
{Component ? <Component /> : <NotFound />}
|
<ViewLoader>
|
||||||
|
<ViewNavigationMenu
|
||||||
|
isNavigationLocked={getIsNavigationLocked()}
|
||||||
|
suppressSettings
|
||||||
|
suppressSpaceHotkey={preset.target === OntimeView.Teleprompter}
|
||||||
|
/>
|
||||||
|
{Component ? <Component /> : <NotFound />}
|
||||||
|
</ViewLoader>
|
||||||
</PresetContext>
|
</PresetContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
import { type HotkeyItem, useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
@@ -24,15 +24,27 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings, suppressSpac
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const hasSavedChanges = hasCustomParams(searchParams);
|
const hasSavedChanges = hasCustomParams(searchParams);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Space binding is left out entirely rather than made a no-op, because
|
||||||
|
* useHotkeys calls preventDefault before it reaches the handler. A handler
|
||||||
|
* which returns early still swallows the key, which would stop Space
|
||||||
|
* activating whichever button the user has focused.
|
||||||
|
*/
|
||||||
|
const spaceHotkey: HotkeyItem[] = suppressSpaceHotkey
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
[
|
||||||
|
'Space',
|
||||||
|
() => {
|
||||||
|
if (isNavigationLocked) return;
|
||||||
|
menuHandler.toggle();
|
||||||
|
},
|
||||||
|
{ preventDefault: true },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
useHotkeys([
|
useHotkeys([
|
||||||
[
|
...spaceHotkey,
|
||||||
'Space',
|
|
||||||
() => {
|
|
||||||
if (isNavigationLocked || suppressSpaceHotkey) return;
|
|
||||||
menuHandler.toggle();
|
|
||||||
},
|
|
||||||
{ preventDefault: true },
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'mod + ,',
|
'mod + ,',
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -205,19 +205,30 @@
|
|||||||
color: $viewer-label-color;
|
color: $viewer-label-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The help dialog is portalled to the body, so unlike the other overlays it sits
|
||||||
|
* outside the view root: fixed positioning is correct here, and it is not caught
|
||||||
|
* by the flip, which keeps it readable on a mirrored rig. Being outside also
|
||||||
|
* means it inherits nothing from the view and states its own colours.
|
||||||
|
*/
|
||||||
.teleprompter__help {
|
.teleprompter__help {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: rgba(0, 0, 0, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
.teleprompter__help-card {
|
.teleprompter__help-card {
|
||||||
max-height: 80%;
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
max-height: 80dvh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: clamp(16px, 2vw, 24px);
|
padding: clamp(16px, 2vw, 24px);
|
||||||
background: $viewer-card-bg-color;
|
|
||||||
|
background: $viewer-background-color;
|
||||||
|
color: $viewer-color;
|
||||||
border-radius: $element-border-radius;
|
border-radius: $element-border-radius;
|
||||||
font-size: $base-font-size;
|
font-size: $base-font-size;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
|
|
||||||
useTeleprompterControls({
|
useTeleprompterControls({
|
||||||
controller,
|
controller,
|
||||||
|
isHelpOpen: showHelp,
|
||||||
onFlip: handleFlip,
|
onFlip: handleFlip,
|
||||||
onFontSize: handleFontSize,
|
onFontSize: handleFontSize,
|
||||||
onResetFontSize: handleResetFontSize,
|
onResetFontSize: handleResetFontSize,
|
||||||
@@ -168,7 +169,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showHelp && <HelpOverlay onClose={handleToggleHelp} />}
|
<HelpOverlay isOpen={showHelp} onClose={handleToggleHelp} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { Dialog } from '@base-ui/react/dialog';
|
||||||
|
|
||||||
import Button from '../../../common/components/buttons/Button';
|
import Button from '../../../common/components/buttons/Button';
|
||||||
|
|
||||||
interface HelpOverlayProps {
|
interface HelpOverlayProps {
|
||||||
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,27 +25,42 @@ const shortcuts: { keys: string; action: string }[] = [
|
|||||||
/**
|
/**
|
||||||
* Most prompter foot pedals and hand controllers are USB HID devices which send
|
* Most prompter foot pedals and hand controllers are USB HID devices which send
|
||||||
* these same keystrokes, so this list doubles as the hardware reference.
|
* these same keystrokes, so this list doubles as the hardware reference.
|
||||||
|
*
|
||||||
|
* Built on the shared Dialog rather than a bare overlay so it behaves as a
|
||||||
|
* modal: focus moves into it, stays inside it, and returns to where it came
|
||||||
|
* from. Escape closes the dialog instead of rewinding the script, and the
|
||||||
|
* prompter keymap stands down for as long as it is open.
|
||||||
*/
|
*/
|
||||||
export default function HelpOverlay({ onClose }: HelpOverlayProps) {
|
export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
|
||||||
return (
|
return (
|
||||||
<div className='teleprompter__help' role='dialog' aria-label='Keyboard shortcuts'>
|
<Dialog.Root
|
||||||
<div className='teleprompter__help-card'>
|
open={isOpen}
|
||||||
<div className='teleprompter__help-title'>Controls</div>
|
onOpenChange={(open) => {
|
||||||
<dl className='teleprompter__help-list'>
|
if (!open) {
|
||||||
{shortcuts.map(({ keys, action }) => (
|
onClose();
|
||||||
<div key={keys} className='teleprompter__help-row'>
|
}
|
||||||
<dt className='teleprompter__help-keys'>{keys}</dt>
|
}}
|
||||||
<dd className='teleprompter__help-action'>{action}</dd>
|
>
|
||||||
</div>
|
<Dialog.Portal>
|
||||||
))}
|
<Dialog.Backdrop className='teleprompter__help' />
|
||||||
</dl>
|
<Dialog.Popup className='teleprompter__help-card'>
|
||||||
<div className='teleprompter__help-note'>
|
<Dialog.Title className='teleprompter__help-title'>Controls</Dialog.Title>
|
||||||
Foot pedals and hand controllers which emit these keys work without any setup.
|
<dl className='teleprompter__help-list'>
|
||||||
</div>
|
{shortcuts.map(({ keys, action }) => (
|
||||||
<Button variant='subtle-white' onClick={onClose} className='teleprompter__help-close'>
|
<div key={keys} className='teleprompter__help-row'>
|
||||||
Close
|
<dt className='teleprompter__help-keys'>{keys}</dt>
|
||||||
</Button>
|
<dd className='teleprompter__help-action'>{action}</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</dl>
|
||||||
|
<div className='teleprompter__help-note'>
|
||||||
|
Foot pedals and hand controllers which emit these keys work without any setup.
|
||||||
|
</div>
|
||||||
|
<Button variant='subtle-white' onClick={onClose} className='teleprompter__help-close'>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Dialog.Popup>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { TeleprompterAction, TeleprompterController } from './teleprompter.
|
|||||||
|
|
||||||
interface UseTeleprompterControlsArgs {
|
interface UseTeleprompterControlsArgs {
|
||||||
controller: TeleprompterController;
|
controller: TeleprompterController;
|
||||||
|
/** the help dialog is modal, so the keymap stands down while it is open */
|
||||||
|
isHelpOpen: boolean;
|
||||||
onFlip: (axis: 'h' | 'v') => void;
|
onFlip: (axis: 'h' | 'v') => void;
|
||||||
onFontSize: (delta: number) => void;
|
onFontSize: (delta: number) => void;
|
||||||
onResetFontSize: () => void;
|
onResetFontSize: () => void;
|
||||||
@@ -67,8 +69,18 @@ export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
|
|||||||
if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
|
if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// the params editor is a form, it owns the keyboard while it is open
|
// the params editor and the help dialog are modal, they own the keyboard
|
||||||
if (useViewParamsEditorStore.getState().isOpen) {
|
if (useViewParamsEditorStore.getState().isOpen || argsRef.current.isHelpOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A focused button is activated by Space and Enter. Resolving those into
|
||||||
|
* prompter actions here, and calling preventDefault, would stop the button
|
||||||
|
* doing its own job: tabbing to the help control and pressing Space would
|
||||||
|
* start the script rather than open the help.
|
||||||
|
*/
|
||||||
|
if ((event.code === 'Space' || event.key === 'Enter') && target?.closest('button, a, [role="button"]')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,23 @@ export function useTeleprompterScroll({
|
|||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const blockRefs = useRef(new Map<string, HTMLElement>());
|
const blockRefs = useRef(new Map<string, HTMLElement>());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The view renders an empty state instead of the scroller until a script is
|
||||||
|
* chosen, so these elements can arrive long after mount. Tracking that in
|
||||||
|
* state is what lets the measuring effect run when they do: keyed only on the
|
||||||
|
* refs it would have run once, against nothing, and playback would sit dead
|
||||||
|
* until the page was reloaded.
|
||||||
|
*/
|
||||||
|
const [isScrollerMounted, setIsScrollerMounted] = useState(false);
|
||||||
|
const attachScroller = useCallback((element: HTMLDivElement | null) => {
|
||||||
|
scrollerRef.current = element;
|
||||||
|
setIsScrollerMounted(Boolean(element && contentRef.current));
|
||||||
|
}, []);
|
||||||
|
const attachContent = useCallback((element: HTMLDivElement | null) => {
|
||||||
|
contentRef.current = element;
|
||||||
|
setIsScrollerMounted(Boolean(element && scrollerRef.current));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// authoritative, sub-pixel scroll position
|
// authoritative, sub-pixel scroll position
|
||||||
const posRef = useRef(0);
|
const posRef = useRef(0);
|
||||||
const lastTsRef = useRef(0);
|
const lastTsRef = useRef(0);
|
||||||
@@ -206,7 +223,7 @@ export function useTeleprompterScroll({
|
|||||||
observer.observe(scroller);
|
observer.observe(scroller);
|
||||||
observer.observe(content);
|
observer.observe(content);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [measure]);
|
}, [measure, isScrollerMounted]);
|
||||||
|
|
||||||
// webfonts land after first paint and reflow the whole document
|
// webfonts land after first paint and reflow the whole document
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -319,14 +336,20 @@ export function useTeleprompterScroll({
|
|||||||
},
|
},
|
||||||
jumpToEnd: () => {
|
jumpToEnd: () => {
|
||||||
catchUpTargetRef.current = Math.max(maxScrollRef.current, 0);
|
catchUpTargetRef.current = Math.max(maxScrollRef.current, 0);
|
||||||
|
// the eased branch never reports the end, only the playing one does
|
||||||
|
if (maxScrollRef.current > 0) {
|
||||||
|
runningRef.current = false;
|
||||||
|
setIsRunning(false);
|
||||||
|
setAtEnd(true);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
reengageFollow: () => setFollowLocked(false),
|
reengageFollow: () => setFollowLocked(false),
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scrollerRef,
|
scrollerRef: attachScroller,
|
||||||
contentRef,
|
contentRef: attachContent,
|
||||||
registerBlock,
|
registerBlock,
|
||||||
handleUserScroll,
|
handleUserScroll,
|
||||||
controller,
|
controller,
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { type Page, expect, test } from '@playwright/test';
|
import { type Page, expect, test } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The note field is used as the script source throughout: every event in the
|
* The note field is the script source throughout, so that these tests do not
|
||||||
* test fixture has one, which keeps these tests independent of how custom field
|
* depend on how custom field keys happen to be spelled.
|
||||||
* keys happen to be spelled in the fixture.
|
|
||||||
*/
|
*/
|
||||||
const teleprompterUrl = '/teleprompter?script=note';
|
const teleprompterUrl = '/teleprompter?script=note';
|
||||||
|
|
||||||
|
const scriptMarker = 'E2E prompter script';
|
||||||
|
/** long enough that the document scrolls well past a screen */
|
||||||
|
const scriptText = `${scriptMarker}. `.repeat(40);
|
||||||
|
|
||||||
function scroller(page: Page) {
|
function scroller(page: Page) {
|
||||||
return page.getByTestId('teleprompter-scroller');
|
return page.getByTestId('teleprompter-scroller');
|
||||||
}
|
}
|
||||||
@@ -15,14 +18,36 @@ function scrollTop(page: Page) {
|
|||||||
return scroller(page).evaluate((element) => element.scrollTop);
|
return scroller(page).evaluate((element) => element.scrollTop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a known script into whichever rundown happens to be loaded.
|
||||||
|
*
|
||||||
|
* These tests used to read the notes of the uploaded fixture, which made them
|
||||||
|
* depend on every spec that runs before them: 214 creates a fresh rundown and
|
||||||
|
* leaves it loaded, so by the time this file ran there were no notes anywhere
|
||||||
|
* and the view was showing its empty state. Seeding is idempotent, so the
|
||||||
|
* rundown gains one event no matter how many tests run.
|
||||||
|
*/
|
||||||
|
async function seedScript(page: Page) {
|
||||||
|
const rundown = await (await page.request.get('/data/rundowns/current')).json();
|
||||||
|
const alreadySeeded = rundown.flatOrder.some((id: string) => rundown.entries[id]?.note?.startsWith(scriptMarker));
|
||||||
|
if (alreadySeeded) return;
|
||||||
|
|
||||||
|
await page.request.post(`/data/rundowns/${rundown.id}/entry`, {
|
||||||
|
data: { type: 'event', title: 'Teleprompter e2e', note: scriptText },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('teleprompter', () => {
|
test.describe('teleprompter', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await seedScript(page);
|
||||||
|
});
|
||||||
|
|
||||||
test('shows the script from the selected source', async ({ page }) => {
|
test('shows the script from the selected source', async ({ page }) => {
|
||||||
await page.goto(teleprompterUrl);
|
await page.goto(teleprompterUrl);
|
||||||
|
|
||||||
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
|
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
|
||||||
await expect(scroller(page)).toBeVisible();
|
await expect(scroller(page)).toBeVisible();
|
||||||
// the fixture uses cue style notes, the first event is Albania
|
await expect(page.getByText(scriptMarker).first()).toBeVisible();
|
||||||
await expect(page.getByText('SF1.01', { exact: true })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('asks for a script source when none is selected', async ({ page }) => {
|
test('asks for a script source when none is selected', async ({ page }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user