* refactor: cleanup routes

* style: smaller base font

* chore: upgrade dependencies

* chore: lock node version to electron

* refactor: pass HTTP to integration controller (#652)

* refactor: deprecate onair control

* refactor: remove playback router

* Several project files user folder (#617)

* chore: automated screenshots (#667)

* feat: app settings (#658)

* refactor: remove deprecated event data (#674)

* Studio clock (#663)

---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Feat: reorder events with alt+ctrl + arrow up/down (#645)

* Warning and danger per event (#677)

---------

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* refactor: stabilise actionHandler (#683)

Co-authored-by: Fabian Posenau <fabian@fphome.de>

* improvement: hide seconds (#675)

* wip: overview (#688)

* fix: focus cursor (#695)

* refactor: update lower third (#665)

* Refactor/time formatting (#696)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* feat: multiple selection (#703)

---------

Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Alex <ac@omnivox.dk>

* fix: test - go to `Edit mode` befor tying to click `Event options` button (#708)

* refactor: runtime service (#715)

* fix: issue with loosing cursor position on message (#719)

* remove info panel (#721)

* Event editor continue (#722)

* update API - part  (#709)

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* refactor: update timers (#729)

* feat: many timers (#706)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* refactor: excel cleanup (#734)

* refactor: allow import of blocks and skip import (#735)

* Project manager (#697)

* refactor: UI for linking events (#763)

* upgraded pipeline actions (#777)

* Over under (#771)

* custom fields (#744)


---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Sheets settings (#774)

---------

Co-authored-by: arc-alex <ac@omnivox.dk>

* style: tweaks to lower thirds (#785)

* refactor: delays account for gaps (#784)

* refactor: partial state updates (#780)

* feat: generate crash report (#787)

* Sheet use limited input device auth flow (#782)

---------

Co-authored-by: cv <34649812+cpvalente@users.noreply.github.com>
Co-authored-by: Carlos Valente <carlosvalente@pm.me>

* Custom fields views (#789)

* refactor: deprecate presenter and subtitle (#795)

* refactor: organise API around resources (#798)

---------

Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>

* Time to end (#804)

* Skip fixes (#805)

* fix: onair derives from playback

* Param nav (#822)

---------

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>

* refactor: download files from interface (#831)

* Quick options (#814)

* End pause (#832)

* chore: bump node version in docker (#834)

* refactor: follow in run mode (#840)

* fix: uncaught error in http integration (#837)

* Apply project (#843)

Co-authored-by: Matteo Gheza <matteo.gheza07@gmail.com>
Co-authored-by: Ary <arylmoraesn@gmail.com>
Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
Co-authored-by: Fabian Posenau <19673098+kellhogs@users.noreply.github.com>
Co-authored-by: Fabian Posenau <fabian@fphome.de>
Co-authored-by: Alex Rohleder <alexrohleder96@gmail.com>
Co-authored-by: asharonbaltazar <asharonbaltazar@outlook.com>
Co-authored-by: Bianca Procopio <biancahprocopio@gmail.com>
Co-authored-by: Fabian Posenau <fabianpos99+github@gmail.com>
This commit is contained in:
Carlos Valente
2024-04-13 10:06:46 +02:00
committed by GitHub
parent debdbd1c5a
commit 55d1aca9b6
640 changed files with 26702 additions and 21623 deletions
@@ -1,14 +1,14 @@
@use '../../theme/v2Styles' as *;
.rundownWrapper {
height: calc(100% - 1.5rem);
width: 100%;
}
.eventContainer {
flex: 1;
margin-top: 1em;
.rundownContainer {
margin-top: 1rem;
display: flex;
flex-direction: column;
padding: 0 4px;
padding-right: 4px; // size of scrollbar
overflow-y: scroll;
-ms-overflow-style: -ms-autohiding-scrollbar;
height: 100%;
}
+103 -74
View File
@@ -1,8 +1,8 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { getFirst, getNext, getPrevious } from 'ontime-utils';
import { isOntimeEvent, MaybeNumber, Playback, RundownCached, SupportedEvent } from 'ontime-types';
import { getFirstNormal, getNextNormal, getPreviousNormal } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
@@ -19,30 +19,24 @@ import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
entries: OntimeRundown;
data: RundownCached;
}
export default function Rundown(props: RundownProps) {
const { entries } = props;
const [statefulEntries, setStatefulEntries] = useState(entries);
export default function Rundown({ data }: RundownProps) {
const { order, rundown } = data;
const [statefulEntries, setStatefulEntries] = useState(order);
const featureData = useRundownEditor();
const { addEvent, reorderEvent } = useEventAction();
const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
const showQuickEntry = eventSettings.showQuickEntry;
const isExtracted = window.location.pathname.includes('/rundown');
const linkPrevious = eventSettings.linkPrevious;
// cursor
const cursor = useAppMode((state) => state.cursor);
const appMode = useAppMode((state) => state.mode);
const viewFollowsCursor = appMode === AppMode.Run;
const moveCursorTo = useAppMode((state) => state.setCursor);
const { cursor, mode: appMode, setCursor } = useAppMode();
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
// DND KIT
const sensors = useSensors(useSensor(PointerSensor));
@@ -60,7 +54,7 @@ export default function Rundown(props: RundownProps) {
}
if (type === 'clone') {
const cursorEvent = entries.find((event) => event.id === cursor);
const cursorEvent = rundown[cursor];
if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
addEvent(newEvent);
@@ -70,45 +64,52 @@ export default function Rundown(props: RundownProps) {
type: SupportedEvent.Event,
};
const options = {
defaultPublic: defaultPublic,
startTimeIsLastEnd: startTimeIsLastEnd,
lastEventId: cursor,
after: cursor,
defaultPublic,
lastEventId: cursor,
linkPrevious,
};
addEvent(newEvent, options);
} else {
addEvent({ type }, { after: cursor });
}
},
[addEvent, defaultPublic, entries, startTimeIsLastEnd],
[addEvent, rundown, defaultPublic, linkPrevious],
);
// Handle keyboard shortcuts
const handleKeyPress = useCallback(
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// handle held key
if (event.repeat) return;
// Check if the alt key is pressed
if (event.altKey && (!event.ctrlKey || !event.shiftKey)) {
const modKeysAlt = event.altKey && !event.ctrlKey && !event.shiftKey;
const modKeysCtrlAlt = event.altKey && event.ctrlKey && !event.shiftKey;
if (modKeysAlt) {
switch (event.code) {
case 'ArrowDown': {
if (entries.length < 1) {
if (order.length < 1) {
return;
}
const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor);
const nextEvent =
cursor === null ? getFirstNormal(rundown, order) : getNextNormal(rundown, order, cursor)?.nextEvent;
if (nextEvent) {
moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
setCursor(nextEvent.id);
}
break;
}
case 'ArrowUp': {
if (entries.length < 1) {
if (order.length < 1) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
const previousEvent = cursor == null ? getFirst(entries) : getPrevious(entries, cursor);
const previousEvent =
cursor === null
? getFirstNormal(rundown, order)
: getPreviousNormal(rundown, order, cursor).previousEvent;
if (previousEvent) {
moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
setCursor(previousEvent.id);
}
break;
}
@@ -133,35 +134,50 @@ export default function Rundown(props: RundownProps) {
break;
}
}
} else if (modKeysCtrlAlt) {
if (order.length < 2 || cursor == null) {
return;
}
// Alt + Ctrl + Arrow Down
if (event.code == 'ArrowDown') {
const { nextEvent, nextIndex } = getNextNormal(rundown, order, cursor);
if (nextEvent && nextIndex !== null) {
reorderEvent(cursor, nextIndex - 1, nextIndex);
}
// Alt + Ctrl + Arrow Up
} else if (event.code == 'ArrowUp') {
const { previousEvent, previousIndex } = getPreviousNormal(rundown, order, cursor);
if (previousEvent && previousIndex !== null) {
reorderEvent(cursor, previousIndex + 1, previousIndex);
}
}
}
},
[cursor, entries, insertAtCursor, moveCursorTo],
[order, cursor, rundown, setCursor, insertAtCursor, reorderEvent],
);
// we copy the state from the store here
// to workaround async updates on the drag mutations
useEffect(() => {
if (entries) {
setStatefulEntries(entries);
}
}, [entries]);
setStatefulEntries(order);
}, [order]);
// listen to keys
useEffect(() => {
document.addEventListener('keydown', handleKeyPress);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyPress);
document.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyPress]);
}, [handleKeyDown]);
useEffect(() => {
// in run mode, we follow selection
if (!viewFollowsCursor || !featureData?.selectedEventId) {
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
}
moveCursorTo(featureData.selectedEventId);
}, [featureData?.selectedEventId, viewFollowsCursor, moveCursorTo]);
setCursor(featureData.selectedEventId);
}, [appMode, featureData.selectedEventId, setCursor]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
@@ -179,71 +195,84 @@ export default function Rundown(props: RundownProps) {
}
};
if (statefulEntries?.length < 1) {
if (statefulEntries.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />;
}
let previousEnd = 0;
let thisEnd = 0;
let previousStart: MaybeNumber = null;
let previousEnd: MaybeNumber = null;
let previousEventId: string | undefined;
let thisStart: MaybeNumber = null;
let thisEnd: MaybeNumber = null;
let thisId = previousEventId;
let eventIndex = 0;
// all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId);
const isEditMode = appMode === AppMode.Edit;
return (
<div className={style.eventContainer} ref={scrollRef}>
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
<SortableContext items={statefulEntries} strategy={verticalListSortingStrategy}>
<div className={style.list}>
{statefulEntries.map((entry, index) => {
{statefulEntries.map((eventId, index) => {
// we iterate through a stateful copy of order to make the operations smoother
// this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here
const event = rundown[eventId];
if (!event) {
return null;
}
if (index === 0) {
eventIndex = 0;
}
let isFirstEvent = false;
if (entry.type === SupportedEvent.Event) {
isFirstEvent = eventIndex === 0;
if (isOntimeEvent(event)) {
// event indexes are 1 based in frontend
eventIndex++;
previousStart = thisStart;
previousEnd = thisEnd;
thisEnd = entry.timeEnd;
previousEventId = entry.id;
previousEventId = thisId;
if (!event.skip) {
thisStart = event.timeStart;
thisEnd = event.timeEnd;
thisId = eventId;
}
}
const isLast = index === entries.length - 1;
const isSelected = featureData?.selectedEventId === entry.id;
const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor;
if (isSelected) {
const isFirst = index === 0;
const isLast = index === order.length - 1;
const isLoaded = featureData?.selectedEventId === event.id;
const isNext = featureData?.nextEventId === event.id;
const hasCursor = event.id === cursor;
if (isLoaded) {
isPast = false;
}
return (
<Fragment key={entry.id}>
<Fragment key={event.id}>
{isEditMode && (hasCursor || isFirst) && <QuickAddBlock previousEventId={previousEventId} />}
<div className={style.entryWrapper} data-testid={`entry-${eventIndex}`}>
{entry.type === SupportedEvent.Event && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
{isOntimeEvent(event) && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={event.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
type={event.type}
isPast={isPast}
isFirstEvent={isFirstEvent}
data={entry}
selected={isSelected}
eventIndex={eventIndex}
data={event}
loaded={isLoaded}
hasCursor={hasCursor}
next={isNext}
isNext={isNext}
previousStart={previousStart}
previousEnd={previousEnd}
previousEventId={previousEventId}
playback={isSelected ? featureData.playback : undefined}
playback={isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
disableEdit={isExtracted}
/>
</div>
</div>
{((showQuickEntry && hasCursor) || isLast) && (
<QuickAddBlock
showKbd={hasCursor}
eventId={entry.id}
previousEventId={previousEventId}
disableAddDelay={entry.type === SupportedEvent.Delay}
disableAddBlock={entry.type === SupportedEvent.Block}
/>
)}
{isEditMode && (hasCursor || isLast) && <QuickAddBlock previousEventId={event.id} />}
</Fragment>
);
})}
@@ -14,7 +14,7 @@ export default function RundownEmpty(props: RundownEmptyProps) {
return (
<div className={style.alignCenter}>
<Empty text='No data yet' style={{ marginTop: '7vh' }} />
<Empty style={{ marginTop: '7vh' }} />
<Button onClick={handleAddNew} variant='ontime-filled' className={style.spaceTop} leftIcon={<IoAdd />}>
Create Event
</Button>
+121 -124
View File
@@ -1,10 +1,8 @@
import { useCallback } from 'react';
import { GetRundownCached, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { calculateDuration, getCueCandidate } from 'ontime-utils';
import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { RUNDOWN } from '../../common/api/apiConstants';
import { useEventAction } from '../../common/hooks/useEventAction';
import { ontimeQueryClient } from '../../common/queryClient';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useAppMode } from '../../common/stores/appModeStore';
import { useEditorSettings } from '../../common/stores/editorSettings';
import { useEmitLog } from '../../common/stores/logger';
@@ -13,59 +11,70 @@ import { cloneEvent } from '../../common/utils/eventsManager';
import BlockBlock from './block-block/BlockBlock';
import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock';
import { useEventSelection } from './useEventSelection';
export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'delete' | 'clone' | 'update' | 'swap';
export type EventItemActions =
| 'set-cursor'
| 'event'
| 'event-before'
| 'delay'
| 'delay-before'
| 'block'
| 'block-before'
| 'delete'
| 'clone'
| 'update'
| 'swap';
interface RundownEntryProps {
type: SupportedEvent;
isPast: boolean;
isFirstEvent: boolean;
data: OntimeRundownEntry;
selected: boolean;
loaded: boolean;
eventIndex: number;
hasCursor: boolean;
next: boolean;
previousEnd: number;
isNext: boolean;
previousStart: MaybeNumber;
previousEnd: MaybeNumber;
previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing
isRolling: boolean; // we need to know even if not related to this event
disableEdit: boolean; // we disable edit when the window is extracted
}
export default function RundownEntry(props: RundownEntryProps) {
const {
isPast,
data,
selected,
loaded,
hasCursor,
next,
isNext,
previousStart,
previousEnd,
previousEventId,
playback,
isRolling,
disableEdit,
isFirstEvent,
eventIndex,
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
const cursor = useAppMode((state) => state.cursor);
const setCursor = useAppMode((state) => state.setCursor);
const openId = useAppMode((state) => state.editId);
const setEditId = useAppMode((state) => state.setEditId);
const removeOpenEvent = useCallback(() => {
if (openId === data.id) {
setEditId(null);
}
if (cursor === data.id) {
setCursor(null);
}
}, [cursor, data.id, openId, setCursor, setEditId]);
const { selectedEvents, clearSelectedEvents } = useEventSelection();
const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
const linkPrevious = eventSettings.linkPrevious;
const removeOpenEvent = useCallback(() => {
if (selectedEvents.has(data.id)) {
clearSelectedEvents();
}
// clear cursor if we are deleting the event that is currently selected
if (cursor === data.id) {
setCursor(null);
}
}, [selectedEvents, data.id, cursor, clearSelectedEvents, setCursor]);
// Create / delete new events
type FieldValue = {
@@ -73,128 +82,116 @@ export default function RundownEntry(props: RundownEntryProps) {
value: unknown;
};
// we assume the data is not changing in the lifecycle of this component
// changes to the data would make rundown re-render, also re-rendering this component
const actionHandler = useCallback(
(action: EventItemActions, payload?: number | FieldValue) => {
switch (action) {
case 'event': {
const newEvent = { type: SupportedEvent.Event };
const options = {
startTimeIsLastEnd,
defaultPublic,
lastEventId: previousEventId,
after: data.id,
};
addEvent(newEvent, options);
break;
}
case 'delay': {
addEvent({ type: SupportedEvent.Delay }, { after: data.id });
break;
}
case 'block': {
addEvent({ type: SupportedEvent.Block }, { after: data.id });
break;
}
case 'swap': {
const { value } = payload as FieldValue;
swapEvents({ from: value as string, to: data.id });
break;
}
case 'delete': {
if (openId === data.id) {
removeOpenEvent();
}
deleteEvent(data.id);
break;
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id);
const rundown = ontimeQueryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
newEvent.cue = getCueCandidate(rundown, data.id);
addEvent(newEvent);
break;
}
case 'update': {
// Handles and filters update requests
const { field, value } = payload as FieldValue;
const newData: Partial<OntimeEvent> = { id: data.id };
if (field === 'durationOverride' && data.type === SupportedEvent.Event) {
// duration defines timeEnd
newData.duration = value as number;
newData.timeEnd = data.timeStart + (value as number);
updateEvent(newData);
} else if (field === 'timeStart' && data.type === SupportedEvent.Event) {
newData.duration = calculateDuration(value as number, data.timeEnd);
newData.timeStart = value as number;
updateEvent(newData);
} else if (field === 'timeEnd' && data.type === SupportedEvent.Event) {
newData.duration = calculateDuration(data.timeStart, value as number);
newData.timeEnd = value as number;
updateEvent(newData);
} else if (field in data) {
// @ts-expect-error not sure how to type this
newData[field] = value;
updateEvent(newData);
} else {
emitError(`Unknown field: ${field}`);
}
break;
}
default:
throw new Error(`Unhandled event ${action}`);
const actionHandler = useMemoisedFn((action: EventItemActions, payload?: number | FieldValue) => {
switch (action) {
case 'event': {
const newEvent = { type: SupportedEvent.Event };
const options = {
after: data.id,
defaultPublic,
lastEventId: previousEventId,
linkPrevious,
};
return addEvent(newEvent, options);
}
},
[
addEvent,
data,
defaultPublic,
deleteEvent,
emitError,
openId,
previousEventId,
removeOpenEvent,
startTimeIsLastEnd,
updateEvent,
swapEvents,
],
);
case 'event-before': {
const newEvent = { type: SupportedEvent.Event };
const options = {
after: previousEventId,
defaultPublic,
linkPrevious,
};
return addEvent(newEvent, options);
}
case 'delay': {
return addEvent({ type: SupportedEvent.Delay }, { after: data.id });
}
case 'delay-before': {
return addEvent({ type: SupportedEvent.Delay }, { after: previousEventId });
}
case 'block': {
return addEvent({ type: SupportedEvent.Block }, { after: data.id });
}
case 'block-before': {
return addEvent({ type: SupportedEvent.Block }, { after: previousEventId });
}
case 'swap': {
const { value } = payload as FieldValue;
return swapEvents({ from: value as string, to: data.id });
}
case 'delete': {
if (selectedEvents.has(data.id)) {
removeOpenEvent();
}
return deleteEvent(data.id);
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id);
addEvent(newEvent, { after: data.id });
break;
}
case 'update': {
// Handles and filters update requests
const { field, value } = payload as FieldValue;
if (field === undefined || value === undefined) {
return;
}
const newData: Partial<OntimeEvent> = { id: data.id };
// if selected events are more than one
// we need to bulk edit
if (selectedEvents.size > 1) {
const changes: Partial<OntimeEvent> = { [field]: value };
batchUpdateEvents(changes, Array.from(selectedEvents));
return clearSelectedEvents();
}
if (field in data) {
// @ts-expect-error -- not sure how to type this
newData[field] = value;
return updateEvent(newData);
}
return emitError(`Unknown field: ${field}`);
}
default:
throw new Error(`Unhandled event ${action}`);
}
});
if (data.type === SupportedEvent.Event) {
return (
<EventBlock
eventIndex={eventIndex}
cue={data.cue}
timeStart={data.timeStart}
timeEnd={data.timeEnd}
duration={data.duration}
timeStrategy={data.timeStrategy}
linkStart={data.linkStart}
eventId={data.id}
isPublic={data.isPublic}
endAction={data.endAction}
timerType={data.timerType}
title={data.title}
note={data.note}
delay={data.delay || 0}
delay={data.delay ?? 0}
previousStart={previousStart}
previousEnd={previousEnd}
colour={data.colour}
isPast={isPast}
next={next}
isNext={isNext}
skip={data.skip}
selected={selected}
loaded={loaded}
hasCursor={hasCursor}
playback={playback}
isRolling={isRolling}
actionHandler={actionHandler}
disableEdit={disableEdit}
isFirstEvent={isFirstEvent}
/>
);
} else if (data.type === SupportedEvent.Block) {
return <BlockBlock data={data} hasCursor={hasCursor} actionHandler={actionHandler} />;
return <BlockBlock data={data} hasCursor={hasCursor} onDelete={() => actionHandler('delete')} />;
} else if (data.type === SupportedEvent.Delay) {
return <DelayBlock data={data} hasCursor={hasCursor} actionHandler={actionHandler} />;
return <DelayBlock data={data} hasCursor={hasCursor} />;
}
return null;
}
@@ -0,0 +1,54 @@
@use '../editors/EditorMixin' as editor;
.rundownExport {
height: 100%;
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
&.extracted {
.list {
flex: 1;
max-width: none;
}
.side {
flex: 1;
max-width: none;
}
}
}
.rundown {
display: flex;
max-height: 100%;
height: 100%;
}
.corner {
@include editor.corner;
display: inline;
z-index: 2;
}
.list {
@include editor.panel;
height: inherit;
padding-left: 0;
box-shadow: $box-shadow-right;
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
min-width: 38rem;
max-width: 45rem;
}
.side {
max-height: 100%;
max-width: 45rem;
margin: 2rem 0;
padding: 1rem;
padding-right: 0;
background-color: $gray-1325;
border-radius: 0 8px 8px 0;
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */
min-width: 30rem;
max-width: 45rem;
}
@@ -3,18 +3,33 @@ import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import EventEditor from './event-editor/EventEditor';
import RundownWrapper from './RundownWrapper';
import style from '../editors/Editor.module.scss';
import style from './RundownExport.module.scss';
const RundownExport = () => {
const isExtracted = window.location.pathname.includes('/rundown');
const classes = cx([style.rundownExport, isExtracted && style.extracted]);
return (
<div className={style.rundown} data-testid='panel-rundown'>
<IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'rundown')} />
<ErrorBoundary>
<RundownWrapper />
</ErrorBoundary>
<div className={classes} data-testid='panel-rundown'>
<div className={style.rundown}>
<div className={style.list}>
<ErrorBoundary>
{!isExtracted && <IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'rundown')} />}
<RundownWrapper />
</ErrorBoundary>
</div>
<div className={style.side}>
<ErrorBoundary>
<EventEditor />
</ErrorBoundary>
</div>
</div>
</div>
);
};
@@ -1,20 +1,18 @@
import Empty from '../../common/components/state/Empty';
import useRundown from '../../common/hooks-query/useRundown';
import RundownMenu from '../menu/RundownMenu';
import RundownHeader from './rundown-header/RundownHeader';
import Rundown from './Rundown';
import styles from '../editors/Editor.module.scss';
import styles from './Rundown.module.scss';
export default function RundownWrapper() {
const { data, status } = useRundown();
return (
<>
<RundownMenu />
<div className={styles.content}>
{status === 'success' && data ? <Rundown entries={data} /> : <Empty text='Connecting to server' />}
</div>
</>
<div className={styles.rundownWrapper}>
<RundownHeader />
{status === 'success' && data ? <Rundown data={data} /> : <Empty text='Connecting to server' />}
</div>
);
}
@@ -1,17 +1,20 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/v2Styles' as *;
@use '../../theme/ontimeStyles' as *;
$block-width: 33rem;
$block-gap: 0.25rem;
$block-element-spacing: 0.25rem;
$block-binder-width: 2rem;
$block-clearance: 0.5rem;
$block-border-radius: 0.5rem;
$block-border-radius: 3px;
$block-text-color: $gray-50;
$block-bg: $gray-1250;
$block-bg2: $gray-1050; // for delay and blocks
$block-box-shadow: rgba(0, 0, 0, 0.5) 0 0 3px 2px;
$secondary-block-height: 2.5rem;
$block-cursor-color: $blue-400;
$block-selected-color: $blue-400;
$block-cursor-color: $orange-400;
@mixin block-styling() {
box-sizing: content-box;
@@ -21,6 +24,8 @@ $block-cursor-color: $blue-400;
margin: 0.25rem 0.125rem;
position: relative;
color: $block-text-color;
min-width: $block-width;
}
@mixin block-spacing() {
@@ -1,32 +1,24 @@
import { useEffect, useRef } from 'react';
import { useRef } from 'react';
import { IconButton } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeBlock, OntimeEvent } from 'ontime-types';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import EditableBlockTitle from '../common/EditableBlockTitle';
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
import type { EventItemActions } from '../RundownEntry';
import style from './BlockBlock.module.scss';
interface BlockBlockProps {
data: OntimeBlock;
hasCursor: boolean;
actionHandler: (
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
onDelete: () => void;
}
export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, actionHandler } = props;
const { data, hasCursor, onDelete } = props;
const handleRef = useRef<null | HTMLSpanElement>(null);
const {
@@ -45,12 +37,6 @@ export default function BlockBlock(props: BlockBlockProps) {
transition,
};
useEffect(() => {
if (hasCursor) {
handleRef?.current?.focus();
}
}, [hasCursor]);
const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
return (
@@ -59,7 +45,14 @@ export default function BlockBlock(props: BlockBlockProps) {
<IoReorderTwo />
</span>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<BlockActionMenu className={style.actionMenu} enableDelete actionHandler={actionHandler} />
<IconButton
aria-label='Delete'
size='sm'
icon={<IoTrash />}
variant='ontime-subtle'
color='#FA5656'
onClick={onDelete}
/>
</div>
);
}
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import { Editable, EditableInput, EditablePreview } from '@chakra-ui/react';
import { useCallback } from 'react';
import { Input } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils';
@@ -15,39 +16,40 @@ interface TitleEditorProps {
export default function EditableBlockTitle(props: TitleEditorProps) {
const { title, eventId, placeholder, className } = props;
const [blockTitle, setBlockTitle] = useState<string>(title || '');
const { updateEvent } = useEventAction();
useEffect(() => {
setBlockTitle(title);
}, [title]);
const handleTitle = useCallback(
const submitCallback = useCallback(
(text: string) => {
if (text === title) {
return;
}
const cleanVal = text.trim();
setBlockTitle(cleanVal);
updateEvent({ id: eventId, title: cleanVal });
},
[title, updateEvent, eventId],
);
const classes = cx([className, style.eventTitle, !blockTitle ? style.noTitle : null]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, {
submitOnEnter: true,
});
const classes = cx([className, style.eventTitle, !value ? style.noTitle : null]);
return (
<Editable
variant='ontime'
value={blockTitle}
<Input
data-testid='block__title'
variant='ontime-ghosted'
value={value}
className={classes}
placeholder={placeholder}
onChange={(value) => setBlockTitle(value)}
onSubmit={(value) => handleTitle(value)}
>
<EditablePreview className={style.preview} />
<EditableInput />
</Editable>
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
autoComplete='off'
fontWeight='600'
letterSpacing='0.25px'
paddingLeft='0'
/>
);
}
@@ -1,5 +1,3 @@
@use '../../../theme/v2Styles' as *;
.titleEditor {
display: block;
font-size: calc(1rem + 2px);
@@ -6,7 +6,6 @@
background-color: $block-bg2;
min-width: 34rem;
display: grid;
grid-template-columns: 2rem 1fr auto;
grid-template-areas: 'drag inpt btns';
@@ -23,3 +22,10 @@
@include drag-style;
grid-area: drag;
}
.actionButtons {
grid-area: btns;
display: flex;
align-items: center;
gap: 0.5rem;
}
@@ -1,36 +1,25 @@
import { useEffect, useRef } from 'react';
import { Button, HStack } from '@chakra-ui/react';
import { Button } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark';
import { IoCheckmarkDone } from '@react-icons/all-files/io5/IoCheckmarkDone';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { OntimeDelay, OntimeEvent } from 'ontime-types';
import { OntimeDelay } from 'ontime-types';
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils';
import BlockActionMenu from '../event-block/composite/BlockActionMenu';
import type { EventItemActions } from '../RundownEntry';
import style from './DelayBlock.module.scss';
interface DelayBlockProps {
data: OntimeDelay;
hasCursor: boolean;
actionHandler: (
action: EventItemActions,
payload?:
| number
| {
field: keyof Omit<OntimeEvent, 'duration'> | 'durationOverride';
value: unknown;
},
) => void;
}
export default function DelayBlock(props: DelayBlockProps) {
const { data, hasCursor, actionHandler } = props;
const { data, hasCursor } = props;
const { applyDelay, deleteEvent } = useEventAction();
const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -72,15 +61,14 @@ export default function DelayBlock(props: DelayBlockProps) {
<IoReorderTwo />
</span>
<DelayInput eventId={data.id} duration={data.duration} />
<HStack spacing='8px' className={style.actionOverlay}>
<Button onClick={applyDelayHandler} size='sm' leftIcon={<IoCheckmark />} variant='ontime-subtle-white'>
Apply
<div className={style.actionButtons}>
<Button onClick={applyDelayHandler} size='sm' leftIcon={<IoCheckmarkDone />} variant='ontime-ghosted-white'>
Make permanent
</Button>
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-subtle-white'>
<Button onClick={cancelDelayHandler} size='sm' leftIcon={<IoClose />} variant='ontime-ghosted-white'>
Cancel
</Button>
<BlockActionMenu enableDelete actionHandler={actionHandler} />
</HStack>
</div>
</div>
);
}
@@ -1,8 +1,6 @@
@use '../../../theme/v2Styles' as *;
@use '../../../theme/ontimeColours' as *;
@use '../blockMixins' as *;
$skip-opacity: 0.1;
$skip-opacity: 0.2;
.eventBlock {
@include block-styling;
@@ -11,12 +9,12 @@ $skip-opacity: 0.1;
display: grid;
grid-template-areas:
'binder ... ... ...'
'binder pb-actions times actions'
'binder pb-actions title next-ind'
'binder pb-actions times ...'
'binder pb-actions title title'
'binder pb-actions estatus estatus'
'binder ... ... ...';
grid-template-columns: $block-binder-width auto 1fr auto;
grid-template-columns: $block-binder-width 3rem 1fr auto;
grid-template-rows: 0.25rem 2.25rem 2.25rem 2.25rem 0.25rem;
align-items: center;
padding-right: $block-clearance;
@@ -30,8 +28,8 @@ $skip-opacity: 0.1;
--status-color-active-override: #{$green-400};
}
&.selected {
background-color: $gray-1350;
&.loaded {
background-color: $gray-1325;
}
&.play {
@@ -49,13 +47,16 @@ $skip-opacity: 0.1;
@include declare-overrides;
}
&.selected {
outline: 1px solid $block-selected-color;
}
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
/* we stop the eventActions from having opacity to fix issue with dropdown drawing order */
&.past:not(.skip) {
.delayNote,
.timerNote,
.statusElements,
.eventTitle,
.eventNote,
@@ -70,7 +71,7 @@ $skip-opacity: 0.1;
&.skip {
border: 1px solid $white-3;
.delayNote,
.timerNote,
.eventTitle,
.eventNote,
.binder,
@@ -128,34 +129,36 @@ $skip-opacity: 0.1;
align-items: center;
gap: $block-clearance;
height: 100%;
.delayNote {
font-size: 0.75rem;
line-height: 0.8rem;
color: $ontime-delay-text;
}
}
.eventTitle {
.titleSection {
grid-area: title;
overflow: hidden;
max-height: calc(2.5em + 2px);
line-height: 1.25em;
}
.eventActions {
grid-area: actions;
display: flex;
gap: $block-clearance;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
.nextTag {
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
font-weight: 600;
}
.eventTitle {
overflow: hidden;
max-height: calc(2.5em + 2px);
line-height: 1.25em;
flex: 1;
}
}
.progressBg {
grid-area: progb;
border-radius: 2px;
border-radius: 1px;
background-color: $gray-1100;
opacity: 1;
height: 100%;
overflow: hidden; /* clip foreground border radius*/
}
.progressBg.hidden {
@@ -190,40 +193,6 @@ $skip-opacity: 0.1;
overflow-y: hidden;
}
.nextTag {
grid-area: next-ind;
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
font-weight: 600;
text-align: right;
}
.indicators {
grid-area: next-ind;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.5rem;
.indicator {
background-color: transparent;
margin: 0.4rem;
margin-right: 0;
border-radius: 0.7rem;
width: 0.7rem;
height: 0.7rem;
}
.indicator.delay {
background-color: $ontime-delay;
}
.indicator.nextDay,
.indicator.overlap,
.indicator.spacing {
background-color: $gray-600;
}
}
.eventStatus {
grid-area: status;
display: flex;
@@ -2,20 +2,23 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoCopyOutline } from '@react-icons/all-files/io5/IoCopyOutline';
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPeopleOutline } from '@react-icons/all-files/io5/IoPeopleOutline';
import { IoReorderTwo } from '@react-icons/all-files/io5/IoReorderTwo';
import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { EndAction, OntimeEvent, Playback, TimerType } from 'ontime-types';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useAppMode } from '../../../common/stores/appModeStore';
import copyToClipboard from '../../../common/utils/copyToClipboard';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection';
import EventBlockInner from './EventBlockInner';
import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss';
@@ -24,19 +27,23 @@ interface EventBlockProps {
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
eventId: string;
eventIndex: number;
isPublic: boolean;
endAction: EndAction;
timerType: TimerType;
title: string;
note: string;
delay: number;
previousEnd: number;
previousStart: MaybeNumber;
previousEnd: MaybeNumber;
colour: string;
isPast: boolean;
next: boolean;
isNext: boolean;
skip: boolean;
selected: boolean;
loaded: boolean;
hasCursor: boolean;
playback?: Playback;
isRolling: boolean;
@@ -49,8 +56,6 @@ interface EventBlockProps {
value: unknown;
},
) => void;
disableEdit: boolean;
isFirstEvent: boolean;
}
export default function EventBlock(props: EventBlockProps) {
@@ -60,57 +65,84 @@ export default function EventBlock(props: EventBlockProps) {
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
isPublic = true,
eventIndex,
endAction,
timerType,
title,
note,
delay,
previousStart,
previousEnd,
colour,
isPast,
next,
isNext,
skip = false,
selected,
loaded,
hasCursor,
playback,
isRolling,
actionHandler,
disableEdit,
isFirstEvent,
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const moveCursorTo = useAppMode((state) => state.setCursor);
const { selectedEvents, setSelectedEvents } = useEventSelection();
const setCursor = useAppMode((state) => state.setCursor);
const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
const openId = useAppMode((state) => state.editId);
const [onContextMenu] = useContextMenu<HTMLDivElement>([
{ label: `Copy ID: ${eventId}`, icon: IoCopyOutline, onClick: () => copyToClipboard(eventId) },
{
label: 'Toggle public',
icon: IoPeopleOutline,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: !isPublic,
}),
},
{
label: 'Add to swap',
icon: IoAdd,
onClick: () => setSelectedEventId(eventId),
withDivider: true,
},
{
label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical,
onClick: () => {
actionHandler('swap', { field: 'id', value: selectedEventId });
clearSelectedEventId();
},
isDisabled: selectedEventId == null || selectedEventId === eventId,
},
]);
const [onContextMenu] = useContextMenu<HTMLDivElement>(
selectedEvents.size > 1
? [
{
label: 'Make public',
icon: IoPeople,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: true,
}),
},
{
label: 'Make private',
icon: IoPeopleOutline,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: false,
}),
},
]
: [
{
label: 'Toggle public',
icon: IoPeopleOutline,
onClick: () =>
actionHandler('update', {
field: 'isPublic',
value: !isPublic,
}),
},
{
label: 'Add to swap',
icon: IoAdd,
onClick: () => setSelectedEventId(eventId),
withDivider: true,
},
{
label: `Swap this event with ${selectedEventId ?? ''}`,
icon: IoSwapVertical,
onClick: () => {
actionHandler('swap', { field: 'id', value: selectedEventId });
clearSelectedEventId();
},
isDisabled: selectedEventId == null || selectedEventId === eventId,
},
{ withDivider: true, label: 'Clone', icon: IoDuplicateOutline, onClick: () => actionHandler('clone') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
],
);
const {
isDragging,
@@ -132,9 +164,19 @@ export default function EventBlock(props: EventBlockProps) {
const binderColours = colour && getAccessibleColour(colour);
// move focus to element if necessary
useEffect(() => {
if (hasCursor) {
handleRef?.current?.focus();
if (!hasCursor || handleRef?.current == null) {
return;
}
const elementInFocus = document.activeElement;
// we know the block is the grandparent of our binder
const blockElement = handleRef.current.closest('#event-block');
// we only move focus if the block doesnt already contain focus
if (blockElement && !blockElement.contains(elementInFocus)) {
handleRef.current.focus();
}
}, [hasCursor]);
@@ -163,18 +205,32 @@ export default function EventBlock(props: EventBlockProps) {
};
}, [handleRef]);
const isSelected = selectedEvents.has(eventId);
const blockClasses = cx([
style.eventBlock,
skip ? style.skip : null,
isPast ? style.past : null,
selected ? style.selected : null,
loaded ? style.loaded : null,
playback ? style[playback] : null,
isSelected ? style.selected : null,
hasCursor ? style.hasCursor : null,
]);
const handleFocusClick = (event: MouseEvent) => {
event.stopPropagation();
moveCursorTo(eventId, true);
// 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
const index = eventIndex - 1;
const editMode = getSelectionMode(event);
setSelectedEvents({ id: eventId, index, selectMode: editMode });
setCursor(eventId);
};
return (
@@ -184,35 +240,43 @@ export default function EventBlock(props: EventBlockProps) {
style={dragStyle}
onClick={handleFocusClick}
onContextMenu={onContextMenu}
id='event-block'
>
<RundownIndicators
timeStart={timeStart}
timeEnd={timeEnd}
previousStart={previousStart}
previousEnd={previousEnd}
delay={delay}
/>
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo />
</span>
<span className={style.cue}>{cue}</span>
</div>
{isVisible && (
<EventBlockInner
isOpen={openId === eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
linkStart={linkStart}
timeStrategy={timeStrategy}
eventId={eventId}
eventIndex={eventIndex}
isPublic={isPublic}
endAction={endAction}
timerType={timerType}
title={title}
note={note}
delay={delay}
previousEnd={previousEnd}
next={next}
isNext={isNext}
skip={skip}
selected={selected}
loaded={loaded}
playback={playback}
isRolling={isRolling}
actionHandler={actionHandler}
disableEdit={disableEdit}
isFirstEvent={isFirstEvent}
/>
)}
</div>
@@ -0,0 +1,37 @@
import { MaybeNumber } from 'ontime-types';
import { dayInMs, millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return;
const delayedStart = Math.max(0, timeStart + delay);
const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`;
}
export function formatOverlap(
previousStart: MaybeNumber,
previousEnd: MaybeNumber,
timeStart: number,
timeEnd: number,
): string | undefined {
if (previousEnd === null) return;
const overlap = previousEnd - timeStart;
if (overlap === 0) return;
if (previousStart && timeStart < previousEnd) {
const overlap = timeEnd - previousStart;
if (overlap > 0) {
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
return `Overlap ${overlapString}`;
}
const gap = timeStart + dayInMs - previousEnd;
const gapString = removeLeadingZero(millisToString(Math.abs(gap)));
return `Gap ${gapString} (next day)`;
}
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
return `${overlap > 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
}
@@ -1,69 +1,58 @@
import { memo, useCallback, useEffect, useState } from 'react';
import { memo, useEffect, useState } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { BiArrowToBottom } from '@react-icons/all-files/bi/BiArrowToBottom';
import { IoArrowDown } from '@react-icons/all-files/io5/IoArrowDown';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { IoOptions } from '@react-icons/all-files/io5/IoOptions';
import { IoFlag } from '@react-icons/all-files/io5/IoFlag';
import { IoPeople } from '@react-icons/all-files/io5/IoPeople';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoPlayForward } from '@react-icons/all-files/io5/IoPlayForward';
import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { IoTime } from '@react-icons/all-files/io5/IoTime';
import { EndAction, Playback, TimerType } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
import { useAppMode } from '../../../common/stores/appModeStore';
import { millisToDelayString } from '../../../common/utils/dateConfig';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import EditableBlockTitle from '../common/EditableBlockTitle';
import { EventItemActions } from '../RundownEntry';
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
import BlockActionMenu from './composite/BlockActionMenu';
import EventBlockPlayback from './composite/EventBlockPlayback';
import EventBlockProgressBar from './composite/EventBlockProgressBar';
import EventBlockTimers from './composite/EventBlockTimers';
import style from './EventBlock.module.scss';
const blockBtnStyle = {
size: 'sm',
};
const tooltipProps = {
openDelay: tooltipDelayMid,
};
interface EventBlockInnerProps {
isOpen: boolean;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
eventId: string;
eventIndex: number;
isPublic: boolean;
endAction: EndAction;
timerType: TimerType;
title: string;
note: string;
delay: number;
previousEnd: number;
next: boolean;
isNext: boolean;
skip: boolean;
selected: boolean;
loaded: boolean;
playback?: Playback;
isRolling: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
disableEdit: boolean;
isFirstEvent: boolean;
}
const EventBlockInner = (props: EventBlockInnerProps) => {
const {
isOpen,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
eventId,
isPublic = true,
endAction,
@@ -71,32 +60,19 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
title,
note,
delay,
previousEnd,
next,
isNext,
skip = false,
selected,
loaded,
playback,
isRolling,
actionHandler,
disableEdit,
isFirstEvent,
} = props;
const [renderInner, setRenderInner] = useState(false);
const setEditId = useAppMode((state) => state.setEditId);
useEffect(() => {
setRenderInner(true);
}, []);
const toggleOpenEvent = useCallback(() => {
if (isOpen) {
setEditId(null);
} else {
setEditId(eventId);
}
}, [eventId, isOpen, setEditId]);
const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause;
@@ -107,72 +83,39 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
playBtnStyles._hover = {};
}
const delayedStart = Math.max(0, timeStart + delay);
const newTime = millisToString(delayedStart);
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
const overlap = previousEnd - timeStart;
const overlapTime = !isFirstEvent
? overlap > 0
? `Overlapping ${millisToDelayString(overlap)}`
: overlap < 0
? `Spacing ${millisToDelayString(overlap)}`
: null
: null;
return !renderInner ? null : (
<>
<EventBlockTimers
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
delay={delay}
previousEnd={previousEnd}
/>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
{next ? (
<Tooltip label='Next event' {...tooltipProps}>
<span className={style.nextTag}>UP NEXT</span>
</Tooltip>
) : (
<span className={style.indicators}>
{delayTime && (
<Tooltip
label={
<div>
{delayTime} <br />
New Time: {newTime}
</div>
}
>
<div className={`${style.indicator} ${style.delay}`} />
</Tooltip>
)}
{overlapTime && (
<Tooltip label={overlapTime}>
<div className={`${style.indicator} ${overlap > 0 ? style.overlap : style.spacing}`} />
</Tooltip>
)}
{timeStart > timeEnd && (
<Tooltip label='Start time is later than end'>
<div className={`${style.indicator} ${style.nextDay}`} />
</Tooltip>
)}
</span>
)}
<div className={style.eventTimers}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
delay={delay}
timeStrategy={timeStrategy}
linkStart={linkStart}
/>
</div>
<div className={style.titleSection}>
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && (
<Tooltip label='Next event' {...tooltipProps}>
<span className={style.nextTag}>UP NEXT</span>
</Tooltip>
)}
</div>
<EventBlockPlayback
eventId={eventId}
skip={skip}
isPlaying={eventIsPlaying}
isPaused={eventIsPaused}
selected={selected}
loaded={loaded}
disablePlayback={skip || isRolling}
/>
<div className={style.statusElements}>
<div className={style.statusElements} id='block-status' data-ispublic={isPublic}>
<span className={style.eventNote}>{note}</span>
<div className={selected ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
{selected && <EventBlockProgressBar playback={playback} />}
<div className={loaded ? style.progressBg : `${style.progressBg} ${style.hidden}`}>
{loaded && <EventBlockProgressBar />}
</div>
<div className={style.eventStatus} tabIndex={-1}>
<Tooltip label={`Time type: ${timerType}`} {...tooltipProps}>
@@ -187,30 +130,11 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
</Tooltip>
<Tooltip label={`${isPublic ? 'Event is public' : 'Event is private'}`} {...tooltipProps}>
<span>
<IoPeople
className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`}
data-ispublic={isPublic}
/>
<IoPeople className={`${style.statusIcon} ${isPublic ? style.active : style.disabled}`} />
</span>
</Tooltip>
</div>
</div>
<div className={style.eventActions}>
<TooltipActionBtn
{...blockBtnStyle}
variant='ontime-subtle-white'
size='sm'
icon={<IoOptions />}
clickHandler={toggleOpenEvent}
tooltip='Event options'
aria-label='Event options'
tabIndex={-1}
backgroundColor={isOpen ? '#2B5ABC' : undefined}
color={isOpen ? 'white' : '#f6f6f6'}
isDisabled={disableEdit}
/>
<BlockActionMenu showClone enableDelete={!selected} actionHandler={actionHandler} />
</div>
</>
);
};
@@ -219,14 +143,16 @@ export default memo(EventBlockInner);
function EndActionIcon(props: { action: EndAction; className: string }) {
const { action, className } = props;
const maybeActiveClasses = cx([action !== EndAction.None && style.active, className]);
if (action === EndAction.LoadNext) {
return <IoPlaySkipForward className={className} />;
return <IoPlaySkipForward className={maybeActiveClasses} />;
}
if (action === EndAction.PlayNext) {
return <IoPlayForward className={className} />;
return <IoPlayForward className={maybeActiveClasses} />;
}
if (action === EndAction.Stop) {
return <IoStop className={className} />;
return <IoStop className={maybeActiveClasses} />;
}
return <IoPlay className={className} />;
}
@@ -240,7 +166,7 @@ function TimerIcon(props: { type: TimerType; className: string }) {
return <IoTime className={className} />;
}
if (type === TimerType.TimeToEnd) {
return <BiArrowToBottom className={className} />;
return <IoFlag className={className} />;
}
return <IoArrowDown className={className} />;
}
@@ -0,0 +1,29 @@
/** binder + gap + pb-actions */
$gap-left: calc(2rem + 0.25rem + 3rem);
.indicators {
font-size: calc(1rem - 5px);
position: absolute;
top: -1em;
z-index: 2;
margin-left: $gap-left;
display: flex;
gap: 0.5rem;
font-weight: 600;
}
@mixin indicator($bg-colour) {
padding: 0 0.5rem;
border-radius: 2px;
background-color: $bg-colour;
color: $ui-white;
}
.delay {
@include indicator($ontime-delay);
}
.gap {
@include indicator($blue-500);
}
@@ -0,0 +1,27 @@
import { MaybeNumber } from 'ontime-types';
import { formatDelay, formatOverlap } from './EventBlock.utils';
import style from './RundownIndicators.module.scss';
interface RundownIndicatorProps {
timeStart: number;
timeEnd: number;
previousStart: MaybeNumber;
previousEnd: MaybeNumber;
delay: number;
}
export default function RundownIndicators(props: RundownIndicatorProps) {
const { timeStart, timeEnd, previousStart, previousEnd, delay } = props;
const hasOverlap = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
const hasDelay = formatDelay(timeStart, delay);
return (
<div className={style.indicators}>
{hasDelay && <div className={style.delay}>{hasDelay}</div>}
{hasOverlap && <div className={style.gap}>{hasOverlap}</div>}
</div>
);
}
@@ -0,0 +1,48 @@
import { formatDelay, formatOverlap } from '../EventBlock.utils';
describe('formatDelay()', () => {
it('adds a given delay to the start time', () => {
const timeStart = 60000; // 1 min
const delay = 60000; // 1 min
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 00:02');
});
});
describe('formatOverlap()', () => {
it('recognises an overlap between two times', () => {
const previousStart = 0;
const previousEnd = 60000; // 1 min
const timeStart = 30000; // 30 sec
const timeEnd = 90000; // 1:30 min
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
expect(result).toEqual('Overlap 0:30');
});
it('handles events the day after, without overlap', () => {
const previousStart = new Date(0).setUTCHours(11);
const previousEnd = new Date(0).setUTCHours(12);
const timeStart = new Date(0).setUTCHours(6);
const timeEnd = new Date(0).setUTCHours(10);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
expect(result).toBe('Gap 18:00:00 (next day)');
});
it('handles events the day after, with overlap', () => {
const previousStart = new Date(0).setUTCHours(9);
const previousEnd = new Date(0).setUTCHours(10);
const timeStart = new Date(0).setUTCHours(6);
const timeEnd = new Date(0).setUTCHours(11);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
expect(result).toBe('Overlap 02:00:00');
});
it('handles events the day after, with gap', () => {
const previousStart = new Date(0).setUTCHours(17);
const previousEnd = new Date(0).setUTCHours(23);
const timeStart = new Date(0).setUTCHours(9);
const timeEnd = new Date(0).setUTCHours(11);
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
expect(result).toBe('Gap 10:00:00 (next day)');
});
});
@@ -1,64 +0,0 @@
import { useCallback } from 'react';
import { IconButton, Menu, MenuButton, MenuDivider, MenuItem, MenuList, Tooltip } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoDuplicateOutline } from '@react-icons/all-files/io5/IoDuplicateOutline';
import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal';
import { IoRemoveCircleOutline } from '@react-icons/all-files/io5/IoRemoveCircleOutline';
import { IoTimerOutline } from '@react-icons/all-files/io5/IoTimerOutline';
import { IoTrashBinSharp } from '@react-icons/all-files/io5/IoTrashBinSharp';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import { EventItemActions } from '../../RundownEntry';
interface BlockActionMenuProps {
enableDelete?: boolean;
showClone?: boolean;
actionHandler: (action: EventItemActions, payload?: any) => void;
className?: string;
}
export default function BlockActionMenu(props: BlockActionMenuProps) {
const { enableDelete, showClone, actionHandler, className } = props;
const handleAddEvent = useCallback(() => actionHandler('event'), [actionHandler]);
const handleAddDelay = useCallback(() => actionHandler('delay'), [actionHandler]);
const handleAddBlock = useCallback(() => actionHandler('block'), [actionHandler]);
const handleClone = useCallback(() => actionHandler('clone'), [actionHandler]);
const handleDelete = useCallback(() => actionHandler('delete'), [actionHandler]);
return (
<Menu isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
<Tooltip label='Add ...' openDelay={tooltipDelayMid}>
<MenuButton
as={IconButton}
aria-label='Event options'
icon={<IoEllipsisHorizontal />}
tabIndex={-1}
variant='ontime-subtle-white'
size='sm'
className={className}
/>
</Tooltip>
<MenuList>
<MenuItem icon={<IoAdd />} onClick={handleAddEvent}>
Add Event after
</MenuItem>
<MenuItem icon={<IoTimerOutline />} onClick={handleAddDelay}>
Add Delay after
</MenuItem>
<MenuItem icon={<IoRemoveCircleOutline />} onClick={handleAddBlock}>
Add Block after
</MenuItem>
{showClone && (
<MenuItem icon={<IoDuplicateOutline />} onClick={handleClone}>
Clone event
</MenuItem>
)}
<MenuDivider />
<MenuItem icon={<IoTrashBinSharp />} onClick={handleDelete} isDisabled={!enableDelete} color='#D20300'>
Delete
</MenuItem>
</MenuList>
</Menu>
);
}
@@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, MouseEvent } from 'react';
import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoReload } from '@react-icons/all-files/io5/IoReload';
@@ -32,19 +32,21 @@ interface EventBlockPlaybackProps {
skip: boolean;
isPlaying: boolean;
isPaused: boolean;
selected: boolean;
loaded: boolean;
disablePlayback: boolean;
}
const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
const { eventId, skip, isPlaying, isPaused, selected, disablePlayback } = props;
const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props;
const { updateEvent } = useEventAction();
const toggleSkip = () => {
const toggleSkip = (event: MouseEvent) => {
event.stopPropagation();
updateEvent({ id: eventId, skip: !skip });
};
const actionHandler = () => {
const actionHandler = (event: MouseEvent) => {
event.stopPropagation();
// is playing -> pause
// is paused -> continue
// otherwise -> start
@@ -57,6 +59,11 @@ const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
}
};
const load = (event: MouseEvent) => {
event.stopPropagation();
setEventPlayback.loadEvent(eventId);
};
const buttonVariant: Partial<StyleVariant> = {};
if (isPaused) {
@@ -87,13 +94,13 @@ const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
aria-label='Skip event'
tooltip='Skip event'
icon={skip ? <IoRemoveCircle /> : <IoRemoveCircleOutline />}
backgroundColor={skip ? '#FA5656' : undefined}
_hover={{ backgroundColor: skip ? '#FF7878' : '#404040' }}
backgroundColor={skip ? '#B20000' : undefined}
_hover={{ backgroundColor: '#FF7878' }}
{...tooltipProps}
{...blockBtnStyle}
clickHandler={toggleSkip}
tabIndex={-1}
isDisabled={selected}
isDisabled={loaded}
/>
<TooltipActionBtn
variant='ontime-subtle-white'
@@ -103,7 +110,7 @@ const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
isDisabled={disablePlayback}
{...tooltipProps}
{...blockBtnStyle}
clickHandler={() => setEventPlayback.loadEvent(eventId)}
clickHandler={load}
tabIndex={-1}
/>
<TooltipActionBtn
@@ -1,5 +1,3 @@
@use '../../../../theme/v2Styles' as *;
.progressBar {
height: 100%;
width: 0;
@@ -7,20 +5,5 @@
transition: 1s linear;
transition-property: width;
&.play {
background-color: $playback-start;
}
&.overtime {
background-color: $playback-negative;
}
&.pause {
background-color: $ontime-paused;
}
&.roll {
background-color: $ontime-roll;
}
background-color: $gray-200;
}
@@ -1,14 +1,10 @@
import { MaybeNumber, Playback } from 'ontime-types';
import { MaybeNumber } from 'ontime-types';
import { useTimer } from '../../../../common/hooks/useSocket';
import { clamp } from '../../../../common/utils/math';
import style from './EventBlockProgressBar.module.scss';
interface EventBlockProgressBarProps {
playback?: Playback;
}
export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber): number {
if (remaining === null || total === null) {
return 0;
@@ -25,10 +21,9 @@ export function getPercentComplete(remaining: MaybeNumber, total: MaybeNumber):
return clamp(100 - (remaining * 100) / total, 0, 100);
}
export default function EventBlockProgressBar(props: EventBlockProgressBarProps) {
const { playback } = props;
export default function EventBlockProgressBar() {
const timer = useTimer();
const progress = `${getPercentComplete(timer.current, timer.duration)}%`;
return <div className={`${style.progressBar} ${playback ? style[playback] : ''}`} style={{ width: progress }} />;
return <div className={style.progressBar} style={{ width: progress }} />;
}
@@ -1,90 +0,0 @@
import { memo } from 'react';
import { OntimeEvent } from 'ontime-types';
import { calculateDuration, dayInMs, millisToString } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import style from '../EventBlock.module.scss';
interface EventBlockTimerProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
delay: number;
previousEnd: number;
}
type TimeActions = 'timeStart' | 'timeEnd' | 'durationOverride';
const EventBlockTimers = (props: EventBlockTimerProps) => {
const { eventId, timeStart, timeEnd, duration, delay, previousEnd } = props;
const { updateEvent } = useEventAction();
const handleSubmit = (field: TimeActions, value: number) => {
const newEventData: Partial<OntimeEvent> = { id: eventId };
switch (field) {
case 'durationOverride': {
// duration defines timeEnd
newEventData.duration = value;
newEventData.timeEnd = timeStart + ((value as number) % dayInMs);
break;
}
case 'timeStart': {
newEventData.duration = calculateDuration(value, timeEnd);
newEventData.timeStart = value;
break;
}
case 'timeEnd': {
newEventData.duration = calculateDuration(timeStart, value);
newEventData.timeEnd = value;
break;
}
}
updateEvent(newEventData);
};
const delayedStart = Math.max(0, timeStart + delay);
const newTime = millisToString(delayedStart);
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
return (
<div className={style.eventTimers}>
<TimeInput
name='timeStart'
submitHandler={handleSubmit}
time={timeStart}
delay={delay}
placeholder='Start'
previousEnd={previousEnd}
/>
<TimeInput
name='timeEnd'
submitHandler={handleSubmit}
time={timeEnd}
delay={delay}
placeholder='End'
previousEnd={previousEnd}
/>
<TimeInput
name='durationOverride'
submitHandler={handleSubmit}
time={duration}
delay={0}
placeholder='Duration'
previousEnd={previousEnd}
/>
{delayTime && (
<div className={style.delayNote}>
{delayTime}
<br />
{`New start: ${newTime}`}
</div>
)}
</div>
);
};
export default memo(EventBlockTimers);
@@ -0,0 +1,90 @@
.eventEditor {
color: $label-gray;
max-height: 100%;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
overflow-x: auto;
}
.content {
padding-right: 4px;
padding-bottom: 4rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 2rem;
overflow-y: auto;
}
.footer {
border-top: 1px solid $white-10;
padding-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.timeSettings {
display: flex;
flex-direction: column;
gap: 1rem;
}
.column {
display: flex;
flex-direction: column;
gap: 1rem;
}
.inputLabel {
display: block;
font-size: calc(1rem - 3px);
color: $label-gray;
margin-bottom: 0.25rem;
text-transform: capitalize;
}
.decorated {
color: var(--decorator-color, $ui-white);
background-color: var(--decorator-bg, $gray-1100);
width: fit-content;
padding: 0 0.5rem;
border-radius: 2px;
}
.delayLabel {
font-size: calc(1rem - 3px);
color: $ontime-delay-text;
margin-top: 0.25rem;
&::after {
content: '\200b';
}
}
.switchLabel {
font-size: calc(1rem - 3px);
color: $label-gray;
display: flex;
align-items: center;
gap: 0.5rem;
max-width: max-content;
cursor: pointer;
}
.inline {
display: flex;
align-items: center;
gap: 1rem;
}
.splitTwo {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 1.5rem;
row-gap: 1rem;
}
@@ -0,0 +1,137 @@
import { CSSProperties, useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Button } from '@chakra-ui/react';
import { CustomFieldLabel, isOntimeEvent, OntimeEvent } from 'ontime-types';
import CopyTag from '../../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../../common/hooks/useEventAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import useRundown from '../../../common/hooks-query/useRundown';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import EventTextArea from './composite/EventTextArea';
import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
export default function EventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const { data: customFields } = useCustomFields();
const { order, rundown } = data;
const { updateEvent } = useEventAction();
const [_searchParams, setSearchParams] = useSearchParams();
const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => {
if (order.length === 0) {
setEvent(null);
return;
}
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId));
if (!selectedEventId) {
setEvent(null);
return;
}
const event = rundown[selectedEventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
}, [order, rundown, selectedEvents]);
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
updateEvent({ id: event?.id, custom: { [fieldLabel]: { value } } });
} else {
updateEvent({ id: event?.id, [field]: value });
}
},
[event?.id, updateEvent],
);
const handleOpenCustomManager = () => {
setSearchParams({ settings: 'project_settings__custom' });
};
if (!event) {
return (
<div className={style.eventEditor} data-testid='editor-container'>
Select an event to edit
</div>
);
}
return (
<div className={style.eventEditor} data-testid='editor-container'>
<div className={style.content}>
<EventEditorTimes
key={`${event.id}-times`}
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
timeStrategy={event.timeStrategy}
linkStart={event.linkStart}
delay={event.delay ?? 0}
isPublic={event.isPublic}
endAction={event.endAction}
timerType={event.timerType}
timeWarning={event.timeWarning}
timeDanger={event.timeDanger}
/>
<EventEditorTitles
key={`${event.id}-titles`}
eventId={event.id}
cue={event.cue}
title={event.title}
note={event.note}
colour={event.colour}
handleSubmit={handleSubmit}
/>
<div className={style.column}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span>Custom Fields</span>
<Button variant='ontime-subtle' size='sm' onClick={handleOpenCustomManager}>
Manage
</Button>
</div>
{Object.keys(customFields).map((label) => {
const key = `${event.id}-${label}`;
const fieldName = `custom-${label}`;
const initialValue = event.custom[label]?.value ?? '';
const { backgroundColor, color } = getAccessibleColour(customFields[label].colour);
return (
<EventTextArea
key={key}
field={fieldName}
label={label}
initialValue={initialValue}
submitHandler={handleSubmit}
className={style.decorated}
style={{ '--decorator-bg': backgroundColor, '--decorator-color': color } as CSSProperties}
/>
);
})}
</div>
</div>
<div className={style.footer}>
<CopyTag label='OSC trigger by id'>{`/ontime/load/id "${event.id}"`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/load/cue "${event.cue}"`}</CopyTag>
</div>
</div>
);
}
@@ -0,0 +1,146 @@
import { memo } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { forgivingStringToMillis, millisToDelayString } from '../../../../common/utils/dateConfig';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EventEditor.module.scss';
interface EventEditorTimesProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
delay: number;
isPublic: boolean;
endAction: EndAction;
timerType: TimerType;
timeWarning: number;
timeDanger: number;
}
type HandledActions = 'timerType' | 'endAction' | 'isPublic' | 'timeWarning' | 'timeDanger';
const EventEditorTimes = (props: EventEditorTimesProps) => {
const {
eventId,
timeStart,
timeEnd,
duration,
timeStrategy,
linkStart,
delay,
isPublic,
endAction,
timerType,
timeWarning,
timeDanger,
} = props;
const { updateEvent } = useEventAction();
const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'isPublic') {
updateEvent({ id: eventId, isPublic: !(value as boolean) });
return;
}
if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = forgivingStringToMillis(value as string);
updateEvent({ id: eventId, [field]: newTime });
return;
}
if (field === 'timerType' || field === 'endAction') {
updateEvent({ id: eventId, [field]: value });
return;
}
};
const hasDelay = delay !== 0;
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${millisToString(
timeStart + delay,
)}${millisToString(timeEnd + delay)}`
: '';
return (
<div className={style.column}>
<div>
<div className={style.inputLabel}>Event schedule</div>
<div className={style.inline}>
<TimeInputFlow
eventId={eventId}
timeStart={timeStart}
timeEnd={timeEnd}
duration={duration}
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
/>
</div>
<div className={style.delayLabel}>{delayLabel}</div>
</div>
<div className={style.splitTwo}>
<div>
<label className={style.inputLabel} htmlFor='timeWarning'>
Warning Time
</label>
<TimeInput name='timeWarning' submitHandler={handleSubmit} time={timeWarning} placeholder='Duration' />
</div>
<div>
<label className={style.inputLabel}>Timer Type</label>
<Select
size='sm'
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.TimeToEnd}>Time to end</option>
<option value={TimerType.Clock}>Clock</option>
</Select>
</div>
<div>
<label className={style.inputLabel} htmlFor='timeDanger'>
Danger Time
</label>
<TimeInput name='timeDanger' submitHandler={handleSubmit} time={timeDanger} placeholder='Duration' />
</div>
<div>
<label className={style.inputLabel}>End Action</label>
<Select
size='sm'
name='endAction'
value={endAction}
onChange={(event) => handleSubmit('endAction', event.target.value)}
variant='ontime'
>
<option value={EndAction.None}>None</option>
<option value={EndAction.Stop}>Stop</option>
<option value={EndAction.LoadNext}>Load Next</option>
<option value={EndAction.PlayNext}>Play Next</option>
</Select>
</div>
</div>
<div>
<span className={style.inputLabel}>Event Visibility</span>
<label className={style.switchLabel}>
<Switch size='md' isChecked={isPublic} onChange={() => handleSubmit('isPublic', isPublic)} variant='ontime' />
{isPublic ? 'Public' : 'Private'}
</label>
</div>
</div>
);
};
export default memo(EventEditorTimes);
@@ -0,0 +1,57 @@
import { memo } from 'react';
import { Input } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils';
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import { type EditorUpdateFields } from '../EventEditor';
import EventTextArea from './EventTextArea';
import EventTextInput from './EventTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
cue: string;
title: string;
note: string;
colour: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorTitles = (props: EventEditorTitlesProps) => {
const { eventId, cue, title, note, colour, handleSubmit } = props;
const cueSubmitHandler = (_field: string, newValue: string) => {
handleSubmit('cue', sanitiseCue(newValue));
};
return (
<div className={style.column}>
<div className={style.splitTwo}>
<div>
<label className={style.inputLabel} htmlFor='eventId'>
Event ID (read only)
</label>
<Input
id='eventId'
size='sm'
variant='ontime-filled'
data-testid='input-textfield'
value={eventId}
readOnly
/>
</div>
<EventTextInput field='cue' label='Cue' initialValue={cue} submitHandler={cueSubmitHandler} maxLength={10} />
</div>
<div>
<label className={style.inputLabel}>Colour</label>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
<EventTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<EventTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
);
};
export default memo(EventEditorTitles);
@@ -0,0 +1,46 @@
import { CSSProperties, useCallback } from 'react';
import { AutoTextArea } from '../../../../common/components/input/auto-text-area/AutoTextArea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { cx } from '../../../../common/utils/styleUtils';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextAreaProps {
className?: string;
field: EditorUpdateFields;
label: string;
initialValue: string;
style?: CSSProperties;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function EventTextArea(props: CountedTextAreaProps) {
const { className, field, label, initialValue, style: givenStyles, submitHandler } = props;
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback);
const classes = cx([style.inputLabel, className]);
return (
<div>
<label className={classes} htmlFor={field} style={givenStyles}>
{label}
</label>
<AutoTextArea
id={field}
rows={1}
size='sm'
resize='none'
variant='ontime-filled'
data-testid='input-textarea'
value={value}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
</div>
);
}
@@ -0,0 +1,44 @@
import { useCallback } from 'react';
import { Input, InputProps } from '@chakra-ui/react';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextInputProps extends InputProps {
field: EditorUpdateFields;
label: string;
initialValue: string;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function EventTextInput(props: CountedTextInputProps) {
const { field, label, initialValue, submitHandler, maxLength } = props;
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, {
submitOnEnter: true,
});
return (
<div>
<label className={style.inputLabel} htmlFor={field}>
{label}
</label>
<Input
id={field}
size='sm'
variant='ontime-filled'
data-testid='input-textfield'
value={value}
maxLength={maxLength || 50}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
autoComplete='off'
/>
</div>
);
}
@@ -1,37 +1,13 @@
@use '../../../theme/v2Styles' as *;
.quickAdd {
display: grid;
grid-template-columns: 1fr auto;
display: flex;
align-items: center;
margin: 0.25rem 0;
font-size: $inner-section-text-size;
padding: 0 0.75rem;
gap: 1rem;
margin: 0.25rem 0;
font-size: calc(1rem - 3px);
padding-inline: calc(2em + 0.5rem) 0.75rem;
}
.btnRow {
justify-self: center;
display: flex;
gap: max(0.5rem, 2rem);
.quickBtn {
font-weight: 400;
width: auto;
padding: 0 1rem;
}
}
.keyboard {
margin-left: 0.5rem;
padding: 0 0.25rem;
color: $label-gray;
border-radius: 2px;
background-color: rgba(0, 0, 0, 0.1);
}
.options {
display: flex;
flex-direction: column;
white-space: nowrap;
.quickBtn {
font-weight: 400;
}
@@ -1,48 +1,40 @@
import { memo, useCallback, useRef } from 'react';
import { Button, Checkbox, Tooltip } from '@chakra-ui/react';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEditorSettings } from '../../../common/stores/editorSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { deviceAlt } from '../../../common/utils/deviceUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps {
showKbd: boolean;
eventId: string;
previousEventId?: string;
disableAddDelay?: boolean;
disableAddBlock: boolean;
}
const QuickAddBlock = (props: QuickAddBlockProps) => {
const { showKbd, eventId, previousEventId, disableAddDelay = true, disableAddBlock } = props;
export default memo(QuickAddBlock);
function QuickAddBlock(props: QuickAddBlockProps) {
const { previousEventId } = props;
const { addEvent } = useEventAction();
const { emitError } = useEmitLog();
const doStartTime = useRef<HTMLInputElement | null>(null);
const doLinkPrevious = useRef<HTMLInputElement | null>(null);
const doPublic = useRef<HTMLInputElement | null>(null);
const eventSettings = useEditorSettings((state) => state.eventSettings);
const defaultPublic = eventSettings.defaultPublic;
const startTimeIsLastEnd = eventSettings.startTimeIsLastEnd;
const handleCreateEvent = useCallback(
(eventType: SupportedEvent) => {
switch (eventType) {
case 'event': {
const isPublicOption = doPublic?.current?.checked;
const startTimeIsLastEndOption = doStartTime?.current?.checked;
const defaultPublic = doPublic?.current?.checked;
const linkPrevious = doLinkPrevious?.current?.checked;
const newEvent = { type: SupportedEvent.Event };
const options = {
defaultPublic: isPublicOption,
startTimeIsLastEnd: startTimeIsLastEndOption,
after: previousEventId,
defaultPublic,
lastEventId: previousEventId,
after: eventId,
linkPrevious,
};
addEvent(newEvent, options);
break;
@@ -50,7 +42,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
case 'delay': {
const options = {
lastEventId: previousEventId,
after: eventId,
after: previousEventId,
};
addEvent({ type: SupportedEvent.Delay }, options);
break;
@@ -58,7 +50,7 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
case 'block': {
const options = {
lastEventId: previousEventId,
after: eventId,
after: previousEventId,
};
addEvent({ type: SupportedEvent.Block }, options);
break;
@@ -69,58 +61,38 @@ const QuickAddBlock = (props: QuickAddBlockProps) => {
}
}
},
[previousEventId, eventId, addEvent, emitError],
[previousEventId, addEvent, emitError],
);
return (
<div className={style.quickAdd}>
<div className={style.btnRow}>
<Tooltip label='Add Event' openDelay={tooltipDelayMid}>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Event)}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
data-testid='quick-add-event'
>
Event {showKbd && <span className={style.keyboard}>{`${deviceAlt} + E`}</span>}
</Button>
</Tooltip>
<Tooltip label='Add Delay' openDelay={tooltipDelayMid}>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Delay)}
size='xs'
variant='ontime-subtle-white'
disabled={disableAddDelay}
className={style.quickBtn}
data-testid='quick-add-delay'
>
Delay {showKbd && <span className={style.keyboard}>{`${deviceAlt} + D`}</span>}
</Button>
</Tooltip>
<Tooltip label='Add Block' openDelay={tooltipDelayMid}>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Block)}
size='xs'
variant='ontime-subtle-white'
disabled={disableAddBlock}
className={style.quickBtn}
data-testid='quick-add-block'
>
Block {showKbd && <span className={style.keyboard}>{`${deviceAlt} + B`}</span>}
</Button>
</Tooltip>
</div>
<div className={style.options}>
<Checkbox ref={doStartTime} size='sm' variant='ontime-ondark' defaultChecked={startTimeIsLastEnd}>
Start time is last end
</Checkbox>
<Checkbox ref={doPublic} size='sm' variant='ontime-ondark' defaultChecked={defaultPublic}>
Event is public
</Checkbox>
</div>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Event)}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
leftIcon={<IoAdd />}
>
Event
</Button>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Delay)}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
leftIcon={<IoAdd />}
>
Delay
</Button>
<Button
onClick={() => handleCreateEvent(SupportedEvent.Block)}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
leftIcon={<IoAdd />}
>
Block
</Button>
</div>
);
};
export default memo(QuickAddBlock);
}
@@ -0,0 +1,7 @@
.header {
padding-inline: calc(2rem - 6px + 0.5rem) calc(1rem + 2px);
display: flex;
align-items: center;
justify-content: space-between;
}
@@ -0,0 +1,28 @@
import { Button, ButtonGroup } from '@chakra-ui/react';
import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
import RundownMenu from './RundownMenu';
import style from './RundownHeader.module.scss';
export default function RundownHeader() {
const appMode = useAppMode((state) => state.mode);
const setAppMode = useAppMode((state) => state.setMode);
const setRunMode = () => setAppMode(AppMode.Run);
const setEditMode = () => setAppMode(AppMode.Edit);
return (
<div className={style.header}>
<ButtonGroup isAttached>
<Button size='sm' variant={appMode === AppMode.Run ? 'ontime-filled' : 'ontime-subtle'} onClick={setRunMode}>
Run
</Button>
<Button size='sm' variant={appMode === AppMode.Edit ? 'ontime-filled' : 'ontime-subtle'} onClick={setEditMode}>
Edit
</Button>
</ButtonGroup>
<RundownMenu />
</div>
);
}
@@ -0,0 +1,33 @@
import { useCallback } from 'react';
import { Button } from '@chakra-ui/react';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { useAppMode } from '../../../common/stores/appModeStore';
import { useEventSelection } from '../useEventSelection';
export default function RundownMenu() {
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setCursor = useAppMode((state) => state.setCursor);
const appMode = useAppMode((state) => state.mode);
const { deleteAllEvents } = useEventAction();
const deleteAll = useCallback(() => {
deleteAllEvents();
clearSelectedEvents();
setCursor(null);
}, [clearSelectedEvents, deleteAllEvents, setCursor]);
return (
<Button
size='sm'
variant='ontime-outlined'
leftIcon={<IoTrash />}
onClick={deleteAll}
color='#FA5656'
isDisabled={appMode === 'run'}
>
Clear rundown
</Button>
);
}
@@ -0,0 +1,30 @@
.timeLabel {
position: absolute;
z-index: 100;
font-size: 0.75rem;
font-weight: 600;
top: 2px;
right: 4px;
color: inherit;
}
.timeAction {
opacity: 0.4;
cursor: pointer;
padding-right: 0.5em;
&.active {
opacity: 1;
color: var(--status-color-active-override, $active-indicator);
}
.fourtyfive {
transform: rotate(-45deg);
}
}
.timerNote {
color: $blue-500;
margin-right: 0.5rem;
font-size: 1.5em;
display: grid;
}
@@ -0,0 +1,121 @@
import { memo } from 'react';
import { InputRightElement, Tooltip } from '@chakra-ui/react';
import { IoAlertCircleOutline } from '@react-icons/all-files/io5/IoAlertCircleOutline';
import { IoLink } from '@react-icons/all-files/io5/IoLink';
import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed';
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
import { MaybeString, OntimeEvent, TimeStrategy } from 'ontime-types';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../common/hooks/useEventAction';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import style from './TimeInputFlow.module.scss';
interface EventBlockTimerProps {
eventId: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
linkStart: MaybeString;
delay: number;
}
type TimeActions = 'timeStart' | 'timeEnd' | 'duration';
const TimeInputFlow = (props: EventBlockTimerProps) => {
const { eventId, timeStart, timeEnd, duration, timeStrategy, linkStart, delay } = props;
const { updateEvent, updateTimer, linkTimer } = useEventAction();
// In sync with EventEditorTimes
const handleSubmit = (field: TimeActions, value: string) => {
updateTimer(eventId, field, value);
};
const handleChangeStrategy = (timeStrategy: TimeStrategy) => {
const newEvent: Partial<OntimeEvent> = { id: eventId, timeStrategy };
updateEvent(newEvent);
};
const handleLink = (doLink: boolean) => {
// the string doesnt mean much for now, not more than an intent to link
// we imagine that we can leverage this to create offsets p+10
linkTimer(eventId, doLink ? 'p' : null);
};
const overMidnight = timeStart > timeEnd;
const hasDelay = delay !== 0;
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
const activeStart = cx([style.timeAction, linkStart ? style.active : null]);
const activeEnd = cx([style.timeAction, isLockedEnd ? style.active : null]);
const activeDuration = cx([style.timeAction, isLockedDuration ? style.active : null]);
return (
<>
<TimeInputWithButton<TimeActions>
name='timeStart'
submitHandler={handleSubmit}
time={timeStart}
hasDelay={hasDelay}
placeholder='Start'
disabled={Boolean(linkStart)}
>
<InputRightElement className={activeStart} onClick={() => handleLink(!linkStart)}>
<span className={style.timeLabel}>S</span>
<span className={style.fourtyfive}>{linkStart ? <IoLink /> : <IoUnlink />}</span>
</InputRightElement>
</TimeInputWithButton>
<TimeInputWithButton<TimeActions>
name='timeEnd'
submitHandler={handleSubmit}
time={timeEnd}
hasDelay={hasDelay}
disabled={isLockedDuration}
placeholder='End'
>
<InputRightElement
className={activeEnd}
onClick={() => handleChangeStrategy(TimeStrategy.LockEnd)}
data-testid='lock__end'
>
<span className={style.timeLabel}>E</span>
{isLockedEnd ? <IoLockClosed /> : <IoLockOpenOutline />}
</InputRightElement>
</TimeInputWithButton>
<TimeInputWithButton<TimeActions>
name='duration'
submitHandler={handleSubmit}
time={duration}
disabled={isLockedEnd}
placeholder='Duration'
>
<InputRightElement
className={activeDuration}
onClick={() => handleChangeStrategy(TimeStrategy.LockDuration)}
data-testid='lock__duration'
>
<span className={style.timeLabel}>D</span>
{isLockedDuration ? <IoLockClosed /> : <IoLockOpenOutline />}
</InputRightElement>
</TimeInputWithButton>
{overMidnight && (
<div className={style.timerNote}>
<Tooltip label='Over midnight' openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren>
<IoAlertCircleOutline />
</Tooltip>
</div>
)}
</>
);
};
export default memo(TimeInputFlow);
@@ -0,0 +1,97 @@
import { MouseEvent } from 'react';
import { isOntimeEvent, OntimeEvent, RundownCached } from 'ontime-types';
import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<string>;
anchoredIndex: number | null;
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
}
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
setSelectedEvents: (selectionArgs) => {
const { id, index, selectMode } = selectionArgs;
const { selectedEvents, anchoredIndex } = get();
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index });
}
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN);
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
if (!selectedEvents.has(id)) {
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
});
}
// if event is already selected, we remove it from selection
// and set the anchor to the event after
selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex(
(eventId, i) => i > index && isOntimeEvent(rundownData.rundown[eventId]) && selectedEvents.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
return set({
selectedEvents,
anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex,
});
}
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN);
if (!rundownData) return;
// get list of rundown with only ontime events
const events: OntimeEvent[] = [];
rundownData.order.forEach((eventId) => {
const event = rundownData.rundown[eventId];
if (isOntimeEvent(event)) {
events.push(event);
}
});
const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index);
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
const selectedEventIds = events.slice(start, end).map((event) => event.id);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
anchoredIndex: index,
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set() }),
}));
export function getSelectionMode(event: MouseEvent): SelectionMode {
if ((isMacOS() && event.metaKey) || event.ctrlKey) {
return 'ctrl';
}
if (event.shiftKey) {
return 'shift';
}
return 'click';
}