feat: edit groups

This commit is contained in:
Carlos Valente
2025-06-25 06:10:12 +02:00
committed by Carlos Valente
parent 4c08482258
commit 473f50b493
75 changed files with 916 additions and 511 deletions
@@ -9,12 +9,13 @@
border: 1px solid transparent;
padding-inline: 0.5em;
outline: none;
&:hover:not(:disabled) {
background-color: $gray-1100;
}
&:focus {
&:focus:not(:read-only) {
background-color: $gray-1000;
border: 1px solid $blue-500;
}
@@ -26,6 +27,7 @@
&::placeholder {
color: $gray-500;
letter-spacing: 0;
}
}
@@ -36,3 +38,7 @@
.large {
height: 2.5rem;
}
.fluid {
width: 100%;
}
@@ -4,20 +4,23 @@ import { cx } from '../../../utils/styleUtils';
import style from './Input.module.scss';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
variant?: 'subtle';
height?: 'medium' | 'large';
fluid?: boolean;
}
const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ className, variant = 'subtle', height = 'medium', ...inputProps },
{ className, variant = 'subtle', height = 'medium', fluid, ...inputProps },
ref,
) {
return (
<input
ref={ref}
type='text'
className={cx([style.input, style[variant], style[height], className])}
autoCorrect='off'
autoComplete='off'
className={cx([style.input, style[variant], style[height], fluid && style.fluid, className])}
{...inputProps}
/>
);
@@ -0,0 +1,135 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { cx } from '../../../utils/styleUtils';
import Input from '../input/Input';
import style from './TimeInput.module.scss';
interface NullableTimeInputProps<T extends string> {
id?: T;
name: T;
submitHandler: (field: T, value: string) => void;
time?: number | null;
emptyDisplay: string;
placeholder?: string;
disabled?: boolean;
align?: 'left' | 'center';
className?: string;
}
export default function NullableTimeInput<T extends string>(props: NullableTimeInputProps<T>) {
const { id, name, submitHandler, time, emptyDisplay, placeholder, disabled, align = 'center', className } = props;
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
/**
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
if (typeof time !== 'number' || isNaN(time)) {
setValue(emptyDisplay);
} else {
setValue(millisToString(time));
}
}, [emptyDisplay, time]);
/**
* @description Selects input text on focus
*/
const handleFocus = useCallback(() => {
inputRef.current?.select();
}, []);
/**
* @description Submit handler
* @param {string} newValue
*/
const handleSubmit = useCallback(
(newValue: string) => {
// skip if user deleted and time is already null
if (newValue === '' && time === null) {
return false;
}
// skip if the value evaluates to the same thing
const valueInMillis = parseUserTime(newValue);
if (valueInMillis === time) {
return false;
}
submitHandler(name, newValue);
return true;
},
[name, submitHandler, time],
);
/**
* @description Prepare time fields
* @param {string} value string to be parsed
*/
const validateAndSubmit = useCallback(
(newValue: string) => {
const success = handleSubmit(newValue);
if (!success) {
resetValue();
}
},
[handleSubmit, resetValue],
);
/**
* @description Handles common keys for submit and cancel
* @param {KeyboardEvent} event
*/
const onKeyDownHandler = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
inputRef.current?.blur();
}
if (event.key === 'Escape') {
ignoreChange.current = true;
inputRef.current?.blur();
resetValue();
}
},
[resetValue],
);
const onBlurHandler = useCallback(
(event: FocusEvent<HTMLInputElement>) => {
if (ignoreChange.current) {
ignoreChange.current = false;
return;
}
validateAndSubmit((event.target as HTMLInputElement).value);
},
[validateAndSubmit],
);
useEffect(() => {
resetValue();
}, [resetValue]);
return (
<Input
id={id}
disabled={disabled}
ref={inputRef}
data-testid={`time-input-${name}`}
className={cx([style.timeInput, className])}
placeholder={placeholder}
onFocus={handleFocus}
onChange={(event) => setValue(event.target.value)}
onBlur={onBlurHandler}
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
style={{
textAlign: align,
}}
/>
);
}
@@ -2,6 +2,5 @@
width: 100%;
max-width: 7.5em;
letter-spacing: 1px;
font-size: 1rem;
font-variant-numeric: tabular-nums;
}
@@ -1,7 +1,6 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger';
import { cx } from '../../../utils/styleUtils';
import Input from '../input/Input';
@@ -19,8 +18,7 @@ interface TimeInputProps<T extends string> {
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { id, name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog();
const { id, name, submitHandler, time, placeholder, disabled, align = 'center', className } = props;
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
const ignoreChange = useRef(false);
@@ -29,16 +27,12 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
* @description Resets input value to given
*/
const resetValue = useCallback(() => {
try {
if (typeof time !== 'number' || isNaN(time)) {
throw new Error(`Invalid time value: ${time}`);
}
if (typeof time !== 'number' || isNaN(time)) {
setValue('00:00:00');
} else {
setValue(millisToString(time));
} catch (error) {
setValue(millisToString(0));
emitError(`Unable to parse time ${time}: ${error}`);
}
}, [emitError, time]);
}, [time]);
/**
* @description Selects input text on focus
@@ -119,9 +113,8 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
);
useEffect(() => {
if (time == null) return;
resetValue();
}, [resetValue, time]);
}, [resetValue]);
return (
<Input
@@ -92,17 +92,15 @@
}
.scrollArrow {
background-color: $gray-1000;
color: $ui-white;
width: 100%;
background: canvas;
z-index: 1;
text-align: center;
cursor: default;
border-radius: 0.375rem;
height: 1rem;
font-size: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.5rem;
display: grid;
place-content: center;
&::before {
content: '';
@@ -113,6 +111,7 @@
}
&[data-direction='up'] {
border-radius: 3px 3px 0 0;
&::before {
top: -100%;
}
@@ -120,6 +119,7 @@
&[data-direction='down'] {
bottom: 0;
border-radius: 0 0 3px 3px;
&::before {
bottom: -100%;
@@ -9,6 +9,7 @@ interface SelectProps<T extends string | null = string> {
options: {
value: NonNullable<T>;
label: string;
disabled?: boolean; // exposed to allow creating a non-selectable option
}[];
placeholder?: string;
value?: T;
@@ -0,0 +1,52 @@
.switch {
position: relative;
display: flex;
align-items: center;
/* Reset and base styles */
appearance: none;
border: 1px solid transparent;
border-radius: 99px;
background-color: $gray-1100;
/* Transitions */
transition: background-color 125ms cubic-bezier(0.26, 0.75, 0.38, 0.45);
/* States */
&[data-checked] {
background-color: $blue-700;
box-shadow: none;
}
&:focus {
border-color: $blue-500;
}
}
.medium {
padding: 2px;
--width: calc(2.5rem + 4px);
--height: 1.5rem;
width: var(--width);
height: var(--height);
}
.large {
padding: 3px;
--width: calc(2.75rem + 6px);
--height: 1.75rem;
width: var(--width);
height: var(--height);
}
.thumb {
aspect-ratio: 1 / 1;
height: 100%;
border-radius: 99px;
background-color: $ui-white;
transition: translate 150ms ease;
&[data-checked] {
translate: calc(var(--width) - var(--height)) 0;
}
}
@@ -0,0 +1,17 @@
import { Switch as BaseSwitch } from '@base-ui-components/react/switch';
import { cx } from '../../utils/styleUtils';
import style from './Switch.module.scss';
interface SwitchProps extends BaseSwitch.Root.Props {
size?: 'medium' | 'large';
}
export default function Switch({ size = 'medium', ...switchProps }: SwitchProps) {
return (
<BaseSwitch.Root className={cx([style.switch, style[size]])} {...switchProps}>
<BaseSwitch.Thumb className={style.thumb} />
</BaseSwitch.Root>
);
}
@@ -790,7 +790,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
order = order.filter((id) => id !== entry.id);
} else {
const parent = entries[entry.parent] as OntimeBlock;
parent.events = parent.events.filter((event) => event !== entry.id);
parent.entries = parent.entries.filter((parentEntry) => parentEntry !== entry.id);
}
delete entries[entry.id];
+14 -14
View File
@@ -171,21 +171,21 @@ export default function Operator() {
return (
<Fragment key={entry.id}>
<OperatorBlock key={entry.id} title={entry.title} />
{entry.events.map((nestedEventId) => {
const nestedEvent = data.entries[nestedEventId];
if (!isOntimeEvent(nestedEvent)) {
{entry.entries.map((nestedEntryId) => {
const nestedEntry = data.entries[nestedEntryId];
if (!isOntimeEvent(nestedEntry)) {
return null;
}
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEvent);
const { isPast, isSelected, isLinkedToLoaded, totalGap } = process(nestedEntry);
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
if (hidePast && isPast) {
return null;
}
const { mainField, secondaryField, subscribedData } = getEventData(
nestedEvent,
nestedEntry,
mainSource,
secondarySource,
subscribe,
@@ -194,16 +194,16 @@ export default function Operator() {
return (
<OperatorEvent
key={nestedEvent.id}
id={nestedEvent.id}
colour={nestedEvent.colour}
cue={nestedEvent.cue}
key={nestedEntry.id}
id={nestedEntry.id}
colour={nestedEntry.colour}
cue={nestedEntry.cue}
main={mainField}
secondary={secondaryField}
timeStart={nestedEvent.timeStart}
duration={nestedEvent.duration}
delay={nestedEvent.delay}
dayOffset={nestedEvent.dayOffset}
timeStart={nestedEntry.timeStart}
duration={nestedEntry.duration}
delay={nestedEntry.delay}
dayOffset={nestedEntry.dayOffset}
isLinkedToLoaded={isLinkedToLoaded}
isSelected={isSelected}
isPast={isPast}
@@ -36,7 +36,7 @@
display: flex;
gap: 0.5rem;
align-items: center;
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
background-color: color-mix(in srgb, var(--user-bg, transparent) 15%, transparent 85%);
}
.entryIndex {
+3 -3
View File
@@ -39,9 +39,9 @@ import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone';
import BlockBlock from './block-block/BlockBlock';
import BlockEnd from './block-block/BlockEnd';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
import BlockEnd from './rundown-block/BlockEnd';
import RundownBlock from './rundown-block/RundownBlock';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';
@@ -445,7 +445,7 @@ export default function Rundown({ data }: RundownProps) {
/>
)}
{isOntimeBlock(entry) ? (
<BlockBlock
<RundownBlock
data={entry}
hasCursor={hasCursor}
collapsed={getIsCollapsed(entry.id)}
@@ -14,8 +14,8 @@ import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/clone';
import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock';
import RundownDelay from './rundown-delay/RundownDelay';
import RundownEvent from './rundown-event/RundownEvent';
import { useEventSelection } from './useEventSelection';
export type EventItemActions =
@@ -48,22 +48,21 @@ interface RundownEntryProps {
isLinkedToLoaded: boolean;
}
export default function RundownEntry(props: RundownEntryProps) {
const {
isPast,
data,
loaded,
hasCursor,
isNext,
previousEntryId,
previousEventId,
playback,
isRolling,
eventIndex,
isNextDay,
totalGap,
isLinkedToLoaded,
} = props;
export default function RundownEntry({
isPast,
data,
loaded,
hasCursor,
isNext,
previousEntryId,
previousEventId,
playback,
isRolling,
eventIndex,
isNextDay,
totalGap,
isLinkedToLoaded,
}: RundownEntryProps) {
const { emitError } = useEmitLog();
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
@@ -167,7 +166,7 @@ export default function RundownEntry(props: RundownEntryProps) {
if (isOntimeEvent(data)) {
return (
<EventBlock
<RundownEvent
eventId={data.id}
eventIndex={eventIndex}
cue={data.cue}
@@ -201,7 +200,7 @@ export default function RundownEntry(props: RundownEntryProps) {
/>
);
} else if (isOntimeDelay(data)) {
return <DelayBlock data={data} hasCursor={hasCursor} />;
return <RundownDelay data={data} hasCursor={hasCursor} />;
}
return null;
}
@@ -10,7 +10,7 @@ import { useAppMode } from '../../common/stores/appModeStore';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import RundownEventEditor from './event-editor/RundownEventEditor';
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
import FinderPlacement from './placements/FinderPlacement';
import RundownWrapper from './RundownWrapper';
@@ -63,7 +63,7 @@ function RundownExport() {
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEventEditor />
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
@@ -21,7 +21,7 @@ describe('makeRundownMetadata()', () => {
block: {
id: 'block',
type: SupportedEntry.Block,
events: ['11', 'delay', '12', '13'],
entries: ['11', 'delay', '12', '13'],
colour: 'red',
} as OntimeBlock,
'11': {
@@ -209,7 +209,7 @@ describe('makeRundownMetadata()', () => {
id: 'block',
type: SupportedEntry.Block,
colour: 'red',
events: ['1', '2'],
entries: ['1', '2'],
} as OntimeBlock,
'1': {
id: '1',
@@ -288,12 +288,12 @@ describe('makeSortableList()', () => {
it('generates a list with block ends', () => {
const order = ['block-1', '2', 'block-3', 'block-4'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock,
'block-1': { type: SupportedEntry.Block, id: 'block-1', entries: ['11'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent,
'block-3': { type: SupportedEntry.Block, id: 'block-3', events: ['31'] } as OntimeBlock,
'block-3': { type: SupportedEntry.Block, id: 'block-3', entries: ['31'] } as OntimeBlock,
'31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent,
'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock,
'block-4': { type: SupportedEntry.Block, id: 'block-4', entries: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(order, entries);
@@ -313,7 +313,7 @@ describe('makeSortableList()', () => {
it('closes dangling blocks', () => {
const order = ['block'];
const entries: RundownEntries = {
block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock,
block: { type: SupportedEntry.Block, id: 'block-1', entries: ['11', '12'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
};
@@ -325,8 +325,8 @@ describe('makeSortableList()', () => {
it('handles a list with a with just blocks', () => {
const order = ['block-1', 'block-2'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock,
'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock,
'block-1': { type: SupportedEntry.Block, id: 'block-1', entries: [] as string[] } as OntimeBlock,
'block-2': { type: SupportedEntry.Block, id: 'block-2', entries: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(order, entries);
@@ -338,62 +338,62 @@ describe('moveUp()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event3'] } as OntimeBlock,
event2: { type: 'event', id: 'event2', parent: null } as OntimeEvent,
block1: { type: 'block', id: 'block1', entries: ['event3'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
block2: { type: 'block', id: 'block2', entries: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
};
it('moves an event up in the list', () => {
const result = moveUp('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false });
})
});
it.todo('disallows nesting blocks', () => {
const result = moveUp('block2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false });
})
});
it('moves an event into a block', () => {
const result = moveUp('event3', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true });
})
});
it('moving up from top is noop', () => {
const result = moveUp('event1', sortableData, entries);
expect(result).toMatchObject({ destinationId: null });
})
});
});
describe('moveDown()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event11'] } as OntimeBlock,
event2: { type: 'event', id: 'event2', parent: null } as OntimeEvent,
block1: { type: 'block', id: 'block1', entries: ['event11'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
block2: { type: 'block', id: 'block2', entries: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
};
it('moves an event down in the list', () => {
const result = moveDown('event1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false });
})
});
it.todo('disallows nesting blocks', () => {
const result = moveDown('block1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false });
})
});
it('moves an event into a block', () => {
const result = moveDown('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true });
})
});
it('moving down from bottom is noop', () => {
const result = moveDown('event3', sortableData, entries);
expect(result).toMatchObject({ destinationId: null });
})
});
});
});
@@ -0,0 +1,147 @@
import { useCallback } from 'react';
import { OntimeBlock } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import NullableTimeInput from '../../../common/components/input/time-input/NullableTimeInput';
import AppLink from '../../../common/components/link/app-link/AppLink';
import Switch from '../../../common/components/switch/Switch';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import TextLikeInput from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput';
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EntryEditorTextInput from './composite/EventTextInput';
import style from './EntryEditor.module.scss';
// title + colour + custom field labels
export type BlockEditorUpdateTextFields = 'targetDuration' | 'title' | 'colour' | string;
export type BlockEditorUpdateMaybeNumberFields = 'targetDuration';
export type BlockEditorBooleanFields = 'isNextDay';
interface BlockEditorProps {
block: OntimeBlock;
}
export default function BlockEditor({ block }: BlockEditorProps) {
const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions();
const handleSubmit = useCallback(
(
field: BlockEditorUpdateTextFields | BlockEditorUpdateMaybeNumberFields | BlockEditorBooleanFields,
value: string | boolean,
) => {
// Handle custom fields
if (typeof field === 'string' && field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEntry({ id: block.id, custom: { [fieldLabel]: value as string } });
return;
}
if (field === 'targetDuration') {
if (value === '') {
return updateEntry({ id: block.id, targetDuration: null });
}
return updateEntry({ id: block.id, targetDuration: parseUserTime(value as string) });
}
if (field === 'isNextDay') {
return updateEntry({ id: block.id, isNextDay: value as boolean });
}
// all other strings are text fields
return updateEntry({ id: block.id, [field]: value as string });
},
[block.id, updateEntry],
);
const isEditor = window.location.pathname.includes('editor');
const planOffset = typeof block.targetDuration !== 'number' ? null : block.targetDuration - block.duration;
console.log('targetDuration:', block.targetDuration);
return (
<div className={style.content}>
<div className={style.column}>
<Editor.Title>Block schedule</Editor.Title>
<div className={style.inline}>
<div>
{
// TODO: format with user time settings
}
<Editor.Label>First event start</Editor.Label>
<TextLikeInput className={style.textLikeInput}>
{millisToString(block.startTime, { fallback: timerPlaceholder })}
</TextLikeInput>
</div>
<div>
<Editor.Label htmlFor='endTime'>Last event end</Editor.Label>
<TextLikeInput className={style.textLikeInput}>
{millisToString(block.endTime, { fallback: timerPlaceholder })}
</TextLikeInput>
</div>
<div>
<Editor.Label htmlFor='duration'>Scheduled duration</Editor.Label>
<TextLikeInput className={style.textLikeInput}>
{millisToString(block.duration, { fallback: enDash })}
</TextLikeInput>
</div>
</div>
<div className={style.inline}>
<div>
<Editor.Label htmlFor='targetDuration'>Target duration</Editor.Label>
<NullableTimeInput
name='targetDuration'
time={block.targetDuration}
submitHandler={handleSubmit}
emptyDisplay={enDash}
/>
</div>
<div>
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
{
// TODO: update remote data
// TODO: remove tab index
}
<TextLikeInput delayed={Boolean(planOffset)} className={style.textLikeInput}>
{millisToString(planOffset, { fallback: enDash })}
</TextLikeInput>
</div>
</div>
<div>
<Editor.Label htmlFor='isNextDay'>Is next day?</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
checked={block.isNextDay}
onCheckedChange={(checked) => {
handleSubmit('isNextDay', checked);
}}
/>
{block.isNextDay ? 'Events start the day after' : '-'}
</Editor.Label>
</div>
</div>
<div className={style.column}>
<Editor.Title>Block data</Editor.Title>
<div>
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={block.colour} handleChange={handleSubmit} />
</div>
<EntryEditorTextInput field='title' label='Title' initialValue={block.title} submitHandler={handleSubmit} />
</div>
<div className={style.column}>
<Editor.Title>
Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} event={block} />
</div>
</div>
);
}
@@ -6,14 +6,13 @@ import { cx } from '../../../common/utils/styleUtils';
import EventEditor from './EventEditor';
import style from './EventEditor.module.scss';
import style from './EntryEditor.module.scss';
interface CuesheetEventEditorProps {
eventId: string;
}
export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
const { eventId } = props;
export default function CuesheetEventEditor({ eventId }: CuesheetEventEditorProps) {
const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | null>(null);
@@ -37,7 +36,7 @@ export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
}
return (
<div className={cx([style.eventEditor, style.inModal])} data-testid='editor-container'>
<div className={cx([style.entryEditor, style.inModal])} data-testid='editor-container'>
<EventEditor event={event} />
</div>
);
@@ -1,4 +1,4 @@
.eventEditor {
.entryEditor {
max-height: 100%;
display: flex;
flex-direction: column;
@@ -88,3 +88,19 @@
grid-template-columns: 1fr 72px;
gap: 1rem;
}
/* approximating the style of a disabled input */
.textLikeInput {
background-color: rgba($gray-1200, 0.4);
font-weight: 400;
color: $gray-200;
border: 1px solid transparent;
justify-content: center;
width: 7.5em;
&:hover {
background-color: rgba($gray-1200, 0.4);
}
}
@@ -6,44 +6,38 @@ import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EventCustom from './composite/EventEditorCustom';
import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import EventEditorTriggers from './composite/EventEditorTriggers';
import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss';
import style from './EntryEditor.module.scss';
// any of the titles + custom field labels
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
// any of the titles + colour + custom field labels
export type EventEditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | string;
interface EventEditorProps {
event: OntimeEvent;
}
export default function EventEditor(props: EventEditorProps) {
const { event } = props;
export default function EventEditor({ event }: EventEditorProps) {
const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions();
const isEditor = window.location.pathname.includes('editor');
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
(field: EventEditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEntry({ id: event?.id, custom: { [fieldLabel]: value } });
updateEntry({ id: event.id, custom: { [fieldLabel]: value } });
} else {
updateEntry({ id: event?.id, [field]: value });
updateEntry({ id: event.id, [field]: value });
}
},
[event?.id, updateEntry],
[event.id, updateEntry],
);
if (!event) {
return <EventEditorEmpty />;
}
return (
<div className={style.content}>
<EventEditorTimes
@@ -55,7 +49,7 @@ export default function EventEditor(props: EventEditorProps) {
timeStrategy={event.timeStrategy}
linkStart={event.linkStart}
countToEnd={event.countToEnd}
delay={event.delay ?? 0}
delay={event.delay}
endAction={event.endAction}
timerType={event.timerType}
timeWarning={event.timeWarning}
@@ -75,7 +69,7 @@ export default function EventEditor(props: EventEditorProps) {
Custom Fields
{isEditor && <AppLink search='settings=feature_settings__custom'>Manage Custom Fields</AppLink>}
</Editor.Title>
<EventCustom fields={customFields} handleSubmit={handleSubmit} event={event} />
<EntryEditorCustomFields fields={customFields} handleSubmit={handleSubmit} event={event} />
</div>
<div className={style.column}>
<Editor.Title>
@@ -1,4 +1,4 @@
.eventEditor {
.entryEditor {
color: $label-gray;
height: 100%;
max-height: 100%;
@@ -46,3 +46,15 @@
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);
}
@@ -1,5 +1,4 @@
import { memo, PropsWithChildren } from 'react';
import { Kbd } from '@chakra-ui/react';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
@@ -10,7 +9,7 @@ export default memo(EventEditorEmpty);
function EventEditorEmpty() {
return (
<div className={style.eventEditor} data-testid='editor-container'>
<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}>
@@ -172,3 +171,7 @@ function EventEditorEmpty() {
function AuxKey({ children }: PropsWithChildren) {
return <span className={style.divider}>{children}</span>;
}
function Kbd({ children }: PropsWithChildren) {
return <span className={style.kbd}>{children}</span>;
}
@@ -1,20 +1,21 @@
import { useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import { isOntimeBlock, isOntimeDelay, OntimeBlock, OntimeEvent } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection';
import { EventEditorFooter } from './composite/EventEditorFooter';
import EventEditorFooter from './composite/EventEditorFooter';
import BlockEditor from './BlockEditor';
import EventEditor from './EventEditor';
import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss';
import style from './EntryEditor.module.scss';
export default function RundownEventEditor() {
export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | null>(null);
const [event, setEvent] = useState<OntimeEvent | OntimeBlock | null>(null);
useEffect(() => {
if (data.order.length === 0) {
@@ -29,7 +30,7 @@ export default function RundownEventEditor() {
}
const event = data.entries[selectedEventId];
if (event && isOntimeEvent(event)) {
if (event && !isOntimeDelay(event)) {
setEvent(event);
} else {
setEvent(null);
@@ -40,8 +41,16 @@ export default function RundownEventEditor() {
return <EventEditorEmpty />;
}
if (isOntimeBlock(event)) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<BlockEditor block={event} />
</div>
);
}
return (
<div className={style.eventEditor} data-testid='editor-container'>
<div className={style.entryEditor} data-testid='editor-container'>
<EventEditor event={event} />
<EventEditorFooter id={event.id} cue={event.cue} />
</div>
@@ -1,23 +1,26 @@
import { CSSProperties, Fragment } from 'react';
import { CustomFields, OntimeEvent } from 'ontime-types';
import { CustomFields, OntimeBlock, OntimeEvent } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { EditorUpdateFields } from '../EventEditor';
import { EventEditorUpdateFields } from '../EventEditor';
import EventEditorImage from './EventEditorImage';
import EventTextArea from './EventTextArea';
import EventTextInput from './EventTextInput';
import EntryEditorTextInput from './EventTextInput';
import style from '../EventEditor.module.scss';
import style from '../EntryEditor.module.scss';
interface EventEditorCustomProps {
interface EntryEditorCustomFieldsProps {
fields: CustomFields;
event: OntimeEvent;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
event: OntimeEvent | OntimeBlock;
handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EventEditorCustom(props: EventEditorCustomProps) {
const { fields: customFields, handleSubmit, event } = props;
export default function EntryEditorCustomFields({
fields: customFields,
handleSubmit,
event,
}: EntryEditorCustomFieldsProps) {
return (
<Fragment>
{Object.keys(customFields).map((fieldKey) => {
@@ -44,7 +47,7 @@ export default function EventEditorCustom(props: EventEditorCustomProps) {
if (customFields[fieldKey].type === 'image') {
return (
<div key={key} className={style.customImage}>
<EventTextInput
<EntryEditorTextInput
key={key}
field={fieldName}
label={labelText}
@@ -9,11 +9,8 @@ interface EventEditorFooterProps {
cue: string;
}
export const EventEditorFooter = memo(_EventEditorFooter);
function _EventEditorFooter(props: EventEditorFooterProps) {
const { id, cue } = props;
export default memo(EventEditorFooter);
function EventEditorFooter({ id, cue }: EventEditorFooterProps) {
const loadById = `/ontime/load/id "${id}"`;
const loadByCue = `/ontime/load/cue "${cue}"`;
@@ -4,9 +4,7 @@ interface EventEditorImageProps {
src: string;
}
export default function EventEditorImage(props: EventEditorImageProps) {
const { src } = props;
export default function EventEditorImage({ src }: EventEditorImageProps) {
return (
<div className={style.imageContainer}>
<img loading='lazy' src={src} />
@@ -1,16 +1,18 @@
import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5';
import { Select, Switch, Tooltip } from '@chakra-ui/react';
import { Tooltip } from '@chakra-ui/react';
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EventEditor.module.scss';
import style from '../EntryEditor.module.scss';
interface EventEditorTimesProps {
eventId: string;
@@ -30,26 +32,25 @@ interface EventEditorTimesProps {
type HandledActions = 'countToEnd' | 'timerType' | 'endAction' | 'timeWarning' | 'timeDanger';
export default memo(EventEditorTimes);
function EventEditorTimes(props: EventEditorTimesProps) {
const {
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
delay,
endAction,
timerType,
timeWarning,
timeDanger,
} = props;
function EventEditorTimes({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
delay,
endAction,
timerType,
timeWarning,
timeDanger,
}: EventEditorTimesProps) {
const { updateEntry } = useEntryActions();
const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'countToEnd') {
updateEntry({ id: eventId, countToEnd: !(value as boolean) });
updateEntry({ id: eventId, countToEnd: value as boolean });
return;
}
@@ -100,27 +101,22 @@ function EventEditorTimes(props: EventEditorTimesProps) {
<div>
<Editor.Label htmlFor='endAction'>End Action</Editor.Label>
<Select
id='endAction'
size='sm'
name='endAction'
value={endAction}
onChange={(event) => handleSubmit('endAction', event.target.value)}
variant='ontime'
>
<option value={EndAction.None}>None</option>
<option value={EndAction.LoadNext}>Load next event</option>
<option value={EndAction.PlayNext}>Play next event</option>
</Select>
onChange={(value) => handleSubmit('endAction', value)}
options={[
{ value: EndAction.None, label: 'None' },
{ value: EndAction.LoadNext, label: 'Load next event' },
{ value: EndAction.PlayNext, label: 'Play next event' },
]}
/>
</div>
<div>
<Editor.Label htmlFor='countToEnd'>Count to End</Editor.Label>
<Editor.Label className={style.switchLabel}>
<Switch
id='countToEnd'
size='md'
isChecked={countToEnd}
onChange={() => handleSubmit('countToEnd', countToEnd)}
variant='ontime'
checked={countToEnd}
onCheckedChange={(value) => handleSubmit('countToEnd', value)}
/>
{countToEnd ? 'On' : 'Off'}
</Editor.Label>
@@ -141,18 +137,15 @@ function EventEditorTimes(props: EventEditorTimesProps) {
<div>
<Editor.Label htmlFor='timerType'>Timer Type</Editor.Label>
<Select
size='sm'
id='timerType'
name='timerType'
value={timerType}
onChange={(event) => handleSubmit('timerType', event.target.value)}
variant='ontime'
>
<option value={TimerType.CountDown}>Count down</option>
<option value={TimerType.CountUp}>Count up</option>
<option value={TimerType.Clock}>Clock</option>
<option value={TimerType.None}>None</option>
</Select>
onChange={(value) => handleSubmit('timerType', value)}
options={[
{ value: TimerType.CountDown, label: 'Count down' },
{ value: TimerType.CountUp, label: 'Count up' },
{ value: TimerType.Clock, label: 'Clock' },
{ value: TimerType.None, label: 'None' },
]}
/>
</div>
<div className={style.inline}>
@@ -1,15 +1,15 @@
import { memo } from 'react';
import { Input } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import { type EditorUpdateFields } from '../EventEditor';
import Input from '../../../../common/components/input/input/Input';
import { type EventEditorUpdateFields } from '../EventEditor';
import EventTextArea from './EventTextArea';
import EventTextInput from './EventTextInput';
import EntryEditorTextInput from './EventTextInput';
import style from '../EventEditor.module.scss';
import style from '../EntryEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
@@ -17,12 +17,11 @@ interface EventEditorTitlesProps {
title: string;
note: string;
colour: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
handleSubmit: (field: EventEditorUpdateFields, value: string) => void;
}
const EventEditorTitles = (props: EventEditorTitlesProps) => {
const { eventId, cue, title, note, colour, handleSubmit } = props;
export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, cue, title, note, colour, handleSubmit }: EventEditorTitlesProps) {
const cueSubmitHandler = (_field: string, newValue: string) => {
handleSubmit('cue', sanitiseCue(newValue));
};
@@ -33,25 +32,22 @@ const EventEditorTitles = (props: EventEditorTitlesProps) => {
<div className={style.splitTwo}>
<div>
<Editor.Label htmlFor='eventId'>Event ID (read only)</Editor.Label>
<Input
id='eventId'
size='sm'
variant='ontime-filled'
data-testid='input-textfield'
value={eventId}
readOnly
/>
<Input id='eventId' data-testid='input-textfield' value={eventId} readOnly fluid />
</div>
<EventTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
<EntryEditorTextInput
field='cue'
label='Cue'
initialValue={cue}
submitHandler={cueSubmitHandler}
maxLength={10}
/>
</div>
<div>
<Editor.Label>Colour</Editor.Label>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
<EventTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<EntryEditorTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
);
};
export default memo(EventEditorTitles);
}
@@ -1,9 +1,12 @@
import { Fragment, useCallback, useState } from 'react';
import { IoAlertCircle, IoCheckmarkCircle, IoTrash } from 'react-icons/io5';
import { Button, IconButton, Select, Tooltip } from '@chakra-ui/react';
import { Tooltip } from '@chakra-ui/react';
import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
import { generateId } from 'ontime-utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
@@ -17,8 +20,7 @@ interface EventEditorTriggersProps {
triggers: Trigger[];
}
export default function EventEditorTriggers(props: EventEditorTriggersProps) {
const { triggers, eventId } = props;
export default function EventEditorTriggers({ triggers, eventId }: EventEditorTriggersProps) {
const showTriggers = triggers.length > 0;
return (
@@ -34,8 +36,7 @@ interface EventTriggerFormProps {
triggers?: Trigger[];
}
function EventTriggerForm(props: EventTriggerFormProps) {
const { eventId, triggers } = props;
function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
const { data: automationSettings } = useAutomationSettings();
const { updateEntry } = useEntryActions();
const [automationId, setAutomationId] = useState<string | undefined>(undefined);
@@ -68,38 +69,21 @@ function EventTriggerForm(props: EventTriggerFormProps) {
return (
<div className={style.triggerForm}>
<Select
size='sm'
variant='ontime'
value={cycleValue}
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
>
<option disabled>Lifecycle Trigger</option>
{eventTriggerOptions.map((cycle) => (
<option key={cycle} value={cycle}>
{cycle}
</option>
))}
</Select>
placeholder='Choose a trigger'
onChange={(value) => setCycleValue(value)}
options={eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }))}
/>
<Select
size='sm'
variant='ontime'
value={automationId}
defaultValue='«invalid»'
onChange={(e) => setAutomationId(e.target.value)}
>
<option disabled value='«invalid»'>
Automation
</option>
{Object.values(automationSettings.automations).map(({ id, title }) => (
<option key={id} value={id}>
{title}
</option>
))}
</Select>
placeholder='Choose an automation'
onChange={(value) => setAutomationId(value)}
options={Object.values(automationSettings.automations).map(({ id, title }) => ({ value: id, label: title }))}
/>
<Button
variant='ontime-subtle'
size='sm'
isDisabled={validationError !== undefined}
disabled={validationError !== undefined}
onClick={() => automationId && handleSubmit(cycleValue, automationId)}
>
Add
@@ -120,8 +104,7 @@ interface ExistingEventTriggersProps {
triggers: Trigger[];
}
function ExistingEventTriggers(props: ExistingEventTriggersProps) {
const { eventId, triggers } = props;
function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) {
const { updateEntry } = useEntryActions();
const { data: automationSettings } = useAutomationSettings();
@@ -154,14 +137,9 @@ function ExistingEventTriggers(props: ExistingEventTriggersProps) {
<div key={id} className={style.trigger}>
<Tag>{triggerLifeCycle}</Tag>
<Tag>{automationTitle}</Tag>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDelete(id)}
/>
<IconButton variant='subtle-destructive' onClick={() => handleDelete(id)}>
<IoTrash />
</IconButton>
</div>
);
})}
@@ -3,19 +3,25 @@ import { type CSSProperties, useCallback, useRef } from 'react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EditorUpdateFields } from '../EventEditor';
import { EventEditorUpdateFields } from '../EventEditor';
interface CountedTextAreaProps {
className?: string;
field: EditorUpdateFields;
field: EventEditorUpdateFields;
label: string;
initialValue: string;
style?: CSSProperties;
submitHandler: (field: EditorUpdateFields, value: string) => void;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EventTextArea(props: CountedTextAreaProps) {
const { className, field, label, initialValue, style: givenStyles, submitHandler } = props;
export default function EventTextArea({
className,
field,
label,
initialValue,
style: givenStyles,
submitHandler,
}: CountedTextAreaProps) {
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -1,20 +1,29 @@
import { useCallback, useRef } from 'react';
import { Input, InputProps } from '@chakra-ui/react';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import Input, { type InputProps } from '../../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EditorUpdateFields } from '../EventEditor';
import { BlockEditorUpdateTextFields } from '../BlockEditor';
import { EventEditorUpdateFields } from '../EventEditor';
interface EventTextInputProps extends InputProps {
field: EditorUpdateFields;
interface EntryEditorTextInputProps extends InputProps {
field: EventEditorUpdateFields | BlockEditorUpdateTextFields;
label: string;
initialValue: string;
placeholder?: string;
submitHandler: (field: EditorUpdateFields, value: string) => void;
submitHandler: (field: EventEditorUpdateFields, value: string) => void;
}
export default function EventTextInput(props: EventTextInputProps) {
const { className, field, label, initialValue, style: givenStyles, submitHandler, maxLength, placeholder } = props;
export default function EntryEditorTextInput({
className,
field,
label,
initialValue,
style: givenStyles,
submitHandler,
maxLength,
placeholder,
}: EntryEditorTextInputProps) {
const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
@@ -30,16 +39,14 @@ export default function EventTextInput(props: EventTextInputProps) {
<Input
id={field}
ref={ref}
size='sm'
variant='ontime-filled'
maxLength={maxLength}
fluid
data-testid='input-textfield'
value={value}
maxLength={maxLength || 100}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
autoComplete='off'
/>
</div>
);
@@ -5,5 +5,5 @@
padding-block: 0.5rem;
padding-left: calc(2em + 0.5rem);
background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
background-color: color-mix(in srgb, var(--user-bg, transparent) 15%, transparent 85%);
}
@@ -14,9 +14,7 @@ interface QuickAddBlockProps {
}
export default memo(QuickAddBlock);
function QuickAddBlock(props: QuickAddBlockProps) {
const { previousEventId, parentBlock, backgroundColor } = props;
function QuickAddBlock({ previousEventId, parentBlock, backgroundColor }: QuickAddBlockProps) {
const { addEntry } = useEntryActions();
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
@@ -2,7 +2,7 @@
.blockEnd {
cursor: default;
height: 0.5rem;
height: 1rem;
background-color: var(--user-bg, $gray-1050);
border-radius: 0 0 $block-border-radius $block-border-radius;
@@ -8,8 +8,7 @@ interface BlockEndProps {
colour?: string;
}
export default function BlockEnd(props: BlockEndProps) {
const { id, colour } = props;
export default function BlockEnd({ id, colour }: BlockEndProps) {
const {
attributes: dragAttributes,
listeners: dragListeners,
@@ -37,6 +36,7 @@ export default function BlockEnd(props: BlockEndProps) {
...dragStyle,
...(colour ? { '--user-bg': colour } : {}),
}}
tabIndex={-1}
/>
);
}
@@ -26,9 +26,11 @@
color: $section-white;
font-size: 1rem;
display: grid;
justify-content: center;
padding-top: 0.25rem;
}
place-content: center;
position: relative;
cursor: pointer;
}
.header {
grid-area: header;
@@ -63,6 +65,8 @@
.drag {
@include drag-style;
position: absolute;
margin-top: 0.25rem;
&.isDragging {
cursor: grabbing;
@@ -1,4 +1,4 @@
import { useRef } from 'react';
import { MouseEvent, useRef } from 'react';
import {
IoChevronDown,
IoChevronUp,
@@ -18,20 +18,21 @@ import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import EditableBlockTitle from '../common/EditableBlockTitle';
import { canDrop } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection';
import style from './BlockBlock.module.scss';
import style from './RundownBlock.module.scss';
interface BlockBlockProps {
interface RundownBlockProps {
data: OntimeBlock;
hasCursor: boolean;
collapsed: boolean;
onCollapse: (collapsed: boolean, groupId: EntryId) => void;
}
export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, collapsed, onCollapse } = props;
export default function RundownBlock({ data, hasCursor, collapsed, onCollapse }: RundownBlockProps) {
const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActions();
const { selectedEvents, setSelectedBlock } = useEventSelection();
const [onContextMenu] = useContextMenu<HTMLDivElement>([
{
@@ -43,7 +44,7 @@ export default function BlockBlock(props: BlockBlockProps) {
label: 'Ungroup',
icon: IoFolderOpenOutline,
onClick: () => ungroup(data.id),
isDisabled: data.events.length === 0,
isDisabled: data.entries.length === 0,
},
{
label: 'Delete Block',
@@ -70,6 +71,20 @@ export default function BlockBlock(props: BlockBlockProps) {
animateLayoutChanges: () => false,
});
const handleFocusClick = (event: MouseEvent) => {
event.stopPropagation();
// event.button === 2 is a right-click
// disable selection if the user selected events and right clicks
// so the context menu shows up
if (selectedEvents.size > 1 && event.button === 2) {
return;
}
// UI indexes are 1 based
setSelectedBlock({ id: data.id });
};
const binderColours = data.colour && getAccessibleColour(data.colour);
const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
@@ -84,6 +99,7 @@ export default function BlockBlock(props: BlockBlockProps) {
<div
className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
ref={setNodeRef}
onClick={handleFocusClick}
onContextMenu={onContextMenu}
style={{
...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
@@ -128,7 +144,7 @@ export default function BlockBlock(props: BlockBlockProps) {
</div>
<div className={style.metaEntry}>
<div>Events</div>
<div>{data.events.length}</div>
<div>{data.entries.length}</div>
</div>
</div>
</div>
@@ -9,15 +9,14 @@ import DelayInput from '../../../common/components/input/delay-input/DelayInput'
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils';
import style from './DelayBlock.module.scss';
import style from './RundownDelay.module.scss';
interface DelayBlockProps {
interface RundownDelayProps {
data: OntimeDelay;
hasCursor: boolean;
}
export default function DelayBlock(props: DelayBlockProps) {
const { data, hasCursor } = props;
export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
const { applyDelay, deleteEntry } = useEntryActions();
const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -2,7 +2,7 @@
$skip-opacity: 0.2;
.eventBlock {
.rundownEvent {
@include block-styling;
background-color: $block-bg;
margin-block: 0.25rem;
@@ -19,12 +19,12 @@ import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection';
import EventBlockInner from './EventBlockInner';
import RundownEventInner from './RundownEventInner';
import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss';
import style from './RundownEvent.module.scss';
interface EventBlockProps {
interface RundownEventProps {
eventId: EntryId;
cue: string;
timeStart: number;
@@ -65,39 +65,38 @@ interface EventBlockProps {
hasTriggers: boolean;
}
export default function EventBlock(props: EventBlockProps) {
const {
eventId,
cue,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
eventIndex,
endAction,
timerType,
title,
note,
delay,
colour,
isPast,
isNext,
skip = false,
parent,
loaded,
hasCursor,
playback,
isRolling,
gap,
isNextDay,
dayOffset,
totalGap,
isLinkedToLoaded,
actionHandler,
hasTriggers,
} = props;
export default function RundownEvent({
eventId,
cue,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
eventIndex,
endAction,
timerType,
title,
note,
delay,
colour,
isPast,
isNext,
skip = false,
parent,
loaded,
hasCursor,
playback,
isRolling,
gap,
isNextDay,
dayOffset,
totalGap,
isLinkedToLoaded,
actionHandler,
hasTriggers,
}: RundownEventProps) {
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { selectedEvents, setSelectedEvents } = useEventSelection();
const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -224,7 +223,7 @@ export default function EventBlock(props: EventBlockProps) {
const isSelected = selectedEvents.has(eventId);
const blockClasses = cx([
style.eventBlock,
style.rundownEvent,
skip ? style.skip : null,
isPast ? style.past : null,
loaded ? style.loaded : null,
@@ -268,7 +267,7 @@ export default function EventBlock(props: EventBlockProps) {
</div>
{isVisible && (
<EventBlockInner
<RundownEventInner
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
@@ -18,13 +18,13 @@ import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle';
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
import EventBlockChip from './composite/EventBlockChip';
import EventBlockPlayback from './composite/EventBlockPlayback';
import EventBlockProgressBar from './composite/EventBlockProgressBar';
import RundownEventChip from './composite/RundownEventChip';
import EventBlockPlayback from './composite/RundownEventPlayback';
import EventBlockProgressBar from './composite/RundownEventProgressBar';
import style from './EventBlock.module.scss';
import style from './RundownEvent.module.scss';
interface EventBlockInnerProps {
interface RundownEventInnerProps {
eventId: string;
timeStart: number;
timeEnd: number;
@@ -50,32 +50,31 @@ interface EventBlockInnerProps {
hasTriggers: boolean;
}
function EventBlockInner(props: EventBlockInnerProps) {
const {
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
endAction,
timerType,
title,
note,
delay,
isNext,
skip = false,
loaded,
playback,
isRolling,
dayOffset,
isPast,
totalGap,
isLinkedToLoaded,
hasTriggers,
} = props;
export default memo(RundownEventInner);
function RundownEventInner({
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
countToEnd,
endAction,
timerType,
title,
note,
delay,
isNext,
skip = false,
loaded,
playback,
isRolling,
dayOffset,
isPast,
totalGap,
isLinkedToLoaded,
hasTriggers,
}: RundownEventInnerProps) {
const [renderInner, setRenderInner] = useState(false);
useEffect(() => {
@@ -119,7 +118,7 @@ function EventBlockInner(props: EventBlockInnerProps) {
disablePlayback={skip || isRolling}
/>
{!skip && (
<EventBlockChip
<RundownEventChip
className={style.chipSection}
id={eventId}
timeStart={timeStart}
@@ -164,8 +163,6 @@ function EventBlockInner(props: EventBlockInnerProps) {
);
}
export default memo(EventBlockInner);
function EndActionIcon(props: { action: EndAction; className: string }) {
const { action, className } = props;
const maybeActiveClasses = cx([action !== EndAction.None && style.active, className]);
@@ -1,4 +1,4 @@
import { formatDelay, formatGap } from './EventBlock.utils';
import { formatDelay, formatGap } from './rundownEvent.utils';
import style from './RundownIndicators.module.scss';
@@ -9,9 +9,7 @@ interface RundownIndicatorProps {
gap: number;
}
export default function RundownIndicators(props: RundownIndicatorProps) {
const { timeStart, delay, gap, isNextDay } = props;
export default function RundownIndicators({ timeStart, delay, gap, isNextDay }: RundownIndicatorProps) {
const hasGap = formatGap(gap, isNextDay);
const hasDelay = formatDelay(timeStart, delay);
@@ -1,4 +1,4 @@
import { formatDelay } from '../EventBlock.utils';
import { formatDelay } from '../rundownEvent.utils';
describe('formatDelay()', () => {
it('adds a given delay to the start time', () => {
@@ -9,9 +9,9 @@ import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, formatTime, useTimeUntilStart } from '../../../../common/utils/time';
import { tooltipDelayFast } from '../../../../ontimeConfig';
import style from './EventBlockChip.module.scss';
import style from './RundownEventChip.module.scss';
interface EventBlockChipProps {
interface RundownEventChipProps {
id: string;
timeStart: number;
delay: number;
@@ -24,8 +24,18 @@ interface EventBlockChipProps {
isLinkedToLoaded: boolean;
}
export default function EventBlockChip(props: EventBlockChipProps) {
const { timeStart, delay, dayOffset, isPast, isLoaded, className, totalGap, id, duration, isLinkedToLoaded } = props;
export default function RundownEventChip({
timeStart,
delay,
dayOffset,
isPast,
isLoaded,
className,
totalGap,
id,
duration,
isLinkedToLoaded,
}: RundownEventChipProps) {
const { playback } = usePlayback();
if (isLoaded) {
@@ -6,7 +6,7 @@ import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { setEventPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import style from '../EventBlock.module.scss';
import style from '../RundownEvent.module.scss';
const blockBtnStyle = {
size: 'sm',
@@ -23,7 +23,7 @@ const tooltipProps = {
openDelay: tooltipDelayMid,
};
interface EventBlockPlaybackProps {
interface RundownEventPlaybackProps {
eventId: string;
skip: boolean;
isPlaying: boolean;
@@ -32,8 +32,15 @@ interface EventBlockPlaybackProps {
disablePlayback: boolean;
}
const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props;
export default memo(RundownEventPlayback);
function RundownEventPlayback({
eventId,
skip,
isPlaying,
isPaused,
loaded,
disablePlayback,
}: RundownEventPlaybackProps) {
const { updateEntry } = useEntryActions();
const toggleSkip = (event: MouseEvent) => {
@@ -123,6 +130,4 @@ const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
/>
</div>
);
};
export default memo(EventBlockPlayback);
}
@@ -1,9 +1,9 @@
import { useTimer } from '../../../../common/hooks/useSocket';
import { getProgress } from '../../../../common/utils/getProgress';
import style from './EventBlockProgressBar.module.scss';
import style from './RundownEventProgressBar.module.scss';
export default function EventBlockProgressBar() {
export default function RundownEventProgressBar() {
const timer = useTimer();
const progress = getProgress(timer.current, timer.duration);
@@ -140,7 +140,7 @@ export function makeSortableList(order: EntryId[], entries: RundownEntries): Ent
// inside a block there are delays and events
// there is no need for special handling
flatIds.push(entry.id);
flatIds.push(...entry.events);
flatIds.push(...entry.entries);
// close the block
flatIds.push(`end-${entry.id}`);
@@ -225,7 +225,7 @@ export function moveDown(entryId: EntryId, sortableData: EntryId[], entries: Run
return { destinationId: nextEntryId, order: 'after', isBlock: false };
}
const firstBlockChild = entries[nextEntryId].events.at(0);
const firstBlockChild = entries[nextEntryId].entries.at(0);
if (firstBlockChild) {
// 2. add before the first child of the block
return { destinationId: firstBlockChild, order: 'before', isBlock: true };
@@ -24,6 +24,7 @@ interface EventBlockTimerProps {
showLabels?: boolean;
}
export default memo(TimeInputFlow);
function TimeInputFlow(props: EventBlockTimerProps) {
const { eventId, countToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, showLabels } = props;
const { updateEntry, updateTimer } = useEntryActions();
@@ -135,5 +136,3 @@ function TimeInputFlow(props: EventBlockTimerProps) {
</>
);
}
export default memo(TimeInputFlow);
@@ -12,6 +12,8 @@ interface EventSelectionStore {
selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber;
cursor: MaybeString;
entryMode: 'event' | 'block' | null;
setSelectedBlock: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
@@ -22,13 +24,21 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
setSelectedEvents: (selectionArgs) => {
const { id, index, selectMode } = selectionArgs;
const { selectedEvents, anchoredIndex } = get();
entryMode: null,
setSelectedBlock: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'block' });
},
setSelectedEvents: ({ id, index, selectMode }) => {
const { selectedEvents, anchoredIndex, entryMode } = get();
// if we are in block mode, we replace the selection and change the mode
if (entryMode === 'block') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id });
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' });
}
// on ctrl + click, we toggle the selection of that event
@@ -42,6 +52,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
cursor: id,
entryMode: 'event',
});
}
@@ -57,6 +68,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
return set({
selectedEvents,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
entryMode: 'event',
});
}
@@ -83,19 +95,23 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
entryMode: 'event',
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null }),
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({ selectedEvents: new Set(firstSelected || undefined), anchoredIndex: null });
set({ selectedEvents: new Set(firstSelected || undefined), anchoredIndex: null, entryMode: null });
},
unselect: (id: string) => {
const { selectedEvents } = get();
const { entryMode, selectedEvents } = get();
selectedEvents.delete(id);
set({ selectedEvents });
set({
selectedEvents,
entryMode: selectedEvents.size === 0 ? null : entryMode,
});
},
}));
@@ -10,7 +10,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { CuesheetOverview } from '../../features/overview/Overview';
import CuesheetEventEditor from '../../features/rundown/event-editor/CuesheetEventEditor';
import CuesheetEventEditor from '../../features/rundown/entry-editor/CuesheetEventEditor';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
@@ -1,12 +1,15 @@
/* element attempts matching input styles */
/* element matching input styles */
.textInput {
height: 2rem;
background-color: transparent;
border-radius: 3px;
border-radius: $component-border-radius-md;
display: flex;
align-items: center;
gap: 0.25rem;
letter-spacing: 1px;
font-size: 1rem;
font-variant-numeric: tabular-nums;
&.delayed {
color: $ontime-delay-text;