diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx
index 308a52361..bc25e2649 100644
--- a/apps/client/src/AppRouter.tsx
+++ b/apps/client/src/AppRouter.tsx
@@ -225,12 +225,19 @@ function PresetView() {
const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return (
-
- {Component ? : }
+ {/*
+ Presets render the same views as the direct routes and need the same
+ wrapper: ViewLoader is what injects the project's CSS override
+ stylesheet. Without it a preset silently ignores configured styling.
+ */}
+
+
+ {Component ? : }
+
);
}
diff --git a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx
index cdc2c869f..5ea38fc8a 100644
--- a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx
+++ b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx
@@ -1,4 +1,4 @@
-import { useDisclosure, useHotkeys } from '@mantine/hooks';
+import { type HotkeyItem, useDisclosure, useHotkeys } from '@mantine/hooks';
import { memo } from 'react';
import { useSearchParams } from 'react-router';
@@ -24,15 +24,27 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings, suppressSpac
const [searchParams] = useSearchParams();
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([
- [
- 'Space',
- () => {
- if (isNavigationLocked || suppressSpaceHotkey) return;
- menuHandler.toggle();
- },
- { preventDefault: true },
- ],
+ ...spaceHotkey,
[
'mod + ,',
() => {
diff --git a/apps/client/src/views/teleprompter/Teleprompter.scss b/apps/client/src/views/teleprompter/Teleprompter.scss
index 39c3f8395..35c0fb8d5 100644
--- a/apps/client/src/views/teleprompter/Teleprompter.scss
+++ b/apps/client/src/views/teleprompter/Teleprompter.scss
@@ -205,19 +205,30 @@
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 {
- position: absolute;
+ position: fixed;
inset: 0;
- display: grid;
- place-items: center;
background: rgba(0, 0, 0, 0.75);
}
.teleprompter__help-card {
- max-height: 80%;
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+
+ max-height: 80dvh;
overflow-y: auto;
padding: clamp(16px, 2vw, 24px);
- background: $viewer-card-bg-color;
+
+ background: $viewer-background-color;
+ color: $viewer-color;
border-radius: $element-border-radius;
font-size: $base-font-size;
}
diff --git a/apps/client/src/views/teleprompter/Teleprompter.tsx b/apps/client/src/views/teleprompter/Teleprompter.tsx
index 7e4d537d2..b1beed4c6 100644
--- a/apps/client/src/views/teleprompter/Teleprompter.tsx
+++ b/apps/client/src/views/teleprompter/Teleprompter.tsx
@@ -101,6 +101,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
useTeleprompterControls({
controller,
+ isHelpOpen: showHelp,
onFlip: handleFlip,
onFontSize: handleFontSize,
onResetFontSize: handleResetFontSize,
@@ -168,7 +169,7 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
>
)}
- {showHelp && }
+
);
}
diff --git a/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx b/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx
index aa2b786d0..a2a6c03b2 100644
--- a/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx
+++ b/apps/client/src/views/teleprompter/help-overlay/HelpOverlay.tsx
@@ -1,6 +1,9 @@
+import { Dialog } from '@base-ui/react/dialog';
+
import Button from '../../../common/components/buttons/Button';
interface HelpOverlayProps {
+ isOpen: boolean;
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
* 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 (
-
-
-
Controls
-
- {shortcuts.map(({ keys, action }) => (
-
-
{keys}
-
{action}
-
- ))}
-
-
- Foot pedals and hand controllers which emit these keys work without any setup.
-
+ Foot pedals and hand controllers which emit these keys work without any setup.
+
+
+
+
+
);
}
diff --git a/apps/client/src/views/teleprompter/useTeleprompterControls.ts b/apps/client/src/views/teleprompter/useTeleprompterControls.ts
index a6a1f51d4..23b9ad0ac 100644
--- a/apps/client/src/views/teleprompter/useTeleprompterControls.ts
+++ b/apps/client/src/views/teleprompter/useTeleprompterControls.ts
@@ -6,6 +6,8 @@ import type { TeleprompterAction, TeleprompterController } from './teleprompter.
interface UseTeleprompterControlsArgs {
controller: TeleprompterController;
+ /** the help dialog is modal, so the keymap stands down while it is open */
+ isHelpOpen: boolean;
onFlip: (axis: 'h' | 'v') => void;
onFontSize: (delta: number) => void;
onResetFontSize: () => void;
@@ -67,8 +69,18 @@ export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
return;
}
- // the params editor is a form, it owns the keyboard while it is open
- if (useViewParamsEditorStore.getState().isOpen) {
+ // the params editor and the help dialog are modal, they own the keyboard
+ 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;
}
diff --git a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts
index b8d5bf0ab..eff2a93c6 100644
--- a/apps/client/src/views/teleprompter/useTeleprompterScroll.ts
+++ b/apps/client/src/views/teleprompter/useTeleprompterScroll.ts
@@ -60,6 +60,23 @@ export function useTeleprompterScroll({
const contentRef = useRef(null);
const blockRefs = useRef(new Map());
+ /**
+ * 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
const posRef = useRef(0);
const lastTsRef = useRef(0);
@@ -206,7 +223,7 @@ export function useTeleprompterScroll({
observer.observe(scroller);
observer.observe(content);
return () => observer.disconnect();
- }, [measure]);
+ }, [measure, isScrollerMounted]);
// webfonts land after first paint and reflow the whole document
useEffect(() => {
@@ -319,14 +336,20 @@ export function useTeleprompterScroll({
},
jumpToEnd: () => {
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),
};
}, []);
return {
- scrollerRef,
- contentRef,
+ scrollerRef: attachScroller,
+ contentRef: attachContent,
registerBlock,
handleUserScroll,
controller,
diff --git a/e2e/tests/features/215-teleprompter.spec.ts b/e2e/tests/features/215-teleprompter.spec.ts
index 71f851cc7..d6f68aa40 100644
--- a/e2e/tests/features/215-teleprompter.spec.ts
+++ b/e2e/tests/features/215-teleprompter.spec.ts
@@ -1,12 +1,15 @@
import { type Page, expect, test } from '@playwright/test';
/**
- * The note field is used as the script source throughout: every event in the
- * test fixture has one, which keeps these tests independent of how custom field
- * keys happen to be spelled in the fixture.
+ * The note field is the script source throughout, so that these tests do not
+ * depend on how custom field keys happen to be spelled.
*/
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) {
return page.getByTestId('teleprompter-scroller');
}
@@ -15,14 +18,36 @@ function scrollTop(page: Page) {
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.beforeEach(async ({ page }) => {
+ await seedScript(page);
+ });
+
test('shows the script from the selected source', async ({ page }) => {
await page.goto(teleprompterUrl);
await expect(page.getByTestId('teleprompter-view')).toBeVisible();
await expect(scroller(page)).toBeVisible();
- // the fixture uses cue style notes, the first event is Albania
- await expect(page.getByText('SF1.01', { exact: true })).toBeVisible();
+ await expect(page.getByText(scriptMarker).first()).toBeVisible();
});
test('asks for a script source when none is selected', async ({ page }) => {