Compare commits

..

18 Commits

Author SHA1 Message Date
Claude eb1ef5788c fix(operator): let the group header size to its content
The header had a fixed height, so a title taller than 2.5rem was clipped
and the follow offset kept placing the running event behind it.

Trim the vertical padding so a single line fits inside the min-height on
its own. The header then keeps the size it has today without depending on
the list shrinking it, and grows when its content needs more. The follow
offset already measures the header at scroll time, so it tracks the
change.
2026-08-31 17:06:14 +00:00
Carlos Valente 35b547f88e fix: keep group visible as header for the running event 2026-08-30 19:25:21 +02:00
Carlos Valente 265b167600 bump version to 4.13.0 2026-08-28 12:04:02 +02:00
Carlos Valente eb7b2a5c4b refactor(finder): improve feature UX
Search every text field with optional filters, keep result selection stable, and expose clearer match context and counts. Cover query parsing, matching, mixed-entry indexing, and the reveal flow.
2026-08-28 11:41:44 +02:00
Carlos Valente e0ae9f7f09 refactor(log): use ToggleButton for filters
Reuse the shared toggle abstraction while preserving the existing filter behaviour and accessible labels.
2026-08-28 11:41:44 +02:00
Carlos Valente b0c778fa62 feat(buttons): add ToggleButton component
Keep toggle styling and the accessible pressed state coupled in one reusable button.
2026-08-28 11:41:44 +02:00
Carlos Valente 98d3b1f6f3 chore(license): resolver has permissive license 2026-08-28 08:40:17 +02:00
Carlos Valente d9dd147d4b refactor(server): replace defensive copies with cow patterns 2026-08-26 20:17:34 +02:00
Carlos Valente 9806f97d27 test(rundown): improve rundown utils coverage 2026-08-25 22:41:37 +02:00
Carlos Valente b9683f00dc fix: prevent stale rundown cache on rename 2026-08-25 21:41:34 +02:00
Carlos Valente 63fdc50c50 docs(llms): document ongoing code migrations 2026-08-25 21:41:18 +02:00
Carlos Valente 5acfc4a122 docs(llms): setup agents documentation 2026-08-25 21:41:18 +02:00
Carlos Valente a84b8b5530 refactor(ui): polish navigation menu and view params editor 2026-08-23 13:42:21 +02:00
Carlos Valente c19aa4ca28 fix(cuesheet): prevent stale column width on refresh 2026-08-23 13:28:24 +02:00
Carlos Valente 805663e2ea chore(deps): migrate to @tanstack/react-table v9 2026-08-23 13:28:24 +02:00
Carlos Valente 0f0b32d444 accept bearer token 2026-08-21 20:52:11 +02:00
Carlos Valente 703dee35a4 style: bump oxfmt to 0.63 and reformat 2026-08-18 13:56:53 +02:00
Carlos Valente 2a7f5b7872 chore(deps): upgrade dependencies 2026-08-18 13:56:53 +02:00
129 changed files with 4348 additions and 5378 deletions
+46
View File
@@ -0,0 +1,46 @@
---
name: code-review
description: Review Ontime changes for concrete correctness, architecture, testing, routing, security, and maintenance issues. For GitHub Copilot review.
---
# Review Ontime changes
Review as a maintainer. Find material defects, not speculative advice, style preferences, or generic checklists. Approve when no material issue remains.
## Load relevant context
Read the full diff, PR description, linked issue, tests, and nearby owning code. Then load only applicable guides:
- Every non-trivial review: [change assessment](../../../docs/agent-guides/change-assessment.md)
- Module placement, server layers, client state, shared packages: [architecture](../../../docs/agent-guides/architecture.md)
- Tests or changed behaviour: [testing](../../../docs/agent-guides/testing.md)
- Comments, abstractions, naming, complexity: [code quality](../../../docs/agent-guides/code-quality.md)
- Authentication, external input, files, integrations, assets, secrets: [security](../../../docs/agent-guides/security.md)
- Routes, URLs, websockets, authentication, cookies, presets, assets: [routing and cloud](../../../docs/agent-guides/routing-and-cloud.md)
- Rundowns, timers, persistence, imports, cache, realtime: [domain invariants](../../../docs/agent-guides/domain-invariants.md)
- Commands, imports, dependencies, formatting, CI claims: [workflow](../../../docs/agent-guides/workflow.md)
Check a guide and nearby canonical code before citing an Ontime convention. Skip unrelated guides.
## Review order
1. Establish intent and affected runtime surfaces.
2. Read tests first. Identify claimed behaviour and coverage.
3. Trace implementation, errors, and state transitions.
4. Check correctness/data integrity, security, architecture/testability, cloud routing, lifecycle/performance, maintainability.
5. Verify claimed checks. Never claim unobserved results.
Passing tests do not prove architecture, routing, comments, or error paths. Review changed behaviour only; include existing problems only when the diff worsens or relies on them.
## Findings
Report only concrete, actionable issues. Each finding: tight line range, direct defect, impact and trigger, smallest viable remedy when unclear.
- **P0 — Critical:** data loss, exploitable vulnerability, broadly broken production. Blocks merge.
- **P1 — High:** likely correctness failure or major supported-deployment regression. Blocks merge.
- **P2 — Medium:** real edge-case defect, architecture regression, missing business-rule test, stale comment, meaningful maintenance risk. Normally blocks merge.
- **P3 — Low:** local improvement with limited impact. No subjective style or tool-managed formatting.
Order by priority. Prefer few high-confidence findings. No praise or checklist before findings. If none, say so and note verification gaps or residual risk.
Then give one concise PR-level value/risk/complexity assessment. Never repeat it per finding.
+22
View File
@@ -0,0 +1,22 @@
# Ontime agent guide
Make the smallest maintainable change. Keep scope narrow. Inspect nearby code first. Reuse helpers and boundaries when semantics match.
## Load only relevant guides
- Commands, validation, formatting, imports, PRs: [workflow](docs/agent-guides/workflow.md).
- Non-trivial planning, implementation, review: [change assessment](docs/agent-guides/change-assessment.md).
- Module placement, server layers, client state, shared packages: [architecture](docs/agent-guides/architecture.md).
- Tests or changed behaviour: [testing](docs/agent-guides/testing.md).
- Comments, naming, abstractions, maintainability: [code quality](docs/agent-guides/code-quality.md).
- Authentication, external input, files, integrations, assets, secrets: [security](docs/agent-guides/security.md).
- Navigation, URLs, API paths, websockets, redirects, cookies, static assets: [routing and cloud](docs/agent-guides/routing-and-cloud.md).
- Rundowns, timers, imports, persistence, cache, websockets: [domain invariants](docs/agent-guides/domain-invariants.md).
Load multiple guides when needed. Skip unrelated guides for mechanical work.
## Before handoff
- Check the final diff for scope, stale comments, temporary code, redundant tests, generated files.
- If work reveals a missing, stable, reusable system, domain, or product invariant, update its owning guide. Exclude guesses, one-off bugs, and implementation details.
- Follow [workflow verification](docs/agent-guides/workflow.md). Report only observed results.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/cli", "name": "@getontime/cli",
"version": "4.13.0-beta.1", "version": "4.13.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
+9 -8
View File
@@ -1,10 +1,10 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "4.13.0-beta.1", "version": "4.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@base-ui/react": "1.6.0", "@base-ui/react": "1.7.0",
"@codemirror/commands": "^6.0.0", "@codemirror/commands": "^6.0.0",
"@codemirror/lang-css": "^6.0.0", "@codemirror/lang-css": "^6.0.0",
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
@@ -13,12 +13,12 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource/open-sans": "^5.2.7", "@fontsource/open-sans": "^5.2.7",
"@mantine/hooks": "^8.3.7", "@mantine/hooks": "^9.5.1",
"@sentry/react": "^10.59.0", "@sentry/react": "^10.59.0",
"@table-nav/react": "^0.0.7", "@table-nav/react": "^0.0.7",
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0", "@tanstack/react-query-devtools": "^5.101.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^9.1.2",
"@uiw/codemirror-theme-vscode": "^4.25.10", "@uiw/codemirror-theme-vscode": "^4.25.10",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.18.0", "axios": "^1.18.0",
@@ -29,7 +29,7 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-fast-compare": "^3.2.2", "react-fast-compare": "^3.2.2",
"react-hook-form": "^7.80.0", "react-hook-form": "^7.80.0",
"react-icons": "5.6.0", "react-icons": "5.7.0",
"react-router": "^8.0.1", "react-router": "^8.0.1",
"react-virtuoso": "^4.18.7", "react-virtuoso": "^4.18.7",
"zustand": "^5.0.14" "zustand": "^5.0.14"
@@ -60,7 +60,8 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@sentry/vite-plugin": "5.1.1", "@sentry/vite-plugin": "5.4.0",
"@types/node": "catalog:",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
@@ -72,8 +73,8 @@
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "8.0.1", "vite": "8.2.1",
"vite-plugin-compression2": "2.5.1", "vite-plugin-compression2": "2.5.3",
"vite-plugin-svgr": "4.5.0", "vite-plugin-svgr": "4.5.0",
"vitest": "catalog:" "vitest": "catalog:"
} }
+2 -19
View File
@@ -20,7 +20,6 @@ const Backstage = lazy(() => import('./views/backstage/Backstage'));
const StudioClock = lazy(() => import('./views/studio/Studio')); const StudioClock = lazy(() => import('./views/studio/Studio'));
const Timeline = lazy(() => import('./views/timeline/TimelinePage')); const Timeline = lazy(() => import('./views/timeline/TimelinePage'));
const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo')); const ProjectInfo = lazy(() => import('./views/project-info/ProjectInfo'));
const Teleprompter = lazy(() => import('./views/teleprompter/Teleprompter'));
const Editor = lazy(() => import('./views/editor/ProtectedEditor')); const Editor = lazy(() => import('./views/editor/ProtectedEditor'));
const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet')); const Cuesheet = lazy(() => import('./views/cuesheet/ProtectedCuesheet'));
@@ -96,15 +95,6 @@ export default function AppRouter() {
</ViewLoader> </ViewLoader>
} }
/> />
<Route
path='teleprompter'
element={
<ViewLoader>
<ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSpaceHotkey />
<Teleprompter />
</ViewLoader>
}
/>
{/*/!* Protected Routes *!/*/} {/*/!* Protected Routes *!/*/}
<Route path='editor' element={<Editor />} /> <Route path='editor' element={<Editor />} />
<Route path='cuesheet' element={<Cuesheet />} /> <Route path='cuesheet' element={<Cuesheet />} />
@@ -175,7 +165,6 @@ const PresetViewMap: Record<OntimeViewPresettable, ComponentType> = {
[OntimeView.StudioClock]: StudioClock, [OntimeView.StudioClock]: StudioClock,
[OntimeView.Countdown]: Countdown, [OntimeView.Countdown]: Countdown,
[OntimeView.ProjectInfo]: ProjectInfo, [OntimeView.ProjectInfo]: ProjectInfo,
[OntimeView.Teleprompter]: Teleprompter,
}; };
/** /**
@@ -225,14 +214,8 @@ function PresetView() {
const Component = PresetViewMap[preset.target as OntimeViewPresettable]; const Component = PresetViewMap[preset.target as OntimeViewPresettable];
return ( return (
<PresetContext value={preset}> <PresetContext value={preset}>
<ViewLoader> <ViewNavigationMenu isNavigationLocked={getIsNavigationLocked()} suppressSettings />
<ViewNavigationMenu {Component ? <Component /> : <NotFound />}
isNavigationLocked={getIsNavigationLocked()}
suppressSettings
suppressSpaceHotkey={preset.target === OntimeView.Teleprompter}
/>
{Component ? <Component /> : <NotFound />}
</ViewLoader>
</PresetContext> </PresetContext>
); );
} }
@@ -0,0 +1,18 @@
import { ComponentProps } from 'react';
import Button from './Button';
type ToggleButtonProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
/** whether the option this button controls is currently on */
pressed: boolean;
};
/**
* A button which carries an on / off state.
*
* Keeps the pressed styling and the accessible state together, so that a toggle
* cannot end up looking active without also announcing that it is.
*/
export default function ToggleButton({ pressed, ...buttonProps }: ToggleButtonProps) {
return <Button variant={pressed ? 'primary' : 'subtle'} aria-pressed={pressed} {...buttonProps} />;
}
@@ -0,0 +1,6 @@
.eyebrow {
font-size: $aux-text-size;
font-weight: 400;
letter-spacing: 0.06em;
text-transform: uppercase;
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './Eyebrow.module.scss';
interface EyebrowProps {
className?: string;
}
export default function Eyebrow({ children, className }: PropsWithChildren<EyebrowProps>) {
return <span className={cx([style.eyebrow, className])}>{children}</span>;
}
@@ -1,66 +0,0 @@
.groups {
display: grid;
gap: 0.875rem;
}
.group {
h3 {
margin: 0 0 0.375rem;
color: var(--shortcut-title-color, currentColor);
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
}
}
.list {
display: grid;
gap: 0.25rem;
}
.row {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.label {
min-width: 0;
line-height: 1.2;
color: var(--shortcut-label-color, currentColor);
}
.keys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.combo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: var(--shortcut-separator-color, currentColor);
font-size: calc(1rem - 5px);
}
@media (max-width: 680px) {
.row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.keys {
justify-content: flex-start;
}
}
@@ -1,42 +0,0 @@
import type { PropsWithChildren } from 'react';
import { cx } from '../../utils/styleUtils';
import Kbd from '../kbd/Kbd';
import style from './KeyboardShortcuts.module.scss';
export function ShortcutGroups({ className, children }: PropsWithChildren<{ className?: string }>) {
return <div className={cx([style.groups, className])}>{children}</div>;
}
export function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className={style.group}>
<h3>{title}</h3>
<div className={style.list}>{children}</div>
</section>
);
}
export function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.row}>
<span className={style.label}>{label}</span>
<span className={style.keys}>{children}</span>
</div>
);
}
export function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.combo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
export function Separator() {
return <span className={style.separator}>/</span>;
}
@@ -1,7 +1,3 @@
.separator {
border-color: $border-color-ondark;
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -23,14 +19,15 @@
width: 22rem; width: 22rem;
height: 100vh; height: 100vh;
height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem;
background-color: $gray-1250; background-color: $gray-1250;
color: $ui-white; color: $ui-white;
border-right: 1px solid $gray-1100; border-right: 1px solid $gray-1100;
box-shadow: $box-shadow-right;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -50,22 +47,51 @@
} }
.header { .header {
flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 3.5rem; gap: 1rem;
padding-inline: 1.5rem; min-height: 4rem;
padding: 0.75rem 1rem 0.75rem 1.25rem;
border-bottom: 1px solid $white-10;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.clientName {
font-size: $aux-text-size;
color: $gray-600;
@include ellipsis-text;
} }
.body { .body {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding-block: 0.5rem 1rem;
} }
.note { .group {
margin-left: auto; display: flex;
color: $white-20; flex-direction: column;
&:not(:first-child) {
margin-top: 0.75rem;
}
}
.groupLabel {
padding: 0.5rem 1.25rem 0.25rem;
color: $gray-700;
} }
@@ -1,7 +1,7 @@
import { Dialog } from '@base-ui/react/dialog'; import { Dialog } from '@base-ui/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks'; import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
import { memo } from 'react'; import { memo, PropsWithChildren } from 'react';
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
import { LuCoffee } from 'react-icons/lu'; import { LuCoffee } from 'react-icons/lu';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
@@ -14,9 +14,11 @@ import { useClientStore } from '../../stores/clientStore';
import { useViewOptionsStore } from '../../stores/viewOptions'; import { useViewOptionsStore } from '../../stores/viewOptions';
import IconButton from '../buttons/IconButton'; import IconButton from '../buttons/IconButton';
import { RenameClientModal } from '../client-modal/RenameClientModal'; import { RenameClientModal } from '../client-modal/RenameClientModal';
import Eyebrow from '../eyebrow/Eyebrow';
import ClientLink from './client-link/ClientLink'; import ClientLink from './client-link/ClientLink';
import EditorNavigation from './editor-navigation/EditorNavigation'; import EditorNavigation from './editor-navigation/EditorNavigation';
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem'; import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
import OtherAddresses from './other-addresses/OtherAddresses'; import OtherAddresses from './other-addresses/OtherAddresses';
import style from './NavigationMenu.module.scss'; import style from './NavigationMenu.module.scss';
@@ -33,7 +35,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
const isSmallScreen = useIsSmallScreen(); const isSmallScreen = useIsSmallScreen();
const [isRenameOpen, handlers] = useDisclosure(false); const [isRenameOpen, handlers] = useDisclosure(false);
const { fullscreen, toggle } = useFullscreen(); const { fullscreen, toggle } = useFullscreenDocument();
const { mirror, toggleMirror } = useViewOptionsStore(); const { mirror, toggleMirror } = useViewOptionsStore();
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions(); const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
const location = useLocation(); const location = useLocation();
@@ -52,75 +54,93 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} /> <RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<Dialog.Title>Ontime</Dialog.Title> <div className={style.headerText}>
<IconButton variant='subtle-white' size='large' onClick={onClose}> <Dialog.Title className={style.title}>Ontime</Dialog.Title>
{name && <span className={style.clientName}>{name}</span>}
</div>
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
<div className={style.body}> <div className={style.body}>
{supportsFullscreen && ( <MenuGroup label='This screen'>
<NavigationMenuItem active={fullscreen} onClick={toggle}> {supportsFullscreen && (
Toggle Fullscreen <NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
{fullscreen ? <IoContract /> : <IoExpand />} )}
<NavigationMenuToggle
checked={mirror}
icon={<IoSwapVertical />}
label='Flip Screen'
onToggle={() => toggleMirror()}
/>
{canUseWakeLock && (
<NavigationMenuToggle
checked={keepAwake}
icon={<LuCoffee />}
label='Keep Awake'
onToggle={toggleKeepAwake}
/>
)}
<NavigationMenuItem onClick={handlers.open}>
<IoPencilOutline />
Rename Client
</NavigationMenuItem> </NavigationMenuItem>
)} </MenuGroup>
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
Flip Screen
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
{keepAwake && <span className={style.note}>Active</span>}
</NavigationMenuItem>
)}
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
<hr className={style.separator} /> <MenuGroup label='Ontime app'>
<EditorNavigation />
<EditorNavigation />
<ClientLink
to='cuesheet'
current={location.pathname === '/cuesheet'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Cuesheet
</ClientLink>
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
<IoLockClosedOutline />
Operator
</ClientLink>
<hr className={style.separator} />
{navigatorConstants.map((route) => (
<ClientLink <ClientLink
key={route.url} to='cuesheet'
to={route.url} current={location.pathname === '/cuesheet'}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined} postAction={isSmallScreen ? onClose : undefined}
> >
{route.label} <IoLockClosedOutline />
Cuesheet
</ClientLink> </ClientLink>
))} <ClientLink
to='op'
current={location.pathname === '/op'}
postAction={isSmallScreen ? onClose : undefined}
>
<IoLockClosedOutline />
Operator
</ClientLink>
</MenuGroup>
<MenuGroup label='Views'>
{navigatorConstants.map((route) => (
<ClientLink
key={route.url}
to={route.url}
current={location.pathname === `/${route.url}`}
postAction={isSmallScreen ? onClose : undefined}
>
{route.label}
</ClientLink>
))}
</MenuGroup>
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} /> <PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
</div> </div>
{isLocalhost && ( {isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
<div>
<OtherAddresses currentLocation={location.pathname} />
</div>
)}
</Dialog.Popup> </Dialog.Popup>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>
); );
} }
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.group}>
<div className={style.groupLabel}>
<Eyebrow>{label}</Eyebrow>
</div>
{children}
</div>
);
}
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) { function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
const location = useLocation(); const location = useLocation();
const { data: urlPresets } = useUrlPresets(); const { data: urlPresets } = useUrlPresets();
@@ -129,8 +149,7 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
if (navPresets.length === 0) return null; if (navPresets.length === 0) return null;
return ( return (
<> <MenuGroup label='Presets'>
<hr className={style.separator} />
{navPresets.map((preset) => ( {navPresets.map((preset) => (
<ClientLink <ClientLink
key={preset.alias} key={preset.alias}
@@ -141,6 +160,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
{preset.alias} {preset.alias}
</ClientLink> </ClientLink>
))} ))}
</> </MenuGroup>
); );
} }
@@ -1,5 +1,5 @@
import { type HotkeyItem, useDisclosure, useHotkeys } from '@mantine/hooks'; import { useHotkeys } from '@mantine/hooks';
import { memo } from 'react'; import { memo, useCallback, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { hasCustomParams } from '../../stores/savedViewParams'; import { hasCustomParams } from '../../stores/savedViewParams';
@@ -13,32 +13,40 @@ interface ViewNavigationMenuProps {
isNavigationLocked?: boolean; isNavigationLocked?: boolean;
/** prevent showing settings */ /** prevent showing settings */
suppressSettings?: boolean; suppressSettings?: boolean;
suppressSpaceHotkey?: boolean; }
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
return (
target.closest(
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
) !== null
);
} }
export default memo(ViewNavigationMenu); export default memo(ViewNavigationMenu);
function ViewNavigationMenu({ isNavigationLocked, suppressSettings, suppressSpaceHotkey }: ViewNavigationMenuProps) { function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { open: showEditFormDrawer } = useViewParamsEditorStore(); const { open: showEditFormDrawer } = useViewParamsEditorStore();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const hasSavedChanges = hasCustomParams(searchParams); const hasSavedChanges = hasCustomParams(searchParams);
// Omitting the binding also avoids useHotkeys preventing the default action. const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
const spaceHotkey: HotkeyItem[] = suppressSpaceHotkey const closeMenu = useCallback(() => setIsMenuOpen(false), []);
? []
: [
[
'Space',
() => {
if (isNavigationLocked) return;
menuHandler.toggle();
},
{ preventDefault: true },
],
];
useHotkeys([ useHotkeys([
...spaceHotkey, [
'Space',
(event) => {
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
event.preventDefault();
toggleMenu();
},
{ preventDefault: false },
],
[ [
'mod + ,', 'mod + ,',
() => { () => {
@@ -56,11 +64,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings, suppressSpac
return ( return (
<> <>
<FloatingNavigation <FloatingNavigation
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle} toggleMenu={isNavigationLocked ? undefined : toggleMenu}
toggleSettings={suppressSettings ? undefined : showEditFormDrawer} toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
hasSavedChanges={hasSavedChanges} hasSavedChanges={hasSavedChanges}
/> />
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />} {!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
</> </>
); );
} }
@@ -2,35 +2,68 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.75rem 1.5rem; padding: 0.625rem 1.25rem;
gap: 0.5rem; gap: 0.625rem;
width: 100%; width: 100%;
border-left: 4px solid transparent; border-left: 3px solid transparent;
color: $action-text-color; color: $gray-200;
font-size: $text-body-size;
white-space: nowrap; white-space: nowrap;
text-align: left;
cursor: pointer; cursor: pointer;
transition-property: color; transition-property: color, background-color;
transition-duration: $transition-time-action; transition-duration: $transition-time-action;
// keep icons on a fixed box so labels align regardless of the glyph
> svg {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
color: $gray-500;
transition: color $transition-time-action;
}
&:hover { &:hover {
color: $ontime-color; background-color: $white-3;
background-color: $gray-1350; color: $ui-white;
> svg {
color: $gray-300;
}
} }
&:active { &:active {
background-color: $border-color-ondark; background-color: $white-7;
} }
&:focus { &:focus-visible {
outline: 2px solid $blue-500; outline: 2px solid $blue-500;
background-color: $gray-1350; outline-offset: -2px;
background-color: $white-3;
}
// toggle rows are labels, the focusable control sits inside them
&:focus-within {
background-color: $white-3;
} }
&.current { &.current {
background-color: $gray-1300; background-color: $bg-active;
border-left: 4px solid $action-text-color; border-left-color: $blue-400;
color: $ui-white;
font-weight: 600;
> svg {
color: $blue-300;
}
} }
} }
.label {
flex: 1;
min-width: 0;
@include ellipsis-text;
}
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import { isKeyEnter } from '../../../utils/keyEvent'; import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
import { cx } from '../../../utils/styleUtils'; import { cx } from '../../../utils/styleUtils';
import style from './NavigationMenuItem.module.scss'; import style from './NavigationMenuItem.module.scss';
@@ -11,6 +11,7 @@ interface NavigationMenuItemProps {
onClick: () => void; onClick: () => void;
} }
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
export default function NavigationMenuItem({ export default function NavigationMenuItem({
active, active,
className, className,
@@ -24,7 +25,9 @@ export default function NavigationMenuItem({
role='button' role='button'
onClick={onClick} onClick={onClick}
onKeyDown={(event) => { onKeyDown={(event) => {
if (isKeyEnter(event)) { if (isKeyEnter(event) || isKeySpace(event)) {
event.preventDefault();
event.stopPropagation();
onClick(); onClick();
} }
}} }}
@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import Switch from '../../switch/Switch';
import style from './NavigationMenuItem.module.scss';
interface NavigationMenuToggleProps {
checked: boolean;
icon: ReactNode;
label: string;
onToggle: () => void;
}
/** A menu row which reflects, and toggles, an on/off state */
export default function NavigationMenuToggle({ checked, icon, label, onToggle }: NavigationMenuToggleProps) {
return (
<label className={style.link}>
{icon}
<span className={style.label}>{label}</span>
<Switch checked={checked} onCheckedChange={onToggle} />
</label>
);
}
@@ -1,22 +1,52 @@
.footer {
flex: 0 0 auto;
padding: 0.75rem 0;
border-top: 1px solid $white-10;
}
.header { .header {
font-size: calc(1rem - 2px); padding: 0 1.25rem 0.25rem;
margin-left: 1rem;
color: $gray-700; color: $gray-700;
} }
.interfaces { .interfaces {
padding: 0.5rem 0.5rem; padding: 0.25rem 1.25rem 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.25rem;
}
.interface {
min-width: 0;
max-width: 100%;
> div {
max-width: 100%;
> button:first-child {
min-width: 0;
max-width: calc(100% - 1.5rem);
justify-content: flex-start;
padding-inline: 0.5rem;
}
}
} }
.interfaceCopy { .interfaceCopy {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0;
width: 100%;
}
.address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.goIcon { .goIcon {
flex: 0 0 auto;
@include rotate-fourty-five; @include rotate-fourty-five;
margin-left: 0.25rem; margin-left: 0.25rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
@@ -3,6 +3,8 @@ import { IoArrowUp } from 'react-icons/io5';
import useInfo from '../../../hooks-query/useInfo'; import useInfo from '../../../hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../utils/linkUtils'; import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
import CopyTag from '../../copy-tag/CopyTag'; import CopyTag from '../../copy-tag/CopyTag';
import Eyebrow from '../../eyebrow/Eyebrow';
import { getExternalInterfaces } from './otherAddresses.utils';
import style from './OtherAddresses.module.scss'; import style from './OtherAddresses.module.scss';
@@ -12,32 +14,33 @@ interface OtherAddressesProps {
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) { export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
const { data } = useInfo(); const { data } = useInfo();
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
// there is no point showing this if we only have one interface if (externalInterfaces.length === 0) {
if (data.networkInterfaces.length < 2) {
return null; return null;
} }
return ( return (
<> <div className={style.footer}>
<div className={style.header}>Accessible on external networks</div> <div className={style.header}>
<Eyebrow>Also available at</Eyebrow>
</div>
<div className={style.interfaces}> <div className={style.interfaces}>
{data?.networkInterfaces?.map((nif) => { {externalInterfaces.map((nif) => {
if (nif.name === 'localhost') {
return null;
}
const address = linkToOtherHost(nif.address, currentLocation); const address = linkToOtherHost(nif.address, currentLocation);
return ( return (
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'> <div key={nif.name} className={style.interface}>
<span className={style.interfaceCopy}> <CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
{nif.address} <IoArrowUp className={style.goIcon} /> <span className={style.interfaceCopy}>
</span> <span className={style.address}>{nif.address}</span>
</CopyTag> <IoArrowUp className={style.goIcon} />
</span>
</CopyTag>
</div>
); );
})} })}
</div> </div>
</> </div>
); );
} }
@@ -0,0 +1,31 @@
import { describe, expect, test } from 'vitest';
import { getExternalInterfaces } from '../otherAddresses.utils';
describe('getExternalInterfaces', () => {
test('returns no alternatives when localhost is the only interface', () => {
expect(getExternalInterfaces([{ name: 'localhost', address: '127.0.0.1' }])).toEqual([]);
});
test('does not treat an interface name as deployment information', () => {
expect(
getExternalInterfaces([
{ name: 'localhost', address: '127.0.0.1' },
{ name: 'cloud', address: '192.168.1.42' },
]),
).toEqual([{ name: 'cloud', address: '192.168.1.42' }]);
});
test('returns non-local interfaces when local and external interfaces are available', () => {
expect(
getExternalInterfaces([
{ name: 'localhost', address: '127.0.0.1' },
{ name: 'Wi-Fi', address: '192.168.1.42' },
{ name: 'Ethernet', address: '10.0.0.42' },
]),
).toEqual([
{ name: 'Wi-Fi', address: '192.168.1.42' },
{ name: 'Ethernet', address: '10.0.0.42' },
]);
});
});
@@ -0,0 +1,6 @@
import { type NetworkInterface } from 'ontime-types';
/** Returns network addresses other than the localhost address injected by the info endpoint. */
export function getExternalInterfaces(interfaces: NetworkInterface[]) {
return interfaces.filter((networkInterface) => networkInterface.name !== 'localhost');
}
@@ -2,6 +2,12 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
> span {
font-variant-numeric: tabular-nums;
text-align: right;
text-transform: uppercase;
}
} }
// attempt to match with ontimeTextInputs // attempt to match with ontimeTextInputs
@@ -1,37 +1,77 @@
.inline { .multiOptionList {
display: inline-flex; overflow-y: auto;
align-items: center; max-height: 12rem;
flex-wrap: wrap; background: $gray-1200;
gap: 1rem; border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
} }
// styles from subtle button
.toggleSelect { .toggleSelect {
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.75rem;
padding-inline: 0.5rem; min-height: 2.5rem;
height: 2.5rem; padding: 0.5rem 0.75rem;
background: $gray-1050; background: transparent;
color: $ui-white; border: 0;
color: $gray-300;
line-height: 1em; line-height: 1em;
border-radius: $component-border-radius-md; cursor: pointer;
transition:
background-color $transition-time-action,
border-color $transition-time-action,
color $transition-time-action;
&:hover:not(:disabled):not(:active) { &:hover {
background: $gray-1000; background: $gray-1000;
color: $blue-500; color: $ui-white;
} }
&:active:not(:disabled) { &:active {
background: $gray-1100; background: $gray-1100;
border-color: $gray-1250; }
&:focus-within {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
&.selected {
background: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
}
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
> [role='checkbox'] {
flex: 0 0 auto;
width: 1.125rem;
height: 1.125rem;
border: 1px solid $gray-400;
&[data-unchecked] {
background: $gray-1100;
}
&[data-checked] {
border-color: $blue-400;
}
&:focus-visible {
outline: 0;
}
} }
&::after { &::after {
content: ''; content: '';
margin-left: 0.25rem; flex: 0 0 auto;
width: 0.75em; margin-left: auto;
height: 0.75em; width: 0.625rem;
height: 0.625rem;
background: var(--user-bg, $gray-900); background: var(--user-bg, $gray-900);
border-radius: 50%; border-radius: 50%;
} }
@@ -2,6 +2,7 @@ import { ComponentProps, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { isStringBoolean } from '../../../views/common/viewUtils'; import { isStringBoolean } from '../../../views/common/viewUtils';
import { cx } from '../../utils/styleUtils';
import Checkbox from '../checkbox/Checkbox'; import Checkbox from '../checkbox/Checkbox';
import Input from '../input/input/Input'; import Input from '../input/input/Input';
import Select, { SelectOption } from '../select/Select'; import Select, { SelectOption } from '../select/Select';
@@ -49,13 +50,7 @@ export default function ParamInput({ paramField }: ParamInputProps) {
} }
if (type === 'boolean') { if (type === 'boolean') {
const paramValue = searchParams.get(id); return <ControlledSwitch id={id} initialValue={isStringBoolean(searchParams.get(id)) ?? defaultValue} />;
return (
<ControlledSwitch
id={id}
initialValue={paramValue === null ? Boolean(defaultValue) : isStringBoolean(paramValue)}
/>
);
} }
if (type === 'number') { if (type === 'number') {
@@ -113,18 +108,20 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
return ( return (
<> <>
<input name={id} hidden readOnly value={paramState.join(',')} /> <input name={id} hidden readOnly value={paramState.join(',')} />
<div className={style.inline}> <div className={style.multiOptionList}>
{values.map((option) => { {values.map((option) => {
const selected = paramState.includes(option.value);
return ( return (
<label <label
key={option.value} key={option.value}
className={style.toggleSelect} className={cx([style.toggleSelect, selected && style.selected])}
style={{ style={{
'--user-bg': option.colour, '--user-bg': option.colour,
}} }}
> >
<Checkbox <Checkbox
checked={paramState.includes(option.value)} checked={selected}
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)} onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
/> />
{option.label} {option.label}
@@ -1,13 +1,3 @@
.drawerFooter {
display: flex;
justify-content: end;
gap: $section-spacing;
button {
padding: 0 2em;
}
}
.backdrop { .backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -34,11 +24,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-block: 1rem 1.5rem;
background-color: $gray-1250; background-color: $ui-black;
color: $ui-white; color: $ui-white;
border-left: 1px solid $gray-1100; border-left: 1px solid $gray-1100;
box-shadow: $box-shadow-l1;
&[data-open] { &[data-open] {
transform: translateX(0%); transform: translateX(0%);
@@ -58,36 +48,54 @@
} }
.header { .header {
padding-inline: 1rem; flex: 0 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 3.5rem; gap: 1rem;
min-height: 4rem;
padding: 0.75rem 1rem;
}
.headerText {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.2;
}
.target {
font-size: $aux-text-size;
color: $gray-600;
} }
.body { .body {
flex: 1; flex: 1;
padding-inline: 1rem; padding: 1rem;
padding-bottom: 10vh;
overflow-y: auto; overflow-y: auto;
} }
.footer { .footer {
flex: 0 0 auto;
display: flex; display: flex;
padding-inline: 1rem; padding: 0.75rem 1rem;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
background-color: $ui-black;
border-top: 1px solid $white-10;
} }
.sectionList { .sectionList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 0.75rem;
padding-right: 0.5rem;
} }
.info { .info {
@@ -4,6 +4,7 @@ import { FormEvent, memo } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { viewLabels } from '../../../viewerConfig';
import useViewSettings from '../../hooks-query/useViewSettings'; import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen'; import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams'; import { useSavedViewParams } from '../../stores/savedViewParams';
@@ -71,8 +72,17 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
<Dialog.Backdrop className={style.backdrop} /> <Dialog.Backdrop className={style.backdrop} />
<Dialog.Popup className={style.drawer}> <Dialog.Popup className={style.drawer}>
<div className={style.header}> <div className={style.header}>
<Dialog.Title>Customise</Dialog.Title> <div className={style.headerText}>
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}> <Dialog.Title className={style.title}>Customise</Dialog.Title>
<span className={style.target}>{viewLabels[target]}</span>
</div>
<IconButton
variant='subtle-white'
size='large'
aria-label='Close'
data-testid='close-view-params'
onClick={handleClose}
>
<IoClose /> <IoClose />
</IconButton> </IconButton>
</div> </div>
@@ -1,10 +1,21 @@
.presetSection { .presetSection {
background-color: $gray-1350; background-color: $gray-1350;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
margin-bottom: 0.75rem;
}
.header {
padding: 0.5rem 1rem;
min-height: 2.25rem;
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 0.25rem;
padding: 1rem 0.5rem; color: $gray-300;
margin-bottom: 1rem; }
.presetList {
border-top: 1px solid $white-7;
max-height: 10rem; max-height: 10rem;
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable; scrollbar-gutter: stable;
@@ -14,12 +25,39 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
min-height: 2.5rem;
padding: 0.375rem 1rem;
font-size: $inner-section-text-size;
&:not(:last-child) {
border-bottom: 1px solid $white-7;
}
&.active { &.active {
color: $blue-500; background-color: $bg-active;
box-shadow: inset 3px 0 $blue-400;
color: $ui-white;
font-weight: 600;
} }
} }
.presetName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presetActions { .presetActions {
margin-left: auto; margin-left: auto;
} }
.current {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
color: $blue-400;
font-size: $aux-text-size;
font-weight: 600;
}
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets'; import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Button from '../buttons/Button'; import Button from '../buttons/Button';
import Eyebrow from '../eyebrow/Eyebrow';
import style from './ViewParamsPresets.module.scss'; import style from './ViewParamsPresets.module.scss';
@@ -26,22 +27,31 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
return ( return (
<div className={style.presetSection}> <div className={style.presetSection}>
{viewPresets.map((preset) => { <div className={style.header}>
const active = searchParams.get('alias') === preset.alias; <Eyebrow>Saved presets</Eyebrow>
return ( </div>
<div key={preset.alias} className={cx([style.preset, active && style.active])}> <div className={style.presetList}>
<div>{preset.alias}</div> {viewPresets.map((preset) => {
<Button const active = searchParams.get('alias') === preset.alias;
variant={active ? 'ghosted' : 'subtle-white'} return (
onClick={() => handleRecall(preset)} <div key={preset.alias} className={cx([style.preset, active && style.active])}>
disabled={active} <span className={style.presetName}>{preset.alias}</span>
className={style.presetActions} {active ? (
> <span className={style.current}>Current</span>
{active ? 'Applied' : 'Apply'} ) : (
</Button> <Button
</div> variant='ghosted'
); size='small'
})} onClick={() => handleRecall(preset)}
className={style.presetActions}
>
Apply
</Button>
)}
</div>
);
})}
</div>
</div> </div>
); );
} }
@@ -1,48 +1,118 @@
.section { .section {
overflow: hidden;
color: $ui-white; color: $ui-white;
font-size: 1rem; font-size: 1rem;
background-color: $white-3;
border: 1px solid $gray-1100;
border-radius: $component-border-radius-md;
} }
.sectionHeader { .sectionHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
height: 2rem; gap: 0.5rem;
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 1rem;
background: transparent;
border: 0;
font: inherit;
text-align: left;
color: $gray-300;
&.collapsible { &.collapsible {
cursor: pointer; cursor: pointer;
transition-property: color, background-color;
transition-duration: $transition-time-action;
&:hover {
background-color: $white-3;
color: $ui-white;
}
&:focus-visible {
outline: 2px solid $blue-500;
outline-offset: -2px;
}
} }
} }
.sectionTitle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
/** marks a section which holds values coming from the URL */
.customised {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background-color: $active-indicator;
flex-shrink: 0;
}
.chevron {
flex-shrink: 0;
rotate: 180deg;
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&.closed {
rotate: 0deg;
}
}
.options {
padding-inline: 1rem;
border-top: 1px solid $white-7;
}
.label { .label {
margin-top: $section-spacing; display: grid;
grid-template-columns: minmax(0, 1fr);
row-gap: 0.25rem;
padding: 0.75rem 0;
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $label-gray;
display: flex; &:not(:last-child) {
flex-direction: column; border-bottom: 1px solid $white-7;
gap: 0.25rem; }
}
/**
* Compact controls sit beside their label instead of below it.
* Placement is done with grid areas so that the markup stays flat.
*/
.inline {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
column-gap: 1rem;
> .title {
grid-area: 1 / 1;
}
> .description {
grid-area: 2 / 1;
}
> *:last-child {
grid-area: 1 / 2 / span 2;
}
} }
.title { .title {
font-size: $inner-section-text-size; color: $ui-white;
display: block;
width: 100%;
} }
.description { .description {
font-size: $inner-section-text-size; color: $gray-400;
display: block; font-size: $aux-text-size;
color: $modal-note-color; line-height: 1.4;
}
.closed {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 0deg;
}
.open {
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
rotate: 180deg;
} }
.hidden { .hidden {
@@ -1,7 +1,9 @@
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from 'react-icons/io5'; import { IoChevronDown } from 'react-icons/io5';
import { useSearchParams } from 'react-router';
import { cx } from '../../utils/styleUtils'; import { cx } from '../../utils/styleUtils';
import Eyebrow from '../eyebrow/Eyebrow';
import { OptionTitle } from './constants'; import { OptionTitle } from './constants';
import ParamInput from './ParamInput'; import ParamInput from './ParamInput';
import { type ParamField } from './viewParams.types'; import { type ParamField } from './viewParams.types';
@@ -14,50 +16,74 @@ interface ViewParamsSectionProps {
options: ParamField[]; options: ParamField[];
} }
/** Fields which are compact enough to share a row with their label */
function isInlineField(option: ParamField): boolean {
return option.type === 'boolean' || option.type === 'colour';
}
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) { export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const [searchParams] = useSearchParams();
const handleCollapse = () => { // hidden options are inputs without a UI, there is nothing to frame them with
if (collapsible) { if (title === OptionTitle.Hidden) {
setCollapsed((prev) => !prev); return <HiddenContents options={options} />;
} }
};
const isCollapsed = Boolean(collapsible && collapsed);
return ( return (
<section className={style.section}> <section className={style.section}>
{title === OptionTitle.Hidden ? ( <SectionHeader
<HiddenContents options={options} /> title={title}
) : ( isCustomised={options.some((option) => searchParams.has(option.id))}
<> collapsed={isCollapsed}
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}> onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
{title} />
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />} {/* collapsed options stay mounted: the form reads its values from the DOM */}
</div> <div className={cx([style.options, isCollapsed && style.hidden])}>
<SectionContents options={options} collapsed={collapsed} /> {options.map((option) => (
</> <label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
)}
</section>
);
}
interface SectionContentsProps {
options: ParamField[];
collapsed: boolean;
}
function SectionContents({ options, collapsed }: SectionContentsProps) {
return (
<>
{options.map((option) => {
return (
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
<span className={style.title}>{option.title}</span> <span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span> <span className={style.description}>{option.description}</span>
<ParamInput paramField={option} /> <ParamInput paramField={option} />
</label> </label>
); ))}
})} </div>
</> </section>
);
}
interface SectionHeaderProps {
title: string;
isCustomised: boolean;
collapsed: boolean;
/** a header without a toggle belongs to a section which cannot collapse */
onToggle?: () => void;
}
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
const label = (
<Eyebrow className={style.sectionTitle}>
{title}
{isCustomised && <span className={style.customised} title='Contains custom values' />}
</Eyebrow>
);
if (!onToggle) {
return <div className={style.sectionHeader}>{label}</div>;
}
return (
<button
type='button'
className={cx([style.sectionHeader, style.collapsible])}
aria-expanded={!collapsed}
onClick={onToggle}
>
{label}
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
</button>
); );
} }
@@ -228,41 +228,11 @@ describe('getURLSearchParamsFromObj()', () => {
}, },
]; ];
const params = { const params = {
bool1: 'off',
bool2: 'on', bool2: 'on',
}; };
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans); const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
expect(result.get('bool1')).toBe('false'); expect(result.get('bool1')).toBe('false');
expect(result.get('bool2')).toBe('true'); expect(result.get('bool2')).toBe('true');
}); });
it('omits booleans which match their default', () => {
const mockOptionsWithBooleans: ViewOption[] = [
{
title: OptionTitle.StyleOverride,
options: [
{
id: 'onByDefault',
title: 'onByDefault',
description: 'On by default',
type: 'boolean',
defaultValue: true,
},
{
id: 'offByDefault',
title: 'offByDefault',
description: 'Off by default',
type: 'boolean',
defaultValue: false,
},
],
},
];
const untouched = getURLSearchParamsFromObj({ onByDefault: 'on' }, mockOptionsWithBooleans);
expect(untouched.toString()).toBe('');
const flipped = getURLSearchParamsFromObj({ offByDefault: 'on' }, mockOptionsWithBooleans);
expect(flipped.get('onByDefault')).toBe('false');
expect(flipped.get('offByDefault')).toBe('true');
});
}); });
@@ -155,14 +155,6 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
}); });
}); });
// Unchecked checkboxes are absent from FormData. Persist false when true is the default.
metadata.booleanFields.forEach((id) => {
if (id in paramsObj) return;
if (metadata.defaultValues[id] !== 'false') {
addUniqueParam(id, 'false');
}
});
// Then process user-provided values // Then process user-provided values
Object.entries(paramsObj).forEach(([id, value]) => { Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length) { if (typeof value === 'string' && value.length) {
@@ -2,8 +2,8 @@ import { MaybeString } from 'ontime-types';
import { RefObject, useCallback, useEffect } from 'react'; import { RefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>( function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: RefObject<ComponentRef>, componentRef: RefObject<ComponentRef | null>,
scrollRef: RefObject<ScrollRef>, scrollRef: RefObject<ScrollRef | null>,
topOffset: number, topOffset: number,
) { ) {
if (!componentRef.current || !scrollRef.current) { if (!componentRef.current || !scrollRef.current) {
@@ -21,18 +21,16 @@ interface UseFollowComponentProps {
followRef: RefObject<HTMLElement | null>; followRef: RefObject<HTMLElement | null>;
scrollRef: RefObject<HTMLElement | null>; scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean; doFollow: boolean;
topOffset?: number; followTrigger: MaybeString; // this would be an entry id or null
setScrollFlag?: (newValue: boolean) => void; getTopOffset: () => number;
followTrigger?: MaybeString; // this would be an entry id or null
} }
export default function useFollowComponent({ export default function useFollowComponent({
followRef, followRef,
scrollRef, scrollRef,
doFollow, doFollow,
topOffset = 100,
setScrollFlag,
followTrigger, followTrigger,
getTopOffset,
}: UseFollowComponentProps) { }: UseFollowComponentProps) {
// when trigger moves, view should follow // when trigger moves, view should follow
useEffect(() => { useEffect(() => {
@@ -41,25 +39,17 @@ export default function useFollowComponent({
} }
if (followRef.current && scrollRef.current) { if (followRef.current && scrollRef.current) {
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded // Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset); // resolve the offset after layout, so that measured values are up to date
setScrollFlag?.(false); scrollToComponent(followRef, scrollRef, getTopOffset());
}); });
} }
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]); }, [followTrigger, doFollow, followRef, scrollRef, getTopOffset]);
const scrollToRefComponent = useCallback( const scrollToRefComponent = useCallback(() => {
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => { scrollToComponent(followRef, scrollRef, getTopOffset());
if (componentRef && containerRef) { }, [followRef, scrollRef, getTopOffset]);
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent; return scrollToRefComponent;
} }
+4
View File
@@ -4,6 +4,10 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Enter'; return event.key === 'Enter';
} }
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
return event.key === ' ' || event.code === 'Space';
}
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean { export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
return event.key === 'Escape'; return event.key === 'Escape';
} }
-35
View File
@@ -1,5 +1,3 @@
import { AppMode } from '../ontimeConfig';
declare module '*.scss' { declare module '*.scss' {
const content: Record<string, string>; const content: Record<string, string>;
export default content; export default content;
@@ -32,39 +30,6 @@ declare global {
} }
} }
/**
* Declare custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
*
* And metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
declare module '@tanstack/react-table' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableMeta<TData extends RowData> {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
canWrite: boolean;
colour?: string;
}
}
/** /**
* Allow passing CSS Properties * Allow passing CSS Properties
*/ */
@@ -53,7 +53,7 @@ ul {
&.active { &.active {
color: $ui-white; color: $ui-white;
border-left-color: $blue-400; border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16); background-color: $bg-active;
font-weight: 600; font-weight: 600;
} }
@@ -79,7 +79,7 @@ ul {
&.active { &.active {
color: $ui-white; color: $ui-white;
border-left-color: $blue-400; border-left-color: $blue-400;
background-color: rgba($blue-500, 0.16); background-color: $bg-active;
font-weight: 600; font-weight: 600;
} }
} }
@@ -124,7 +124,7 @@ $card-padding: 2rem;
// active rows use a panel-local class today; keep the treatment here so all // active rows use a panel-local class today; keep the treatment here so all
// settings tables share the same state without each panel owning its colours // settings tables share the same state without each panel owning its colours
tr[class*='current'] { tr[class*='current'] {
background-color: rgba($blue-500, 0.16); background-color: $bg-active;
> td:first-child { > td:first-child {
box-shadow: inset 3px 0 $blue-400; box-shadow: inset 3px 0 $blue-400;
@@ -12,21 +12,21 @@ import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPres
import { isUrlSafe } from '../../../../../common/utils/regex'; import { isUrlSafe } from '../../../../../common/utils/regex';
import { enDash } from '../../../../../common/utils/styleUtils'; import { enDash } from '../../../../../common/utils/styleUtils';
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets'; import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
import { viewLabels } from '../../../../../viewerConfig';
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions'; import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import style from './URLPresetForm.module.scss'; import style from './URLPresetForm.module.scss';
const targetOptions: SelectOption<OntimeViewPresettable>[] = [ const targetOptions: SelectOption<OntimeViewPresettable>[] = [
{ value: OntimeView.Cuesheet, label: 'Cuesheet' }, { value: OntimeView.Cuesheet, label: viewLabels[OntimeView.Cuesheet] },
{ value: OntimeView.Operator, label: 'Operator' }, { value: OntimeView.Operator, label: viewLabels[OntimeView.Operator] },
{ value: OntimeView.Timer, label: 'Timer' }, { value: OntimeView.Timer, label: viewLabels[OntimeView.Timer] },
{ value: OntimeView.Backstage, label: 'Backstage' }, { value: OntimeView.Backstage, label: viewLabels[OntimeView.Backstage] },
{ value: OntimeView.Timeline, label: 'Timeline' }, { value: OntimeView.Timeline, label: viewLabels[OntimeView.Timeline] },
{ value: OntimeView.StudioClock, label: 'Studio Clock' }, { value: OntimeView.StudioClock, label: viewLabels[OntimeView.StudioClock] },
{ value: OntimeView.Countdown, label: 'Countdown' }, { value: OntimeView.Countdown, label: viewLabels[OntimeView.Countdown] },
{ value: OntimeView.ProjectInfo, label: 'Project Info' }, { value: OntimeView.ProjectInfo, label: viewLabels[OntimeView.ProjectInfo] },
{ value: OntimeView.Teleprompter, label: 'Teleprompter' },
]; ];
const formId = 'url-preset-form'; const formId = 'url-preset-form';
+19 -30
View File
@@ -3,6 +3,7 @@ import { useCallback, useState } from 'react';
import { IoClose } from 'react-icons/io5'; import { IoClose } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
import ToggleButton from '../../common/components/buttons/ToggleButton';
import { clearLogs, useLogData } from '../../common/stores/logger'; import { clearLogs, useLogData } from '../../common/stores/logger';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import * as Panel from '../app-settings/panel-utils/PanelUtils'; import * as Panel from '../app-settings/panel-utils/PanelUtils';
@@ -55,72 +56,60 @@ export default function Log() {
<div className={cx([style.container, isExtracted && style.extracted])}> <div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}> <Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span> <span className={style.filterLabel}>Filter by</span>
<Button <ToggleButton
variant={showUser ? 'primary' : 'subtle'} pressed={showUser}
size='small' size='small'
aria-pressed={showUser}
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
onClick={() => setShowUser((s) => !s)} onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)} onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.User} {LogOrigin.User}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showClient ? 'primary' : 'subtle'} pressed={showClient}
size='small' size='small'
aria-pressed={showClient}
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
onClick={() => setShowClient((s) => !s)} onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)} onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Client} {LogOrigin.Client}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showServer ? 'primary' : 'subtle'} pressed={showServer}
size='small' size='small'
aria-pressed={showServer}
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
onClick={() => setShowServer((s) => !s)} onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)} onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Server} {LogOrigin.Server}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showPlayback ? 'primary' : 'subtle'} pressed={showPlayback}
size='small' size='small'
aria-pressed={showPlayback}
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
onClick={() => setShowPlayback((s) => !s)} onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)} onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Playback} {LogOrigin.Playback}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showRx ? 'primary' : 'subtle'} pressed={showRx}
size='small' size='small'
aria-pressed={showRx}
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
onClick={() => setShowRx((s) => !s)} onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)} onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Rx} {LogOrigin.Rx}
</Button> </ToggleButton>
<Button <ToggleButton
variant={showTx ? 'primary' : 'subtle'} pressed={showTx}
size='small' size='small'
aria-pressed={showTx}
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
onClick={() => setShowTx((s) => !s)} onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)} onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{LogOrigin.Tx} {LogOrigin.Tx}
</Button> </ToggleButton>
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}> <Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear <IoClose /> Clear
</Button> </Button>
@@ -18,6 +18,12 @@
padding-bottom: 95vh; padding-bottom: 95vh;
} }
.groupSection {
display: flex;
flex-direction: column;
gap: 2px;
}
.editPrompt { .editPrompt {
position: fixed; position: fixed;
z-index: $zindex-dialog; z-index: $zindex-dialog;
+27 -14
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill'; import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage'; import EmptyPage from '../../common/components/state/EmptyPage';
@@ -25,7 +25,10 @@ import { OperatorData, useOperatorData } from './useOperatorData';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
const selectedOffset = 50; /** Keeps the running event clear of the list edge when no group header is pinned above it */
const edgeOffset = 50;
/** How far the running event may drift from where we placed it before we stop following */
const followTolerance = 50;
export default function OperatorLoader() { export default function OperatorLoader() {
const { data, status } = useOperatorData(); const { data, status } = useOperatorData();
@@ -54,11 +57,20 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
// The header height varies with the viewport, so measure it at scroll time.
const getTopOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Sit right under the pinned header, so it covers the previous event instead of half of it.
return header ? header.offsetHeight + 2 : edgeOffset;
}, []);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: selectedOffset, getTopOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -82,15 +94,16 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) { if (!selectedRef.current || !scrollRef.current) {
const selectedRect = selectedRef.current.getBoundingClientRect(); return;
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
} }
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Measure the drift from where an automated scroll would place the event.
const distanceFromTop = selectedRect.top - scrollerRect.top - getTopOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > followTolerance;
setLockAutoScroll(hasScrolledOutOfThreshold);
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -186,9 +199,9 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
} }
return ( return (
<Fragment key={entry.id}> <div className={style.groupSection} key={entry.id}>
<OperatorGroup <OperatorGroup
key={entry.id} ref={isCurrentParent ? stickyHeaderRef : undefined}
title={entry.title} title={entry.title}
colour={entry.colour} colour={entry.colour}
count={entry.entries.length} count={entry.entries.length}
@@ -239,7 +252,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/> />
); );
})} })}
</Fragment> </div>
); );
} }
return null; return null;
@@ -1,13 +1,20 @@
.group { .group {
width: 100%; width: 100%;
/* Padding is kept under the min-height so a single line fits without the list having to shrink the header,
while a taller title still grows the row. */
min-height: 2.5rem; min-height: 2.5rem;
padding: 0.4rem 0.75rem; padding: 0.25rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500); border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350; background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%); background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
position: sticky;
/* Cover the list padding so rows cannot scroll above the header. */
top: -0.25rem;
z-index: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -1,4 +1,4 @@
import { CSSProperties, memo } from 'react'; import { type CSSProperties, type Ref, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
@@ -10,15 +10,16 @@ interface OperatorGroup {
colour: string; colour: string;
count: number; count: number;
duration: number; duration: number;
ref?: Ref<HTMLDivElement>;
} }
export default memo(OperatorGroup); export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) { function OperatorGroup({ title, colour, count, duration, ref }: OperatorGroup) {
const groupColour = colour || '#929292'; const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour); const groupColours = getAccessibleColour(groupColour);
return ( return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}> <div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties} ref={ref}>
<span className={style.title}>{title}</span> <span className={style.title}>{title}</span>
<span className={style.meta}> <span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span> <span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
@@ -17,10 +17,59 @@
} }
.shortcuts { .shortcuts {
display: grid;
gap: 0.875rem;
margin-top: 0.875rem; margin-top: 0.875rem;
}
--shortcut-title-color: #{$ui-white}; .shortcutGroup {
--shortcut-separator-color: #{$gray-500}; h3 {
margin: 0 0 0.375rem;
color: $ui-white;
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
}
}
.shortcutList {
display: grid;
gap: 0.25rem;
}
.shortcutRow {
min-height: 1.625rem;
display: grid;
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
align-items: center;
gap: 0.75rem;
font-size: calc(1rem - 3px);
}
.shortcutLabel {
min-width: 0;
line-height: 1.2;
}
.shortcutKeys {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.25rem 0.5rem;
min-width: 0;
}
.keyCombo {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
gap: 0.25rem 0;
}
.separator {
color: $gray-500;
font-size: calc(1rem - 5px);
} }
.prompt { .prompt {
@@ -35,4 +84,13 @@
.shortcutSection { .shortcutSection {
margin-top: 1rem; margin-top: 1rem;
} }
.shortcutRow {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.shortcutKeys {
justify-content: flex-start;
}
} }
@@ -1,13 +1,7 @@
import { memo } from 'react'; import { PropsWithChildren, memo } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import { import Kbd from '../../../common/components/kbd/Kbd';
Combo,
Separator,
Shortcut,
ShortcutGroup,
ShortcutGroups,
} from '../../../common/components/keyboard-shortcuts/KeyboardShortcuts';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils'; import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss'; import style from './EventEditorEmpty.module.scss';
@@ -19,7 +13,7 @@ function EventEditorEmpty() {
<div className={style.entryEditor} data-testid='editor-container'> <div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}> <div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title> <Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<ShortcutGroups className={style.shortcuts}> <div className={style.shortcuts}>
<ShortcutGroup title='Search'> <ShortcutGroup title='Search'>
<Shortcut label='Find in rundown'> <Shortcut label='Find in rundown'>
<Combo keys={[deviceMod, 'F']} /> <Combo keys={[deviceMod, 'F']} />
@@ -106,8 +100,40 @@ function EventEditorEmpty() {
<Combo keys={[deviceAlt, 'Shift', 'D']} /> <Combo keys={[deviceAlt, 'Shift', 'D']} />
</Shortcut> </Shortcut>
</ShortcutGroup> </ShortcutGroup>
</ShortcutGroups> </div>
</div> </div>
</div> </div>
); );
} }
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className={style.shortcutGroup}>
<h3>{title}</h3>
<div className={style.shortcutList}>{children}</div>
</section>
);
}
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
return (
<div className={style.shortcutRow}>
<span className={style.shortcutLabel}>{label}</span>
<span className={style.shortcutKeys}>{children}</span>
</div>
);
}
function Combo({ keys }: { keys: string[] }) {
return (
<span className={style.keyCombo}>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
);
}
function Separator() {
return <span className={style.separator}>/</span>;
}
@@ -8,10 +8,16 @@ export default memo(FinderPlacement);
function FinderPlacement() { function FinderPlacement() {
const [isOpen, handler] = useDisclosure(); const [isOpen, handler] = useDisclosure();
useHotkeys([ /**
['mod + f', handler.toggle, { preventDefault: true }], * The empty tagsToIgnore is significant: by default the hook skips input elements,
['Escape', handler.close, { preventDefault: true }], * which would make the shortcut dead while editing an entry.
]); *
* This opens rather than toggles. Toggling on a key that also mounts and unmounts the
* dialog races against it, and browsers treat a repeated find shortcut as "focus the
* search again" rather than "close it". The finder selects its input instead, and
* Escape closes.
*/
useHotkeys([['mod + f', handler.open, { preventDefault: true }]], []);
if (isOpen) { if (isOpen) {
return <Finder isOpen={isOpen} onClose={handler.close} />; return <Finder isOpen={isOpen} onClose={handler.close} />;
@@ -312,6 +312,7 @@ export default function RundownEvent({
onClick={handleFocusClick} onClick={handleFocusClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
data-testid='rundown-event' data-testid='rundown-event'
data-selected={isSelected}
{...(isPlaying ? { 'data-running': true } : {})} {...(isPlaying ? { 'data-running': true } : {})}
> >
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} /> <RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
@@ -1,15 +1,14 @@
import type { ColumnDef } from '@tanstack/react-table';
import type { CustomFields } from 'ontime-types'; import type { CustomFields } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import type { CuesheetColumnDef } from '../../../views/cuesheet/cuesheet-table/cuesheetTable.features';
/** /**
* Creates column definitions for the rundown table * Creates column definitions for the rundown table
* Reuses cuesheetColsFactory with preset=undefined for full access * Reuses cuesheetColsFactory with preset=undefined for full access
*/ */
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] { export function makeRundownColumns(customFields: CustomFields): CuesheetColumnDef[] {
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true // When preset=undefined, factory defaults to fullRead=true, fullWrite=true
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run) // canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
return makeCuesheetColumns(customFields, AppMode.Edit, undefined); return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
@@ -35,7 +35,6 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
{ value: OntimeView.Timer, label: 'Timer' }, { value: OntimeView.Timer, label: 'Timer' },
{ value: OntimeView.Cuesheet, label: 'Cuesheet' }, { value: OntimeView.Cuesheet, label: 'Cuesheet' },
{ value: OntimeView.Operator, label: 'Operator' }, { value: OntimeView.Operator, label: 'Operator' },
{ value: OntimeView.Teleprompter, label: 'Teleprompter' },
{ value: '<<companion>>', label: 'Companion' }, { value: '<<companion>>', label: 'Companion' },
...urlPresetData.map((preset) => ({ ...urlPresetData.map((preset) => ({
value: `preset-${preset.alias}`, value: `preset-${preset.alias}`,
+1
View File
@@ -48,6 +48,7 @@ $playback-under: $green-500;
// interface panels // interface panels
$bg-container-l1: $gray-1350; $bg-container-l1: $gray-1350;
$bg-container-l2: $gray-1300; $bg-container-l2: $gray-1300;
$bg-active: rgba($action-blue, 0.16);
$backdrop-color: rgba(0, 0, 0, 0.7); $backdrop-color: rgba(0, 0, 0, 0.7);
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0; $box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
+25 -8
View File
@@ -1,13 +1,30 @@
export const navigatorConstants = [ import { OntimeView } from 'ontime-types';
{ url: 'timer', label: 'Timer' },
{ url: 'backstage', label: 'Backstage' }, /** User facing name of each view, the single source for view naming in the UI */
{ url: 'timeline', label: 'Timeline' }, export const viewLabels: Record<OntimeView, string> = {
{ url: 'studio', label: 'Studio Clock' }, [OntimeView.Editor]: 'Editor',
{ url: 'countdown', label: 'Countdown' }, [OntimeView.Cuesheet]: 'Cuesheet',
{ url: 'teleprompter', label: 'Teleprompter' }, [OntimeView.Operator]: 'Operator',
{ url: 'info', label: 'Project Info' }, [OntimeView.Timer]: 'Timer',
[OntimeView.Backstage]: 'Backstage',
[OntimeView.Timeline]: 'Timeline',
[OntimeView.StudioClock]: 'Studio Clock',
[OntimeView.Countdown]: 'Countdown',
[OntimeView.ProjectInfo]: 'Project Info',
};
/** Views offered in the navigation menu, in the order they are shown. Their value is also their route */
const navigatorViews = [
OntimeView.Timer,
OntimeView.Backstage,
OntimeView.Timeline,
OntimeView.StudioClock,
OntimeView.Countdown,
OntimeView.ProjectInfo,
]; ];
export const navigatorConstants = navigatorViews.map((view) => ({ url: view, label: viewLabels[view] }));
// default time format to use for users in 12 hour clocks // default time format to use for users in 12 hour clocks
export const FORMAT_12 = 'h:mm:ss a'; export const FORMAT_12 = 'h:mm:ss a';
// default time format to use for users in 24 hour clocks // default time format to use for users in 24 hour clocks
+31 -3
View File
@@ -100,7 +100,20 @@ $item-height: 3.5rem;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom))); padding-bottom: 95vh;
}
/* Flex prevents row margins collapsing and bounds the sticky header to its group. */
.sub-section {
display: flex;
flex-direction: column;
/* The select view renders the same cards in a flat list. */
.sub--group {
position: sticky;
top: 0;
z-index: 1;
}
} }
/* ====================== LIST-ITEM ======================*/ /* ====================== LIST-ITEM ======================*/
@@ -196,9 +209,14 @@ $item-height: 3.5rem;
.sub--group { .sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325); box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background: /* The opaque base prevents rows showing through; background shorthand cannot layer this colour. */
background-color: var(--background-color-override, $viewer-background-color);
background-image:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%), linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
var(--card-background-color-override, $viewer-card-bg-color); linear-gradient(
var(--card-background-color-override, $viewer-card-bg-color),
var(--card-background-color-override, $viewer-card-bg-color)
);
.sub__binder { .sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color)); background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
@@ -230,6 +248,16 @@ $item-height: 3.5rem;
} }
} }
/* Reserve the green fill for the running event. */
.sub--group.sub--live {
box-shadow: inset 0 0 0 2px $active-green;
}
/* Keep the armed state quieter than the live ring. */
.sub--group.sub--armed {
box-shadow: inset 0 0 0 2px $gray-1000;
}
.sub__title { .sub__title {
grid-area: title; grid-area: title;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils'; import { dayInMs } from 'ontime-utils';
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5'; import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button'; import Button from '../../common/components/buttons/Button';
@@ -22,6 +22,7 @@ import {
CountdownTarget, CountdownTarget,
extendEventData, extendEventData,
getIsLive, getIsLive,
groupSubscriptionTargets,
isOutsideRange, isOutsideRange,
preferredFormat12, preferredFormat12,
preferredFormat24, preferredFormat24,
@@ -48,11 +49,22 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false); const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
// Responsive sizing and wrapped titles make the sticky header height variable, so measure it at scroll time.
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// Preserve the combined margins between the header and running event.
return header ? header.offsetHeight + 4 : 0;
}, []);
const scrollToComponent = useFollowComponent({ const scrollToComponent = useFollowComponent({
followRef: selectedRef, followRef: selectedRef,
scrollRef, scrollRef,
doFollow: !lockAutoScroll, doFollow: !lockAutoScroll,
topOffset: 0, getTopOffset: getStickyOffset,
followTrigger: selectedEventId, followTrigger: selectedEventId,
}); });
@@ -75,15 +87,16 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
// prevent considering automated scrolls as user scrolls // prevent considering automated scrolls as user scrolls
const handleUserScroll = () => { const handleUserScroll = () => {
if (selectedRef?.current && scrollRef?.current) { if (!selectedRef.current || !scrollRef.current) {
const selectedRect = selectedRef.current.getBoundingClientRect(); return;
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
} }
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
// Keep the threshold relative to the visible rows below the sticky header.
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}; };
const throttledHandleScroll = throttle(handleUserScroll, 1000); const throttledHandleScroll = throttle(handleUserScroll, 1000);
@@ -98,41 +111,63 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
return ( return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => { {sections.map((section) => {
// while a group is live, surface the running event's title as the secondary line const rows = section.group ? [section.group, ...section.events] : section.events;
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined; // the running event anchors the scroll, the group header stays pinned above it
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource); const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return ( return (
<div <div key={section.group?.id ?? rows[0].id} className='sub-section'>
key={event.id} {rows.map((event) => {
ref={isLive ? selectedRef : undefined} // while a group is live, surface the running event's title as the secondary line
className={cx([ const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
'sub', const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
isLive && 'sub--live', const isGroupedEvent = !event.isGroup && Boolean(event.parent);
isArmed && 'sub--armed', const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
event.isGroup && 'sub--group', // a subscribed group is live when any of its children is the selected/running event
isGroupedEvent && 'sub--in-group', const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
])} const isArmed = !isLive && activeEntryId === selectedEventId;
data-testid={event.cue} // only ever hand the ref to a single row, sharing it would null it out on the next commit
> const isAnchor = isLive && (anchorId === null || event.id === anchorId);
<div const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
className='sub__binder' const countdownEvent = extendEventData(
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }} event,
/> currentDay,
<ScheduleTime event={countdownEvent} showExpected={showExpected} /> actualStart,
<SubscriptionStatus event={countdownEvent} /> plannedStart,
<div className={cx(['sub__title', !displayTitle && 'subdued'])}> offset,
{event.isGroup && <span className='sub__eyebrow'>Group</span>} mode,
{displayTitle} reportData,
</div> );
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>} const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div
key={event.id}
ref={rowRef}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
})}
</div> </div>
); );
})} })}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata'; import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils'; import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
/** /**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes. * Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,3 +126,89 @@ describe('resolveSubscriptionTarget()', () => {
expect(result?.liveEntry).toBeNull(); expect(result?.liveEntry).toBeNull();
}); });
}); });
describe('groupSubscriptionTargets()', () => {
/**
* Resolves a group the same way the view does, so that the tests exercise the real target shape
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
*/
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
const resolved = resolveSubscriptionTarget(group, flat);
if (resolved === null) {
throw new Error('test setup: group has no playable children');
}
return resolved;
}
it('returns no sections for an empty subscription list', () => {
expect(groupSubscriptionTargets([])).toEqual([]);
});
it('gives each ungrouped event its own section', () => {
const e1 = makeEvent({ id: 'e1' });
const e2 = makeEvent({ id: 'e2' });
expect(groupSubscriptionTargets([e1, e2])).toEqual([
{ group: null, events: [e1] },
{ group: null, events: [e2] },
]);
});
it('absorbs the children of a subscribed group into its section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1, c2]);
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
});
it('keeps a subscribed group with no subscribed children as an empty section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
});
it('does not absorb an event which belongs to a different group', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const flat = [group1, c1, group2, c2];
const resolved1 = resolveGroup(group1, flat);
const resolved2 = resolveGroup(group2, flat);
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: resolved2, events: [c2] },
]);
});
it('does not absorb an event whose parent group is not subscribed', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
// only the first group is subscribed, so the second group's child stands alone
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: null, events: [c2] },
]);
});
it('closes a section when an ungrouped event follows a group', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const e1 = makeEvent({ id: 'e1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
{ group: resolved, events: [c1] },
{ group: null, events: [e1] },
]);
});
});
@@ -252,6 +252,42 @@ export function resolveSubscriptionTarget(
}; };
} }
/**
* A subscribed group along with the subscribed events which belong to it.
* Events without a subscribed parent group form their own section with no group.
*/
export type CountdownSection = {
group: CountdownTarget | null;
events: CountdownTarget[];
};
/**
* Folds the flat, rundown ordered subscription targets into sections.
* A group opens a section which absorbs the following targets that declare it as parent,
* which allows the group to be rendered as a sticky header for its own events.
*/
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
const sections: CountdownSection[] = [];
for (const target of targets) {
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
if (target.isGroup) {
sections.push({ group: target, events: [] });
continue;
}
const previousSection = sections.at(-1);
if (previousSection?.group?.id === target.parent) {
previousSection.events.push(target);
continue;
}
sections.push({ group: null, events: [target] });
}
return sections;
}
export function extendEventData( export function extendEventData(
event: CountdownTarget, event: CountdownTarget,
currentDay: number, currentDay: number,
@@ -7,14 +7,13 @@ import {
useSensor, useSensor,
useSensors, useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table';
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from 'react';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { CuesheetColumnDef } from '../cuesheet-table/cuesheetTable.features';
import { useColumnOrder } from '../cuesheet-table/useColumnManager'; import { useColumnOrder } from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps { interface CuesheetDndProps {
columns: ColumnDef<ExtendedEntry>[]; columns: CuesheetColumnDef[];
tableRoot?: 'editor' | 'cuesheet'; tableRoot?: 'editor' | 'cuesheet';
} }
@@ -1,5 +1,5 @@
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { useTable } from '@tanstack/react-table';
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
@@ -29,12 +29,17 @@ import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow'; import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import TableMenu from './cuesheet-table-menu/TableMenu'; import TableMenu from './cuesheet-table-menu/TableMenu';
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar'; import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
import {
CuesheetColumnDef,
CuesheetTable as CuesheetTableInstance,
cuesheetTableFeatures,
} from './cuesheetTable.features';
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager'; import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
import style from './CuesheetTable.module.scss'; import style from './CuesheetTable.module.scss';
type CuesheetTableBaseProps = { type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[]; columns: CuesheetColumnDef[];
cuesheetMode: AppMode; cuesheetMode: AppMode;
source: RundownSource; source: RundownSource;
insertElement?: ReactNode; insertElement?: ReactNode;
@@ -120,7 +125,8 @@ export default function CuesheetTable({
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot); const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot); const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
const table = useReactTable({ const table = useTable({
features: cuesheetTableFeatures,
data: flatRundown, data: flatRundown,
columns, columns,
columnResizeMode: 'onChange', columnResizeMode: 'onChange',
@@ -131,7 +137,6 @@ export default function CuesheetTable({
}, },
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing, onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
meta, meta,
}); });
@@ -195,7 +200,7 @@ export default function CuesheetTable({
return colSizes; return colSizes;
// eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need // eslint-disable-next-line react-compiler/react-compiler -- unfortunately this is what we need
// eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation // eslint-disable-next-line react-hooks/exhaustive-deps -- this works well and follows documentation
}, [table.getState().columnSizingInfo, table.getState().columnSizing]); }, [columns, table.state.columnResizing, table.state.columnSizing]);
const allLeafColumns = table.getAllLeafColumns(); const allLeafColumns = table.getAllLeafColumns();
const { rows } = table.getRowModel(); const { rows } = table.getRowModel();
@@ -214,9 +219,7 @@ export default function CuesheetTable({
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []); const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
const fixedHeaderContent = useCallback(() => { const fixedHeaderContent = useCallback(() => {
return table.getHeaderGroups().map((headerGroup) => { return table.getHeaderGroups().map((headerGroup) => {
const HeaderComponent = table.getState().columnSizingInfo.isResizingColumn const HeaderComponent = table.state.columnResizing.isResizingColumn ? CuesheetHeader : SortableCuesheetHeader;
? CuesheetHeader
: SortableCuesheetHeader;
// if the table is being resized, we render non-sortable headers to avoid performance issues // if the table is being resized, we render non-sortable headers to avoid performance issues
return ( return (
@@ -279,8 +282,8 @@ interface CuesheetVirtuosoContext {
columnSizeVars: { [key: string]: number }; columnSizeVars: { [key: string]: number };
cursor: string | null; cursor: string | null;
listeners: ReturnType<typeof useTableNav>['listeners']; listeners: ReturnType<typeof useTableNav>['listeners'];
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows']; rows: ReturnType<CuesheetTableInstance['getRowModel']>['rows'];
table: Table<ExtendedEntry>; table: CuesheetTableInstance;
handleAddNew?: (type: SupportedEntry) => void; handleAddNew?: (type: SupportedEntry) => void;
} }
@@ -1,16 +1,16 @@
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { HeaderGroup, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import type { CuesheetHeaderGroup } from '../cuesheetTable.features';
import { Draggable, SortableCell, TableCell } from './SortableCell'; import { Draggable, SortableCell, TableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps { interface CuesheetHeaderProps {
headerGroup: HeaderGroup<ExtendedEntry>; headerGroup: CuesheetHeaderGroup;
cuesheetMode: AppMode; cuesheetMode: AppMode;
hideIndexColumn: boolean; hideIndexColumn: boolean;
} }
@@ -46,7 +46,7 @@ export function SortableCuesheetHeader({ headerGroup, cuesheetMode, hideIndexCol
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }} injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />} draggable={<Draggable header={header} />}
> >
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder ? null : <FlexRender header={header} />}
</SortableCell> </SortableCell>
); );
})} })}
@@ -85,7 +85,7 @@ export function CuesheetHeader({ headerGroup, cuesheetMode, hideIndexColumn }: C
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }} injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
draggable={<Draggable header={header} />} draggable={<Draggable header={header} />}
> >
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder ? null : <FlexRender header={header} />}
</TableCell> </TableCell>
); );
})} })}
@@ -1,14 +1,14 @@
import { Table, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types'; import { EntryId, RGBColour, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react'; import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './EventRow.module.scss'; import style from './EventRow.module.scss';
@@ -25,7 +25,7 @@ interface EventRowProps {
skip: boolean; skip: boolean;
parent: EntryId | null; parent: EntryId | null;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry<OntimeEntry>>; table: CuesheetTable;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean; hasCursor?: boolean;
} }
@@ -133,7 +133,7 @@ function EventRow({
data-testid={`cuesheet-cell-${cell.column.id}`} data-testid={`cuesheet-cell-${cell.column.id}`}
data-column-id={cell.column.id} data-column-id={cell.column.id}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} <FlexRender cell={cell} />
</td> </td>
); );
})} })}
@@ -1,12 +1,12 @@
import { Table, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
import { CSSProperties, memo } from 'react'; import { CSSProperties, memo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './GroupRow.module.scss'; import style from './GroupRow.module.scss';
@@ -15,7 +15,7 @@ interface GroupRowProps {
colour: string; colour: string;
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry>; table: CuesheetTable;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean; hasCursor?: boolean;
} }
@@ -76,7 +76,7 @@ function GroupRow({
}} }}
role='cell' role='cell'
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} <FlexRender cell={cell} />
</td> </td>
); );
})} })}
@@ -1,14 +1,14 @@
import { Table, flexRender } from '@tanstack/react-table'; import { FlexRender } from '@tanstack/react-table';
import { EntryId, SupportedEntry } from 'ontime-types'; import { EntryId, SupportedEntry } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils'; import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { CSSProperties, memo, useMemo } from 'react'; import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { cx, enDash, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu'; import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import type { CuesheetTable } from '../cuesheetTable.features';
import style from './MilestoneRow.module.scss'; import style from './MilestoneRow.module.scss';
@@ -20,7 +20,7 @@ interface MilestoneRowProps {
colour: string; colour: string;
rowId: string; rowId: string;
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry>; table: CuesheetTable;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean; hasCursor?: boolean;
} }
@@ -102,7 +102,7 @@ function MilestoneRow({
}} }}
tabIndex={-1} tabIndex={-1}
> >
{canRender && flexRender(cell.column.columnDef.cell, cell.getContext())} {canRender && <FlexRender cell={cell} />}
</td> </td>
); );
})} })}
@@ -1,9 +1,8 @@
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { CSSProperties, ReactNode } from 'react'; import { CSSProperties, ReactNode } from 'react';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import type { CuesheetHeaderCell } from '../cuesheetTable.features';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
@@ -48,7 +47,7 @@ export function TableCell({ colSpan, injectedStyles, children, draggable }: Sort
} }
interface DraggableProps { interface DraggableProps {
header: Header<ExtendedEntry, unknown>; header: CuesheetHeaderCell;
} }
export function Draggable({ header }: DraggableProps) { export function Draggable({ header }: DraggableProps) {
@@ -1,4 +1,3 @@
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types'; import { CustomFields, TimeStrategy, URLPreset, isOntimeDelay, isOntimeEvent } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { useCallback } from 'react'; import { useCallback } from 'react';
@@ -8,6 +7,7 @@ import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time'; import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies'; import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
import type { CuesheetCellContext, CuesheetColumnDef } from '../cuesheetTable.features';
import DurationInput from './DurationInput'; import DurationInput from './DurationInput';
import EditableImage from './EditableImage'; import EditableImage from './EditableImage';
import FlagCell from './FlagCell'; import FlagCell from './FlagCell';
@@ -17,11 +17,11 @@ import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell'; import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string { function getColumnLabel(column: CuesheetCellContext['column']): string {
return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id; return typeof column.columnDef.header === 'string' ? column.columnDef.header : column.id;
} }
function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) { function MakeStart({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -60,7 +60,7 @@ function MakeStart({ getValue, row, table, column }: CellContext<ExtendedEntry,
); );
} }
function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) { function MakeEnd({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -100,7 +100,7 @@ function MakeEnd({ getValue, row, table, column }: CellContext<ExtendedEntry, un
); );
} }
function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntry, unknown>) { function MakeDuration({ getValue, row, table, column }: CuesheetCellContext) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -131,7 +131,7 @@ function MakeDuration({ getValue, row, table, column }: CellContext<ExtendedEntr
); );
} }
function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function MakeMultiLineField({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -160,7 +160,7 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
); );
} }
function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function LazyImage({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -178,7 +178,7 @@ function LazyImage({ row, column, table }: CellContext<ExtendedEntry, unknown>)
return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />; return <EditableImage initialValue={initialValue} updateValue={update} readOnly={!canWrite} />;
} }
function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function MakeSingleLineField({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -207,7 +207,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
); );
} }
function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) { function MakeFlagField({ row }: CuesheetCellContext) {
const event = row.original; const event = row.original;
if (!isOntimeEvent(event) || !event.flag) { if (!isOntimeEvent(event) || !event.flag) {
return null; return null;
@@ -215,7 +215,7 @@ function MakeFlagField({ row }: CellContext<ExtendedEntry, unknown>) {
return <FlagCell />; return <FlagCell />;
} }
function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unknown>) { function MakeCustomField({ row, column, table }: CuesheetCellContext) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -255,8 +255,8 @@ export function makeCuesheetColumns(
customFields: CustomFields, customFields: CustomFields,
cuesheetMode: AppMode, cuesheetMode: AppMode,
preset: URLPreset | undefined, preset: URLPreset | undefined,
): ColumnDef<ExtendedEntry>[] { ): CuesheetColumnDef[] {
const columnsDef: ColumnDef<ExtendedEntry>[] = []; const columnsDef: CuesheetColumnDef[] = [];
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode); const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
if (canRead('flag')) { if (canRead('flag')) {
@@ -2,7 +2,6 @@ import { Popover } from '@base-ui/react/popover';
import { Toggle } from '@base-ui/react/toggle'; import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group'; import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar'; import { Toolbar } from '@base-ui/react/toolbar';
import type { Column } from '@tanstack/react-table';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5'; import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
@@ -10,9 +9,9 @@ import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox'; import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import PopoverContents from '../../../../common/components/popover/Popover'; import PopoverContents from '../../../../common/components/popover/Popover';
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { AppMode } from '../../../../ontimeConfig'; import { AppMode } from '../../../../ontimeConfig';
import { useCuesheetPermissions } from '../../useTablePermissions'; import { useCuesheetPermissions } from '../../useTablePermissions';
import type { CuesheetColumn } from '../cuesheetTable.features';
import CuesheetShareModal from './CuesheetShareModal'; import CuesheetShareModal from './CuesheetShareModal';
import style from './CuesheetTableSettings.module.scss'; import style from './CuesheetTableSettings.module.scss';
@@ -37,7 +36,7 @@ type TableModeControls = {
}; };
interface CuesheetTableHeaderToolbarProps { interface CuesheetTableHeaderToolbarProps {
columns: Column<ExtendedEntry, unknown>[]; columns: CuesheetColumn[];
optionsStore: TableHeaderOptionsStore; optionsStore: TableHeaderOptionsStore;
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
@@ -111,7 +110,7 @@ interface ViewSettingsProps {
} }
interface ColumnSettingsProps { interface ColumnSettingsProps {
columns: Column<ExtendedEntry, unknown>[]; columns: CuesheetColumn[];
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
handleClearToggles: () => void; handleClearToggles: () => void;
@@ -203,7 +202,7 @@ function ColumnSettings({
return ( return (
<Editor.Label key={`${column.id}-${visible}`} className={style.option}> <Editor.Label key={`${column.id}-${visible}`} className={style.option}>
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} /> <Checkbox defaultChecked={visible} onCheckedChange={(checked) => column.toggleVisibility(checked)} />
{columnHeader as ReactNode} {columnHeader as ReactNode}
</Editor.Label> </Editor.Label>
); );
@@ -12,7 +12,7 @@
transition: background-color 0.2s ease-in-out; transition: background-color 0.2s ease-in-out;
&[data-background-rundown='true'] { &[data-background-rundown='true'] {
background-color: rgba($ontime-color, 0.5); background-color: $bg-active;
.apart::before { .apart::before {
content: 'BACKGROUND EDIT'; content: 'BACKGROUND EDIT';
@@ -0,0 +1,74 @@
import {
columnOrderingFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
metaHelper,
tableFeatures,
} from '@tanstack/react-table';
import type { CellContext, Column, ColumnDef, Header, HeaderGroup, Table } from '@tanstack/react-table';
import type { TimeField } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import type { AppMode } from '../../../ontimeConfig';
/**
* Custom data we pass to the table
* - `handleUpdate` callback to update the entry when the user edits a cell
* - `handleUpdateTimer` callback to update the timer for a specific event
* - `options-showDelayedTimes` whether to show or hide delayed times
* - `options-hideTableSeconds` whether to hide seconds in the table
* - `options-hideIndexColumn` whether to hide the index column
* - `options-cuesheetMode` run or edit mode
*/
export interface CuesheetTableMeta {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom: boolean) => void;
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => void;
options: {
showDelayedTimes: boolean;
hideTableSeconds: boolean;
hideIndexColumn: boolean;
cuesheetMode: AppMode;
};
}
/**
* Metadata specific for each column
* - `canWrite` whether the user can write to this column
* - `colour` background colour associated with a custom field
*/
export interface CuesheetColumnMeta {
canWrite: boolean;
colour?: string;
}
/**
* Features registered in the cuesheet and rundown tables.
* In v9 an API only exists once its feature is registered, so this list is the
* source of truth for what the table can do:
* - `columnOrderingFeature`: user reorders columns by dragging the headers
* - `columnVisibilityFeature`: user toggles columns in the table settings
* - `columnSizingFeature`: column widths, exposed to CSS as custom properties
* - `columnResizingFeature`: the drag handle in the header (requires sizing)
*
* The `tableMeta` / `columnMeta` slots replace the v8 global module augmentation:
* they scope our meta types to this table instead of every table in the app.
*/
export const cuesheetTableFeatures = tableFeatures({
columnOrderingFeature,
columnVisibilityFeature,
columnSizingFeature,
columnResizingFeature,
tableMeta: metaHelper<CuesheetTableMeta>(),
columnMeta: metaHelper<CuesheetColumnMeta>(),
});
export type CuesheetFeatures = typeof cuesheetTableFeatures;
/** Convenience aliases so consumers do not need to repeat the feature generic */
export type CuesheetColumnDef = ColumnDef<CuesheetFeatures, ExtendedEntry>;
export type CuesheetTable = Table<CuesheetFeatures, ExtendedEntry>;
export type CuesheetCellContext = CellContext<CuesheetFeatures, ExtendedEntry>;
export type CuesheetHeaderGroup = HeaderGroup<CuesheetFeatures, ExtendedEntry>;
export type CuesheetHeaderCell = Header<CuesheetFeatures, ExtendedEntry, unknown>;
export type CuesheetColumn = Column<CuesheetFeatures, ExtendedEntry, unknown>;
@@ -1,10 +1,10 @@
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef, ColumnSizingState, Updater } from '@tanstack/react-table'; import { ColumnSizingState, Updater } from '@tanstack/react-table';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { debounce } from '../../../common/utils/debounce'; import { debounce } from '../../../common/utils/debounce';
import { makeStageKey } from '../../../common/utils/localStorage'; import { makeStageKey } from '../../../common/utils/localStorage';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { CuesheetColumnDef } from './cuesheetTable.features';
type TableRoot = 'editor' | 'cuesheet'; type TableRoot = 'editor' | 'cuesheet';
@@ -38,7 +38,7 @@ export function useColumnSizes(tableRoot: TableRoot = 'cuesheet') {
}; };
} }
export function useColumnOrder(columns: ColumnDef<ExtendedEntry>[], tableRoot: TableRoot = 'cuesheet') { export function useColumnOrder(columns: CuesheetColumnDef[], tableRoot: TableRoot = 'cuesheet') {
const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]); const tableOrderKey = useMemo(() => makeStageKey(`${tableRoot}-table-order`), [tableRoot]);
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({ const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
@@ -3,11 +3,14 @@
.error { .error {
padding-inline: 0.5rem; padding-inline: 0.5rem;
font-size: 1rem; font-size: 1rem;
height: 3rem; // rows grow when a match is shown from a note or custom field
min-height: 3rem;
padding-block: 0.35rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem;
} }
.entry[data-selected='true'] { .entry[data-selected='true'] {
@@ -18,21 +21,47 @@
color: $label-gray; color: $label-gray;
} }
.more {
padding-inline: 0.5rem;
padding-block: 0.75rem;
font-size: calc(1rem - 2px);
color: $label-gray;
border-top: 1px solid $gray-1000;
text-align: center;
}
.error { .error {
color: $error-red; color: $error-red;
} }
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding-top: 0.75rem;
}
.filterLabel {
font-size: calc(1rem - 3px);
color: $label-gray;
margin-right: 0.15rem;
}
.data { .data {
display: grid; display: grid;
grid-template-areas: grid-template-areas:
'index cue' 'index cue'
'index title'; 'index title'
'index match';
column-gap: 1rem; column-gap: 1rem;
grid-template-rows: min-content 1fr; grid-template-rows: min-content 1fr;
min-width: 0;
.index { .index {
grid-area: index; grid-area: index;
background-color: var(--color, $gray-1000); // background and text colour come from getAccessibleColour, which keeps the
// number legible whatever colour the user gave the entry
border-radius: 2px; border-radius: 2px;
padding-block: 0.25rem; padding-block: 0.25rem;
width: 3.5rem; width: 3.5rem;
@@ -42,14 +71,33 @@
.title { .title {
grid-area: title; grid-area: title;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.cue { .cue {
grid-area: cue; grid-area: cue;
font-size: calc(1rem - 2px); font-size: calc(1rem - 2px);
color: $label-gray; color: $label-gray;
max-height: 1em;
min-height: 0; min-height: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.match {
grid-area: match;
font-size: calc(1rem - 3px);
color: $label-gray;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.matchLabel {
color: $ui-white;
margin-right: 0.4rem;
} }
} }
@@ -63,13 +111,14 @@
color: $label-gray; color: $label-gray;
} }
.filterHint { .count {
text-align: right; text-align: right;
white-space: nowrap;
} }
.em { .go {
color: $ui-white; white-space: nowrap;
margin-inline: 0.25rem; padding-left: 1rem;
} }
.hints { .hints {
@@ -98,7 +147,7 @@
flex-direction: column; flex-direction: column;
} }
.filterHint { .count {
text-align: left; text-align: left;
} }
} }
+117 -52
View File
@@ -1,11 +1,12 @@
import { useDebouncedCallback } from '@mantine/hooks'; import { MaybeString } from 'ontime-types';
import { SupportedEntry } from 'ontime-types'; import { KeyboardEvent, useDeferredValue, useEffect, useRef, useState } from 'react';
import { KeyboardEvent, useState } from 'react';
import ToggleButton from '../../../common/components/buttons/ToggleButton';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd'; import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal'; import Modal from '../../../common/components/modal/Modal';
import useFinder from './useFinder'; import { getAccessibleColour } from '../../../common/utils/styleUtils';
import useFinder, { FinderResult } from './useFinder';
import style from './Finder.module.scss'; import style from './Finder.module.scss';
@@ -15,46 +16,76 @@ interface FinderProps {
} }
export default function Finder({ isOpen, onClose }: FinderProps) { export default function Finder({ isOpen, onClose }: FinderProps) {
const { find, select, results, error } = useFinder(); const [search, setSearch] = useState('');
const [selected, setSelected] = useState(0); const [filter, setFilter] = useState<MaybeString>(null);
const [selectedId, setSelectedId] = useState<MaybeString>(null);
const debouncedFind = useDebouncedCallback(find, 100); /**
* Keeps typing responsive while the list re-renders.
* The search itself is cheap, rendering the results is what costs.
*/
const deferredSearch = useDeferredValue(search);
const { select, results, error, total, filters, appliedFilter } = useFinder(deferredSearch, filter);
const inputRef = useRef<HTMLInputElement>(null);
const activeRef = useRef<HTMLLIElement>(null);
/**
* We track the selection by ID so that it survives the result list changing under us:
* an entry that no longer exists falls back to the first result instead of dangling past the end
*/
const activeIndex = Math.max(
0,
results.findIndex((entry) => entry.id === selectedId),
);
const activeEntry = results.at(activeIndex);
/** keep the highlighted entry in view while navigating with the keyboard */
useEffect(() => {
activeRef.current?.scrollIntoView({ block: 'nearest' });
}, [activeEntry?.id]);
const navigate = (event: KeyboardEvent<HTMLDivElement>) => { const navigate = (event: KeyboardEvent<HTMLDivElement>) => {
// pressing the search shortcut again selects the query, ready to be replaced
if ((event.metaKey || event.ctrlKey) && event.key === 'f') {
event.preventDefault();
inputRef.current?.select();
return;
}
// all operations need results // all operations need results
if (results.length === 0) { if (results.length === 0) {
return; return;
} }
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
setSelected((prev) => (prev + 1) % results.length); setSelectedId(results[(activeIndex + 1) % results.length].id);
} }
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
setSelected((prev) => (prev - 1 + results.length) % results.length); setSelectedId(results[(activeIndex - 1 + results.length) % results.length].id);
} }
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
submit(); submit(activeEntry);
} }
}; };
const submit = () => { const submit = (entry: FinderResult | undefined) => {
const selectedEvent = results[selected]; if (!entry) {
select(selectedEvent); return;
}
select(entry);
onClose(); onClose();
}; };
const handleMouseMoveEvent = (event: React.MouseEvent<HTMLUListElement>) => { /** Scopes the search to a single field, or back to all fields when tapped again */
const target = event.target as HTMLElement; const handleFilter = (filterKey: string) => {
const li = target.closest('li'); setFilter((previous) => (previous === filterKey ? null : filterKey));
if (li) { inputRef.current?.focus();
const index = Number(li.dataset.index);
if (!isNaN(index)) {
setSelected(index);
}
}
}; };
const hiddenResults = total - results.length;
return ( return (
<Modal <Modal
title='' title=''
@@ -63,35 +94,68 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
showBackdrop showBackdrop
bodyElements={ bodyElements={
<div onKeyDown={navigate}> <div onKeyDown={navigate}>
<Input height='large' fluid onChange={debouncedFind} placeholder='Search...' /> <Input
<ul className={style.scrollContainer} onMouseMove={handleMouseMoveEvent}> ref={inputRef}
height='large'
fluid
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder='Search...'
/>
<div className={style.filters} data-testid='finder-filters'>
<span className={style.filterLabel}>Filter by</span>
{filters.map((option) => (
<ToggleButton
key={option.key}
pressed={appliedFilter === option.key}
size='small'
onClick={() => handleFilter(option.key)}
>
{option.label}
</ToggleButton>
))}
</div>
<ul className={style.scrollContainer}>
{error && <li className={style.error}>{error}</li>} {error && <li className={style.error}>{error}</li>}
{results.length === 0 && <li className={style.empty}>No results</li>} {!error && results.length === 0 && <li className={style.empty}>No results</li>}
{results.length > 0 && {results.map((entry) => {
results.map((entry, index) => { const isSelected = activeEntry?.id === entry.id;
const isSelected = selected === index; // the title and cue are already on the row, a match anywhere else needs showing
const displayIndex = entry.type === SupportedEntry.Event ? entry.eventIndex : '-'; const showMatch = entry.match !== null && entry.match.key !== 'title' && entry.match.key !== 'cue';
const displayCue = 'cue' in entry ? entry.cue : '';
return ( return (
<li <li
key={entry.id} key={entry.id}
className={style.entry} ref={isSelected ? activeRef : undefined}
data-selected={isSelected} className={style.entry}
data-index={index} data-testid='finder-result'
onClick={submit} data-selected={isSelected}
> onClick={() => submit(entry)}
<div className={style.data}> onPointerEnter={() => setSelectedId(entry.id)}
<div className={style.index} style={{ '--color': entry.colour }}> >
{displayIndex} <div className={style.data}>
</div> <div className={style.index} style={getAccessibleColour(entry.colour)}>
<div className={style.cue}>{displayCue}</div> {entry.eventIndex ?? '-'}
<div className={style.title}>{entry.title}</div>
</div> </div>
{isSelected && <span>Go </span>} <div className={style.cue}>{entry.cue}</div>
</li> <div className={style.title}>{entry.title}</div>
); {showMatch && (
})} <div className={style.match} data-testid='finder-result-match'>
<span className={style.matchLabel}>{entry.match?.label}</span>
{entry.match?.excerpt}
</div>
)}
</div>
{isSelected && <span className={style.go}>Go </span>}
</li>
);
})}
{hiddenResults > 0 && (
<li className={style.more} data-testid='finder-more'>
{hiddenResults} more {hiddenResults === 1 ? 'result' : 'results'} keep typing to narrow the search
</li>
)}
</ul> </ul>
</div> </div>
} }
@@ -112,10 +176,11 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
Close Close
</span> </span>
</div> </div>
<div className={style.filterHint}> {total > 0 && (
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or <div className={style.count} data-testid='finder-count'>
<span className={style.em}>title</span> {hiddenResults > 0 ? `Showing ${results.length} of ${total}` : `${total} result${total === 1 ? '' : 's'}`}
</div> </div>
)}
</div> </div>
} }
/> />
@@ -0,0 +1,162 @@
import { CustomFields, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone, SupportedEntry } from 'ontime-types';
import { parseQuery, searchByIndex, searchByText } from './useFinder';
function makeEvent(id: string, overrides: Partial<OntimeEvent> = {}): OntimeEvent {
return {
type: SupportedEntry.Event,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeEvent;
}
function makeGroup(id: string, overrides: Partial<OntimeGroup> = {}): OntimeGroup {
return {
type: SupportedEntry.Group,
id,
title: '',
note: '',
colour: '#000000',
custom: {},
...overrides,
} as OntimeGroup;
}
function makeMilestone(id: string, overrides: Partial<OntimeMilestone> = {}): OntimeMilestone {
return {
type: SupportedEntry.Milestone,
id,
cue: '',
title: '',
note: '',
colour: '#000000',
custom: {},
parent: null,
...overrides,
} as OntimeMilestone;
}
function makeDelay(id: string): OntimeDelay {
return { type: SupportedEntry.Delay, id, duration: 1000, parent: null };
}
describe('parseQuery()', () => {
const filters = [
{ key: 'cue', label: 'Cue' },
{ key: 'Camera_Notes', label: 'Camera Notes' },
];
it.each([
['cue 12', { filterKey: 'cue', searchString: '12' }],
['cue:12', { filterKey: 'cue', searchString: '12' }],
['camera_notes:wide', { filterKey: 'Camera_Notes', searchString: 'wide' }],
])('parses the field prefix in %s', (searchValue, expected) => {
expect(parseQuery(searchValue, filters)).toStrictEqual(expected);
});
it('keeps an unprefixed query as a search across all fields', () => {
expect(parseQuery('zebrafish', filters)).toStrictEqual({ filterKey: null, searchString: 'zebrafish' });
});
it('recognises a filter before any search text has been entered', () => {
expect(parseQuery('cue', filters)).toStrictEqual({ filterKey: 'cue', searchString: '' });
});
});
describe('searchByText()', () => {
const customFields: CustomFields = {
Camera_Notes: { type: 'text', label: 'Camera Notes', colour: '#000000' },
Slide: { type: 'image', label: 'Slide', colour: '#000000' },
};
it('searches cue, title, note, and text custom fields in rundown order', () => {
const data = [
makeMilestone('milestone', { cue: 'needle' }),
makeGroup('group', { title: 'needle' }),
makeEvent('note', { note: 'find the needle here' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results.map(({ id, match }) => ({ id, field: match?.key }))).toStrictEqual([
{ id: 'milestone', field: 'cue' },
{ id: 'group', field: 'title' },
{ id: 'note', field: 'note' },
{ id: 'custom', field: 'Camera_Notes' },
]);
expect(outcome.total).toBe(4);
});
it('searches only the selected field', () => {
const data = [
makeEvent('title', { title: 'needle' }),
makeEvent('note', { note: 'needle' }),
makeEvent('custom', { custom: { Camera_Notes: 'needle' } }),
];
const outcome = searchByText(data, customFields, 'note', 'needle');
expect(outcome.results.map((result) => result.id)).toStrictEqual(['note']);
expect(outcome.total).toBe(1);
});
it('reports the first matching field so the result can explain why it matched', () => {
const data = [makeEvent('event', { cue: 'NEEDLE', title: 'another needle' })];
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results[0].match).toStrictEqual({ key: 'cue', label: 'Cue', excerpt: 'NEEDLE' });
});
it('does not search image custom fields', () => {
const data = [makeEvent('image-only', { custom: { Slide: 'needle' } })];
expect(searchByText(data, customFields, null, 'needle')).toStrictEqual({ results: [], error: null, total: 0 });
});
it('reports the full match count while limiting rendered results', () => {
const data = Array.from({ length: 51 }, (_, index) => makeEvent(String(index), { title: 'needle' }));
const outcome = searchByText(data, customFields, null, 'needle');
expect(outcome.results).toHaveLength(50);
expect(outcome.total).toBe(51);
});
});
describe('searchByIndex()', () => {
it('counts only events while preserving the flat rundown position', () => {
const data = [
makeGroup('group'),
makeDelay('delay'),
makeEvent('first'),
makeMilestone('milestone'),
makeEvent('second'),
];
const outcome = searchByIndex(data, '2');
expect(outcome.results).toHaveLength(1);
expect(outcome.results[0]).toMatchObject({ id: 'second', index: 4, eventIndex: 2 });
expect(outcome.total).toBe(1);
});
it.each(['0', 'not-a-number'])('rejects invalid index %s', (index) => {
expect(searchByIndex([makeEvent('event')], index)).toStrictEqual({
results: [],
error: 'Invalid index',
total: 0,
});
});
it('returns no result when the event index is beyond the rundown', () => {
expect(searchByIndex([makeEvent('event')], '2')).toStrictEqual({ results: [], error: null, total: 0 });
});
});
+228 -208
View File
@@ -1,239 +1,259 @@
import { EntryId, MaybeString, SupportedEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types'; import {
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; CustomFields,
EntryId,
MaybeNumber,
MaybeString,
OntimeEntry,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
isOntimeDelay,
isOntimeEvent,
} from 'ontime-types';
import { useCallback, useMemo } from 'react';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../../common/hooks-query/useRundown'; import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry'; import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
const maxResults = 12; /** How many results we render, the total number of matches is reported separately */
const maxResults = 50;
/** Notes can hold a whole script, we only show enough to explain the match */
const excerptPadding = 40;
type FilterableGroup = { const indexFilter = 'index';
type: SupportedEntry.Group;
id: EntryId;
index: number;
title: string;
colour: string;
};
type FilterableEvent = { /** Everything except delays, which carry no text to search */
type: SupportedEntry.Event; type SearchableEntry = OntimeEvent | OntimeGroup | OntimeMilestone;
type FinderFilter = { key: string; label: string };
/**
* Offered to the user as filter badges. Index is a positional lookup rather than a
* text field, so it is handled separately from the fields a search runs over.
*/
const staticFilters: FinderFilter[] = [
{ key: indexFilter, label: 'Index' },
{ key: 'cue', label: 'Cue' },
{ key: 'title', label: 'Title' },
{ key: 'note', label: 'Note' },
];
/** Why an entry matched, so the UI can show the user */
type FinderMatch = { key: string; label: string; excerpt: string };
export type FinderResult = {
id: EntryId; id: EntryId;
/** position in the flat rundown, which is how the rundown reveals an entry */
index: number; index: number;
eventIndex: number; /** 1-based position among events, null for groups and milestones */
eventIndex: MaybeNumber;
title: string; title: string;
/** groups have no cue */
cue: string; cue: string;
colour: string; colour: string;
parent: MaybeString; parent: MaybeString;
/** absent when the entry was found by index rather than by matching text */
match: FinderMatch | null;
}; };
type FilterableMilestone = { type SearchOutcome = { results: FinderResult[]; error: MaybeString; total: number };
type: SupportedEntry.Milestone;
id: EntryId;
index: number;
title: string;
cue: string;
colour: string;
parent: MaybeString;
};
type FilterableEntry = FilterableGroup | FilterableEvent | FilterableMilestone; const noResults: SearchOutcome = { results: [], error: null, total: 0 };
export default function useFinder() { /** Groups are the only searchable entry with neither a cue nor a parent */
function toResult(entry: SearchableEntry, index: number, eventIndex: MaybeNumber, match: FinderMatch | null) {
return {
id: entry.id,
index,
eventIndex,
title: entry.title,
cue: 'cue' in entry ? entry.cue : '',
colour: entry.colour,
parent: 'parent' in entry ? entry.parent : null,
match,
} satisfies FinderResult;
}
/** Shows enough of a long value for the user to see why it matched */
function makeExcerpt(value: string, matchIndex: number, searchLength: number): string {
const start = Math.max(0, matchIndex - excerptPadding);
const end = Math.min(value.length, matchIndex + searchLength + excerptPadding);
return `${start > 0 ? '…' : ''}${value.slice(start, end)}${end < value.length ? '…' : ''}`;
}
/**
* The first field of an entry to contain the search string, if any.
* Fields are tried in the order we prefer to report a match.
*/
function findMatch(
entry: SearchableEntry,
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): FinderMatch | null {
function check(key: string, label: string, value: string): FinderMatch | null {
if (!value || (filterKey !== null && key !== filterKey)) {
return null;
}
const matchIndex = value.toLowerCase().indexOf(searchString);
if (matchIndex === -1) {
return null;
}
return { key, label, excerpt: makeExcerpt(value, matchIndex, searchString.length) };
}
// groups have no cue, the rest is common to every searchable entry
const fromCue = 'cue' in entry ? check('cue', 'Cue', entry.cue) : null;
const match = fromCue ?? check('title', 'Title', entry.title) ?? check('note', 'Note', entry.note);
if (match !== null) {
return match;
}
// custom fields are named by the project, so these can only be reached generically
for (const [key, value] of Object.entries(entry.custom)) {
const definition = customFields[key];
if (definition?.type !== 'text') {
continue;
}
const custom = check(key, definition.label || key, value);
if (custom) return custom;
}
return null;
}
/**
* Splits the raw search value into an optional field filter and the text to look for.
* Both `cue 12` and `cue:12` are accepted so that typing agrees with the filter badges.
*/
export function parseQuery(searchValue: string, filters: FinderFilter[]) {
for (const filter of filters) {
// the search value is already lowercased, custom field keys are not
const prefix = filter.key.toLowerCase();
if (searchValue === prefix) {
return { filterKey: filter.key, searchString: '' };
}
if (searchValue.startsWith(`${prefix} `) || searchValue.startsWith(`${prefix}:`)) {
return { filterKey: filter.key, searchString: searchValue.slice(prefix.length + 1).trim() };
}
}
return { filterKey: null, searchString: searchValue };
}
/** Finds the single event at a 1-based position in the rundown */
export function searchByIndex(data: OntimeEntry[], indexString: string): SearchOutcome {
const target = Number(indexString);
if (isNaN(target) || target < 1) {
return { ...noResults, error: 'Invalid index' };
}
let eventIndex = 0;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (!isOntimeEvent(entry)) {
continue;
}
eventIndex++;
if (eventIndex === target) {
return { results: [toResult(entry, i, eventIndex, null)], error: null, total: 1 };
}
}
return noResults;
}
/**
* Matches entries on a single field when one is selected, otherwise on every text field.
* Results keep rundown order, which keeps them predictable during a show.
*/
export function searchByText(
data: OntimeEntry[],
customFields: CustomFields,
filterKey: MaybeString,
searchString: string,
): SearchOutcome {
const results: FinderResult[] = [];
let total = 0;
// indexes exposed to the UI are 1-based
let eventIndex = 0;
for (let i = 0; i < data.length; i++) {
const entry = data[i];
if (isOntimeDelay(entry)) {
continue;
}
const isEvent = isOntimeEvent(entry);
if (isEvent) {
eventIndex++;
}
const match = findMatch(entry, customFields, filterKey, searchString);
if (match === null) {
continue;
}
total++;
if (results.length < maxResults) {
results.push(toResult(entry, i, isEvent ? eventIndex : null, match));
}
}
return { results, error: null, total };
}
/**
* @param searchValue - the text the user is looking for
* @param activeFilter - a field selected from the filter badges, if any
*/
export default function useFinder(searchValue: string, activeFilter: MaybeString) {
const { data, rundownId } = useFlatRundown(); const { data, rundownId } = useFlatRundown();
const [results, setResults] = useState<FilterableEntry[]>([]); const { data: customFields } = useCustomFields();
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId); const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
/** Filters the rundown to a given evaluation */ /** The filters offered to the user: the fixed fields plus whatever the project defines */
const find = useCallback( const filters = useMemo<FinderFilter[]>(() => {
(event: ChangeEvent<HTMLInputElement>) => { const customFilters = Object.entries(customFields)
if (!data || data.length === 0) { .filter(([_key, field]) => field.type === 'text')
setError('No data'); .map(([key, field]) => ({ key, label: field.label || key }));
return; return [...staticFilters, ...customFilters];
} }, [customFields]);
setError(null);
if (event.target.value === '') { const { results, error, total, appliedFilter } = useMemo(() => {
setResults([]); if (data.length === 0) {
return; return { ...noResults, error: 'No data', appliedFilter: activeFilter };
} }
const searchValue = event.target.value.toLowerCase(); const normalised = searchValue.trim().toLowerCase();
lastSearchString.current = searchValue; if (normalised === '') {
return { ...noResults, appliedFilter: activeFilter };
}
if (searchValue.startsWith('index ')) { /**
const searchString = searchValue.slice('index '.length).trim(); * If a badge is selected it scopes the search; otherwise, keyword prefixes in the input
const { results, error } = searchByIndex(searchString); * (e.g. "cue:" / "title:") are parsed and the matching badge is highlighted.
setResults(results); */
setError(error); const { filterKey, searchString } = activeFilter
return; ? { filterKey: activeFilter, searchString: normalised }
} : parseQuery(normalised, filters);
if (searchValue.startsWith('cue ')) { if (filterKey === indexFilter) {
const searchString = searchValue.slice('cue '.length).trim(); return { ...searchByIndex(data, searchString), appliedFilter: filterKey };
const { results, error } = searchByCue(searchString); }
setResults(results); if (searchString === '') {
setError(error); // a filter is selected, but there is nothing to match on yet
return; return { ...noResults, appliedFilter: filterKey };
} }
return { ...searchByText(data, customFields, filterKey, searchString), appliedFilter: filterKey };
const searchString = searchValue.startsWith('title ') ? searchValue.slice('title '.length).trim() : searchValue; }, [data, customFields, filters, searchValue, activeFilter]);
const { results, error } = searchByTitle(searchString);
setResults(results);
setError(error);
/** Returns a single item with a matching index */
function searchByIndex(searchString: string) {
const searchIndex = Number(searchString);
if (isNaN(searchIndex) || searchIndex < 1) {
return { results: [], error: 'Invalid index' };
}
if (searchIndex > data.length) {
return { results: [], error: null };
}
// indexes exposed to the UI are 1-based
let eventIndex = 1;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
const event = data[i];
if (isOntimeEvent(event)) {
if (eventIndex === searchIndex) {
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
break;
}
eventIndex++;
}
}
return { results, error: null };
}
/** Returns maxResults of OntimeEvents that match the cue field */
function searchByCue(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEvent[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const event = data[i];
if (isOntimeEvent(event)) {
if (event.cue.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: event.id,
index: i,
eventIndex,
title: event.title,
cue: event.cue,
colour: event.colour,
parent: event.parent,
} satisfies FilterableEvent);
}
eventIndex++;
}
}
return { results, error: null };
}
/** Returns maxResults of OntimeEvents that match the title field*/
function searchByTitle(searchString: string) {
// indexes exposed to the UI are 1-based
let eventIndex = 1;
// limit amount of results we show
let remaining = maxResults;
const results: FilterableEntry[] = [];
for (let i = 0; i < data.length; i++) {
if (remaining <= 0) {
break;
}
const entry = data[i];
if (isOntimeEvent(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Event,
id: entry.id,
index: i,
eventIndex,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableEvent);
}
eventIndex++;
} else if (isOntimeGroup(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Group,
id: entry.id,
index: i,
title: entry.title,
colour: entry.colour,
} satisfies FilterableGroup);
}
} else if (isOntimeMilestone(entry)) {
if (entry.title.toLowerCase().includes(searchString)) {
remaining--;
results.push({
type: SupportedEntry.Milestone,
id: entry.id,
index: i,
title: entry.title,
cue: entry.cue,
colour: entry.colour,
parent: entry.parent,
} satisfies FilterableMilestone);
}
}
}
return { results, error: null };
}
},
[data],
);
const select = useCallback( const select = useCallback(
(selectedEvent: FilterableEntry) => { (result: FinderResult) => {
selectAndRevealEntry({ selectAndRevealEntry({ id: result.id, index: result.index, parent: result.parent });
id: selectedEvent.id,
index: selectedEvent.index,
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
});
}, },
[selectAndRevealEntry], [selectAndRevealEntry],
); );
/** clear results when source data changes */ return { select, results, error, total, filters, appliedFilter };
useEffect(() => {
setResults([]);
setError(null);
// fake a submit event to re-run the search
if (lastSearchString.current) {
find({ target: { value: lastSearchString.current } } as ChangeEvent<HTMLInputElement>);
}
}, [data, find]);
return { find, select, results, error };
} }
@@ -1,201 +0,0 @@
@use '@/theme/viewerDefs' as *;
.teleprompter {
--tp-flip-x: 1;
--tp-flip-y: 1;
/* Keep shared stage-screen settings usable on smaller clients. */
--tp-font-size: min(var(--tp-configured-font-size), 8vw);
position: relative;
height: 100dvh;
width: 100%;
overflow: hidden;
font-family: var(--font-family-override, $viewer-font-family);
background: var(--background-color-override, #000000);
color: var(--color-override, #ffffff);
transform: scale(var(--tp-flip-x), var(--tp-flip-y));
transform-origin: center center;
&--flip-h {
--tp-flip-x: -1;
}
&--flip-v {
--tp-flip-y: -1;
}
}
.teleprompter__scroller {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
/* The animation loop is the only writer of scrollTop. */
overflow-anchor: none;
scroll-behavior: auto;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.teleprompter__content {
width: var(--tp-text-width);
margin-inline: auto;
font-size: var(--tp-font-size);
line-height: var(--tp-line-height);
/* Percent padding resolves against width, so use viewport-height units. */
padding-top: calc(var(--tp-reading-line) * 1dvh);
padding-bottom: calc(100dvh - var(--tp-reading-line) * 1dvh);
}
.teleprompter__block {
margin-bottom: 1em;
&[data-loaded] .teleprompter__heading {
color: $accent-color;
}
}
.teleprompter__group {
font-size: 0.34em;
text-transform: uppercase;
letter-spacing: 0.1em;
color: $viewer-label-color;
margin-bottom: 0.35em;
}
.teleprompter__heading {
font-size: 0.38em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: $viewer-secondary-color;
margin-bottom: 0.3em;
}
.teleprompter__body {
white-space: pre-wrap;
overflow-wrap: break-word;
}
.teleprompter__dim {
position: absolute;
inset: 0 0 auto 0;
height: calc(var(--tp-reading-line) * 1%);
pointer-events: none;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0));
}
.teleprompter__reading-line {
position: absolute;
top: calc(var(--tp-reading-line) * 1%);
left: 0;
right: 0;
width: var(--tp-text-width);
margin-inline: auto;
height: calc(var(--tp-font-size) * var(--tp-line-height));
pointer-events: none;
}
.teleprompter__reading-marker {
position: absolute;
top: 0;
bottom: 0;
background: $accent-color;
clip-path: polygon(0 0, 100% 50%, 0 100%);
font-size: var(--tp-font-size);
--tp-gutter: max(0px, calc((100vw - 100%) / 2));
width: min(1.2em, calc(var(--tp-gutter) * 0.6));
right: calc(100% + var(--tp-gutter) * 0.2);
}
.teleprompter__controls {
position: absolute;
bottom: min(2vh, 16px);
left: 50%;
/* Cancel the view flip so controls remain readable to the operator. */
transform: translateX(-50%) scale(var(--tp-flip-x), var(--tp-flip-y));
display: flex;
align-items: center;
gap: min(1vh, 8px);
padding: min(1vh, 8px) clamp(8px, 1vw, 16px);
background: rgba(white, 8%);
border-radius: $element-border-radius;
backdrop-filter: blur(6px);
opacity: 1;
transition: opacity $viewer-transition-time;
&--idle {
opacity: 0;
pointer-events: none;
}
}
.teleprompter__speed {
display: flex;
align-items: baseline;
gap: 0.2em;
min-width: 4em;
justify-content: center;
font-size: clamp(14px, 1.4vw, 22px);
font-variant-numeric: tabular-nums;
}
.teleprompter__speed-unit {
font-size: 0.6em;
color: $viewer-label-color;
}
.teleprompter__help {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
}
.teleprompter__help-card {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(92vw, 32rem);
max-height: 85dvh;
overflow-y: auto;
padding: 1.5rem;
background: $viewer-background-color;
color: $viewer-color;
border-radius: $element-border-radius;
font-size: 1rem;
}
.teleprompter__help-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.teleprompter__help-title {
font-size: 1.25rem;
font-weight: 600;
}
.teleprompter__help-groups {
--shortcut-title-color: #{$viewer-label-color};
--shortcut-label-color: #{$viewer-secondary-color};
--shortcut-separator-color: #{$viewer-label-color};
}
@@ -1,175 +0,0 @@
import { OntimeView } from 'ontime-types';
import { type CSSProperties, useState } from 'react';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useSelectedEventId } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
import { cx } from '../../common/utils/styleUtils';
import Loader from '../common/loader/Loader';
import ControlOverlay from './control-overlay/ControlOverlay';
import HelpOverlay from './help-overlay/HelpOverlay';
import ScriptBlockView from './script-block/ScriptBlock';
import { defaults, getTeleprompterOptions, useTeleprompterOptions } from './teleprompter.options';
import { stepFontSize } from './teleprompter.scroll';
import { buildScript, composeFlip } from './teleprompter.utils';
import { useSyncTeleprompterParams } from './useSyncTeleprompterParams';
import { useTeleprompterControls } from './useTeleprompterControls';
import { type TeleprompterData, useTeleprompterData } from './useTeleprompterData';
import { useTeleprompterScroll } from './useTeleprompterScroll';
import './Teleprompter.scss';
export default function TeleprompterLoader() {
const { data, status } = useTeleprompterData();
useWindowTitle('Teleprompter');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
}
return <Teleprompter {...data} />;
}
function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterData) {
'use memo';
const options = useTeleprompterOptions();
const selectedEventId = useSelectedEventId();
const isMirrored = useViewOptionsStore((state) => state.mirror);
const [showHelp, setShowHelp] = useState(false);
const fromParams = { flipH: options.flipH, flipV: options.flipV, fontSize: options.fontSize };
const paramsKey = `${fromParams.flipH}|${fromParams.flipV}|${fromParams.fontSize}`;
const [live, setLive] = useState(fromParams);
const [seededFrom, setSeededFrom] = useState(paramsKey);
// Reset live controls before commit when the URL configuration changes.
if (seededFrom !== paramsKey) {
setSeededFrom(paramsKey);
setLive(fromParams);
}
const viewOptions = getTeleprompterOptions(customFields);
const blocks = buildScript(rundown, rundownMetadata, customFields, {
scriptSource: options.scriptSource,
heading: options.heading,
hideEmpty: options.hideEmpty,
showGroups: options.showGroups,
});
const {
scrollerRef,
contentRef,
registerBlock,
handleUserScroll,
controller,
isRunning,
speed,
followLocked,
atEnd,
} = useTeleprompterScroll({
initialSpeed: options.speed,
followLoaded: options.followLoaded,
selectedEventId,
readingLinePos: options.readingLinePos,
blocks,
});
const handleFlip = (axis: 'h' | 'v') =>
setLive((current) => {
const key = axis === 'h' ? 'flipH' : 'flipV';
return { ...current, [key]: !current[key] };
});
const handleFontSize = (steps: number) =>
setLive((current) => ({ ...current, fontSize: stepFontSize(current.fontSize, steps) }));
const handleResetFontSize = () => setLive((current) => ({ ...current, fontSize: defaults.fontSize }));
const handleToggleHelp = () => setShowHelp((current) => !current);
useSyncTeleprompterParams({ speed, fontSize: live.fontSize, flipH: live.flipH, flipV: live.flipV });
useTeleprompterControls({
controller,
isHelpOpen: showHelp,
onFlip: handleFlip,
onFontSize: handleFontSize,
onResetFontSize: handleResetFontSize,
onToggleHelp: handleToggleHelp,
});
const hasScriptSource = options.scriptSource !== 'none';
const effectiveFlip = composeFlip(live.flipH, live.flipV, isMirrored);
const viewStyles = {
'--tp-configured-font-size': `${live.fontSize}px`,
'--tp-line-height': options.lineHeight,
'--tp-text-width': `${options.textWidth}%`,
'--tp-reading-line': options.readingLinePos,
} as CSSProperties;
return (
<div
className={cx([
'teleprompter',
effectiveFlip.flipH && 'teleprompter--flip-h',
effectiveFlip.flipV && 'teleprompter--flip-v',
])}
style={viewStyles}
data-testid='teleprompter-view'
>
<ViewParamsEditor target={OntimeView.Teleprompter} viewOptions={viewOptions} />
{!hasScriptSource ? (
<EmptyPage text='Select which field holds the script in the view options' />
) : blocks.length === 0 ? (
<EmptyPage text='There is no script text in the selected field' />
) : (
<>
<div
className='teleprompter__scroller'
data-testid='teleprompter-scroller'
ref={scrollerRef}
onWheel={handleUserScroll}
onTouchMove={handleUserScroll}
onPointerDown={handleUserScroll}
>
<div className='teleprompter__content' ref={contentRef}>
{blocks.map((block) => (
<ScriptBlockView key={block.id} block={block} registerRef={registerBlock} />
))}
</div>
</div>
<div className='teleprompter__dim' />
{options.readingLine && (
<div className='teleprompter__reading-line'>
<span className='teleprompter__reading-marker' />
</div>
)}
<ControlOverlay
isRunning={isRunning}
speed={speed}
canReengageFollow={followLocked && options.followLoaded}
atEnd={atEnd}
controller={controller}
onToggleHelp={handleToggleHelp}
/>
</>
)}
<HelpOverlay isOpen={showHelp} onClose={handleToggleHelp} />
</div>
);
}
@@ -1,96 +0,0 @@
import { resolveTeleprompterAction, type TeleprompterKeyEvent } from '../teleprompter.keymap';
import { SPEED_STEP, SPEED_STEP_COARSE } from '../teleprompter.scroll';
function makeEvent(overrides: Partial<TeleprompterKeyEvent>): TeleprompterKeyEvent {
return {
code: '',
key: '',
shiftKey: false,
ctrlKey: false,
metaKey: false,
altKey: false,
repeat: false,
...overrides,
};
}
describe('resolveTeleprompterAction()', () => {
test('space toggles playback', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Space' }))).toEqual({ type: 'togglePlay' });
});
test('ignores a repeating space', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Space', repeat: true }))).toBeNull();
});
test('vertical arrows nudge by a line', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown' }))).toEqual({ type: 'nudge', lines: 1 });
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowUp' }))).toEqual({ type: 'nudge', lines: -1 });
});
test('a repeating arrow still nudges, so the key can be held', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowDown', repeat: true }))).toEqual({
type: 'nudge',
lines: 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 });
});
test('horizontal arrows change speed', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight' }))).toEqual({ type: 'speed', delta: SPEED_STEP });
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft' }))).toEqual({ type: 'speed', delta: -SPEED_STEP });
});
test('shift makes the speed step coarse', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', shiftKey: true }))).toEqual({
type: 'speed',
delta: SPEED_STEP_COARSE,
});
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowLeft', shiftKey: true }))).toEqual({
type: 'speed',
delta: -SPEED_STEP_COARSE,
});
});
test('home rewinds and escape rewinds and stops', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Home' }))).toEqual({ type: 'rewind' });
expect(resolveTeleprompterAction(makeEvent({ code: 'Escape' }))).toEqual({ type: 'rewindAndPause' });
expect(resolveTeleprompterAction(makeEvent({ code: 'End' }))).toEqual({ type: 'jumpToEnd' });
});
test('f flips, shift+f flips the other axis', () => {
expect(resolveTeleprompterAction(makeEvent({ key: 'f' }))).toEqual({ type: 'flip', axis: 'h' });
expect(resolveTeleprompterAction(makeEvent({ key: 'F', shiftKey: true }))).toEqual({ type: 'flip', axis: 'v' });
});
test('changes and resets the font size', () => {
expect(resolveTeleprompterAction(makeEvent({ key: '+' }))).toEqual({ type: 'fontSize', steps: 1 });
expect(resolveTeleprompterAction(makeEvent({ key: '=' }))).toEqual({ type: 'fontSize', steps: 1 });
expect(resolveTeleprompterAction(makeEvent({ key: '-' }))).toEqual({ type: 'fontSize', steps: -1 });
expect(resolveTeleprompterAction(makeEvent({ key: '_' }))).toEqual({ type: 'fontSize', steps: -1 });
expect(resolveTeleprompterAction(makeEvent({ key: '0' }))).toEqual({ type: 'resetFontSize' });
});
test('l re-engages the follow and ? shows the help', () => {
expect(resolveTeleprompterAction(makeEvent({ key: 'l' }))).toEqual({ type: 'reengageFollow' });
expect(resolveTeleprompterAction(makeEvent({ key: '?', shiftKey: true }))).toEqual({ type: 'toggleHelp' });
});
test('never shadows a shortcut which carries a modifier', () => {
expect(resolveTeleprompterAction(makeEvent({ key: ',', metaKey: true }))).toBeNull();
expect(resolveTeleprompterAction(makeEvent({ code: 'Space', ctrlKey: true }))).toBeNull();
expect(resolveTeleprompterAction(makeEvent({ code: 'ArrowRight', altKey: true }))).toBeNull();
});
test('ignores keys it does not bind', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'KeyQ', key: 'q' }))).toBeNull();
});
test('leaves Enter alone, so a focused transport button can still be pressed', () => {
expect(resolveTeleprompterAction(makeEvent({ code: 'Enter', key: 'Enter' }))).toBeNull();
});
});
@@ -1,109 +0,0 @@
import { getOptionsFromParams, getTeleprompterOptions } from '../teleprompter.options';
import { DEFAULT_SPEED, MAX_SPEED, MIN_SPEED } from '../teleprompter.scroll';
describe('getOptionsFromParams()', () => {
test('provides sensible defaults with no params', () => {
const options = getOptionsFromParams(new URLSearchParams());
expect(options).toMatchObject({
scriptSource: 'none',
heading: 'title',
hideEmpty: true,
showGroups: true,
speed: DEFAULT_SPEED,
followLoaded: true,
fontSize: 52,
lineHeight: 1.3,
textWidth: 80,
readingLine: true,
readingLinePos: 25,
flipH: false,
flipV: false,
});
});
test('reads the script source verbatim so it can be handed to getPropertyValue', () => {
const options = getOptionsFromParams(new URLSearchParams('script=custom-prompter'));
expect(options.scriptSource).toBe('custom-prompter');
});
test('booleans which default to true can be turned off', () => {
const options = getOptionsFromParams(
new URLSearchParams('hideEmpty=false&showGroups=false&followLoaded=false&readingLine=false'),
);
expect(options).toMatchObject({
hideEmpty: false,
showGroups: false,
followLoaded: false,
readingLine: false,
});
});
test('booleans which default to false can be turned on', () => {
const options = getOptionsFromParams(new URLSearchParams('flipH=true&flipV=true'));
expect(options).toMatchObject({
flipH: true,
flipV: true,
});
});
test('clamps the speed to the usable range', () => {
expect(getOptionsFromParams(new URLSearchParams('speed=1000')).speed).toBe(MAX_SPEED);
expect(getOptionsFromParams(new URLSearchParams('speed=0')).speed).toBe(MIN_SPEED);
});
test('falls back to the default for a non numeric value', () => {
expect(getOptionsFromParams(new URLSearchParams('speed=fast')).speed).toBe(DEFAULT_SPEED);
expect(getOptionsFromParams(new URLSearchParams('speed=')).speed).toBe(DEFAULT_SPEED);
expect(getOptionsFromParams(new URLSearchParams('fontSize=huge')).fontSize).toBe(52);
});
test('rejects an unknown value for an enumerated option', () => {
expect(getOptionsFromParams(new URLSearchParams('heading=banana')).heading).toBe('title');
});
test('preset values take precedence over the search params', () => {
const options = getOptionsFromParams(
new URLSearchParams('speed=10&script=custom-a'),
new URLSearchParams('speed=20&script=custom-b'),
);
expect(options.speed).toBe(20);
expect(options.scriptSource).toBe('custom-b');
});
});
describe('getTeleprompterOptions()', () => {
test('every declared default is what parsing an empty query produces', () => {
const parsed = getOptionsFromParams(new URLSearchParams()) as Record<string, unknown>;
const parsedByParamId: Record<string, unknown> = { ...parsed, script: parsed.scriptSource };
const declared = getTeleprompterOptions({}).flatMap((section) => section.options);
expect(declared.length).toBeGreaterThan(0);
for (const field of declared) {
if (!('defaultValue' in field) || field.defaultValue === undefined) continue;
const expected = field.defaultValue;
expect({ id: field.id, value: parsedByParamId[field.id] }).toEqual({ id: field.id, value: expected });
}
});
});
describe('option value round trip', () => {
test('every value the editor offers survives parsing', () => {
const selects = getTeleprompterOptions({})
.flatMap((section) => section.options)
.filter((field) => field.type === 'option' && field.id !== 'script');
expect(selects.length).toBeGreaterThan(0);
for (const field of selects) {
for (const { value } of field.values) {
const parsed = getOptionsFromParams(new URLSearchParams(`${field.id}=${value}`)) as Record<string, unknown>;
expect({ id: field.id, value, parsed: parsed[field.id] }).toEqual({ id: field.id, value, parsed: value });
}
}
});
});
@@ -1,139 +0,0 @@
import {
advance,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
linesPerMinuteToPxPerSecond,
MAX_FONT_SIZE,
MAX_FRAME_DELTA_MS,
MAX_SPEED,
MIN_FONT_SIZE,
MIN_SPEED,
stepFontSize,
} from '../teleprompter.scroll';
describe('linesPerMinuteToPxPerSecond()', () => {
test('converts a read rate into a pixel rate', () => {
// 30 lines a minute over a 64px line is half a line a second
expect(linesPerMinuteToPxPerSecond(30, 64)).toBe(32);
});
});
describe('stepFontSize()', () => {
test('steps by a ratio, so a press is the same visual change at any size', () => {
// a fixed pixel step would be a big jump at 20px and imperceptible at 200px
expect(stepFontSize(100, 1)).toBe(110);
expect(stepFontSize(20, 1)).toBe(22);
});
test('shrinking undoes growing', () => {
expect(stepFontSize(stepFontSize(50, 1), -1)).toBe(50);
});
test('stays inside the range the option accepts', () => {
expect(stepFontSize(MAX_FONT_SIZE, 5)).toBe(MAX_FONT_SIZE);
expect(stepFontSize(MIN_FONT_SIZE, -5)).toBe(MIN_FONT_SIZE);
});
});
describe('clampSpeed()', () => {
test('bounds the speed to the usable range', () => {
expect(clampSpeed(MIN_SPEED - 10)).toBe(MIN_SPEED);
expect(clampSpeed(MAX_SPEED + 10)).toBe(MAX_SPEED);
expect(clampSpeed(30)).toBe(30);
});
test('falls back to the minimum for a non number, rather than stalling at zero', () => {
expect(clampSpeed(Number.NaN)).toBe(MIN_SPEED);
});
});
describe('frameDeltaSeconds()', () => {
test('clamps a long gap so returning to a background tab cannot teleport the script', () => {
// requestAnimationFrame is suspended while hidden, so the first timestamp
// back can be minutes stale
expect(frameDeltaSeconds(1000 / 60)).toBeCloseTo(1 / 60, 6);
expect(frameDeltaSeconds(60_000)).toBe(MAX_FRAME_DELTA_MS / 1000);
});
test('ignores nonsense deltas', () => {
expect(frameDeltaSeconds(-5)).toBe(0);
expect(frameDeltaSeconds(Number.NaN)).toBe(0);
});
});
describe('advance()', () => {
test('accumulates sub-pixel movement without losing any to rounding', () => {
// 32px/s sampled at 60fps is 0.53px a frame: rounding each frame would stall
const pxPerSecond = 32;
const frame = 1 / 60;
let position = 0;
for (let i = 0; i < 100; i += 1) {
position = advance(position, pxPerSecond, frame, 10_000).position;
}
expect(position).toBeCloseTo((pxPerSecond * 100) / 60, 5);
});
test('reports the end of the script once the bottom is reached', () => {
expect(advance(499, 100, 1, 500).atEnd).toBe(true);
expect(advance(100, 100, 1, 500).atEnd).toBe(false);
});
test('reports the end without bounding the position, which the caller owns', () => {
// the loop has to bound the position anyway, for nudges and for a document
// which shrank, so this does not do it a second time
expect(advance(490, 100, 1, 500)).toEqual({ position: 590, atEnd: true });
});
test('never reports the end for a document which does not overflow', () => {
// it may simply not have been measured yet, and stopping playback on an
// unmeasured document would look like the prompter refusing to run
expect(advance(0, 100, 1, 0).atEnd).toBe(false);
});
});
describe('easeCatchUp()', () => {
test('approaches the target monotonically from either side', () => {
let fromAbove = 500;
let fromBelow = 0;
let previousAbove = 501;
let previousBelow = -1;
for (let i = 0; i < 20; i += 1) {
fromBelow = easeCatchUp(fromBelow, 500, 1 / 60);
fromAbove = easeCatchUp(fromAbove, 0, 1 / 60);
expect(fromBelow).toBeGreaterThan(previousBelow);
expect(fromBelow).toBeLessThanOrEqual(500);
expect(fromAbove).toBeLessThan(previousAbove);
expect(fromAbove).toBeGreaterThanOrEqual(0);
previousBelow = fromBelow;
previousAbove = fromAbove;
}
});
test('settles exactly on the target instead of creeping forever', () => {
let position = 0;
for (let i = 0; i < 300; i += 1) {
position = easeCatchUp(position, 500, 1 / 60);
}
expect(position).toBe(500);
});
test('is framerate independent, so a jump takes the same time on any display', () => {
let atSixty = 0;
for (let i = 0; i < 60; i += 1) {
atSixty = easeCatchUp(atSixty, 1000, 1 / 60);
}
let atThirty = 0;
for (let i = 0; i < 30; i += 1) {
atThirty = easeCatchUp(atThirty, 1000, 1 / 30);
}
expect(atSixty).toBeCloseTo(atThirty, 3);
});
});
@@ -1,218 +0,0 @@
import { type CustomFields, type OntimeEntry, type Rundown, SupportedEntry } from 'ontime-types';
import type { RundownMetadata, RundownMetadataObject } from '../../../common/utils/rundownMetadata';
import { buildScript, composeFlip } from '../teleprompter.utils';
function makeEvent(id: string, overrides: Partial<OntimeEntry> = {}): OntimeEntry {
return {
type: SupportedEntry.Event,
id,
cue: id.toUpperCase(),
title: `Title ${id}`,
note: `Note ${id}`,
skip: false,
custom: { script: `Script ${id}` },
parent: null,
...overrides,
} as OntimeEntry;
}
function makeGroup(id: string, title: string, entries: string[]): OntimeEntry {
return { type: SupportedEntry.Group, id, title, entries } as OntimeEntry;
}
function makeMetadata(overrides: Partial<RundownMetadata> = {}): RundownMetadata {
return { isPast: false, isLoaded: false, groupId: null, ...overrides } as RundownMetadata;
}
function makeRundown(entries: OntimeEntry[], flatOrder?: string[]): Rundown {
return {
id: 'default',
title: 'test',
order: flatOrder ?? entries.map((entry) => entry.id),
flatOrder: flatOrder ?? entries.map((entry) => entry.id),
entries: Object.fromEntries(entries.map((entry) => [entry.id, entry])),
revision: 1,
};
}
const customFields: CustomFields = {
script: { type: 'text', colour: '', label: 'Script' },
poster: { type: 'image', colour: '', label: 'Poster' },
};
const defaultOptions = {
scriptSource: 'custom-script',
heading: 'title' as const,
hideEmpty: true,
showGroups: true,
};
function metadataFor(ids: string[], overrides: Record<string, Partial<RundownMetadata>> = {}): RundownMetadataObject {
return Object.fromEntries(ids.map((id) => [id, makeMetadata(overrides[id])]));
}
describe('buildScript()', () => {
test('resolves the script from the selected custom field, in rundown order', () => {
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
const blocks = buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions);
expect(blocks).toHaveLength(2);
expect(blocks.map((block) => block.id)).toEqual(['a', 'b']);
expect(blocks[0].text).toBe('Script a');
});
test('returns nothing when no script source is selected', () => {
const rundown = makeRundown([makeEvent('a')]);
expect(buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'none' })).toEqual(
[],
);
});
test('refuses an image custom field, which the select filters but the URL does not', () => {
const rundown = makeRundown([makeEvent('a', { custom: { poster: 'https://example.com/a.png' } })]);
const blocks = buildScript(rundown, metadataFor(['a']), customFields, {
...defaultOptions,
scriptSource: 'custom-poster',
});
expect(blocks).toEqual([]);
});
test('reads the note and the title as script sources', () => {
const rundown = makeRundown([makeEvent('a')]);
expect(
buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'note' })[0].text,
).toBe('Note a');
expect(
buildScript(rundown, metadataFor(['a']), customFields, { ...defaultOptions, scriptSource: 'title' })[0].text,
).toBe('Title a');
});
test('skips entries which are not events', () => {
const rundown = makeRundown([
makeEvent('a'),
{ type: SupportedEntry.Delay, id: 'd', duration: 10 } as OntimeEntry,
{ type: SupportedEntry.Milestone, id: 'm', title: 'milestone' } as OntimeEntry,
]);
const blocks = buildScript(rundown, metadataFor(['a', 'd', 'm']), customFields, defaultOptions);
expect(blocks.map((block) => block.id)).toEqual(['a']);
});
test('skips events flagged as skipped', () => {
const rundown = makeRundown([makeEvent('a', { skip: true }), makeEvent('b')]);
const blocks = buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions);
expect(blocks.map((block) => block.id)).toEqual(['b']);
});
test('hideEmpty drops events with no script text', () => {
const rundown = makeRundown([makeEvent('a', { custom: { script: ' ' } }), makeEvent('b')]);
expect(buildScript(rundown, metadataFor(['a', 'b']), customFields, defaultOptions).map((b) => b.id)).toEqual(['b']);
expect(
buildScript(rundown, metadataFor(['a', 'b']), customFields, { ...defaultOptions, hideEmpty: false }).map(
(b) => b.id,
),
).toEqual(['a', 'b']);
});
test('marks the block belonging to the loaded event', () => {
const rundown = makeRundown([makeEvent('a'), makeEvent('b')]);
const metadata = metadataFor(['a', 'b'], { b: { isLoaded: true } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.isLoaded)).toEqual([false, true]);
});
test('emits a group title once, on the first block of the group', () => {
const rundown = makeRundown(
[makeGroup('g', 'Morning session', ['a', 'b']), makeEvent('a'), makeEvent('b')],
['g', 'a', 'b'],
);
const metadata = metadataFor(['g', 'a', 'b'], { a: { groupId: 'g' }, b: { groupId: 'g' } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null]);
});
test('emits each group title as the script moves between groups', () => {
const rundown = makeRundown(
[
makeGroup('g1', 'Morning session', ['a']),
makeEvent('a'),
makeGroup('g2', 'Afternoon session', ['b']),
makeEvent('b'),
],
['g1', 'a', 'g2', 'b'],
);
const metadata = metadataFor(['g1', 'a', 'g2', 'b'], { a: { groupId: 'g1' }, b: { groupId: 'g2' } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', 'Afternoon session']);
});
test('repeats a group title when the script returns to it after an ungrouped event', () => {
// the reader has lost the context by then, so naming the group again is right
const rundown = makeRundown(
[makeGroup('g', 'Morning session', ['a', 'c']), makeEvent('a'), makeEvent('b'), makeEvent('c')],
['g', 'a', 'b', 'c'],
);
const metadata = metadataFor(['g', 'a', 'b', 'c'], { a: { groupId: 'g' }, c: { groupId: 'g' } });
const blocks = buildScript(rundown, metadata, customFields, defaultOptions);
expect(blocks.map((block) => block.groupTitle)).toEqual(['Morning session', null, 'Morning session']);
});
test('does not emit group titles when they are turned off', () => {
const rundown = makeRundown([makeGroup('g', 'Morning session', ['a']), makeEvent('a')], ['g', 'a']);
const metadata = metadataFor(['g', 'a'], { a: { groupId: 'g' } });
const blocks = buildScript(rundown, metadata, customFields, { ...defaultOptions, showGroups: false });
expect(blocks[0].groupTitle).toBeNull();
});
describe('headings', () => {
const rundown = makeRundown([makeEvent('a')]);
const metadata = metadataFor(['a']);
test('shows the title, the cue, both, or nothing', () => {
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'title' })[0].heading).toBe(
'Title a',
);
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'cue' })[0].heading).toBe('A');
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'both' })[0].heading).toBe(
'A · Title a',
);
expect(buildScript(rundown, metadata, customFields, { ...defaultOptions, heading: 'none' })[0].heading).toBe('');
});
test('leaves no dangling separator when an event has no cue', () => {
const noCue = makeRundown([makeEvent('a', { cue: '' })]);
expect(buildScript(noCue, metadata, customFields, { ...defaultOptions, heading: 'both' })[0].heading).toBe(
'Title a',
);
});
});
});
describe('composeFlip()', () => {
test('passes the per view flips through when Flip Screen is off', () => {
expect(composeFlip(false, false, false)).toEqual({ flipH: false, flipV: false });
expect(composeFlip(true, false, false)).toEqual({ flipH: true, flipV: false });
expect(composeFlip(false, true, false)).toEqual({ flipH: false, flipV: true });
});
test('Flip Screen alone flips both axes, matching rotate(180deg) in every other view', () => {
expect(composeFlip(false, false, true)).toEqual({ flipH: true, flipV: true });
});
test('a horizontal flip and Flip Screen leave only the vertical axis flipped', () => {
// scale(-1, 1) composed with scale(-1, -1) is scale(1, -1)
expect(composeFlip(true, false, true)).toEqual({ flipH: false, flipV: true });
});
test('both flips cancel Flip Screen out', () => {
expect(composeFlip(true, true, true)).toEqual({ flipH: false, flipV: false });
});
});
@@ -1,135 +0,0 @@
import type { MouseEvent } from 'react';
import { IoAdd, IoArrowUp, IoHelpCircleOutline, IoLocate, IoPause, IoPlay, IoRemove } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../../common/utils/styleUtils';
import { SPEED_STEP } from '../teleprompter.scroll';
import type { TeleprompterController } from '../teleprompter.types';
interface ControlOverlayProps {
isRunning: boolean;
speed: number;
canReengageFollow: boolean;
atEnd: boolean;
controller: TeleprompterController;
onToggleHelp: () => void;
}
export default function ControlOverlay({
isRunning,
speed,
canReengageFollow,
atEnd,
controller,
onToggleHelp,
}: ControlOverlayProps) {
const isActive = useFadeOutOnInactivity(true);
// Pointer activation yields focus so the next Space reaches the transport.
const press = (action: () => void) => (event: MouseEvent<HTMLButtonElement>) => {
if (event.detail > 0) {
event.currentTarget.blur();
}
action();
};
return (
<div className={cx(['teleprompter__controls', !isActive && 'teleprompter__controls--idle'])}>
<Tooltip
text={isRunning ? 'Pause (Space)' : 'Play (Space)'}
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(controller.togglePlay)}
data-testid='teleprompter-play'
aria-label={isRunning ? 'Pause' : 'Play'}
/>
}
>
{isRunning ? <IoPause /> : <IoPlay />}
</Tooltip>
<Tooltip
text='Slow down (Left arrow)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(() => controller.changeSpeed(-SPEED_STEP))}
aria-label='Slow down'
/>
}
>
<IoRemove />
</Tooltip>
<div className='teleprompter__speed' data-testid='teleprompter-speed'>
{speed}
<span className='teleprompter__speed-unit'>lpm</span>
</div>
<Tooltip
text='Speed up (Right arrow)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(() => controller.changeSpeed(SPEED_STEP))}
aria-label='Speed up'
/>
}
>
<IoAdd />
</Tooltip>
<Tooltip
text='Rewind to the top (Home)'
render={
<IconButton
variant={atEnd ? 'primary' : 'subtle-white'}
size='large'
onClick={press(() => controller.rewind())}
aria-label='Rewind to top'
/>
}
>
<IoArrowUp />
</Tooltip>
<Tooltip
text={
canReengageFollow ? 'Jump back to the loaded event and follow it again (L)' : 'Following the loaded event'
}
render={
<IconButton
variant={canReengageFollow ? 'primary' : 'subtle-white'}
size='large'
disabled={!canReengageFollow}
onClick={press(controller.reengageFollow)}
data-testid='teleprompter-follow'
aria-label='Follow the loaded event'
/>
}
>
<IoLocate />
</Tooltip>
<Tooltip
text='Keyboard shortcuts (?)'
render={
<IconButton
variant='subtle-white'
size='large'
onClick={press(onToggleHelp)}
aria-label='Keyboard shortcuts'
/>
}
>
<IoHelpCircleOutline />
</Tooltip>
</div>
);
}
@@ -1,102 +0,0 @@
import { Dialog } from '@base-ui/react/dialog';
import { IoClose } from 'react-icons/io5';
import IconButton from '../../../common/components/buttons/IconButton';
import {
Combo,
Separator,
Shortcut,
ShortcutGroup,
ShortcutGroups,
} from '../../../common/components/keyboard-shortcuts/KeyboardShortcuts';
interface HelpOverlayProps {
isOpen: boolean;
onClose: () => void;
}
export default function HelpOverlay({ isOpen, onClose }: HelpOverlayProps) {
return (
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
>
<Dialog.Portal>
<Dialog.Backdrop className='teleprompter__help' />
<Dialog.Popup className='teleprompter__help-card'>
<div className='teleprompter__help-header'>
<Dialog.Title className='teleprompter__help-title'>Prompter shortcuts</Dialog.Title>
<IconButton variant='subtle-white' size='large' onClick={onClose} aria-label='Close'>
<IoClose />
</IconButton>
</div>
<ShortcutGroups className='teleprompter__help-groups'>
<ShortcutGroup title='Transport'>
<Shortcut label='Start / stop scrolling'>
<Combo keys={['Space']} />
</Shortcut>
<Shortcut label='Slower / faster'>
<Combo keys={['←']} />
<Separator />
<Combo keys={['→']} />
</Shortcut>
<Shortcut label='Larger speed steps'>
<Combo keys={['Shift', '←']} />
<Separator />
<Combo keys={['Shift', '→']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Navigation'>
<Shortcut label='Nudge one line'>
<Combo keys={['↑']} />
<Separator />
<Combo keys={['↓']} />
</Shortcut>
<Shortcut label='Jump a screen'>
<Combo keys={['PgUp']} />
<Separator />
<Combo keys={['PgDn']} />
</Shortcut>
<Shortcut label='Jump to top / end'>
<Combo keys={['Home']} />
<Separator />
<Combo keys={['End']} />
</Shortcut>
<Shortcut label='Rewind and stop'>
<Combo keys={['Esc']} />
</Shortcut>
<Shortcut label='Follow the loaded event again'>
<Combo keys={['L']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Display'>
<Shortcut label='Font size'>
<Combo keys={['+']} />
<Separator />
<Combo keys={['-']} />
</Shortcut>
<Shortcut label='Reset font size'>
<Combo keys={['0']} />
</Shortcut>
<Shortcut label='Flip horizontally / vertically'>
<Combo keys={['F']} />
<Separator />
<Combo keys={['Shift', 'F']} />
</Shortcut>
<Shortcut label='Show this list'>
<Combo keys={['?']} />
</Shortcut>
</ShortcutGroup>
</ShortcutGroups>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
@@ -1,22 +0,0 @@
import type { ScriptBlock } from '../teleprompter.types';
interface ScriptBlockProps {
block: ScriptBlock;
registerRef: (id: string, element: HTMLElement | null) => void;
}
export default function ScriptBlockView({ block, registerRef }: ScriptBlockProps) {
'use memo';
return (
<section
className='teleprompter__block'
ref={(element) => registerRef(block.id, element)}
data-loaded={block.isLoaded || undefined}
>
{block.groupTitle && <div className='teleprompter__group'>{block.groupTitle}</div>}
{block.heading && <h2 className='teleprompter__heading'>{block.heading}</h2>}
<p className='teleprompter__body'>{block.text}</p>
</section>
);
}
@@ -1,65 +0,0 @@
import { SPEED_STEP, SPEED_STEP_COARSE } from './teleprompter.scroll';
import type { TeleprompterAction } from './teleprompter.types';
export type TeleprompterKeyEvent = {
code: string;
key: string;
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
altKey: boolean;
repeat: boolean;
};
export function resolveTeleprompterAction(event: TeleprompterKeyEvent): TeleprompterAction | null {
if (event.ctrlKey || event.metaKey || event.altKey) {
return null;
}
switch (event.code) {
case 'Space':
return event.repeat ? null : { type: 'togglePlay' };
case 'ArrowDown':
return { type: 'nudge', lines: 1 };
case 'ArrowUp':
return { type: 'nudge', lines: -1 };
case 'PageDown':
return { type: 'page', direction: 1 };
case 'PageUp':
return { type: 'page', direction: -1 };
case 'ArrowRight':
return { type: 'speed', delta: event.shiftKey ? SPEED_STEP_COARSE : SPEED_STEP };
case 'ArrowLeft':
return { type: 'speed', delta: event.shiftKey ? -SPEED_STEP_COARSE : -SPEED_STEP };
case 'Home':
return { type: 'rewind' };
case 'End':
return { type: 'jumpToEnd' };
case 'Escape':
return { type: 'rewindAndPause' };
}
// Character bindings use key so they work across keyboard layouts.
switch (event.key) {
case '?':
return { type: 'toggleHelp' };
case '+':
case '=':
return { type: 'fontSize', steps: 1 };
case '-':
case '_':
return { type: 'fontSize', steps: -1 };
case '0':
return { type: 'resetFontSize' };
}
const lowerKey = event.key.toLowerCase();
if (lowerKey === 'f') {
return { type: 'flip', axis: event.shiftKey ? 'v' : 'h' };
}
if (lowerKey === 'l') {
return { type: 'reengageFollow' };
}
return null;
}
@@ -1,223 +0,0 @@
import type { CustomFields } from 'ontime-types';
import { use, useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import type { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { makeOptionsFromCustomFields } from '../../common/components/view-params-editor/viewParams.utils';
import { PresetContext } from '../../common/context/PresetContext';
import { isStringBoolean } from '../common/viewUtils';
import { DEFAULT_SPEED, MAX_FONT_SIZE, MAX_SPEED, MIN_FONT_SIZE, MIN_SPEED } from './teleprompter.scroll';
import type { HeadingSource, TeleprompterOptions } from './teleprompter.types';
const headingOptions: { value: HeadingSource; label: string }[] = [
{ value: 'title', label: 'Title' },
{ value: 'cue', label: 'Cue' },
{ value: 'both', label: 'Cue and title' },
{ value: 'none', label: 'None' },
];
const headingSources = headingOptions.map((option) => option.value);
export const defaults = {
script: 'none',
heading: 'title' as HeadingSource,
hideEmpty: true,
showGroups: true,
speed: DEFAULT_SPEED,
followLoaded: true,
fontSize: 52,
lineHeight: 1.3,
textWidth: 80,
readingLine: true,
readingLinePos: 25,
flipH: false,
flipV: false,
};
const bounds = {
speed: [MIN_SPEED, MAX_SPEED],
fontSize: [MIN_FONT_SIZE, MAX_FONT_SIZE],
lineHeight: [1, 4],
textWidth: [20, 100],
readingLinePos: [0, 100],
} as const;
export const getTeleprompterOptions = (customFields: CustomFields): ViewOption[] => {
const scriptOptions = makeOptionsFromCustomFields(customFields, [
{ value: 'none', label: 'None' },
{ value: 'note', label: 'Note' },
{ value: 'title', label: 'Title' },
]);
return [
{
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'script',
title: 'Script',
description: 'Select the data source which holds the script to read',
type: 'option',
values: scriptOptions,
defaultValue: defaults.script,
},
{
id: 'heading',
title: 'Segment heading',
description: 'What to show above each segment of the script',
type: 'option',
values: headingOptions,
defaultValue: defaults.heading,
},
],
},
{
title: OptionTitle.BehaviourOptions,
collapsible: true,
options: [
{
id: 'speed',
title: 'Speed',
description: `Scroll speed in lines per minute (${MIN_SPEED}-${MAX_SPEED}). Adjustable live with the arrow keys`,
type: 'number',
defaultValue: defaults.speed,
},
{
id: 'followLoaded',
title: 'Follow loaded event',
description: 'Scroll to the segment of the loaded event. Scrolling by hand releases the follow',
type: 'boolean',
defaultValue: defaults.followLoaded,
},
],
},
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hideEmpty',
title: 'Hide events without a script',
description: 'Prevents showing headings for events which have no script text',
type: 'boolean',
defaultValue: defaults.hideEmpty,
},
{
id: 'showGroups',
title: 'Show group names',
description: 'Shows the group name when the script moves into a new group',
type: 'boolean',
defaultValue: defaults.showGroups,
},
],
},
{
title: OptionTitle.StyleOverride,
collapsible: true,
options: [
{
id: 'fontSize',
title: 'Font size',
description: 'Base font size in pixels. Adjustable live with the + and - keys',
type: 'number',
defaultValue: defaults.fontSize,
},
{
id: 'lineHeight',
title: 'Line height',
description: 'Spacing between lines, as a multiple of the font size',
type: 'number',
defaultValue: defaults.lineHeight,
},
{
id: 'textWidth',
title: 'Text width',
description: 'Width of the text column as a percentage of the screen. Narrower means less eye movement',
type: 'number',
defaultValue: defaults.textWidth,
},
{
id: 'readingLine',
title: 'Reading line',
description: 'Shows a marker beside the line which should be read',
type: 'boolean',
defaultValue: defaults.readingLine,
},
{
id: 'readingLinePos',
title: 'Reading line position',
description: 'Position of the reading line as a percentage from the top of the screen',
type: 'number',
defaultValue: defaults.readingLinePos,
},
{
id: 'flipH',
title: 'Flip horizontally',
description:
'Mirrors the view horizontally, which is what a beam splitter rig needs. Toggled live with F. Flip Screen in the navigation menu flips both axes at once, which is a rotation rather than a mirror',
type: 'boolean',
defaultValue: defaults.flipH,
},
{
id: 'flipV',
title: 'Flip vertically',
description: 'Mirrors the view vertically. Note this also moves the reading line. Toggled live with Shift+F',
type: 'boolean',
defaultValue: defaults.flipV,
},
],
},
];
};
function toNumber(value: string | null, [min, max]: readonly [number, number], fallback: number): number {
if (value === null || value === '') return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function toBoolean(value: string | null, fallback: boolean): boolean {
return value === null ? fallback : isStringBoolean(value);
}
function toEnum<T extends string>(value: string | null, allowed: readonly T[], fallback: T): T {
return allowed.includes(value as T) ? (value as T) : fallback;
}
export function getOptionsFromParams(
searchParams: URLSearchParams,
defaultValues?: URLSearchParams,
): TeleprompterOptions {
const getValue = (key: string) => defaultValues?.get(key) ?? searchParams.get(key);
return {
scriptSource: getValue('script') ?? defaults.script,
heading: toEnum(getValue('heading'), headingSources, defaults.heading),
hideEmpty: toBoolean(getValue('hideEmpty'), defaults.hideEmpty),
showGroups: toBoolean(getValue('showGroups'), defaults.showGroups),
speed: toNumber(getValue('speed'), bounds.speed, defaults.speed),
followLoaded: toBoolean(getValue('followLoaded'), defaults.followLoaded),
fontSize: toNumber(getValue('fontSize'), bounds.fontSize, defaults.fontSize),
lineHeight: toNumber(getValue('lineHeight'), bounds.lineHeight, defaults.lineHeight),
textWidth: toNumber(getValue('textWidth'), bounds.textWidth, defaults.textWidth),
readingLine: toBoolean(getValue('readingLine'), defaults.readingLine),
readingLinePos: toNumber(getValue('readingLinePos'), bounds.readingLinePos, defaults.readingLinePos),
flipH: toBoolean(getValue('flipH'), defaults.flipH),
flipV: toBoolean(getValue('flipV'), defaults.flipV),
};
}
export function useTeleprompterOptions(): TeleprompterOptions {
const [searchParams] = useSearchParams();
const maybePreset = use(PresetContext);
return useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
}
@@ -1,53 +0,0 @@
export const MIN_SPEED = 1;
export const MAX_SPEED = 40;
export const DEFAULT_SPEED = 14;
export const SPEED_STEP = 1;
export const SPEED_STEP_COARSE = 5;
export const MIN_FONT_SIZE = 12;
export const MAX_FONT_SIZE = 400;
const FONT_SIZE_STEP_RATIO = 1.1;
export function stepFontSize(current: number, steps: number): number {
return clamp(Math.round(current * FONT_SIZE_STEP_RATIO ** steps), MIN_FONT_SIZE, MAX_FONT_SIZE);
}
export const MAX_FRAME_DELTA_MS = 100;
const CATCH_UP_RATE = 8;
const CATCH_UP_EPSILON = 0.5;
export function clamp(value: number, min: number, max: number): number {
if (Number.isNaN(value)) return min;
return Math.min(Math.max(value, min), max);
}
export function clampSpeed(value: number): number {
return clamp(value, MIN_SPEED, MAX_SPEED);
}
export function linesPerMinuteToPxPerSecond(linesPerMinute: number, lineHeightPx: number): number {
return (linesPerMinute / 60) * lineHeightPx;
}
export function frameDeltaSeconds(deltaMs: number): number {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return 0;
return Math.min(deltaMs, MAX_FRAME_DELTA_MS) / 1000;
}
export function advance(
position: number,
pxPerSecond: number,
deltaSeconds: number,
maxScroll: number,
): { position: number; atEnd: boolean } {
const next = position + pxPerSecond * deltaSeconds;
return { position: next, atEnd: maxScroll > 0 && next >= maxScroll };
}
export function easeCatchUp(current: number, target: number, deltaSeconds: number): number {
if (deltaSeconds <= 0) return current;
const next = target + (current - target) * Math.exp(-CATCH_UP_RATE * deltaSeconds);
return Math.abs(next - target) < CATCH_UP_EPSILON ? target : next;
}
@@ -1,52 +0,0 @@
import type { MaybeString } from 'ontime-types';
export type HeadingSource = 'none' | 'title' | 'cue' | 'both';
export type ScriptBlock = {
id: string;
heading: string;
text: string;
groupTitle: MaybeString;
isLoaded: boolean;
};
export type TeleprompterOptions = {
scriptSource: string;
heading: HeadingSource;
hideEmpty: boolean;
showGroups: boolean;
/** lines per minute */
speed: number;
followLoaded: boolean;
fontSize: number;
lineHeight: number;
textWidth: number;
readingLine: boolean;
readingLinePos: number;
flipH: boolean;
flipV: boolean;
};
export type TeleprompterAction =
| { type: 'togglePlay' }
| { type: 'nudge'; lines: number }
| { type: 'page'; direction: 1 | -1 }
| { type: 'speed'; delta: number }
| { type: 'rewind' }
| { type: 'rewindAndPause' }
| { type: 'jumpToEnd' }
| { type: 'flip'; axis: 'h' | 'v' }
| { type: 'fontSize'; steps: number }
| { type: 'resetFontSize' }
| { type: 'reengageFollow' }
| { type: 'toggleHelp' };
export type TeleprompterController = {
togglePlay: () => void;
nudge: (lines: number) => void;
page: (direction: 1 | -1) => void;
changeSpeed: (delta: number) => void;
rewind: (alsoPause?: boolean) => void;
jumpToEnd: () => void;
reengageFollow: () => void;
};
@@ -1,80 +0,0 @@
import { type CustomFields, isOntimeEvent, isOntimeGroup, type MaybeString, type Rundown } from 'ontime-types';
import type { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { getPropertyValue } from '../common/viewUtils';
import type { HeadingSource, ScriptBlock, TeleprompterOptions } from './teleprompter.types';
type BuildScriptOptions = Pick<TeleprompterOptions, 'scriptSource' | 'heading' | 'hideEmpty' | 'showGroups'>;
function makeHeading(source: HeadingSource, cue: string, title: string): string {
switch (source) {
case 'cue':
return cue;
case 'title':
return title;
case 'both':
return [cue, title].filter(Boolean).join(' · ');
case 'none':
return '';
}
}
function isReadableSource(scriptSource: string, customFields: CustomFields): boolean {
if (!scriptSource.startsWith('custom-')) {
return true;
}
const key = scriptSource.slice('custom-'.length);
return customFields[key]?.type === 'text';
}
/** Builds the continuous script in rundown order. */
export function buildScript(
rundown: Rundown,
rundownMetadata: RundownMetadataObject,
customFields: CustomFields,
options: BuildScriptOptions,
): ScriptBlock[] {
const { scriptSource, heading, hideEmpty, showGroups } = options;
if (scriptSource === 'none' || !isReadableSource(scriptSource, customFields)) {
return [];
}
const blocks: ScriptBlock[] = [];
let lastGroupId: MaybeString = null;
for (const id of rundown.flatOrder) {
const entry = rundown.entries[id];
if (!isOntimeEvent(entry) || entry.skip) {
continue;
}
const metadata = rundownMetadata[id];
const text = getPropertyValue(entry, scriptSource, rundown.entries)?.trim() ?? '';
if (hideEmpty && !text) {
continue;
}
const groupId = metadata?.groupId ?? null;
let groupTitle: MaybeString = null;
if (showGroups && groupId && groupId !== lastGroupId) {
const group = rundown.entries[groupId];
groupTitle = isOntimeGroup(group) ? group.title : null;
}
lastGroupId = groupId;
blocks.push({
id,
heading: makeHeading(heading, entry.cue, entry.title),
text,
groupTitle,
isLoaded: Boolean(metadata?.isLoaded),
});
}
return blocks;
}
export function composeFlip(flipH: boolean, flipV: boolean, isMirrored: boolean): { flipH: boolean; flipV: boolean } {
return { flipH: flipH !== isMirrored, flipV: flipV !== isMirrored };
}
@@ -1,50 +0,0 @@
import { use, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
import { defaults } from './teleprompter.options';
const SETTLE_MS = 400;
interface LiveParams {
speed: number;
fontSize: number;
flipH: boolean;
flipV: boolean;
}
/** Keeps keyboard and overlay changes shareable through the URL. */
export function useSyncTeleprompterParams({ speed, fontSize, flipH, flipV }: LiveParams) {
const [, setSearchParams] = useSearchParams();
const isPreset = Boolean(use(PresetContext));
useEffect(() => {
if (isPreset) return;
const timeout = setTimeout(() => {
const values = {
speed: speed === defaults.speed ? null : String(speed),
fontSize: fontSize === defaults.fontSize ? null : String(fontSize),
flipH: flipH === defaults.flipH ? null : String(flipH),
flipV: flipV === defaults.flipV ? null : String(flipV),
};
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
for (const [key, value] of Object.entries(values)) {
if (value === null) {
next.delete(key);
} else {
next.set(key, value);
}
}
return next;
},
{ replace: true },
);
}, SETTLE_MS);
return () => clearTimeout(timeout);
}, [flipH, flipV, fontSize, isPreset, setSearchParams, speed]);
}
@@ -1,74 +0,0 @@
import { useEffect, useRef } from 'react';
import { useViewParamsEditorStore } from '../../common/components/view-params-editor/viewParamsEditor.store';
import { resolveTeleprompterAction } from './teleprompter.keymap';
import type { TeleprompterAction, TeleprompterController } from './teleprompter.types';
interface UseTeleprompterControlsArgs {
controller: TeleprompterController;
isHelpOpen: boolean;
onFlip: (axis: 'h' | 'v') => void;
onFontSize: (steps: number) => void;
onResetFontSize: () => void;
onToggleHelp: () => void;
}
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
export function useTeleprompterControls(args: UseTeleprompterControlsArgs) {
const argsRef = useRef(args);
useEffect(() => {
argsRef.current = args;
});
useEffect(() => {
function applyAction(action: TeleprompterAction) {
const { controller, onFlip, onFontSize, onResetFontSize, onToggleHelp } = argsRef.current;
switch (action.type) {
case 'togglePlay':
return controller.togglePlay();
case 'nudge':
return controller.nudge(action.lines);
case 'page':
return controller.page(action.direction);
case 'speed':
return controller.changeSpeed(action.delta);
case 'rewind':
return controller.rewind();
case 'rewindAndPause':
return controller.rewind(true);
case 'jumpToEnd':
return controller.jumpToEnd();
case 'reengageFollow':
return controller.reengageFollow();
case 'flip':
return onFlip(action.axis);
case 'fontSize':
return onFontSize(action.steps);
case 'resetFontSize':
return onResetFontSize();
case 'toggleHelp':
return onToggleHelp();
}
}
function handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement | null;
if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
return;
}
if (useViewParamsEditorStore.getState().isOpen || argsRef.current.isHelpOpen) {
return;
}
const action = resolveTeleprompterAction(event);
if (!action) return;
event.preventDefault();
applyAction(action);
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
}
@@ -1,26 +0,0 @@
import type { CustomFields, Rundown } from 'ontime-types';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import type { RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { type ViewData, aggregateQueryStatus } from '../utils/viewLoader.utils';
export interface TeleprompterData {
rundown: Rundown;
rundownMetadata: RundownMetadataObject;
customFields: CustomFields;
}
export function useTeleprompterData(): ViewData<TeleprompterData> {
const { data: rundown, rundownMetadata, status: rundownStatus } = useRundownWithMetadata();
const { data: customFields, status: customFieldStatus } = useCustomFields();
return {
data: {
rundown,
rundownMetadata,
customFields,
},
status: aggregateQueryStatus([rundownStatus, customFieldStatus]),
};
}
@@ -1,282 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
advance,
clamp,
clampSpeed,
easeCatchUp,
frameDeltaSeconds,
linesPerMinuteToPxPerSecond,
} from './teleprompter.scroll';
import type { ScriptBlock, TeleprompterController } from './teleprompter.types';
const PAGE_FRACTION = 0.85;
const EXTERNAL_SCROLL_EPSILON = 2;
/**
* Uses layout coordinates because client rects include the teleprompter's CSS
* transform while scrollTop does not.
*/
function getLayoutTop(element: HTMLElement): number {
let top = 0;
let current: HTMLElement | null = element;
while (current) {
top += current.offsetTop;
current = current.offsetParent instanceof HTMLElement ? current.offsetParent : null;
}
return top;
}
interface UseTeleprompterScrollArgs {
initialSpeed: number;
followLoaded: boolean;
selectedEventId: string | null;
readingLinePos: number;
blocks: ScriptBlock[];
}
/**
* Owns the scroll position of the teleprompter.
*
* The animation frame is the only writer of scrollTop. Controls update refs so
* smooth scrolling and playback cannot compete for the DOM position.
*/
export function useTeleprompterScroll({
initialSpeed,
followLoaded,
selectedEventId,
readingLinePos,
blocks,
}: UseTeleprompterScrollArgs) {
const scrollerRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const blockRefs = useRef(new Map<string, HTMLElement>());
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);
const runningRef = useRef(false);
const speedRef = useRef(initialSpeed);
const lineHeightRef = useRef(0);
const maxScrollRef = useRef(0);
const catchUpTargetRef = useRef<number | null>(null);
const pendingDeltaRef = useRef(0);
const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(initialSpeed);
const [followLocked, setFollowLocked] = useState(false);
const [atEnd, setAtEnd] = useState(false);
const tick = useCallback((timestamp: number) => {
const el = scrollerRef.current;
if (!el) return;
// 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;
}
}, []);
useEffect(() => {
lastTsRef.current = performance.now();
let frame = requestAnimationFrame(function loop(timestamp) {
tick(timestamp);
frame = requestAnimationFrame(loop);
});
return () => cancelAnimationFrame(frame);
}, [tick]);
const measure = useCallback(() => {
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) return;
maxScrollRef.current = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const computed = getComputedStyle(content);
const parsedLineHeight = Number.parseFloat(computed.lineHeight);
if (Number.isFinite(parsedLineHeight) && parsedLineHeight > 0) {
lineHeightRef.current = parsedLineHeight;
} else {
const parsedFontSize = Number.parseFloat(computed.fontSize);
lineHeightRef.current = Number.isFinite(parsedFontSize) ? parsedFontSize * 1.2 : 0;
}
}, []);
useEffect(() => {
speedRef.current = speed;
}, [speed]);
const [speedFromOption, setSpeedFromOption] = useState(initialSpeed);
// Keep the live speed in sync with view-param and remote redirect changes.
if (speedFromOption !== initialSpeed) {
setSpeedFromOption(initialSpeed);
setSpeed(initialSpeed);
}
useEffect(() => {
measure();
const scroller = scrollerRef.current;
const content = contentRef.current;
if (!scroller || !content) return;
const observer = new ResizeObserver(() => measure());
observer.observe(scroller);
observer.observe(content);
return () => observer.disconnect();
}, [measure, isScrollerMounted]);
useEffect(() => {
let cancelled = false;
void document.fonts?.ready.then(() => {
if (!cancelled) measure();
});
return () => {
cancelled = true;
};
}, [measure]);
useEffect(() => {
const onVisibilityChange = () => {
lastTsRef.current = performance.now();
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, []);
// 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 || followLocked || !selectedEventId) return;
const scroller = scrollerRef.current;
const target = blockRefs.current.get(selectedEventId);
if (!scroller || !target) return;
const offset = (scroller.clientHeight * readingLinePos) / 100;
const top = getLayoutTop(target) - getLayoutTop(scroller) - offset;
catchUpTargetRef.current = clamp(top, 0, maxScrollRef.current);
setAtEnd(false);
}, [selectedEventId, followLoaded, followLocked, readingLinePos, hasSelectedBlock]);
const handleUserScroll = useCallback(() => {
if (followLoaded) {
setFollowLocked(true);
}
}, [followLoaded]);
const registerBlock = useCallback((id: string, element: HTMLElement | null) => {
if (element) {
blockRefs.current.set(id, element);
} else {
blockRefs.current.delete(id);
}
}, []);
const controller: TeleprompterController = useMemo(() => {
const play = () => {
if (maxScrollRef.current > 0 && posRef.current >= maxScrollRef.current) {
return;
}
runningRef.current = true;
setIsRunning(true);
setAtEnd(false);
};
const pause = () => {
runningRef.current = false;
setIsRunning(false);
};
return {
togglePlay: () => (runningRef.current ? pause() : play()),
nudge: (lines: number) => {
pendingDeltaRef.current += lines * lineHeightRef.current;
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);
},
changeSpeed: (delta: number) => setSpeed((current) => clampSpeed(current + delta)),
rewind: (alsoPause = false) => {
catchUpTargetRef.current = 0;
if (alsoPause) pause();
setAtEnd(false);
},
jumpToEnd: () => {
catchUpTargetRef.current = maxScrollRef.current;
if (maxScrollRef.current > 0) {
runningRef.current = false;
setIsRunning(false);
setAtEnd(true);
}
},
reengageFollow: () => setFollowLocked(false),
};
}, []);
return {
scrollerRef: attachScroller,
contentRef: attachContent,
registerBlock,
handleUserScroll,
controller,
isRunning,
speed,
followLocked,
atEnd,
};
}
+1 -1
View File
@@ -169,7 +169,7 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
{ {
id: 'timerColour', id: 'timerColour',
title: 'Timer Colour', title: 'Timer Colour',
description: 'Timer colour. Default: #f6f6f6', description: 'Timer colour. Default: #F6F6F6',
type: 'colour', type: 'colour',
defaultValue: 'f6f6f6', defaultValue: 'f6f6f6',
}, },
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-electron", "name": "ontime-electron",
"version": "4.13.0-beta.1", "version": "4.13.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -14,8 +14,8 @@
"main": "src/main.js", "main": "src/main.js",
"devDependencies": { "devDependencies": {
"electron": "38.2.1", "electron": "38.2.1",
"electron-builder": "26.9.1", "electron-builder": "26.15.3",
"wait-on": "^7.2.0" "wait-on": "^9.0.0"
}, },
"scripts": { "scripts": {
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .", "dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
@@ -151,7 +151,6 @@ function makeViewMenu(clientUrl) {
makeItemOpenInBrowser('Editor', `${clientUrl}/editor`), makeItemOpenInBrowser('Editor', `${clientUrl}/editor`),
makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`), makeItemOpenInBrowser('Cuesheet', `${clientUrl}/cuesheet`),
makeItemOpenInBrowser('Operator', `${clientUrl}/op`), makeItemOpenInBrowser('Operator', `${clientUrl}/op`),
makeItemOpenInBrowser('Teleprompter', `${clientUrl}/teleprompter`),
{ type: 'separator' }, { type: 'separator' },
makeItemOpenInBrowser('Timer', `${clientUrl}/timer`), makeItemOpenInBrowser('Timer', `${clientUrl}/timer`),
makeItemOpenInBrowser('Backstage', `${clientUrl}/backstage`), makeItemOpenInBrowser('Backstage', `${clientUrl}/backstage`),
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getontime/resolver", "name": "@getontime/resolver",
"version": "4.13.0-beta.1", "version": "4.13.0",
"type": "module", "type": "module",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts", "types": "./dist/main.d.ts",
@@ -17,7 +17,7 @@
"parser" "parser"
], ],
"author": "", "author": "",
"license": "AGPL-3.0-only", "license": "ISC",
"devDependencies": { "devDependencies": {
"ontime-types": "workspace:^4.2.1", "ontime-types": "workspace:^4.2.1",
"tsdown": "^0.22.4", "tsdown": "^0.22.4",
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "4.13.0-beta.1", "version": "4.13.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -10,7 +10,7 @@
"cookie": "1.0.2", "cookie": "1.0.2",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"cors": "2.8.6", "cors": "2.8.6",
"dotenv": "^16.0.1", "dotenv": "^17.0.0",
"express": "5.2.1", "express": "5.2.1",
"express-static-gzip": "3.0.1", "express-static-gzip": "3.0.1",
"express-validator": "7.3.2", "express-validator": "7.3.2",
@@ -31,11 +31,11 @@
"@types/multer": "2.1.0", "@types/multer": "2.1.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"esbuild": "^0.24.0", "esbuild": "^0.28.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"server-timing": "^3.3.3", "server-timing": "^3.3.3",
"ts-essentials": "catalog:", "ts-essentials": "catalog:",
"tsx": "^4.19.2", "tsx": "^4.23.12",
"typescript": "catalog:", "typescript": "catalog:",
"vitest": "catalog:" "vitest": "catalog:"
}, },
@@ -1,5 +1,7 @@
import { deepEqual } from 'fast-equals';
import { import {
EndAction, EndAction,
OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeGroup, OntimeGroup,
OntimeMilestone, OntimeMilestone,
@@ -8,13 +10,21 @@ import {
TimerType, TimerType,
Trigger, Trigger,
} from 'ontime-types'; } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils'; import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
createDelay,
createEvent,
createGroup,
createMilestone,
} from 'ontime-utils';
import { assertType } from 'vitest'; import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js'; import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
import { parseRundown } from '../rundown.parser.js'; import { parseRundown } from '../rundown.parser.js';
import { import {
calculateDayOffset, calculateDayOffset,
cloneEntryData,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
getIntegerAndFraction, getIntegerAndFraction,
@@ -715,3 +725,68 @@ describe('eventDurationMatchGroupTarget()', () => {
expect(result).toStrictEqual(null); expect(result).toStrictEqual(null);
}); });
}); });
describe('cloneEntryData()', () => {
const trigger: Trigger = {
id: 'trigger-1',
title: 'Go on air',
trigger: TimerLifeCycle.onStart,
automationId: 'automation-1',
};
// the real factories, so these are complete entries exactly as the rundown holds them
const entries: [string, OntimeEntry][] = [
['event', createEvent({ custom: { sponsor: 'a value' }, triggers: [trigger] }, 'cue-1') as OntimeEvent],
['group', createGroup({ id: 'group-1', entries: ['a', 'b'], custom: { sponsor: 'a value' } })],
['milestone', createMilestone({ id: 'milestone-1', custom: { sponsor: 'a value' } })],
['delay', createDelay({ id: 'delay-1', duration: 10 })],
];
/**
* Fails if any nested object or array in the clone is the same reference as the source,
* so a field added later that needs a copy of its own is caught here without the clone
* having to enumerate fields.
*/
function expectNoSharedReferences(clone: unknown, source: unknown, path: string) {
if (typeof source !== 'object' || source === null) return;
expect(clone, `${path} is shared with the source`).not.toBe(source);
const cloneRecord = clone as Record<string, unknown>;
const sourceRecord = source as Record<string, unknown>;
for (const key of Object.keys(sourceRecord)) {
expectNoSharedReferences(cloneRecord[key], sourceRecord[key], `${path}.${key}`);
}
}
/** the two halves of the structuredClone contract: same value, no shared references */
it.each(entries)('clones a %s to the same value structuredClone would produce', (_type, entry) => {
expect(cloneEntryData(entry)).toStrictEqual(structuredClone(entry));
});
it.each(entries)('shares no nested object or array with the source %s', (_type, entry) => {
expectNoSharedReferences(cloneEntryData(entry), entry, 'entry');
});
/**
* Regression: normalising an absent container to an empty one makes deepEqual report a
* change on every comparison, which would have the runtime re-broadcast and re-save the
* restore point on every tick. See PR #2178.
*/
it.each([
['event', makeOntimeEvent({ id: 'partial' })],
['group', makeOntimeGroup({ id: 'partial', entries: undefined })],
])('gives a partial %s exactly the keys structuredClone would, so it stays deep-equal', (_type, entry) => {
const clone = cloneEntryData(entry);
// asserting on keys, not values: `toBeUndefined()` cannot tell an absent key from an own
// key holding undefined, and it is key presence that decides the deepEqual below
expect(Object.keys(clone).sort()).toEqual(Object.keys(structuredClone(entry)).sort());
// this is the comparison runtime.service.ts uses to decide whether to re-broadcast an
// entry; if the clone gains a key, every tick looks like a change
expect(deepEqual(clone, entry)).toBe(true);
});
it('throws on an entry type it does not know how to clone', () => {
expect(() => cloneEntryData({ id: 'x', type: 'unknown' } as unknown as OntimeEvent)).toThrow(
'Unsupported entry type for cloning',
);
});
});
+38 -14
View File
@@ -27,6 +27,7 @@ import {
isPlayableEvent, isPlayableEvent,
} from 'ontime-types'; } from 'ontime-types';
import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils'; import { addToRundown, createGroup, customFieldLabelToKey, getInsertAfterId, insertAtIndex } from 'ontime-utils';
import type { DeepReadonly } from 'ts-essentials';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js'; import { consoleError } from '../../utils/console.js';
@@ -34,6 +35,7 @@ import { ProcessedRundownMetadata, makeRundownMetadata } from './rundown.parser.
import type { RundownMetadata } from './rundown.types.js'; import type { RundownMetadata } from './rundown.types.js';
import { import {
applyPatchToEntry, applyPatchToEntry,
cloneRundown,
cloneSimpleRundownEntry, cloneSimpleRundownEntry,
deleteById, deleteById,
doesInvalidateMetadata, doesInvalidateMetadata,
@@ -43,9 +45,14 @@ import {
} from './rundown.utils.js'; } from './rundown.utils.js';
/** /**
* The currently loaded rundown in cache * The currently loaded rundown in cache.
*
* Reassigned - never mutated in place - when a different rundown is loaded: the persistence
* layer stores this object by reference, so repurposing it for another rundown would rewrite
* the previously loaded rundown's stored record. Mutating it in place while it represents the
* same rundown (ie. from commit) is intended, and is what keeps the stored record current.
*/ */
const cachedRundown: Rundown = { let cachedRundown: Rundown = {
id: '', id: '',
title: '', title: '',
order: [], order: [],
@@ -79,9 +86,16 @@ export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetada
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields; export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId]; export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
type Transaction = { /**
* @param R the type callers see for `rundown` - a plain, mutable `Rundown` when the
* transaction was opened with `mutableRundown: true`, otherwise a `DeepReadonly<Rundown>`
* so that accidentally mutating an entry (or an order array) on a non-mutable transaction
* - which would silently corrupt the live cache without going through commit() - is a
* compile-time error instead of a runtime bug.
*/
type Transaction<R> = {
customFields: CustomFields; customFields: CustomFields;
rundown: Rundown; rundown: R;
commit: (shouldProcess?: boolean) => Promise<{ commit: (shouldProcess?: boolean) => Promise<{
rundown: Readonly<Rundown>; rundown: Readonly<Rundown>;
@@ -102,11 +116,17 @@ type TransactionOptions = {
rundownId?: string; rundownId?: string;
}; };
export function createTransaction(options: TransactionOptions): Transaction { export function createTransaction(options: TransactionOptions & { mutableRundown: true }): Transaction<Rundown>;
export function createTransaction(
options: TransactionOptions & { mutableRundown?: false },
): Transaction<DeepReadonly<Rundown>>;
export function createTransaction(
options: TransactionOptions,
): Transaction<Rundown> | Transaction<DeepReadonly<Rundown>> {
const targetId = options.rundownId ?? cachedRundown.id; const targetId = options.rundownId ?? cachedRundown.id;
const isLoaded = targetId === cachedRundown.id; const isLoaded = targetId === cachedRundown.id;
const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown); const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);
const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown; const rundown = options.mutableRundown ? cloneRundown(sourceRundown) : sourceRundown;
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields; const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
/** /**
@@ -707,21 +727,25 @@ export const customFieldMutation = {
* Expose function to add an initial rundown to the system * Expose function to add an initial rundown to the system
*/ */
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) { export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
const rundown = structuredClone(initialRundown); const rundown = cloneRundown(initialRundown);
const customFields = structuredClone(initialCustomFields); const customFields = structuredClone(initialCustomFields);
const processedData = processRundown(rundown, customFields, { mutate: true }); const processedData = processRundown(rundown, customFields, { mutate: true });
// update the cache values
cachedRundown.id = rundown.id;
cachedRundown.title = rundown.title;
projectCustomFields = customFields; projectCustomFields = customFields;
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData; const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
cachedRundown.entries = entries;
cachedRundown.order = order; // a fresh object, so that the record already stored for a previously loaded rundown keeps
cachedRundown.flatOrder = metadata.flatEntryOrder; // pointing at that rundown's data - see the note on cachedRundown
cachedRundown.revision = rundown.revision; cachedRundown = {
id: rundown.id,
title: rundown.title,
entries,
order,
flatOrder: metadata.flatEntryOrder,
revision: rundown.revision,
};
rundownMetadata = metadata; rundownMetadata = metadata;
// defer writing to the database // defer writing to the database
@@ -33,7 +33,7 @@ import {
import { makeNewRundown } from '../../models/dataModel.js'; import { makeNewRundown } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js'; import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { RundownMetadata } from './rundown.types.js'; import { RundownMetadata } from './rundown.types.js';
import { calculateDayOffset, cleanupCustomFields } from './rundown.utils.js'; import { calculateDayOffset, cleanupCustomFields, cloneEntryData } from './rundown.utils.js';
/** /**
* Parse a rundowns object along with the project custom fields * Parse a rundowns object along with the project custom fields
@@ -234,7 +234,7 @@ export function makeRundownMetadata(customFields: CustomFields, options?: { muta
}; };
function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T { function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T {
return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup); return processEntry(rundownMeta, customFields, mutate ? entry : cloneEntryData(entry), childOfGroup);
} }
function getMetadata(): ProcessedRundownMetadata { function getMetadata(): ProcessedRundownMetadata {
@@ -39,6 +39,7 @@ import {
import { parseRundown } from './rundown.parser.js'; import { parseRundown } from './rundown.parser.js';
import type { RundownMetadata } from './rundown.types.js'; import type { RundownMetadata } from './rundown.types.js';
import { import {
cloneRundown,
generateEvent, generateEvent,
getFirstInsertId, getFirstInsertId,
getIntegerAndFraction, getIntegerAndFraction,
@@ -626,7 +627,7 @@ export async function editCustomField(
// ... reassign references in the background rundowns // ... reassign references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) { for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) { if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]); const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey); customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
await updateBackgroundRundown(rundownId, backgroundRundown); await updateBackgroundRundown(rundownId, backgroundRundown);
} }
@@ -666,7 +667,7 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
// remove references in the background rundowns // remove references in the background rundowns
for (const rundownId of Object.keys(projectRundowns)) { for (const rundownId of Object.keys(projectRundowns)) {
if (rundownId !== rundown.id) { if (rundownId !== rundown.id) {
const backgroundRundown = structuredClone(projectRundowns[rundownId]); const backgroundRundown = cloneRundown(projectRundowns[rundownId]);
customFieldMutation.removeUsages(backgroundRundown, key); customFieldMutation.removeUsages(backgroundRundown, key);
await updateBackgroundRundown(rundownId, backgroundRundown); await updateBackgroundRundown(rundownId, backgroundRundown);
} }
@@ -818,7 +819,7 @@ export async function renameRundown(id: string, title: string) {
const dataProvider = getDataProvider(); const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id); const rundown = dataProvider.getRundown(id);
await dataProvider.setRundown(id, { ...rundown, title }); await dataProvider.setRundown(id, { ...rundown, title, revision: rundown.revision + 1 });
/** /**
* If we are modifying the loaded rundown we re-init it * If we are modifying the loaded rundown we re-init it
@@ -846,7 +847,7 @@ export async function duplicateExistingRundown(id: string) {
const dataProvider = getDataProvider(); const dataProvider = getDataProvider();
const rundown = dataProvider.getRundown(id); const rundown = dataProvider.getRundown(id);
const duplicatedRundown: Rundown = structuredClone(rundown); const duplicatedRundown: Rundown = cloneRundown(rundown);
duplicatedRundown.id = generateId(); duplicatedRundown.id = generateId();
duplicatedRundown.title = `Copy of ${rundown.title}`; duplicatedRundown.title = `Copy of ${rundown.title}`;
duplicatedRundown.revision = 0; duplicatedRundown.revision = 0;
@@ -329,7 +329,7 @@ export function mergeRundownPreservingFields(
const structure = isOntimeGroup(incomingEntry) const structure = isOntimeGroup(incomingEntry)
? { entries: incomingEntry.entries } ? { entries: incomingEntry.entries }
: { parent: incomingEntry.parent }; : { parent: incomingEntry.parent };
entries[id] = structuredClone({ ...merged, ...structure }); entries[id] = cloneEntryData({ ...merged, ...structure });
} }
return { return {
@@ -353,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
/** List of event properties which do not need the rundown to be regenerated */ /** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist { enum RegenerateWhitelist {
'id', // adding it for completeness, users cannot change ID id, // adding it for completeness, users cannot change ID
'type', // adding it for completeness, users cannot change ID type, // adding it for completeness, users cannot change ID
'cue', cue,
'title', title,
'note', note,
'endAction', endAction,
'timerType', timerType,
'countToEnd', countToEnd,
'colour', colour,
'timeWarning', timeWarning,
'timeDanger', timeDanger,
'custom', custom,
'triggers', triggers,
} }
/** /**
@@ -499,6 +499,61 @@ export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): Ont
throw new Error(`Unsupported entry type for cloning: ${entry}`); throw new Error(`Unsupported entry type for cloning: ${entry}`);
} }
/**
* Fast, shape-aware clone of a single entry, preserving its identity (id, revision, etc).
* Drop-in replacement for `structuredClone(entry)`
*/
export function cloneEntryData<T extends OntimeEntry>(entry: T): T {
switch (entry.type) {
case SupportedEntry.Event: {
const clone: OntimeEvent = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
if (clone.triggers) clone.triggers = clone.triggers.map((trigger) => ({ ...trigger }));
return clone as T;
}
case SupportedEntry.Group: {
const clone: OntimeGroup = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
if (clone.entries) clone.entries = clone.entries.slice();
return clone as T;
}
case SupportedEntry.Milestone: {
const clone: OntimeMilestone = { ...entry };
if (clone.custom) clone.custom = { ...clone.custom };
return clone as T;
}
case SupportedEntry.Delay:
return { ...entry } as T;
default: {
// exhaustiveness guard: a new member of `SupportedEntry` is named in the error here
const unhandled: never = entry;
throw new Error(`Unsupported entry type for cloning: ${(unhandled as OntimeEntry).type}`);
}
}
}
/**
* Fast, shape-aware clone of a whole rundown.
* Drop-in replacement for `structuredClone(rundown)`: every entry (and its nested
* `custom` / `triggers` / `entries` containers) gets its own copy, so callers can mutate
* the result freely without touching the source - same contract as structuredClone,
* at a fraction of the cost since we skip the generic serialization algorithm.
*/
export function cloneRundown(rundown: Readonly<Rundown>): Rundown {
const entries: RundownEntries = {};
for (const id in rundown.entries) {
entries[id] = cloneEntryData(rundown.entries[id]);
}
return {
id: rundown.id,
title: rundown.title,
revision: rundown.revision,
order: rundown.order.slice(),
flatOrder: rundown.flatOrder.slice(),
entries,
};
}
/** /**
* Utility for calculating if the current events should have a day offset * Utility for calculating if the current events should have a day offset
* @param current the current event under test * @param current the current event under test
@@ -464,9 +464,8 @@ export async function upload(sheetId: string, options: ImportMap) {
sheetOrder.forEach((entryId, index) => { sheetOrder.forEach((entryId, index) => {
const isGroupEnd = entryId.startsWith('group-end-'); const isGroupEnd = entryId.startsWith('group-end-');
const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId; const id = isGroupEnd ? entryId.split('group-end-')[1] : entryId;
const entry = isGroupEnd // cellRequestFromEvent only reads the entry to build a cell request, no clone is needed
? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) const entry = isGroupEnd ? ({ id: entryId, type: SupportedEntry.Group } as OntimeGroup) : rundown.entries[id];
: structuredClone(rundown.entries[id]);
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata)); updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, sheetMetadata));
}); });
} catch (e) { } catch (e) {
-21
View File
@@ -1,21 +0,0 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
/**
* Wraps the app authenticate middleware with support for the Authorization header.
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
* rather than cookies or query params; any other request falls through to the
* app middleware, keeping the behaviour of the shared middleware untouched.
*/
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
if (hasPassword) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
return next();
}
}
return fallback(req, res, next);
};
}

Some files were not shown because too many files have changed in this diff Show More