Compare commits

...

12 Commits

Author SHA1 Message Date
arc-alex 905db382cd refactor: titlecard component 2026-07-17 19:37:42 +02:00
Carlos Valente c191786e50 refactor(navigation): clear params from editor drawer 2026-07-17 15:50:42 +02:00
Carlos Valente 6911e37c18 refactor(automation): improve automation UX 2026-07-17 10:53:45 +02:00
Claude 86b2132cdc feat(automation): expose group title and allow setting secondary text
Surface {{groupNow.*}} template variables (title, note, colour, times,
custom fields) in the automation template autocomplete so events inside a
group can reference their group. The runtime store already carries
groupNow, so substitution and filters worked already; this makes it
discoverable.

Extend the message-secondary action with an optional text field so an
automation can set the secondary message content, not just its source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZYPZVdZLWowU7DyzGkWyy
2026-07-17 10:53:45 +02:00
Carlos Valente 25f0dba83a chore: add codegraph to gitignore 2026-07-16 10:46:10 +02:00
Carlos Valente daa032e92c ux(finder): add navigation shortcuts 2026-07-14 12:04:44 +02:00
Carlos Valente ae65ec97d6 fix(shortcut): correct shortcut to delete 2026-07-14 12:04:44 +02:00
Carlos Valente e7e4302100 refactor(shortcuts): improve readability of info element 2026-07-14 12:04:44 +02:00
Carlos Valente 7e7d35980a refactor(op): improve status recognition 2026-07-14 11:58:59 +02:00
Carlos Valente 5001dda284 feat(op): add group visual relation 2026-07-14 11:58:59 +02:00
Carlos Valente a75d35bdc0 refactor(op): improve delay indicator styles 2026-07-14 11:58:59 +02:00
Claude 2e631194e1 fix(ui): improve progress bar with local interpolation
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-07-14 11:25:22 +02:00
46 changed files with 1319 additions and 454 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
@@ -0,0 +1,14 @@
.kbd {
display: inline-block;
min-width: 1.5rem;
padding: 0.0625rem 0.375rem;
border-radius: 2px;
background-color: $gray-1100;
color: $ui-white;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
font-family: monospace;
font-size: calc(1rem - 4px);
line-height: 1.5;
text-align: center;
white-space: nowrap;
}
@@ -0,0 +1,7 @@
import { PropsWithChildren } from 'react';
import style from './Kbd.module.scss';
export default function Kbd({ children }: PropsWithChildren) {
return <kbd className={style.kbd}>{children}</kbd>;
}
@@ -38,8 +38,6 @@ $progress-bar-br: 3px;
.multiprogress-bar__indicator-bar {
background-color: var(--background-color-override, $ui-black);
opacity: 0.8;
transition: 1s linear;
transition-property: width;
.multiprogress-bar--ignore-css-override & {
background-color: $ui-black;
@@ -1,5 +1,6 @@
import { MaybeNumber } from 'ontime-types';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import { getProgress } from '../../utils/getProgress';
import { cx } from '../../utils/styleUtils';
@@ -34,7 +35,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
className = '',
} = props;
const percentRemaining = 100 - getProgress(now, complete);
const percentRemaining = 100 - useAnimatedProgress(now, complete);
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
const isOvertime = now !== null && now < 0;
@@ -11,6 +11,7 @@ import {
} from '../../../stores/savedViewParams';
import { handleLinks } from '../../../utils/linkUtils';
import IconButton from '../../buttons/IconButton';
import Tooltip from '../../tooltip/Tooltip';
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
import style from './ClientLink.module.scss';
@@ -105,16 +106,20 @@ function BrowserNavigationItem({ current, to, postAction, children }: PropsWithC
{isCustomised && (
<span className={style.trailing}>
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Clear saved view settings'
title='Clear saved view settings'
onClick={clearViewSettings}
<Tooltip
text='Reset to default'
render={
<IconButton
variant='ghosted-white'
size='small'
className={style.clear}
aria-label='Reset to default'
onClick={clearViewSettings}
/>
}
>
<IoCloseOutline />
</IconButton>
</Tooltip>
</span>
)}
</NavigationMenuItem>
@@ -14,6 +14,4 @@ $progress-bar-br: 3px;
.progress-bar__indicator {
height: $progress-bar-size;
background-color: var(--timer-progress-override, $accent-color);
transition: 1s linear;
transition-property: width;
}
@@ -1,6 +1,6 @@
import { MaybeNumber } from 'ontime-types';
import { getProgress } from '../../utils/getProgress';
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
import './ProgressBar.scss';
@@ -12,7 +12,7 @@ interface ProgressBarProps {
export default function ProgressBar(props: ProgressBarProps) {
const { current, duration, className } = props;
const progress = getProgress(current, duration);
const progress = useAnimatedProgress(current, duration);
return (
<div className={`progress-bar__bg ${className}`}>
@@ -4,40 +4,60 @@
position: relative;
display: flex;
flex-direction: column;
}
.title-card__title,
.title-card__placeholder {
font-weight: 600;
font-size: $title-font-size;
line-height: 1.2em;
}
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: $view-card-padding;
border-radius: $element-border-radius;
.title-card__title {
color: var(--color-override, $viewer-color);
padding-right: 1em;
min-height: 1.2em;
}
border-left: 1vw solid;
.title-card__placeholder {
color: var(--label-color-override, $viewer-label-color);
}
.title-card__title:empty::before {
color: var(--label-color-override, $viewer-label-color);
content: attr(data-placeholder);
}
.title-card__secondary {
font-size: $base-font-size;
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
.title-card__title {
font-weight: 600;
line-height: 1.4em;
padding-right: 1em;
color: var(--color-override, $viewer-color);
}
.title-card__label {
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: $timer-label-size;
color: var(--secondary-color-override, $viewer-secondary-color);
text-transform: uppercase;
.title-card__secondary {
color: var(--secondary-color-override, $viewer-secondary-color);
line-height: 1.2em;
}
&--accent {
color: var(--accent-color-override, $accent-color);
&.md {
.title-card__title {
font-size: $title-font-size;
}
.title-card__secondary {
font-size: $base-font-size;
}
}
&.lg {
.title-card__title {
font-size: $large-font-size;
}
.title-card__secondary {
font-size: $title-font-size;
}
.schedule__ {
font-size: $base-font-size;
}
}
.title-card__label {
position: absolute;
right: 1rem;
top: 0.5rem;
font-size: $timer-label-size;
color: var(--secondary-color-override, $viewer-secondary-color);
text-transform: uppercase;
&--accent {
color: var(--accent-color-override, $accent-color);
}
}
}
@@ -1,33 +1,60 @@
import { ForwardedRef, forwardRef } from 'react';
import { OntimeEvent } from 'ontime-types';
import { useTranslation } from '../../../translation/TranslationProvider';
import { cx } from '../../utils/styleUtils';
import { ExtendedEntry } from '../../utils/rundownMetadata';
import { cx, enDash } from '../../utils/styleUtils';
import './TitleCard.scss';
interface TitleCardProps {
type TitleCardMainProps = {
title?: string;
label?: 'now' | 'next';
secondary?: string;
className?: string;
}
colour?: string;
textAlign?: 'left' | 'right' | 'center';
size?: 'md' | 'lg';
placeholder?: string;
};
type TitleCardExpectedProps = TitleCardMainProps & {
event: ExtendedEntry<OntimeEvent>;
expectedStart: number;
showExpected: boolean;
};
type TitleCardNoExpectedProps = TitleCardMainProps & {
event?: undefined;
expectedStart?: undefined;
showExpected?: false;
};
type TitleCardProps = TitleCardExpectedProps | TitleCardNoExpectedProps;
export default function TitleCard({
label,
title,
secondary,
className = '',
colour = 'transparent',
textAlign = 'left',
size = 'md',
placeholder = enDash,
}: TitleCardProps) {
'use memo';
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
const { label, title, secondary, className = '' } = props;
const { getLocalizedString } = useTranslation();
const accent = label === 'now';
return (
<div className={cx(['title-card', className])} ref={ref}>
<span className='title-card__title'>{title}</span>
<div className={cx(['title-card', className, size])} style={{ borderColor: colour }}>
<span className='title-card__title' style={{ textAlign }} data-placeholder={placeholder}>
{title === '' ? null : title}
</span>
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
{label && getLocalizedString(`common.${label}`)}
</span>
<div className='title-card__secondary'>{secondary}</div>
</div>
);
});
TitleCard.displayName = 'TitleCard';
export default TitleCard;
}
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router';
import useViewSettings from '../../hooks-query/useViewSettings';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useSavedViewParams } from '../../stores/savedViewParams';
import Button from '../buttons/Button';
import IconButton from '../buttons/IconButton';
import Info from '../info/Info';
@@ -27,6 +28,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { data: viewSettings } = useViewSettings();
const { isOpen, close } = useViewParamsEditorStore();
const clearSavedParams = useSavedViewParams((store) => store.clear);
const isSmallScreen = useIsSmallScreen();
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
@@ -36,6 +38,7 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
};
const resetParams = () => {
clearSavedParams(target);
setSearchParams(getPreservedParams());
};
@@ -0,0 +1,41 @@
import { MaybeNumber, Playback } from 'ontime-types';
import { useEffect, useRef, useState } from 'react';
import { getProgress } from '../utils/getProgress';
import { usePlayback } from './useSocket';
/**
* Returns the live completion percentage (0100) of a countdown, interpolated locally.
*/
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
const playback = usePlayback();
const isRunning = playback === Playback.Play || playback === Playback.Roll;
const baseline = useRef({ current, at: performance.now() });
const [, setTick] = useState(0);
// there is only something to animate while a running timer is counting down towards 0
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
// re-anchor to the authoritative value whenever the server pushes a new timer update
useEffect(() => {
baseline.current = { current, at: performance.now() };
}, [current, duration, playback]);
// while counting down, re-render every animation frame so the derived progress stays smooth
useEffect(() => {
if (!shouldAnimate) {
return;
}
let frame = requestAnimationFrame(function tick() {
setTick((value) => value + 1);
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [shouldAnimate]);
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
const anchored = baseline.current.current;
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
return getProgress(value, duration);
}
@@ -293,8 +293,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<h3>Outputs</h3>
<Info>
Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info>
{fieldOutputs.map((output, index) => {
@@ -341,12 +342,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</label>
<label>
Address
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
@@ -376,7 +382,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.httpSection}>
<label>
Target URL
<Input
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
@@ -384,6 +390,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
@@ -5,6 +5,7 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
import Input from '../../../../common/components/input/input/Input';
import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
@@ -71,8 +72,8 @@ export default function OntimeActionForm({
{ value: 'playback-pause', label: 'Playback: pause' },
{ value: 'playback-roll', label: 'Playback: roll' },
{ value: 'message-set', label: 'Primary Message: set' },
{ value: 'message-secondary', label: 'Secondary Message: source' },
{ value: 'message-set', label: 'Primary Message' },
{ value: 'message-secondary', label: 'Secondary Message' },
]}
/>
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
@@ -96,7 +97,12 @@ export default function OntimeActionForm({
<>
<label>
Text (leave empty for no change)
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
<TemplateInput
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Timer is finished'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
@@ -120,31 +126,48 @@ export default function OntimeActionForm({
)}
{selectedAction === 'message-secondary' && (
<label>
Timer secondary source
<Select<SecondarySource | 'null' | null>
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`)}
options={[
{ value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
<>
<label>
Text (leave empty for no change)
<TemplateInput
{...register(`outputs.${index}.text`)}
value={watch(`outputs.${index}.text`) ?? ''}
fluid
placeholder='eg: Next up: keynote'
/>
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label>
<label>
Timer secondary source
<Select<SecondarySource | 'no-change' | 'null' | null>
onValueChange={(value) => {
// null -> no selection
if (value === null) return;
// no-change -> leave the current secondary source untouched
if (value === 'no-change') {
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
return;
}
// 'null' -> clear the secondary source
if (value === 'null') {
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
return;
}
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
}}
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
options={[
{ value: 'no-change', label: 'No change' },
{ value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
]}
/>
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
</label>
</>
)}
<div className={style.test}>{children}</div>
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
<tbody>
{!showForm && triggers.length === 0 && (
<Panel.TableEmpty
label='Create an automation to attach triggers to'
label='Create a trigger to run an automation'
handleClick={canAdd ? () => setShowForm(true) : undefined}
/>
)}
@@ -1,29 +1,67 @@
.wrapper {
.inputShell {
position: relative;
}
.suggestions {
background: $gray-1250;
color: $ui-white;
position: absolute;
top: 100%;
left: 0;
.fluid {
width: 100%;
margin: 0;
z-index: $zindex-floating;
padding-block: 0.25rem;
}
max-height: 200px;
overflow-y: auto;
.input {
padding-right: 2rem;
}
.expandButton {
position: absolute;
top: 50%;
right: 0.25rem;
transform: translateY(-50%);
}
.positioner {
outline: 0;
}
.popup {
box-sizing: border-box;
min-width: var(--anchor-width);
max-width: var(--available-width);
border: 1px solid $gray-1000;
border-radius: $component-border-radius-md;
background: $gray-1250;
color: $label-gray;
}
li {
padding: 0.25rem;
}
.list {
box-sizing: border-box;
max-height: min(20rem, var(--available-height));
overflow-y: auto;
overscroll-behavior: contain;
padding-block: 0.25rem;
outline: 0;
}
li:hover {
.item {
box-sizing: border-box;
padding: 0.25rem 0.5rem;
outline: 0;
cursor: default;
user-select: none;
overflow-wrap: anywhere;
&[data-highlighted] {
color: $ui-white;
background: $blue-700;
}
}
.expandedEditor {
min-height: min(18rem, 45vh);
font-family: monospace;
line-height: 1.4;
}
.footerHint {
margin-right: auto;
color: $label-gray;
font-size: calc(1rem - 2px);
}
@@ -1,69 +1,258 @@
import { mergeRefs, useClickOutside } from '@mantine/hooks';
import { forwardRef, useMemo, useState } from 'react';
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import type { ChangeEvent, ReactNode, Ref } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoExpandOutline } from 'react-icons/io5';
import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
import Button from '../../../../../common/components/buttons/Button';
import IconButton from '../../../../../common/components/buttons/IconButton';
import { type InputProps } from '../../../../../common/components/input/input/Input';
import Textarea from '../../../../../common/components/input/textarea/Textarea';
import Modal from '../../../../../common/components/modal/Modal';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import { cx } from '../../../../../common/utils/styleUtils';
import { makeAutoCompleteList } from './templateInput.utils';
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
import style from './TemplateInput.module.scss';
interface TemplateInputProps extends InputProps {}
interface TemplateInputProps extends Omit<InputProps, 'value'> {
ref?: Ref<HTMLInputElement>;
value?: string;
}
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
const { value, onChange, ...rest } = props;
interface TemplateEditorModalProps {
autocompleteList: string[];
isOpen: boolean;
onClose: () => void;
onSave: (value: string) => void;
value: string;
}
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
interface TemplateAutocompleteRootProps {
autocomplete: TemplateAutocompleteState;
children: ReactNode;
value: string;
}
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
}
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
onChange?.({
target: { name, value },
currentTarget: { name, value },
} as ChangeEvent<HTMLInputElement>);
}
export default function TemplateInput({
className,
disabled,
fluid,
height = 'medium',
onChange,
readOnly,
ref,
value,
variant = 'subtle',
...rest
}: TemplateInputProps) {
const { data } = useCustomFields();
const localRef = useClickOutside(() => setShowSuggestions(false));
const inputRef = useRef<HTMLInputElement | null>(null);
const [inputValue, setInputValue] = useState(value || '');
const [isExpanded, setIsExpanded] = useState(false);
const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data);
}, [data]);
const [inputValue, setInputValue] = useState(value || '');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const updateInputValue = useCallback(
(nextValue: string) => {
setInputValue(nextValue);
emitInputChange(rest.name, nextValue, onChange);
},
[onChange, rest.name],
);
const updateSuggestions = (value: string) => {
const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
const { setCursorForValue } = autocomplete;
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
useEffect(() => {
const nextValue = value || '';
setInputValue(nextValue);
setCursorForValue(nextValue, nextValue.length);
}, [setCursorForValue, value]);
const setInputRef = useCallback(
(element: HTMLInputElement | null) => {
inputRef.current = element;
assignRef(ref, element);
},
[ref],
);
const openExpandedEditor = () => {
autocomplete.setShowSuggestions(false);
setIsExpanded(true);
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
if (event.target.value.endsWith('{')) {
setShowSuggestions(true);
setSuggestions(updateSuggestions(event.target.value));
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
setShowSuggestions(false);
} else if (showSuggestions) {
setSuggestions(updateSuggestions(event.target.value));
}
onChange?.(event);
const closeExpandedEditor = () => {
setIsExpanded(false);
};
const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false);
const saveExpandedEditor = (nextValue: string) => {
updateInputValue(nextValue);
autocomplete.setCursorForValue(nextValue, nextValue.length);
setIsExpanded(false);
};
return (
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
{showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}>
{suggestions.map((suggestion) => (
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
{suggestion}
</li>
))}
</ul>
)}
</div>
<>
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
<div className={cx([style.inputShell, fluid && style.fluid])}>
<BaseAutocomplete.Input
ref={setInputRef}
className={cx([
inputStyle.input,
inputStyle[variant],
inputStyle[height],
fluid && inputStyle.fluid,
style.input,
className,
])}
{...rest}
disabled={disabled}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
readOnly={readOnly}
/>
<IconButton
aria-label='Expand template editor'
className={style.expandButton}
disabled={disabled || readOnly}
onClick={openExpandedEditor}
size='small'
title='Expand template editor'
variant='ghosted-white'
>
<IoExpandOutline />
</IconButton>
</div>
</TemplateAutocompleteRoot>
<TemplateEditorModal
autocompleteList={autocompleteList}
isOpen={isExpanded}
onClose={closeExpandedEditor}
onSave={saveExpandedEditor}
value={inputValue}
/>
</>
);
});
}
export default TemplateInput;
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
return (
<BaseAutocomplete.Root
items={autocomplete.suggestions}
autoHighlight
highlightItemOnHover
mode='none'
open={autocomplete.open}
value={value}
onOpenChange={autocomplete.setShowSuggestions}
onValueChange={autocomplete.handleValueChange}
>
{children}
<TemplateSuggestionPopup />
</BaseAutocomplete.Root>
);
}
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
const [draftValue, setDraftValue] = useState(value);
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
const { setShowSuggestions } = autocomplete;
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
useEffect(() => {
if (!isOpen) {
return;
}
setDraftValue(value);
setShowSuggestions(false);
}, [isOpen, setShowSuggestions, value]);
const handleClose = () => {
setShowSuggestions(false);
onClose();
};
const handleSave = () => {
setShowSuggestions(false);
onSave(draftValue);
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
showBackdrop
showCloseButton
title='Edit template'
bodyElements={
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
<BaseAutocomplete.Input
autoFocus
className={style.expandedEditor}
onClick={autocomplete.updateCursor}
onFocus={autocomplete.updateCursor}
onKeyUp={autocomplete.updateCursor}
onSelect={autocomplete.updateCursor}
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
/>
</TemplateAutocompleteRoot>
}
footerElements={
<>
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant='primary'>
Save
</Button>
</>
}
/>
);
}
function TemplateSuggestionPopup() {
return (
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
<BaseAutocomplete.Popup className={style.popup}>
<BaseAutocomplete.List className={style.list}>
<BaseAutocomplete.Collection>
{(suggestion: string) => (
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
{suggestion}
</BaseAutocomplete.Item>
)}
</BaseAutocomplete.Collection>
</BaseAutocomplete.List>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
);
}
@@ -1,4 +1,4 @@
import { matchRemaining } from '../templateInput.utils';
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => {
@@ -15,3 +15,54 @@ describe('matchRemaining()', () => {
expect(matchRemaining('test', 'test')).toBe('');
});
});
describe('selectActiveTemplate()', () => {
it('returns the last unclosed template fragment', () => {
expect(selectActiveTemplate('send {{event')).toBe('{{event');
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
});
it('ignores single braces and closed templates', () => {
expect(selectActiveTemplate('send {')).toBe('');
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
});
it('only considers templates before the cursor', () => {
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
});
it('selects a partial template when the cursor is inside a completed template', () => {
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
});
});
describe('completeTemplateAtCursor()', () => {
it('completes the active template before the cursor', () => {
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
value: 'send {{timer.current}} after',
cursorIndex: 22,
});
});
it('preserves text before and after the cursor', () => {
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
value: 'before {{eventNow.title}} after',
cursorIndex: 25,
});
});
it('preserves a following template when completing between templates', () => {
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
cursorIndex: 31,
});
});
it('replaces the whole template when the cursor is inside a completed template', () => {
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
value: 'before {{timer.duration}} after',
cursorIndex: 25,
});
});
});
@@ -54,6 +54,16 @@ const eventStaticPropertiesNext = [
'{{eventNext.delay}}',
];
const groupStaticPropertiesNow = [
'{{groupNow.id}}',
'{{groupNow.title}}',
'{{groupNow.note}}',
'{{groupNow.colour}}',
'{{groupNow.timeStart}}',
'{{groupNow.timeEnd}}',
'{{groupNow.duration}}',
];
const staticAuxProperties = (index: 1 | 2 | 3) => [
`{{auxtimer${index}.current}}`,
`{{auxtimer${index}.duration}}`,
@@ -75,12 +85,19 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
...groupStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
...staticAuxProperties(1),
...staticAuxProperties(2),
...staticAuxProperties(3),
];
}
interface TemplateCompletion {
cursorIndex: number;
value: string;
}
/**
* Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st'
@@ -111,13 +128,54 @@ export function matchRemaining(a: string, b: string) {
return '';
}
/**
* Selects the last starting template in a string
*/
export function selectFromLastTemplate(text: string) {
const lastBraceIndex = text.lastIndexOf('{{');
if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
const textBeforeCursor = text.slice(0, cursorIndex);
const start = textBeforeCursor.lastIndexOf('{{');
if (start === -1) {
return null;
}
return '';
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
if (closeBeforeCursor > start) {
return null;
}
const closeAfterStart = text.indexOf('}}', start);
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
return {
end,
start,
template: text.slice(start, cursorIndex),
};
}
/**
* Selects the last unclosed starting template before the cursor.
*/
export function selectActiveTemplate(text: string, cursorIndex = text.length) {
return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
}
/**
* Replaces the active template fragment before the cursor with the selected suggestion.
*/
export function completeTemplateAtCursor(
text: string,
suggestion: string,
cursorIndex = text.length,
): TemplateCompletion {
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
if (!activeTemplateRange) {
const value = text + matchRemaining(text, suggestion);
return { value, cursorIndex: value.length };
}
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
return {
value,
cursorIndex: activeTemplateRange.start + suggestion.length,
};
}
@@ -0,0 +1,72 @@
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
import { useCallback, useMemo, useState, type RefObject } from 'react';
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
function getCursorIndex(element: TemplateElement | null, fallback: string) {
return element?.selectionStart ?? fallback.length;
}
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
requestAnimationFrame(() => {
element?.focus();
element?.setSelectionRange(cursorIndex, cursorIndex);
});
}
export function useTemplateAutocomplete<T extends TemplateElement>(
value: string,
autocompleteList: string[],
elementRef: RefObject<T | null>,
onValueChange: (value: string) => void,
) {
const [showSuggestions, setShowSuggestions] = useState(false);
const [cursor, setCursor] = useState(value.length);
const activeTemplate = selectActiveTemplate(value, cursor);
const suggestions = useMemo(() => {
if (!activeTemplate) {
return [];
}
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
}, [activeTemplate, autocompleteList]);
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
setCursor(cursorIndex);
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
}, []);
const updateCursor = useCallback(() => {
const cursorIndex = getCursorIndex(elementRef.current, value);
setCursorForValue(value, cursorIndex);
}, [elementRef, setCursorForValue, value]);
const handleValueChange = useCallback(
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
if (eventDetails.reason === 'item-press') {
eventDetails.cancel();
const completed = completeTemplateAtCursor(value, nextValue, cursor);
setCursorForValue(completed.value, completed.cursorIndex);
onValueChange(completed.value);
focusCursor(elementRef.current, completed.cursorIndex);
return;
}
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
setCursorForValue(nextValue, cursorIndex);
onValueChange(nextValue);
},
[cursor, elementRef, onValueChange, setCursorForValue, value],
);
return {
handleValueChange,
open: showSuggestions && suggestions.length > 0,
setCursorForValue,
setShowSuggestions,
suggestions,
updateCursor,
};
}
@@ -180,7 +180,13 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
return (
<Fragment key={entry.id}>
<OperatorGroup key={entry.id} title={entry.title} />
<OperatorGroup
key={entry.id}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
@@ -217,6 +223,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isLoaded}
isPast={isPast}
groupColour={entry.colour}
selectedRef={isLoaded ? selectedRef : undefined}
showStart={showStart}
subscribed={subscribedData}
@@ -22,17 +22,39 @@
background-color: $gray-1250;
}
&.grouped {
position: relative;
padding-left: 0.35rem;
background:
linear-gradient(90deg, color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%), transparent 8rem),
$viewer-card-bg-color;
}
&.running {
border-top: 1px solid $gray-1300;
background-color: var(--operator-running-bg-override, $active-green);
}
&.grouped.running {
background:
linear-gradient(90deg, color-mix(in srgb, transparent 82%, var(--group-colour, $gray-500) 18%), transparent 8rem),
var(--operator-running-bg-override, $active-green);
}
&.past {
border-top: 1px solid transparent;
opacity: 0.2;
}
}
.groupRail {
position: absolute;
inset-block: 0;
left: 0;
width: 0.35rem;
background-color: var(--group-colour, $gray-500);
}
.binder {
grid-area: binder;
color: $section-white;
@@ -74,6 +96,9 @@
.plannedStart,
.timeUntil,
.runningTime {
display: flex;
align-items: center;
gap: 0.25rem;
border-radius: $component-border-radius-md;
padding: 0.25rem 0.5rem;
line-height: 1;
@@ -95,6 +120,21 @@
letter-spacing: 1px;
}
.live {
color: $ui-black;
background-color: $ui-white;
}
.due {
color: $ui-black;
background-color: $orange-500;
}
.done {
color: $white-60;
background-color: $white-7;
}
.runningTime {
grid-area: running;
font-size: 1.25rem;
@@ -25,6 +25,7 @@ interface OperatorEventProps {
isLinkedToLoaded: boolean;
isSelected: boolean;
isPast: boolean;
groupColour?: string;
selectedRef?: RefObject<HTMLDivElement | null>;
showStart: boolean;
subscribed: Subscribed;
@@ -46,6 +47,7 @@ function OperatorEvent({
isLinkedToLoaded,
isSelected,
isPast,
groupColour,
selectedRef,
showStart,
subscribed,
@@ -68,7 +70,12 @@ function OperatorEvent({
const mouseHandlers = useLongPress(handleLongPress);
const cueColours = colour && getAccessibleColour(colour);
const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
const operatorClasses = cx([
style.event,
groupColour && style.grouped,
isSelected && style.running,
isPast && style.past,
]);
const hasFields = subscribed.some((field) => field.value);
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
@@ -85,8 +92,10 @@ function OperatorEvent({
data-testid={cue}
ref={selectedRef}
onContextMenu={handleLongPress}
style={groupColour ? ({ '--group-colour': groupColour } as CSSProperties) : undefined}
{...mouseHandlers}
>
{groupColour && <div className={style.groupRail} />}
<div className={style.binder} style={{ ...cueColours }}>
<span className={style.cue}>{cue}</span>
</div>
@@ -153,11 +162,11 @@ function OperatorEventSchedule({
isLinkedToLoaded,
}: OperatorEventScheduleProps) {
if (isPast) {
return <span className={style.timeUntil}>DONE</span>;
return <span className={cx([style.timeUntil, style.done])}>DONE</span>;
}
if (isSelected) {
return <span className={style.timeUntil}>LIVE</span>;
return <span className={cx([style.timeUntil, style.live])}>LIVE</span>;
}
return (
@@ -186,7 +195,7 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
return (
<span className={style.timeUntil} data-testid='time-until'>
<span className={cx([style.timeUntil, isDue && style.due])} data-testid='time-until'>
{timeUntilString}
</span>
);
@@ -1,13 +1,33 @@
.group {
width: 100%;
padding: 0.25rem 0.5rem;
min-height: 2.5rem;
padding: 0.4rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 1rem;
letter-spacing: 0;
}
// tablet
@media (min-width: $min-tablet) {
.group {
padding: 0.25rem 1rem;
}
.title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.5rem;
color: color-mix(in srgb, currentColor 60%, transparent);
font-size: 0.875rem;
font-weight: 500;
}
@@ -1,12 +1,29 @@
import { memo } from 'react';
import { CSSProperties, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
import style from './OperatorGroup.module.scss';
interface OperatorGroup {
title: string;
colour: string;
count: number;
duration: number;
}
export default memo(OperatorGroup);
function OperatorGroup({ title }: OperatorGroup) {
return <div className={style.group}>{title}</div>;
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour);
return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<span className={style.title}>{title}</span>
<span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
<span>{formatDuration(duration)}</span>
</span>
</div>
);
}
@@ -3,58 +3,94 @@
height: 100%;
max-height: 100%;
overflow-y: auto;
padding: 0.5rem;
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
overflow-x: auto;
}
.shortcutSection {
flex: 1;
margin-top: 15vh;
width: min(100%, 48rem);
margin-top: clamp(1.5rem, 8vh, 5rem);
margin-inline: auto;
gap: 1rem;
}
.shortcuts {
font-size: calc(1rem - 3px);
border-collapse: separate;
border-spacing: 4rem 0;
display: grid;
gap: 0.875rem;
margin-top: 0.875rem;
}
tr {
white-space: nowrap;
td:nth-child(odd) {
text-align: left;
}
td:nth-child(even) {
text-align: right;
}
.shortcutGroup {
h3 {
margin: 0 0 0.375rem;
color: $ui-white;
font-size: calc(1rem - 3px);
font-weight: 600;
text-transform: uppercase;
}
}
.spacer {
height: 1rem;
.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 {
margin-left: 4rem;
}
.divider {
display: inline-block;
text-align: center;
width: 1em;
}
.kbd {
font-family: monospace;
white-space: nowrap;
font-size: calc(1rem - 2px);
padding: 0.125rem 0.5rem;
background-color: $gray-1200;
color: $ui-white;
border-radius: 2px;
font-weight: 400;
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.4);
}
@media (max-width: 680px) {
.entryEditor {
padding: 0.75rem 1rem;
}
.shortcutSection {
margin-top: 1rem;
}
.shortcutRow {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.shortcutKeys {
justify-content: flex-start;
}
}
@@ -1,6 +1,7 @@
import { PropsWithChildren, memo } from 'react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import Kbd from '../../../common/components/kbd/Kbd';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import style from './EventEditorEmpty.module.scss';
@@ -12,216 +13,124 @@ function EventEditorEmpty() {
<div className={style.entryEditor} data-testid='editor-container'>
<div className={style.shortcutSection}>
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
<table className={style.shortcuts}>
<tbody>
<tr>
<td>Find in rundown</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>F</Kbd>
</td>
</tr>
<tr>
<td>Open Settings</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>,</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Select entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Select group</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Jump to top / bottom</td>
<td>
<Kbd>Home</Kbd>
<AuxKey>/</AuxKey>
<Kbd>End</Kbd>
</td>
</tr>
<tr>
<td>Page up / down</td>
<td>
<Kbd>PgUp</Kbd>
<AuxKey>/</AuxKey>
<Kbd>PgDn</Kbd>
</td>
</tr>
<tr>
<td>Deselect entry</td>
<td>
<Kbd>Esc</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Reorder selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
</td>
</tr>
<tr>
<td>Copy selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>C</Kbd>
</td>
</tr>
<tr>
<td>Cut selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>X</Kbd>
</td>
</tr>
<tr>
<td>Paste above</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Paste below</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
</tr>
<tr>
<td>Clone selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Delete selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd>
</td>
</tr>
<tr className={style.spacer} />
<tr>
<td>Add event below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add event above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
</tr>
<tr>
<td>Add group below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
<td>Add group above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>G</Kbd>
</td>
</tr>
<tr>
<td>Add milestone below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
<td>Add milestone above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>M</Kbd>
</td>
</tr>
<tr>
<td>Add delay below</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Add delay above</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
</tbody>
</table>
<div className={style.shortcuts}>
<ShortcutGroup title='Search'>
<Shortcut label='Find in rundown'>
<Combo keys={[deviceMod, 'F']} />
</Shortcut>
<Shortcut label='Open settings'>
<Combo keys={[deviceMod, ',']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Navigation'>
<Shortcut label='Select entry'>
<Combo keys={[deviceAlt, '↑']} />
<Separator />
<Combo keys={[deviceAlt, '↓']} />
</Shortcut>
<Shortcut label='Select group'>
<Combo keys={[deviceAlt, 'Shift', '↑']} />
<Separator />
<Combo keys={[deviceAlt, 'Shift', '↓']} />
</Shortcut>
<Shortcut label='Jump to top / bottom'>
<Combo keys={['Home']} />
<Separator />
<Combo keys={['End']} />
</Shortcut>
<Shortcut label='Page up / down'>
<Combo keys={['PgUp']} />
<Separator />
<Combo keys={['PgDn']} />
</Shortcut>
<Shortcut label='Deselect entry'>
<Combo keys={['Esc']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Editing'>
<Shortcut label='Reorder selected entry'>
<Combo keys={[deviceAlt, deviceMod, '↑']} />
<Separator />
<Combo keys={[deviceAlt, deviceMod, '↓']} />
</Shortcut>
<Shortcut label='Copy selected entry'>
<Combo keys={[deviceMod, 'C']} />
</Shortcut>
<Shortcut label='Cut selected entry'>
<Combo keys={[deviceMod, 'X']} />
</Shortcut>
<Shortcut label='Paste below'>
<Combo keys={[deviceMod, 'V']} />
</Shortcut>
<Shortcut label='Paste above'>
<Combo keys={[deviceMod, 'Shift', 'V']} />
</Shortcut>
<Shortcut label='Clone selected entry'>
<Combo keys={[deviceMod, 'D']} />
</Shortcut>
<Shortcut label='Delete selected entry'>
<Combo keys={[deviceAlt, 'Backspace']} />
</Shortcut>
</ShortcutGroup>
<ShortcutGroup title='Insert'>
<Shortcut label='Add event below / above'>
<Combo keys={[deviceAlt, 'E']} />
<Separator />
<Combo keys={[deviceAlt, 'Shift', 'E']} />
</Shortcut>
<Shortcut label='Add group below / above'>
<Combo keys={[deviceAlt, 'G']} />
<Separator />
<Combo keys={[deviceAlt, 'Shift', 'G']} />
</Shortcut>
<Shortcut label='Add milestone below / above'>
<Combo keys={[deviceAlt, 'M']} />
<Separator />
<Combo keys={[deviceAlt, 'Shift', 'M']} />
</Shortcut>
<Shortcut label='Add delay below / above'>
<Combo keys={[deviceAlt, 'D']} />
<Separator />
<Combo keys={[deviceAlt, 'Shift', 'D']} />
</Shortcut>
</ShortcutGroup>
</div>
</div>
</div>
);
}
function AuxKey({ children }: PropsWithChildren) {
return <span className={style.divider}>{children}</span>;
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
return (
<section className={style.shortcutGroup}>
<h3>{title}</h3>
<div className={style.shortcutList}>{children}</div>
</section>
);
}
function Kbd({ children }: PropsWithChildren) {
return <span className={style.kbd}>{children}</span>;
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>;
}
@@ -18,7 +18,7 @@ import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -156,7 +156,7 @@ export default function RundownEvent({
type: 'item',
label: 'Delete',
icon: IoTrash,
shortcut: `${deviceMod}+Del`,
shortcut: `${deviceAlt}+Backspace`,
onClick: () => {
clearSelectedEvents();
deleteEntry(Array.from(selectedEvents));
@@ -202,7 +202,7 @@ export default function RundownEvent({
type: 'item',
label: 'Delete',
icon: IoTrash,
shortcut: `${deviceMod}+Del`,
shortcut: `${deviceAlt}+Backspace`,
onClick: () => {
deleteEntry([eventId]);
unselect(eventId);
@@ -3,7 +3,5 @@
width: 0;
border-radius: 1px 0 0 1px;
transition: 1s linear;
transition-property: width;
background-color: $gray-200;
}
@@ -1,12 +1,12 @@
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
import { useTimer } from '../../../../common/hooks/useSocket';
import { getProgress } from '../../../../common/utils/getProgress';
import style from './RundownEventProgressBar.module.scss';
export default function RundownEventProgressBar() {
const timer = useTimer();
const progress = getProgress(timer.current, timer.duration);
const progress = useAnimatedProgress(timer.current, timer.duration);
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
}
@@ -18,7 +18,7 @@ import Tag from '../../../common/components/tag/Tag';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
@@ -66,7 +66,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
type: 'item',
label: 'Delete Group',
icon: IoTrash,
shortcut: `${deviceMod}+Del`,
shortcut: `${deviceAlt}+Backspace`,
onClick: () => deleteEntry([data.id]),
},
]);
@@ -9,7 +9,7 @@ import useReactiveTextInput from '../../../common/components/input/text-input/us
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { deviceAlt } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection';
@@ -38,7 +38,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
type: 'item',
label: 'Delete',
icon: IoTrash,
shortcut: `${deviceMod}+Del`,
shortcut: `${deviceAlt}+Backspace`,
onClick: () => deleteEntry([entryId]),
},
]);
+1
View File
@@ -12,6 +12,7 @@ $viewer-opacity-disabled: 0.6;
$timer-label-size: clamp(12px, 1.25vw, 20px);
$base-font-size: clamp(15px, 1.5vw, 28px);
$title-font-size: clamp(18px, 2.25vw, 42px);
$large-font-size: clamp(40px, 4.5vw, 80px);
$timer-value-size: clamp(24px, 2.5vw, 48px);
$header-font-size: clamp(24px, 2.5vw, 48px);
@@ -32,6 +32,11 @@
color: var(--label-color-override, $viewer-label-color);
}
.title-card {
// overwrite the title-card bg color so they don't stack as it is transparent
background-color: transparent;
}
/* =================== HEADER + EXTRAS ===================*/
.project-header {
@@ -1,6 +1,5 @@
import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
import { enDash } from '../../common/utils/styleUtils';
import { getPropertyValue } from '../common/viewUtils';
/**
@@ -46,9 +45,9 @@ export function getCardData(
}
// if we are loaded, we show the upcoming event as next
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash;
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title');
const nowSecondary = getPropertyValue(eventNow, secondarySource);
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title');
const nextSecondary = getPropertyValue(eventNext, secondarySource);
return {
@@ -54,17 +54,51 @@
}
.footer {
width: 100%;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
font-size: calc(1rem - 2px);
color: $label-gray;
}
.filterHint {
text-align: right;
}
.em {
color: $ui-white;
margin-inline: 0.25rem;
}
.hints {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
color: $label-gray;
font-size: calc(1rem - 3px);
}
.hintItem {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.scrollContainer {
max-height: 70vh;
overflow: auto;
padding-top: 1rem;
}
@media (max-width: 680px) {
.footer {
align-items: flex-start;
flex-direction: column;
}
.filterHint {
text-align: left;
}
}
+20 -2
View File
@@ -3,6 +3,7 @@ import { SupportedEntry } from 'ontime-types';
import { KeyboardEvent, useState } from 'react';
import Input from '../../../common/components/input/input/Input';
import Kbd from '../../../common/components/kbd/Kbd';
import Modal from '../../../common/components/modal/Modal';
import useFinder from './useFinder';
@@ -96,8 +97,25 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
}
footerElements={
<div className={style.footer}>
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
<span className={style.em}>title</span> to filter search.
<div className={style.hints}>
<span className={style.hintItem}>
<Kbd></Kbd>
<Kbd></Kbd>
Navigate
</span>
<span className={style.hintItem}>
<Kbd>Enter</Kbd>
Go
</span>
<span className={style.hintItem}>
<Kbd>Esc</Kbd>
Close
</span>
</div>
<div className={style.filterHint}>
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
<span className={style.em}>title</span>
</div>
</div>
}
/>
@@ -1,8 +1,8 @@
import { Day } from 'ontime-types';
import { CSSProperties, RefObject } from 'react';
import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress';
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
import { getProgress } from '../../common/utils/getProgress';
import { alpha, cx } from '../../common/utils/styleUtils';
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
@@ -169,7 +169,7 @@ function TimelineEntryStatus({
/** Generates a block level progress bar */
function ActiveBlock() {
const { current, duration } = useTimer();
const progress = getProgress(current, duration);
const progress = useAnimatedProgress(current, duration);
return (
<div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} />
);
-4
View File
@@ -65,10 +65,6 @@
/* =================== TITLES ===================*/
.event {
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: $view-card-padding;
border-radius: $element-border-radius;
&.now {
grid-area: now;
}
+18 -2
View File
@@ -206,8 +206,24 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
{!hideCards && (
<>
{showNow && <TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />}
{showNext && <TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />}
{showNow && (
<TitleCard
className='event now'
label='now'
title={nowMain}
secondary={nowSecondary}
colour={eventNow?.colour}
/>
)}
{showNext && (
<TitleCard
className='event next'
label='next'
title={nextMain}
secondary={nextSecondary}
colour={eventNext?.colour}
/>
)}
</>
)}
</div>
@@ -163,5 +163,70 @@ describe('parseOutput', () => {
secondarySource: 'secondary',
});
});
it('parses message-secondary with a text value', () => {
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
text: 'hello',
}),
).toMatchObject({
text: 'hello',
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: undefined,
text: 'hello',
}),
).toMatchObject({
text: 'hello',
});
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 'hello',
}),
).toMatchObject({
secondarySource: 'secondary',
text: 'hello',
});
// an empty text is treated as no change
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: '',
}),
).toMatchObject({
secondarySource: 'secondary',
text: undefined,
});
// text can be set while clearing the secondary source
expect(
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: null,
text: 'hello',
}),
).toMatchObject({
secondarySource: null,
text: 'hello',
});
expect(() =>
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 123,
}),
).toThrow('Unexpected payload type:');
});
});
});
@@ -0,0 +1,113 @@
import { runtimeStorePlaceholder } from 'ontime-types';
import * as messageService from '../../../services/message-service/message.service.js';
import { toOntimeAction } from '../clients/ontime.client.js';
vi.mock('../../../services/message-service/message.service.js', () => ({
patch: vi.fn(),
}));
describe('toOntimeAction()', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('parses templates in primary message text', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-set',
text: 'Current: {{timer.current}}',
visible: true,
},
{
...runtimeStorePlaceholder,
timer: {
...runtimeStorePlaceholder.timer,
current: 42,
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
timer: {
text: 'Current: 42',
visible: true,
},
});
});
it('parses templates in secondary message text', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-secondary',
secondarySource: 'secondary',
text: 'Next: {{eventNext.title}}',
},
{
...runtimeStorePlaceholder,
eventNext: {
id: 'next-event',
type: 'event',
cue: '2',
title: 'Keynote',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: {},
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
timer: {
secondarySource: 'secondary',
},
secondary: 'Next: Keynote',
});
});
it('can set secondary message text without changing the secondary source', () => {
toOntimeAction(
{
type: 'ontime',
action: 'message-secondary',
text: 'Next: {{eventNext.title}}',
},
{
...runtimeStorePlaceholder,
eventNext: {
id: 'next-event',
type: 'event',
cue: '2',
title: 'Keynote',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: {},
},
},
);
expect(messageService.patch).toHaveBeenCalledWith({
secondary: 'Next: Keynote',
});
});
});
@@ -132,7 +132,7 @@ function send(output: AutomationOutput[], store: RuntimeStore) {
} else if (isHTTPOutput(payload)) {
emitHTTP(payload, store);
} else if (isOntimeAction(payload)) {
toOntimeAction(payload);
toOntimeAction(payload, store);
} else {
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
}
@@ -225,13 +225,28 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
}
if (maybeOntimeAction.action === 'message-secondary') {
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
// the secondary text is optional, an empty string is treated as no change
let text: string | undefined = undefined;
if ('text' in maybeOntimeAction) {
assert.isString(maybeOntimeAction.text);
text = indeterminateText(maybeOntimeAction.text);
}
if (!('secondarySource' in maybeOntimeAction) || maybeOntimeAction.secondarySource === undefined) {
return {
type: 'ontime',
action: 'message-secondary',
text,
};
}
// null is used to clear the secondary source
if (maybeOntimeAction.secondarySource === null) {
return {
type: 'ontime',
action: 'message-secondary',
secondarySource: null,
text,
};
}
@@ -240,6 +255,7 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
type: 'ontime',
action: 'message-secondary',
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
text,
};
}
@@ -1,12 +1,14 @@
import { LogOrigin, OntimeAction } from 'ontime-types';
import { LogOrigin, OntimeAction, RuntimeStore } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import { DeepReadonly } from 'ts-essentials';
import { logger } from '../../../classes/Logger.js';
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
import * as messageService from '../../../services/message-service/message.service.js';
import { runtimeService } from '../../../services/runtime-service/runtime.service.js';
import { parseTemplateNested } from '../automation.utils.js';
export function toOntimeAction(action: OntimeAction) {
export function toOntimeAction(action: OntimeAction, store: DeepReadonly<RuntimeStore>) {
const actionType = action.action;
switch (actionType) {
// Aux timer actions
@@ -55,18 +57,25 @@ export function toOntimeAction(action: OntimeAction) {
case 'message-set': {
messageService.patch({
timer: {
text: action.text,
text: action.text ? parseTemplateNested(action.text, store) : action.text,
visible: action.visible,
},
});
break;
}
case 'message-secondary': {
messageService.patch({
timer: {
secondarySource: action.secondarySource,
},
});
const secondary = action.text ? parseTemplateNested(action.text, store) : action.text;
const patch =
action.secondarySource === undefined
? { secondary }
: {
timer: {
secondarySource: action.secondarySource,
},
secondary,
};
messageService.patch(patch);
break;
}
@@ -111,9 +111,9 @@ export type OntimeAction =
text?: string;
visible?: boolean;
}
// TODO: when setting a secondary source of type secondary we could specify a value to it
| {
type: 'ontime';
action: OntimeMessageSecondary;
secondarySource: SecondarySource;
secondarySource?: SecondarySource;
text?: string;
};