mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-17 21:24:11 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 077748e5a4 |
@@ -1,5 +0,0 @@
|
||||
# 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
|
||||
@@ -11,7 +11,6 @@ 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';
|
||||
@@ -106,20 +105,16 @@ function BrowserNavigationItem({ current, to, postAction, children }: PropsWithC
|
||||
{isCustomised && (
|
||||
<span className={style.trailing}>
|
||||
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
|
||||
<Tooltip
|
||||
text='Reset to default'
|
||||
render={
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
className={style.clear}
|
||||
aria-label='Reset to default'
|
||||
onClick={clearViewSettings}
|
||||
/>
|
||||
}
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
className={style.clear}
|
||||
aria-label='Clear saved view settings'
|
||||
title='Clear saved view settings'
|
||||
onClick={clearViewSettings}
|
||||
>
|
||||
<IoCloseOutline />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</span>
|
||||
)}
|
||||
</NavigationMenuItem>
|
||||
|
||||
@@ -4,60 +4,40 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: $view-card-padding;
|
||||
border-radius: $element-border-radius;
|
||||
.title-card__title,
|
||||
.title-card__placeholder {
|
||||
font-weight: 600;
|
||||
font-size: $title-font-size;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
border-left: 1vw solid;
|
||||
.title-card__title {
|
||||
color: var(--color-override, $viewer-color);
|
||||
padding-right: 1em;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__title:empty::before {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
.title-card__placeholder {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
}
|
||||
|
||||
.title-card__title {
|
||||
font-weight: 600;
|
||||
line-height: 1.4em;
|
||||
padding-right: 1em;
|
||||
color: var(--color-override, $viewer-color);
|
||||
}
|
||||
.title-card__secondary {
|
||||
font-size: $base-font-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
.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;
|
||||
|
||||
&.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);
|
||||
}
|
||||
&--accent {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,33 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { ExtendedEntry } from '../../utils/rundownMetadata';
|
||||
import { cx, enDash } from '../../utils/styleUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import './TitleCard.scss';
|
||||
|
||||
type TitleCardMainProps = {
|
||||
interface TitleCardProps {
|
||||
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, size])} style={{ borderColor: colour }}>
|
||||
<span className='title-card__title' style={{ textAlign }} data-placeholder={placeholder}>
|
||||
{title === '' ? null : title}
|
||||
</span>
|
||||
<div className={cx(['title-card', className])} ref={ref}>
|
||||
<span className='title-card__title'>{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,7 +6,6 @@ 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';
|
||||
@@ -28,7 +27,6 @@ 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);
|
||||
@@ -38,7 +36,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
clearSavedParams(target);
|
||||
setSearchParams(getPreservedParams());
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export const useTimerViewControl = createSelector((state: RuntimeStore) => ({
|
||||
blackout: state.message.timer.blackout,
|
||||
blink: state.message.timer.blink,
|
||||
secondarySource: state.message.timer.secondarySource,
|
||||
secondaryPlacement: state.message.timer.secondaryPlacement,
|
||||
}));
|
||||
|
||||
export const useTimerMessageInput = createSelector((state: RuntimeStore) => ({
|
||||
@@ -43,6 +44,7 @@ export const useMessagePreview = createSelector((state: RuntimeStore) => ({
|
||||
blackout: state.message.timer.blackout,
|
||||
phase: state.timer.phase,
|
||||
secondarySource: state.message.timer.secondarySource,
|
||||
secondaryPlacement: state.message.timer.secondaryPlacement,
|
||||
showTimerMessage: state.message.timer.visible && Boolean(state.message.timer.text),
|
||||
timerType: state.eventNow?.timerType ?? null,
|
||||
countToEnd: state.eventNow?.countToEnd ?? false,
|
||||
@@ -56,6 +58,8 @@ export const setMessage = {
|
||||
timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }),
|
||||
timerSecondarySource: (payload: TimerMessage['secondarySource']) =>
|
||||
sendSocket('message', { timer: { secondarySource: payload } }),
|
||||
timerSecondaryPlacement: (payload: TimerMessage['secondaryPlacement']) =>
|
||||
sendSocket('message', { timer: { secondaryPlacement: payload } }),
|
||||
};
|
||||
|
||||
export const usePlaybackControl = createSelector((state: RuntimeStore) => ({
|
||||
@@ -227,9 +231,9 @@ export const useTimerSocket = createSelector((state: RuntimeStore) => ({
|
||||
timerTypeNow: state.eventNow?.timerType ?? TimerType.CountDown,
|
||||
countToEndNow: state.eventNow?.countToEnd ?? false,
|
||||
auxTimer: {
|
||||
aux1: state.auxtimer1.current,
|
||||
aux2: state.auxtimer2.current,
|
||||
aux3: state.auxtimer3.current,
|
||||
aux1: { current: state.auxtimer1.current, direction: state.auxtimer1.direction },
|
||||
aux2: { current: state.auxtimer2.current, direction: state.auxtimer2.direction },
|
||||
aux3: { current: state.auxtimer3.current, direction: state.auxtimer3.direction },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -293,9 +293,8 @@ 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. <br /> <br />
|
||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
or to change properties of Ontime itself.
|
||||
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{fieldOutputs.map((output, index) => {
|
||||
@@ -342,17 +341,12 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.address`)}
|
||||
value={output.address}
|
||||
fluid
|
||||
placeholder='/cue/start'
|
||||
/>
|
||||
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Arguments
|
||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
|
||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
@@ -382,7 +376,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
<div className={style.httpSection}>
|
||||
<label>
|
||||
Target URL
|
||||
<TemplateInput
|
||||
<Input
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
@@ -390,7 +384,6 @@ 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'
|
||||
/>
|
||||
|
||||
+28
-51
@@ -5,7 +5,6 @@ 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';
|
||||
|
||||
@@ -72,8 +71,8 @@ export default function OntimeActionForm({
|
||||
{ value: 'playback-pause', label: 'Playback: pause' },
|
||||
{ value: 'playback-roll', label: 'Playback: roll' },
|
||||
|
||||
{ value: 'message-set', label: 'Primary Message' },
|
||||
{ value: 'message-secondary', label: 'Secondary Message' },
|
||||
{ value: 'message-set', label: 'Primary Message: set' },
|
||||
{ value: 'message-secondary', label: 'Secondary Message: source' },
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
|
||||
@@ -97,12 +96,7 @@ export default function OntimeActionForm({
|
||||
<>
|
||||
<label>
|
||||
Text (leave empty for no change)
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
value={watch(`outputs.${index}.text`) ?? ''}
|
||||
fluid
|
||||
placeholder='eg: Timer is finished'
|
||||
/>
|
||||
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
|
||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
@@ -126,48 +120,31 @@ export default function OntimeActionForm({
|
||||
)}
|
||||
|
||||
{selectedAction === 'message-secondary' && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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 a trigger to run an automation'
|
||||
label='Create an automation to attach triggers to'
|
||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
+17
-55
@@ -1,67 +1,29 @@
|
||||
.inputShell {
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fluid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
.suggestions {
|
||||
background: $gray-1250;
|
||||
color: $label-gray;
|
||||
}
|
||||
color: $ui-white;
|
||||
|
||||
.list {
|
||||
box-sizing: border-box;
|
||||
max-height: min(20rem, var(--available-height));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
z-index: $zindex-floating;
|
||||
padding-block: 0.25rem;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
box-sizing: border-box;
|
||||
padding: 0.25rem 0.5rem;
|
||||
outline: 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
color: $label-gray;
|
||||
|
||||
&[data-highlighted] {
|
||||
li {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
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);
|
||||
}
|
||||
|
||||
+43
-232
@@ -1,258 +1,69 @@
|
||||
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 { mergeRefs, useClickOutside } from '@mantine/hooks';
|
||||
import { forwardRef, useMemo, useState } from 'react';
|
||||
|
||||
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 Input, { type InputProps } from '../../../../../common/components/input/input/Input';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { cx } from '../../../../../common/utils/styleUtils';
|
||||
import { makeAutoCompleteList } from './templateInput.utils';
|
||||
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
|
||||
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
|
||||
|
||||
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
|
||||
import style from './TemplateInput.module.scss';
|
||||
|
||||
interface TemplateInputProps extends Omit<InputProps, 'value'> {
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
value?: string;
|
||||
}
|
||||
interface TemplateInputProps extends InputProps {}
|
||||
|
||||
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 TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
|
||||
const { value, onChange, ...rest } = props;
|
||||
const { data } = useCustomFields();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const localRef = useClickOutside(() => setShowSuggestions(false));
|
||||
|
||||
const autocompleteList = useMemo(() => {
|
||||
return makeAutoCompleteList(data);
|
||||
}, [data]);
|
||||
|
||||
const updateInputValue = useCallback(
|
||||
(nextValue: string) => {
|
||||
setInputValue(nextValue);
|
||||
emitInputChange(rest.name, nextValue, onChange);
|
||||
},
|
||||
[onChange, rest.name],
|
||||
);
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
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 updateSuggestions = (value: string) => {
|
||||
const template = selectFromLastTemplate(value);
|
||||
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
|
||||
};
|
||||
|
||||
const closeExpandedEditor = () => {
|
||||
setIsExpanded(false);
|
||||
};
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
|
||||
const saveExpandedEditor = (nextValue: string) => {
|
||||
updateInputValue(nextValue);
|
||||
autocomplete.setCursorForValue(nextValue, nextValue.length);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
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));
|
||||
}
|
||||
|
||||
setDraftValue(value);
|
||||
setShowSuggestions(false);
|
||||
}, [isOpen, setShowSuggestions, value]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowSuggestions(false);
|
||||
onClose();
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSuggestion = (value: string) => {
|
||||
setInputValue((prev) => {
|
||||
const remaining = matchRemaining(prev as string, value);
|
||||
return prev + remaining;
|
||||
});
|
||||
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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
export default TemplateInput;
|
||||
|
||||
+1
-52
@@ -1,4 +1,4 @@
|
||||
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
|
||||
import { matchRemaining } from '../templateInput.utils';
|
||||
|
||||
describe('matchRemaining()', () => {
|
||||
it('should return a partial string needed for autocomplete', () => {
|
||||
@@ -15,54 +15,3 @@ 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
-64
@@ -54,16 +54,6 @@ 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}}`,
|
||||
@@ -85,19 +75,12 @@ 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'
|
||||
@@ -128,54 +111,13 @@ export function matchRemaining(a: string, b: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
|
||||
const textBeforeCursor = text.slice(0, cursorIndex);
|
||||
const start = textBeforeCursor.lastIndexOf('{{');
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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.
|
||||
* Selects the last starting template in a string
|
||||
*/
|
||||
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 };
|
||||
export function selectFromLastTemplate(text: string) {
|
||||
const lastBraceIndex = text.lastIndexOf('{{');
|
||||
if (lastBraceIndex !== -1) {
|
||||
return text.slice(lastBraceIndex);
|
||||
}
|
||||
|
||||
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
|
||||
return {
|
||||
value,
|
||||
cursorIndex: activeTemplateRange.start + suggestion.length,
|
||||
};
|
||||
return '';
|
||||
}
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,15 @@
|
||||
|
||||
.secondaryContent {
|
||||
border-top: 1px solid $white-7;
|
||||
// when the event timer is demoted here (secondary swapped to main) it keeps its colour treatment
|
||||
color: var(--override-colour, inherit);
|
||||
|
||||
&[data-phase='pending'] {
|
||||
color: $ontime-roll;
|
||||
}
|
||||
&[data-phase='overtime'] {
|
||||
color: $playback-negative;
|
||||
}
|
||||
}
|
||||
|
||||
.blackout {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TimerPhase, TimerType } from 'ontime-types';
|
||||
import { IoArrowDown, IoArrowUp, IoBan, IoTime } from 'react-icons/io5';
|
||||
import { IoArrowDown, IoArrowUp, IoBan, IoSwapVertical, IoTime } from 'react-icons/io5';
|
||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||
|
||||
import { CornerWithPip } from '../../../common/components/editor-utils/EditorUtils';
|
||||
@@ -20,10 +20,11 @@ const secondarySourceLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function TimerPreview() {
|
||||
const { blink, blackout, countToEnd, phase, secondarySource, showTimerMessage, timerType } = useMessagePreview();
|
||||
const { blink, blackout, countToEnd, phase, secondarySource, secondaryPlacement, showTimerMessage, timerType } =
|
||||
useMessagePreview();
|
||||
const { data } = useViewSettings();
|
||||
|
||||
const main = (() => {
|
||||
const eventLabel = (() => {
|
||||
if (showTimerMessage) return 'Message';
|
||||
if (timerType === TimerType.None) return timerPlaceholder;
|
||||
if (phase === TimerPhase.Pending) return 'Standby to start';
|
||||
@@ -33,7 +34,7 @@ export default function TimerPreview() {
|
||||
return 'Timer';
|
||||
})();
|
||||
|
||||
const secondary = (() => {
|
||||
const secondaryLabel = (() => {
|
||||
// message is a fullscreen overlay or secondary is not active
|
||||
if (showTimerMessage || !secondarySource) return null;
|
||||
|
||||
@@ -41,6 +42,11 @@ export default function TimerPreview() {
|
||||
return secondarySourceLabels[secondarySource];
|
||||
})();
|
||||
|
||||
// when the secondary is promoted to the main slot the two labels swap; the event timer is demoted
|
||||
const isSwapped = secondaryPlacement === 'main' && secondaryLabel !== null && !showTimerMessage;
|
||||
const mainDisplay = isSwapped ? secondaryLabel : eventLabel;
|
||||
const secondaryDisplay = isSwapped ? eventLabel : secondaryLabel;
|
||||
|
||||
const overrideColour = (() => {
|
||||
// override fallback colours from starter project
|
||||
if (phase === TimerPhase.Warning) return data.warningColor ?? '#ffa528';
|
||||
@@ -48,7 +54,9 @@ export default function TimerPreview() {
|
||||
return data.normalColor ?? '#FFFC';
|
||||
})();
|
||||
|
||||
const showColourOverride = main == 'Timer';
|
||||
// the event timer keeps its colour treatment in whichever slot it now occupies
|
||||
const eventInMain = !isSwapped;
|
||||
const showColourOverride = eventLabel == 'Timer';
|
||||
const contentClasses = cx([blink && style.blink, blackout && style.blackout]);
|
||||
|
||||
return (
|
||||
@@ -57,12 +65,20 @@ export default function TimerPreview() {
|
||||
<div className={contentClasses}>
|
||||
<div
|
||||
className={style.mainContent}
|
||||
data-phase={showColourOverride && phase}
|
||||
style={showColourOverride ? { '--override-colour': overrideColour } : {}}
|
||||
data-phase={eventInMain && showColourOverride && phase}
|
||||
style={eventInMain && showColourOverride ? { '--override-colour': overrideColour } : {}}
|
||||
>
|
||||
{main}
|
||||
{mainDisplay}
|
||||
</div>
|
||||
{secondary !== null && <div className={style.secondaryContent}>{secondary}</div>}
|
||||
{secondaryDisplay !== null && (
|
||||
<div
|
||||
className={style.secondaryContent}
|
||||
data-phase={!eventInMain && showColourOverride && phase}
|
||||
style={!eventInMain && showColourOverride ? { '--override-colour': overrideColour } : {}}
|
||||
>
|
||||
{secondaryDisplay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={style.eventStatus}>
|
||||
<Tooltip
|
||||
@@ -105,6 +121,14 @@ export default function TimerPreview() {
|
||||
>
|
||||
<LuArrowDownToLine />
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
text='Secondary swapped into main slot'
|
||||
render={<span />}
|
||||
className={style.statusIcon}
|
||||
data-active={isSwapped}
|
||||
>
|
||||
<IoSwapVertical />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { SecondarySource } from 'ontime-types';
|
||||
import { SecondaryPlacement, SecondarySource } from 'ontime-types';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Button from '../../../common/components/buttons/Button';
|
||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||
import RadioGroup from '../../../common/components/radio-group/RadioGroup';
|
||||
import Select from '../../../common/components/select/Select';
|
||||
import { setMessage, useTimerViewControl } from '../../../common/hooks/useSocket';
|
||||
import TimerPreview from './TimerPreview';
|
||||
@@ -42,7 +43,7 @@ export default function TimerControlsPreview() {
|
||||
}
|
||||
|
||||
function SecondarySourceControl() {
|
||||
const { secondarySource } = useTimerViewControl();
|
||||
const { secondarySource, secondaryPlacement } = useTimerViewControl();
|
||||
const [value, setValue] = useState<SecondarySource>('aux1');
|
||||
|
||||
// sync secondary source with external changes
|
||||
@@ -52,6 +53,8 @@ function SecondarySourceControl() {
|
||||
}
|
||||
}, [secondarySource]);
|
||||
|
||||
const isActive = secondarySource !== null;
|
||||
|
||||
const toggleSecondary = () => {
|
||||
if (secondarySource === value) {
|
||||
setMessage.timerSecondarySource(null);
|
||||
@@ -79,12 +82,19 @@ function SecondarySourceControl() {
|
||||
setValue(value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant={secondarySource !== null ? 'primary' : 'subtle'}
|
||||
fluid
|
||||
onClick={toggleSecondary}
|
||||
data-testid='toggle secondary'
|
||||
>
|
||||
<Editor.Label htmlFor='secondary-placement'>Placement</Editor.Label>
|
||||
<RadioGroup<SecondaryPlacement>
|
||||
id='secondary-placement'
|
||||
orientation='horizontal'
|
||||
value={secondaryPlacement}
|
||||
disabled={!isActive}
|
||||
onValueChange={(placement) => setMessage.timerSecondaryPlacement(placement)}
|
||||
items={[
|
||||
{ value: 'below', label: 'Below timer' },
|
||||
{ value: 'main', label: 'Swap with timer' },
|
||||
]}
|
||||
/>
|
||||
<Button variant={isActive ? 'primary' : 'subtle'} fluid onClick={toggleSecondary} data-testid='toggle secondary'>
|
||||
Show secondary
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,6 @@ $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,11 +32,6 @@
|
||||
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,5 +1,6 @@
|
||||
import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
|
||||
|
||||
import { enDash } from '../../common/utils/styleUtils';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
/**
|
||||
@@ -45,9 +46,9 @@ export function getCardData(
|
||||
}
|
||||
|
||||
// if we are loaded, we show the upcoming event as next
|
||||
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title');
|
||||
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash;
|
||||
const nowSecondary = getPropertyValue(eventNow, secondarySource);
|
||||
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title');
|
||||
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
|
||||
const nextSecondary = getPropertyValue(eventNext, secondarySource);
|
||||
|
||||
return {
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
/* =================== 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;
|
||||
}
|
||||
@@ -150,6 +154,28 @@
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
// when the event timer is demoted into the secondary slot it keeps its (phase-aware) colour
|
||||
&--as-timer {
|
||||
color: var(--timer-colour, var(--timer-color-override, $ui-white));
|
||||
border-top-color: color-mix(in srgb, var(--timer-colour, $external-color) 10%, transparent);
|
||||
|
||||
&.secondary--paused {
|
||||
opacity: $viewer-opacity-disabled;
|
||||
transition: $viewer-transition-time;
|
||||
}
|
||||
|
||||
&.secondary--finished {
|
||||
color: var(--timer-overtime-color-override, $timer-finished-color);
|
||||
}
|
||||
|
||||
&[data-phase='warning'] {
|
||||
color: var(--timer-colour, var(--timer-warning-color-override));
|
||||
}
|
||||
&[data-phase='danger'] {
|
||||
color: var(--timer-colour, var(--timer-danger-color-override));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getShowMessage,
|
||||
getShowModifiers,
|
||||
getShowProgressBar,
|
||||
getTimerSlots,
|
||||
getTotalTime,
|
||||
} from './timer.utils';
|
||||
import { TimerData, useTimerData } from './useTimerData';
|
||||
@@ -132,15 +133,25 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
hideSecondary,
|
||||
);
|
||||
|
||||
// when the operator promotes the secondary source to the main slot, swap the two so the event
|
||||
// timer is demoted (never removed). Frozen overtime end-messages keep the event timer prominent.
|
||||
const isSwapped = message.timer.secondaryPlacement === 'main' && Boolean(secondaryContent) && !showEndMessage;
|
||||
const { main: mainSlot, secondary: secondarySlot } = getTimerSlots(
|
||||
isSwapped,
|
||||
{ content: display, timerType: viewTimerType, phase: time.phase },
|
||||
secondaryContent,
|
||||
);
|
||||
|
||||
// gather presentation styles
|
||||
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
|
||||
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
|
||||
const timerFontSize = getEstimatedFontSize(mainSlot.content ?? display, secondarySlot.content);
|
||||
const subduePaused = !isPlaying && viewTimerType !== TimerType.Clock;
|
||||
const userStyles = {
|
||||
...(keyColour && { '--timer-bg': keyColour }),
|
||||
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
|
||||
...(font && { '--timer-font': font }),
|
||||
};
|
||||
// the event timer keeps its (phase-aware) colour in whichever slot it occupies
|
||||
const eventTimerColour = resolvedTimerColour ? { '--timer-colour': resolvedTimerColour } : undefined;
|
||||
|
||||
// gather option data
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
@@ -175,17 +186,32 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
</FitText>
|
||||
) : (
|
||||
<div
|
||||
className={cx(['timer', subduePaused && 'timer--paused', showFinished && 'timer--finished'])}
|
||||
style={{ fontSize: `${timerFontSize}vw` }}
|
||||
data-type={viewTimerType}
|
||||
data-phase={time.phase}
|
||||
className={cx([
|
||||
'timer',
|
||||
mainSlot.isEventTimer && subduePaused && 'timer--paused',
|
||||
mainSlot.isEventTimer && showFinished && 'timer--finished',
|
||||
])}
|
||||
style={{ fontSize: `${timerFontSize}vw`, ...(mainSlot.isEventTimer ? eventTimerColour : {}) }}
|
||||
data-type={mainSlot.timerType}
|
||||
data-phase={mainSlot.phase}
|
||||
>
|
||||
{display}
|
||||
{mainSlot.content}
|
||||
</div>
|
||||
)}
|
||||
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
|
||||
<div
|
||||
className={cx([
|
||||
'secondary',
|
||||
!secondarySlot.content && 'secondary--hidden',
|
||||
secondarySlot.isEventTimer && 'secondary--as-timer',
|
||||
secondarySlot.isEventTimer && subduePaused && 'secondary--paused',
|
||||
secondarySlot.isEventTimer && showFinished && 'secondary--finished',
|
||||
])}
|
||||
style={secondarySlot.isEventTimer ? eventTimerColour : undefined}
|
||||
data-type={secondarySlot.timerType}
|
||||
data-phase={secondarySlot.phase}
|
||||
>
|
||||
<FitText mode='multi' min={64} max={256}>
|
||||
{secondaryContent}
|
||||
{secondarySlot.content}
|
||||
</FitText>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,24 +232,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
|
||||
{!hideCards && (
|
||||
<>
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
{showNow && <TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />}
|
||||
{showNext && <TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { MessageState, SimpleDirection, TimerPhase, TimerType } from 'ontime-types';
|
||||
|
||||
import { getSecondaryDisplay, getTimerSlots } from './timer.utils';
|
||||
|
||||
function makeMessage(partial: Partial<MessageState['timer']> = {}, secondary = ''): MessageState {
|
||||
return {
|
||||
timer: {
|
||||
text: '',
|
||||
visible: false,
|
||||
blink: false,
|
||||
blackout: false,
|
||||
secondarySource: null,
|
||||
secondaryPlacement: 'below',
|
||||
...partial,
|
||||
},
|
||||
secondary,
|
||||
};
|
||||
}
|
||||
|
||||
const eventTimer = { content: '00:10:00', timerType: TimerType.CountDown, phase: TimerPhase.Warning };
|
||||
|
||||
describe('getTimerSlots()', () => {
|
||||
it('keeps the event timer in the main slot when not swapped', () => {
|
||||
const { main, secondary } = getTimerSlots(false, eventTimer, 'AUX');
|
||||
|
||||
expect(main).toMatchObject({ content: '00:10:00', phase: TimerPhase.Warning, isEventTimer: true });
|
||||
expect(secondary).toMatchObject({ content: 'AUX', phase: undefined, isEventTimer: false });
|
||||
});
|
||||
|
||||
it('swaps the secondary into the main slot and demotes the event timer', () => {
|
||||
const { main, secondary } = getTimerSlots(true, eventTimer, 'AUX');
|
||||
|
||||
expect(main).toMatchObject({ content: 'AUX', isEventTimer: false, phase: undefined });
|
||||
// the event timer is never removed, only demoted, and keeps its phase
|
||||
expect(secondary).toMatchObject({ content: '00:10:00', isEventTimer: true, phase: TimerPhase.Warning });
|
||||
});
|
||||
|
||||
it('does not swap when there is no secondary content to promote', () => {
|
||||
const { main, secondary } = getTimerSlots(true, eventTimer, undefined);
|
||||
|
||||
expect(main.isEventTimer).toBe(true);
|
||||
expect(secondary.isEventTimer).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSecondaryDisplay()', () => {
|
||||
it('returns nothing when the secondary is hidden', () => {
|
||||
const message = makeMessage({ secondarySource: 'aux1' });
|
||||
expect(
|
||||
getSecondaryDisplay(message, { current: 5000, direction: SimpleDirection.CountDown }, 'min', false, false, true),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the secondary message text for the secondary source', () => {
|
||||
const message = makeMessage({ secondarySource: 'secondary' }, 'hello');
|
||||
expect(getSecondaryDisplay(message, null, 'min', false, false, false)).toBe('hello');
|
||||
});
|
||||
|
||||
it('formats an aux source as a timer honouring its direction', () => {
|
||||
const message = makeMessage({ secondarySource: 'aux1' });
|
||||
|
||||
// a running count-up aux shows elapsed time without a negative sign
|
||||
const countUp = getSecondaryDisplay(
|
||||
message,
|
||||
{ current: 5000, direction: SimpleDirection.CountUp },
|
||||
'min',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(countUp).toBe('00:00:05');
|
||||
|
||||
// a count-down aux past zero shows overtime as a negative value
|
||||
const countDown = getSecondaryDisplay(
|
||||
message,
|
||||
{ current: -5000, direction: SimpleDirection.CountDown },
|
||||
'min',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
expect(countDown).toBe('-00:00:05');
|
||||
});
|
||||
|
||||
it('returns nothing when no secondary source is selected', () => {
|
||||
const message = makeMessage({ secondarySource: null });
|
||||
expect(getSecondaryDisplay(message, null, 'min', false, false, false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
OntimeEvent,
|
||||
Playback,
|
||||
RundownEntries,
|
||||
SimpleDirection,
|
||||
TimerMessage,
|
||||
TimerPhase,
|
||||
TimerType,
|
||||
@@ -12,6 +13,11 @@ import { isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import { getFormattedTimer, getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
/**
|
||||
* The current value and direction of the aux timer feeding the secondary slot
|
||||
*/
|
||||
export type AuxTimerValue = { current: MaybeNumber; direction: SimpleDirection };
|
||||
|
||||
/**
|
||||
* Whether a message should be shown
|
||||
*/
|
||||
@@ -119,7 +125,7 @@ export function getShowModifiers(
|
||||
*/
|
||||
export function getSecondaryDisplay(
|
||||
message: MessageState,
|
||||
currentAux: MaybeNumber,
|
||||
currentAux: AuxTimerValue | null,
|
||||
localisedMinutes: string,
|
||||
removeSeconds: boolean,
|
||||
removeLeadingZero: boolean,
|
||||
@@ -133,7 +139,9 @@ export function getSecondaryDisplay(
|
||||
message.timer.secondarySource === 'aux2' ||
|
||||
message.timer.secondarySource === 'aux3'
|
||||
) {
|
||||
return getFormattedTimer(currentAux, TimerType.CountDown, localisedMinutes, {
|
||||
// honour the aux timer's own direction so a promoted aux reads correctly
|
||||
const timerType = currentAux?.direction === SimpleDirection.CountUp ? TimerType.CountUp : TimerType.CountDown;
|
||||
return getFormattedTimer(currentAux?.current ?? null, timerType, localisedMinutes, {
|
||||
removeSeconds,
|
||||
removeLeadingZero,
|
||||
});
|
||||
@@ -144,6 +152,45 @@ export function getSecondaryDisplay(
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes what a timer slot (main or secondary) renders and how it should be styled
|
||||
*/
|
||||
export type TimerSlot = {
|
||||
content: string | undefined;
|
||||
timerType: TimerType | undefined;
|
||||
phase: TimerPhase | undefined;
|
||||
isEventTimer: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assigns the event timer and the secondary content to the main (large) and secondary (small) slots.
|
||||
* When the operator promotes the secondary source to the main slot, the two are swapped so the event
|
||||
* timer is never removed from screen — it is only demoted to the smaller slot.
|
||||
*/
|
||||
export function getTimerSlots(
|
||||
isSwapped: boolean,
|
||||
eventTimer: { content: string; timerType: TimerType; phase: TimerPhase },
|
||||
secondaryContent: string | undefined,
|
||||
): { main: TimerSlot; secondary: TimerSlot } {
|
||||
const eventSlot: TimerSlot = {
|
||||
content: eventTimer.content,
|
||||
timerType: eventTimer.timerType,
|
||||
phase: eventTimer.phase,
|
||||
isEventTimer: true,
|
||||
};
|
||||
const secondarySlot: TimerSlot = {
|
||||
content: secondaryContent,
|
||||
timerType: undefined,
|
||||
phase: undefined,
|
||||
isEventTimer: false,
|
||||
};
|
||||
|
||||
if (isSwapped && secondaryContent) {
|
||||
return { main: secondarySlot, secondary: eventSlot };
|
||||
}
|
||||
return { main: eventSlot, secondary: secondarySlot };
|
||||
}
|
||||
|
||||
/**
|
||||
* What should we be showing in the cards?
|
||||
*/
|
||||
|
||||
@@ -163,70 +163,5 @@ 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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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, store);
|
||||
toOntimeAction(payload);
|
||||
} else {
|
||||
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
|
||||
}
|
||||
|
||||
@@ -225,28 +225,13 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
||||
}
|
||||
|
||||
if (maybeOntimeAction.action === 'message-secondary') {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
|
||||
// null is used to clear the secondary source
|
||||
if (maybeOntimeAction.secondarySource === null) {
|
||||
return {
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: null,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,7 +240,6 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { LogOrigin, OntimeAction, RuntimeStore } from 'ontime-types';
|
||||
import { LogOrigin, OntimeAction } 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, store: DeepReadonly<RuntimeStore>) {
|
||||
export function toOntimeAction(action: OntimeAction) {
|
||||
const actionType = action.action;
|
||||
switch (actionType) {
|
||||
// Aux timer actions
|
||||
@@ -57,25 +55,18 @@ export function toOntimeAction(action: OntimeAction, store: DeepReadonly<Runtime
|
||||
case 'message-set': {
|
||||
messageService.patch({
|
||||
timer: {
|
||||
text: action.text ? parseTemplateNested(action.text, store) : action.text,
|
||||
text: action.text,
|
||||
visible: action.visible,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'message-secondary': {
|
||||
const secondary = action.text ? parseTemplateNested(action.text, store) : action.text;
|
||||
const patch =
|
||||
action.secondarySource === undefined
|
||||
? { secondary }
|
||||
: {
|
||||
timer: {
|
||||
secondarySource: action.secondarySource,
|
||||
},
|
||||
secondary,
|
||||
};
|
||||
|
||||
messageService.patch(patch);
|
||||
messageService.patch({
|
||||
timer: {
|
||||
secondarySource: action.secondarySource,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,4 +33,11 @@ describe('validateTimerMessage()', () => {
|
||||
|
||||
expect(validateTimerMessage(payload)).toStrictEqual(expected);
|
||||
});
|
||||
it('coerces the secondary placement to a permitted value', () => {
|
||||
expect(validateTimerMessage({ secondaryPlacement: 'main' })).toStrictEqual({ secondaryPlacement: 'main' });
|
||||
expect(validateTimerMessage({ secondaryPlacement: 'below' })).toStrictEqual({ secondaryPlacement: 'below' });
|
||||
});
|
||||
it('falls back to below for an invalid placement', () => {
|
||||
expect(validateTimerMessage({ secondaryPlacement: 'nonsense' })).toStrictEqual({ secondaryPlacement: 'below' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
|
||||
if ('blink' in message) result.blink = coerceBoolean(message.blink);
|
||||
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
|
||||
if ('secondarySource' in message) result.secondarySource = coerceSecondary(message.secondarySource);
|
||||
if ('secondaryPlacement' in message) result.secondaryPlacement = coercePlacement(message.secondaryPlacement);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -45,3 +46,20 @@ function coerceSecondary(source: unknown): TimerMessage['secondarySource'] {
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that the placement value is one of the permitted values
|
||||
*/
|
||||
function assertPlacement(placement: unknown): placement is TimerMessage['secondaryPlacement'] {
|
||||
return placement === 'below' || placement === 'main';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the placement value is one of the permitted values
|
||||
*/
|
||||
function coercePlacement(placement: unknown): TimerMessage['secondaryPlacement'] {
|
||||
if (!assertPlacement(placement)) {
|
||||
return 'below';
|
||||
}
|
||||
return placement;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
text?: string;
|
||||
secondarySource: SecondarySource;
|
||||
};
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
export type SecondarySource = 'aux1' | 'aux2' | 'aux3' | 'secondary' | null;
|
||||
|
||||
/**
|
||||
* Where the selected secondary source is displayed in the timer view
|
||||
* - below: shown as a smaller timer under the main timer (default)
|
||||
* - main: swapped into the main slot, demoting the event timer to the secondary slot
|
||||
*/
|
||||
export type SecondaryPlacement = 'below' | 'main';
|
||||
|
||||
export type TimerMessage = {
|
||||
text: string;
|
||||
visible: boolean;
|
||||
blink: boolean;
|
||||
blackout: boolean;
|
||||
secondarySource: SecondarySource;
|
||||
secondaryPlacement: SecondaryPlacement;
|
||||
};
|
||||
|
||||
export type MessageState = {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
||||
blink: false,
|
||||
blackout: false,
|
||||
secondarySource: null,
|
||||
secondaryPlacement: 'below',
|
||||
},
|
||||
secondary: '',
|
||||
},
|
||||
|
||||
@@ -108,7 +108,12 @@ export type { ApiAction, ApiActionTag, ApiResponse } from './api/websocket/api.t
|
||||
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
||||
export { Playback } from './definitions/runtime/Playback.type.js';
|
||||
export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLifecycle.type.js';
|
||||
export type { TimerMessage, MessageState, SecondarySource } from './definitions/runtime/MessageControl.type.js';
|
||||
export type {
|
||||
TimerMessage,
|
||||
MessageState,
|
||||
SecondarySource,
|
||||
SecondaryPlacement,
|
||||
} from './definitions/runtime/MessageControl.type.js';
|
||||
|
||||
export type { RundownState } from './definitions/runtime/RundownState.type.js';
|
||||
export type { Offset } from './definitions/runtime/Offset.type.js';
|
||||
|
||||
Reference in New Issue
Block a user