mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 14:14:17 +00:00
V3 (#657)
* 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:
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
-18
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user