mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-19 14:14:17 +00:00
feat: editor layout
This commit is contained in:
committed by
Carlos Valente
parent
967e92e2c8
commit
da02ecd113
@@ -35,8 +35,8 @@
|
|||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
line-height: 1em;
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
@@ -70,10 +70,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item .iconStrong {
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcut {
|
.shortcut {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type DropdownMenuItemDivider = { type: 'divider' };
|
|||||||
type DropdownMenuItem = {
|
type DropdownMenuItem = {
|
||||||
type: 'item' | 'destructive';
|
type: 'item' | 'destructive';
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string;
|
||||||
icon?: IconType;
|
icon?: IconType;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -39,9 +40,12 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
|
|||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
data-type={item.type}
|
data-type={item.type}
|
||||||
>
|
>
|
||||||
<span className={style.label}>
|
<span className={style.content}>
|
||||||
{item.icon && <item.icon />}
|
<span className={style.label}>
|
||||||
{item.label}
|
{item.icon && <item.icon />}
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{item.description && <span className={style.description}>{item.description}</span>}
|
||||||
</span>
|
</span>
|
||||||
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
|
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
|
||||||
</BaseMenu.Item>
|
</BaseMenu.Item>
|
||||||
@@ -79,9 +83,12 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
||||||
<span className={style.label}>
|
<span className={style.content}>
|
||||||
{item.icon && <item.icon />}
|
<span className={style.label}>
|
||||||
{item.label}
|
{item.icon && <item.icon />}
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
{item.description && <span className={style.description}>{item.description}</span>}
|
||||||
</span>
|
</span>
|
||||||
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
|
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
|
||||||
</BaseMenu.Item>
|
</BaseMenu.Item>
|
||||||
|
|||||||
@@ -41,5 +41,4 @@
|
|||||||
|
|
||||||
padding-inline: 0.5em;
|
padding-inline: 0.5em;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop } from 'react-icons/io5';
|
import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop } from 'react-icons/io5';
|
||||||
import { Playback, TimerPhase } from 'ontime-types';
|
import { Playback, TimerPhase } from 'ontime-types';
|
||||||
import { validatePlayback } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { setPlayback } from '../../../../common/hooks/useSocket';
|
import { setPlayback } from '../../../../common/hooks/useSocket';
|
||||||
|
import { getPlaybackControlState } from '../playbackControl.utils';
|
||||||
import TapButton from '../tap-button/TapButton';
|
import TapButton from '../tap-button/TapButton';
|
||||||
|
|
||||||
import style from './PlaybackButtons.module.scss';
|
import style from './PlaybackButtons.module.scss';
|
||||||
@@ -15,44 +14,32 @@ interface PlaybackButtonsProps {
|
|||||||
timerPhase: TimerPhase;
|
timerPhase: TimerPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlaybackButtons(props: PlaybackButtonsProps) {
|
export default function PlaybackButtons({ playback, numEvents, selectedEventIndex, timerPhase }: PlaybackButtonsProps) {
|
||||||
const { playback, numEvents, selectedEventIndex, timerPhase } = props;
|
const {
|
||||||
|
isPlaying,
|
||||||
const isRolling = playback === Playback.Roll;
|
isPaused,
|
||||||
const isPlaying = playback === Playback.Play;
|
isRolling,
|
||||||
const isPaused = playback === Playback.Pause;
|
disableGo,
|
||||||
const isArmed = playback === Playback.Armed;
|
disableNext,
|
||||||
|
disablePrev,
|
||||||
const isFirst = selectedEventIndex === 0;
|
disableStart,
|
||||||
const isLast = selectedEventIndex === numEvents - 1;
|
disablePause,
|
||||||
const noEvents = numEvents === 0;
|
disableRoll,
|
||||||
|
disableStop,
|
||||||
const disableGo = isRolling || noEvents;
|
disableReload,
|
||||||
const disableNext = isRolling || noEvents || isLast;
|
goAction,
|
||||||
const disablePrev = isRolling || noEvents || isFirst;
|
goLabel,
|
||||||
|
} = getPlaybackControlState({
|
||||||
const playbackCan = validatePlayback(playback, timerPhase);
|
playback,
|
||||||
const disableStart = !playbackCan.start;
|
numEvents,
|
||||||
const disablePause = !playbackCan.pause;
|
selectedEventIndex,
|
||||||
const disableRoll = !playbackCan.roll || noEvents;
|
timerPhase,
|
||||||
const disableStop = !playbackCan.stop;
|
});
|
||||||
const disableReload = !playbackCan.reload;
|
|
||||||
|
|
||||||
const [goModeAction, goModeText] = useMemo(() => {
|
|
||||||
if (isArmed) {
|
|
||||||
return [setPlayback.start, 'Start'];
|
|
||||||
} else if (isLast) {
|
|
||||||
return [setPlayback.stop, 'Finish'];
|
|
||||||
} else if (selectedEventIndex === null) {
|
|
||||||
return [setPlayback.startNext, 'Start'];
|
|
||||||
}
|
|
||||||
return [setPlayback.startNext, 'Next'];
|
|
||||||
}, [isArmed, isLast, selectedEventIndex]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.buttonContainer}>
|
<div className={style.buttonContainer}>
|
||||||
<TapButton disabled={disableGo} onClick={goModeAction} aspect='fill' className={style.go}>
|
<TapButton disabled={disableGo} onClick={goAction} aspect='fill' className={style.go}>
|
||||||
{goModeText}
|
{goLabel}
|
||||||
</TapButton>
|
</TapButton>
|
||||||
<div className={style.playbackContainer}>
|
<div className={style.playbackContainer}>
|
||||||
<TapButton onClick={setPlayback.start} disabled={disableStart} theme={Playback.Play} active={isPlaying}>
|
<TapButton onClick={setPlayback.start} disabled={disableStart} theme={Playback.Play} active={isPlaying}>
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
gap: $element-inner-spacing;
|
gap: $element-inner-spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timerDisplay {
|
||||||
|
grid-area: timer;
|
||||||
|
max-width: 18.75rem;
|
||||||
|
min-width: 5em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------> INDICATORS
|
// ---------> INDICATORS
|
||||||
|
|
||||||
.indicators {
|
.indicators {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
|||||||
<div className={style.indicatorNegative} data-active={isOvertime} />
|
<div className={style.indicatorNegative} data-active={isOvertime} />
|
||||||
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
|
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
|
||||||
</div>
|
</div>
|
||||||
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} />
|
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} phase={timer.phase} />
|
||||||
<div className={style.status}>
|
<div className={style.status}>
|
||||||
{isWaiting ? (
|
{isWaiting ? (
|
||||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Playback, TimerPhase } from 'ontime-types';
|
||||||
|
import { validatePlayback } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { setPlayback } from '../../../common/hooks/useSocket';
|
||||||
|
|
||||||
|
export interface PlaybackControlInput {
|
||||||
|
playback: Playback;
|
||||||
|
numEvents: number;
|
||||||
|
selectedEventIndex: number | null;
|
||||||
|
timerPhase: TimerPhase;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackControlState {
|
||||||
|
// Playback states
|
||||||
|
isPlaying: boolean;
|
||||||
|
isPaused: boolean;
|
||||||
|
isRolling: boolean;
|
||||||
|
isArmed: boolean;
|
||||||
|
isStopped: boolean;
|
||||||
|
|
||||||
|
// Position states
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
noEvents: boolean;
|
||||||
|
|
||||||
|
// Disable flags
|
||||||
|
disableGo: boolean;
|
||||||
|
disableNext: boolean;
|
||||||
|
disablePrev: boolean;
|
||||||
|
disableStart: boolean;
|
||||||
|
disablePause: boolean;
|
||||||
|
disableRoll: boolean;
|
||||||
|
disableStop: boolean;
|
||||||
|
disableReload: boolean;
|
||||||
|
disableAddTime: boolean;
|
||||||
|
|
||||||
|
// Go button configuration
|
||||||
|
goAction: () => void;
|
||||||
|
goLabel: 'Start' | 'Next' | 'Finish';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized playback control state calculator.
|
||||||
|
* Consolidates all playback logic, disable states, and derived values in one place.
|
||||||
|
*/
|
||||||
|
export function getPlaybackControlState({
|
||||||
|
playback,
|
||||||
|
numEvents,
|
||||||
|
selectedEventIndex,
|
||||||
|
timerPhase,
|
||||||
|
}: PlaybackControlInput): PlaybackControlState {
|
||||||
|
const isFirst = selectedEventIndex === 0;
|
||||||
|
const isLast = selectedEventIndex === numEvents - 1;
|
||||||
|
const noEvents = numEvents === 0;
|
||||||
|
const isRolling = playback === Playback.Roll;
|
||||||
|
|
||||||
|
const playbackCan = validatePlayback(playback, timerPhase);
|
||||||
|
const { action: goAction, label: goLabel } = getGoAction(playback, selectedEventIndex, isLast);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPlaying: playback === Playback.Play,
|
||||||
|
isPaused: playback === Playback.Pause,
|
||||||
|
isRolling,
|
||||||
|
isArmed: playback === Playback.Armed,
|
||||||
|
isStopped: playback === Playback.Stop,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
noEvents,
|
||||||
|
disableGo: isRolling || noEvents,
|
||||||
|
disableNext: isRolling || noEvents || isLast,
|
||||||
|
disablePrev: isRolling || noEvents || isFirst,
|
||||||
|
disableStart: !playbackCan.start,
|
||||||
|
disablePause: !playbackCan.pause,
|
||||||
|
disableRoll: !playbackCan.roll || noEvents,
|
||||||
|
disableStop: !playbackCan.stop,
|
||||||
|
disableReload: !playbackCan.reload,
|
||||||
|
disableAddTime: playback !== Playback.Play && playback !== Playback.Pause,
|
||||||
|
goAction,
|
||||||
|
goLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the action and label for the "Go" button based on playback state.
|
||||||
|
*/
|
||||||
|
function getGoAction(
|
||||||
|
playback: Playback,
|
||||||
|
selectedEventIndex: number | null,
|
||||||
|
isLast: boolean,
|
||||||
|
): { action: () => void; label: 'Start' | 'Next' | 'Finish' } {
|
||||||
|
if (playback === Playback.Armed) return { action: setPlayback.start, label: 'Start' };
|
||||||
|
if (isLast) return { action: setPlayback.stop, label: 'Finish' };
|
||||||
|
if (selectedEventIndex === null) return { action: setPlayback.startNext, label: 'Start' };
|
||||||
|
return { action: setPlayback.startNext, label: 'Next' };
|
||||||
|
}
|
||||||
@@ -1,23 +1,34 @@
|
|||||||
@use '@/theme/viewerDefs' as *;
|
@use '@/theme/viewerDefs' as *;
|
||||||
|
|
||||||
.timer {
|
.timer {
|
||||||
grid-area: timer;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
max-width: 18.75rem;
|
|
||||||
min-width: 5em;
|
|
||||||
|
|
||||||
color: $timer-color;
|
line-height: 0.9;
|
||||||
line-height: 0.9em;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 3.5rem;
|
font-size: 3.5rem;
|
||||||
|
|
||||||
&.finished {
|
&[data-phase='default'] {
|
||||||
color: $timer-finished-color;
|
color: $timer-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.muted {
|
&[data-phase='warning'] {
|
||||||
|
color: $warning-orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-phase='danger'] {
|
||||||
|
color: $error-red;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-phase='overtime'] {
|
||||||
|
color: $playback-negative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-phase='pending'] {
|
||||||
|
color: $ontime-roll;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-phase='none'] {
|
||||||
color: $muted-gray;
|
color: $muted-gray;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MaybeNumber } from 'ontime-types';
|
import { MaybeNumber, TimerPhase } from 'ontime-types';
|
||||||
import { millisToString } from 'ontime-utils';
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils';
|
import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils';
|
||||||
@@ -7,19 +7,20 @@ import style from './TimerDisplay.module.scss';
|
|||||||
|
|
||||||
interface TimerDisplayProps {
|
interface TimerDisplayProps {
|
||||||
time: MaybeNumber;
|
time: MaybeNumber;
|
||||||
|
phase: TimerPhase;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays time in ms in formatted timetag
|
* Displays time in ms in formatted timetag
|
||||||
* Used in editor
|
* Used in editor
|
||||||
*/
|
*/
|
||||||
export default function TimerDisplay(props: TimerDisplayProps) {
|
export default function TimerDisplay({ time, phase, className }: TimerDisplayProps) {
|
||||||
const { time } = props;
|
const display = millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
|
||||||
|
|
||||||
const isNegative = (time ?? 0) < 0;
|
return (
|
||||||
const display =
|
<div className={cx([style.timer, className])} data-phase={phase}>
|
||||||
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
|
{display}
|
||||||
const classes = cx([style.timer, isNegative ? style.finished : null, time === null && style.muted]);
|
</div>
|
||||||
|
);
|
||||||
return <div className={classes}>{display}</div>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
@use '../../../../theme/ontimeColours' as *;
|
||||||
|
@use '../../../../theme/ontimeStyles' as *;
|
||||||
|
|
||||||
|
$element-height: 4.5rem; // matches go button height in PlaybackButtons
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: $panel-gap $section-spacing;
|
||||||
|
background: $bg-container-l2;
|
||||||
|
border-radius: $panel-border-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemGroup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $element-spacing;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goButton,
|
||||||
|
.iconButtonWithLabel,
|
||||||
|
.iconButton {
|
||||||
|
width: $element-height;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.goButton {
|
||||||
|
height: $element-height;
|
||||||
|
min-width: 8rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goLabel {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcutHint {
|
||||||
|
font-size: calc(1rem - 4px);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addSection {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 1.5rem;
|
||||||
|
background: $white-10;
|
||||||
|
margin-inline: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timerSection {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negativeIndicator {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: $playback-negative;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
&[data-active='true'] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----> Aux timer section (right)
|
||||||
|
|
||||||
|
.auxSection {
|
||||||
|
justify-self: end;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $element-inner-spacing;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auxControls {
|
||||||
|
display: flex;
|
||||||
|
gap: $element-inner-spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auxTimeInput,
|
||||||
|
.auxTimeDisplay {
|
||||||
|
width: 6.5rem;
|
||||||
|
min-width: 6.5rem;
|
||||||
|
max-width: 6.5rem;
|
||||||
|
height: $element-height;
|
||||||
|
|
||||||
|
text-align: center;
|
||||||
|
background: $gray-1050;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auxTimeDisplay {
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: $gray-200;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
|
||||||
|
padding-inline: 0.5em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardcoded value where the timer is over the buttons
|
||||||
|
@media (max-width: 1048px) {
|
||||||
|
.container {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timerSection {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auxSection {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hardcoded value where the timer is over the buttons
|
||||||
|
@media (max-width: 728px) {
|
||||||
|
.addSection {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
import { IoAdd, IoArrowDown, IoArrowUp, IoPause, IoPlay, IoPlaySkipForward, IoRemove, IoStop } from 'react-icons/io5';
|
||||||
|
import { useHotkeys, useLocalStorage } from '@mantine/hooks';
|
||||||
|
import { Playback, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
|
||||||
|
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||||
|
|
||||||
|
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||||
|
import {
|
||||||
|
setAuxTimer,
|
||||||
|
setPlayback,
|
||||||
|
useAuxTimerControl,
|
||||||
|
useAuxTimerTime,
|
||||||
|
usePlaybackControl,
|
||||||
|
useTimer,
|
||||||
|
} from '../../../../common/hooks/useSocket';
|
||||||
|
import { enDash } from '../../../../common/utils/styleUtils';
|
||||||
|
import { formatDuration } from '../../../../common/utils/time';
|
||||||
|
import { getPlaybackControlState } from '../playbackControl.utils';
|
||||||
|
import TapButton from '../tap-button/TapButton';
|
||||||
|
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||||
|
|
||||||
|
import style from './TrackingPlaybackBar.module.scss';
|
||||||
|
|
||||||
|
export default function TrackingPlaybackBar() {
|
||||||
|
const timer = useTimer();
|
||||||
|
|
||||||
|
const { playback, numEvents, selectedEventIndex } = usePlaybackControl();
|
||||||
|
|
||||||
|
const { playback: auxPlayback, direction: auxDirection } = useAuxTimerControl(1);
|
||||||
|
const auxTime = useAuxTimerTime(1);
|
||||||
|
|
||||||
|
const [addTimeInMs] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 });
|
||||||
|
|
||||||
|
const { disableGo, disableNext, disableAddTime, isPlaying, goAction, goLabel } = getPlaybackControlState({
|
||||||
|
playback,
|
||||||
|
numEvents,
|
||||||
|
selectedEventIndex,
|
||||||
|
timerPhase: timer.phase,
|
||||||
|
});
|
||||||
|
|
||||||
|
const disableAddTimeWithAmount = disableAddTime || addTimeInMs === 0;
|
||||||
|
|
||||||
|
const handleAddTime = (direction: 'add' | 'remove') => {
|
||||||
|
if (disableAddTimeWithAmount) return;
|
||||||
|
if (direction === 'add') {
|
||||||
|
setPlayback.addTime(addTimeInMs);
|
||||||
|
} else {
|
||||||
|
setPlayback.addTime(-1 * addTimeInMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuxPlayPause = () => {
|
||||||
|
if (auxPlayback === SimplePlayback.Start) {
|
||||||
|
setAuxTimer.pause(1);
|
||||||
|
} else {
|
||||||
|
setAuxTimer.start(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuxStop = () => {
|
||||||
|
setAuxTimer.stop(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuxDirectionToggle = () => {
|
||||||
|
const newDirection =
|
||||||
|
auxDirection === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
|
||||||
|
setAuxTimer.setDirection(1, newDirection);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuxTimeChange = (_field: string, value: string) => {
|
||||||
|
const newTimeInMs = parseUserTime(value);
|
||||||
|
setAuxTimer.setDuration(1, newTimeInMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
useHotkeys([
|
||||||
|
['Space', () => !disableGo && goAction(), { preventDefault: true }],
|
||||||
|
['N', () => !disableNext && setPlayback.next(), { preventDefault: true }],
|
||||||
|
['Escape', () => playback !== Playback.Stop && setPlayback.stop(), { preventDefault: true }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||||
|
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||||
|
const displayTime = isWaiting ? timer.secondaryTimer : timer.current;
|
||||||
|
|
||||||
|
const addTimeLabel = formatDuration(addTimeInMs);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.container}>
|
||||||
|
<div className={style.itemGroup}>
|
||||||
|
<TapButton
|
||||||
|
onClick={goAction}
|
||||||
|
disabled={disableGo}
|
||||||
|
theme={Playback.Play}
|
||||||
|
active={isPlaying}
|
||||||
|
className={style.goButton}
|
||||||
|
>
|
||||||
|
<span className={style.goLabel}>{goLabel}</span>
|
||||||
|
<span className={style.shortcutHint}>[space]</span>
|
||||||
|
</TapButton>
|
||||||
|
|
||||||
|
<TapButton
|
||||||
|
onClick={setPlayback.next}
|
||||||
|
disabled={disableNext}
|
||||||
|
className={style.iconButtonWithLabel}
|
||||||
|
aspect='square'
|
||||||
|
>
|
||||||
|
<IoPlaySkipForward />
|
||||||
|
<span className={style.shortcutHint}>[n]</span>
|
||||||
|
</TapButton>
|
||||||
|
|
||||||
|
<TapButton
|
||||||
|
onClick={setPlayback.stop}
|
||||||
|
disabled={playback === Playback.Stop}
|
||||||
|
className={style.iconButtonWithLabel}
|
||||||
|
aspect='square'
|
||||||
|
>
|
||||||
|
<IoStop />
|
||||||
|
<span className={style.shortcutHint}>[esc]</span>
|
||||||
|
</TapButton>
|
||||||
|
|
||||||
|
<div className={style.addSection}>
|
||||||
|
<div className={style.separator} />
|
||||||
|
<TapButton
|
||||||
|
onClick={() => handleAddTime('remove')}
|
||||||
|
disabled={disableAddTimeWithAmount}
|
||||||
|
className={style.iconButtonWithLabel}
|
||||||
|
aspect='square'
|
||||||
|
>
|
||||||
|
<IoRemove />
|
||||||
|
{addTimeLabel}
|
||||||
|
</TapButton>
|
||||||
|
<TapButton
|
||||||
|
onClick={() => handleAddTime('add')}
|
||||||
|
disabled={disableAddTimeWithAmount}
|
||||||
|
className={style.iconButtonWithLabel}
|
||||||
|
aspect='square'
|
||||||
|
>
|
||||||
|
<IoAdd />
|
||||||
|
{addTimeLabel}
|
||||||
|
</TapButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.timerSection}>
|
||||||
|
<span className={style.negativeIndicator} data-active={isOvertime}>
|
||||||
|
{enDash}
|
||||||
|
</span>
|
||||||
|
<TimerDisplay time={displayTime} phase={timer.phase} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.auxSection}>
|
||||||
|
<TapButton onClick={handleAuxPlayPause} className={style.iconButton} theme={Playback.Play} aspect='square'>
|
||||||
|
{auxPlayback === SimplePlayback.Start ? <IoPause /> : <IoPlay />}
|
||||||
|
</TapButton>
|
||||||
|
<TapButton
|
||||||
|
onClick={handleAuxStop}
|
||||||
|
disabled={auxPlayback === SimplePlayback.Stop}
|
||||||
|
className={style.iconButton}
|
||||||
|
theme={Playback.Stop}
|
||||||
|
aspect='square'
|
||||||
|
>
|
||||||
|
<IoStop />
|
||||||
|
</TapButton>
|
||||||
|
{auxPlayback !== SimplePlayback.Stop ? (
|
||||||
|
<div className={style.auxTimeDisplay}>{millisToString(auxTime)}</div>
|
||||||
|
) : (
|
||||||
|
<TimeInput
|
||||||
|
name='aux1-tracking'
|
||||||
|
submitHandler={handleAuxTimeChange}
|
||||||
|
time={auxTime}
|
||||||
|
className={style.auxTimeInput}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<TapButton
|
||||||
|
onClick={handleAuxDirectionToggle}
|
||||||
|
disabled={auxPlayback !== SimplePlayback.Stop}
|
||||||
|
className={style.iconButton}
|
||||||
|
aspect='square'
|
||||||
|
>
|
||||||
|
{auxDirection === SimpleDirection.CountDown ? <IoArrowDown /> : <IoArrowUp />}
|
||||||
|
</TapButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { getDefaultFormat } from '../../common/utils/time';
|
|||||||
import { isTouchDevice } from '../../externals';
|
import { isTouchDevice } from '../../externals';
|
||||||
import Loader from '../../views/common/loader/Loader';
|
import Loader from '../../views/common/loader/Loader';
|
||||||
|
|
||||||
import EditModal from './edit-modal/EditModal';
|
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
|
||||||
import FollowButton from './follow-button/FollowButton';
|
import FollowButton from './follow-button/FollowButton';
|
||||||
import OperatorEvent from './operator-event/OperatorEvent';
|
import OperatorEvent from './operator-event/OperatorEvent';
|
||||||
import OperatorGroup from './operator-group/OperatorGroup';
|
import OperatorGroup from './operator-group/OperatorGroup';
|
||||||
@@ -118,7 +118,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
return (
|
return (
|
||||||
<div className={style.operatorContainer} data-testid='operator-view'>
|
<div className={style.operatorContainer} data-testid='operator-view'>
|
||||||
<ViewParamsEditor target={OntimeView.Operator} viewOptions={operatorOptions} />
|
<ViewParamsEditor target={OntimeView.Operator} viewOptions={operatorOptions} />
|
||||||
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
{editEvent && <CustomFieldEditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
||||||
|
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -9,14 +9,14 @@ import Textarea from '../../../common/components/input/textarea/Textarea';
|
|||||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
import { EditEvent } from '../operator.types';
|
import { EditEvent } from '../operator.types';
|
||||||
|
|
||||||
import style from './EditModal.module.scss';
|
import style from './CustomFieldEditModal.module.scss';
|
||||||
|
|
||||||
interface EditModalProps {
|
interface CustomFieldEditModalProps {
|
||||||
event: EditEvent;
|
event: EditEvent;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditModal(props: EditModalProps) {
|
export default function CustomFieldEditModal(props: CustomFieldEditModalProps) {
|
||||||
const { event, onClose } = props;
|
const { event, onClose } = props;
|
||||||
|
|
||||||
const { updateEntry } = useEntryActions();
|
const { updateEntry } = useEntryActions();
|
||||||
@@ -2,7 +2,13 @@ import { memo, PropsWithChildren } from 'react';
|
|||||||
|
|
||||||
import { useIsMobileScreen } from '../../common/hooks/useIsMobileScreen';
|
import { useIsMobileScreen } from '../../common/hooks/useIsMobileScreen';
|
||||||
|
|
||||||
import { ClockOverview, MetadataTimes, OffsetOverview, StartTimes, TimerOverview } from './composite/TimeElements';
|
import {
|
||||||
|
ClockOverview,
|
||||||
|
MetadataTimes,
|
||||||
|
OffsetOverview,
|
||||||
|
StartTimesRuntime,
|
||||||
|
TimerOverview,
|
||||||
|
} from './composite/TimeElements';
|
||||||
import TitleOverview from './composite/TitleOverview';
|
import TitleOverview from './composite/TitleOverview';
|
||||||
import { OverviewWrapper } from './OverviewWrapper';
|
import { OverviewWrapper } from './OverviewWrapper';
|
||||||
|
|
||||||
@@ -10,8 +16,10 @@ export default memo(CuesheetOverview);
|
|||||||
function CuesheetOverview({ children }: PropsWithChildren) {
|
function CuesheetOverview({ children }: PropsWithChildren) {
|
||||||
const isMobileScreen = useIsMobileScreen();
|
const isMobileScreen = useIsMobileScreen();
|
||||||
|
|
||||||
if (isMobileScreen) return <CuesheetMobile>{children}</CuesheetMobile>;
|
if (isMobileScreen) {
|
||||||
else return <CuesheetDesktop>{children}</CuesheetDesktop>;
|
return <CuesheetMobile>{children}</CuesheetMobile>;
|
||||||
|
}
|
||||||
|
return <CuesheetDesktop>{children}</CuesheetDesktop>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CuesheetMobile({ children }: PropsWithChildren) {
|
function CuesheetMobile({ children }: PropsWithChildren) {
|
||||||
@@ -27,7 +35,7 @@ function CuesheetDesktop({ children }: PropsWithChildren) {
|
|||||||
return (
|
return (
|
||||||
<OverviewWrapper navElements={children}>
|
<OverviewWrapper navElements={children}>
|
||||||
<TitleOverview />
|
<TitleOverview />
|
||||||
<StartTimes shouldFormat />
|
<StartTimesRuntime shouldFormat />
|
||||||
<TimerOverview />
|
<TimerOverview />
|
||||||
<OffsetOverview />
|
<OffsetOverview />
|
||||||
<MetadataTimes />
|
<MetadataTimes />
|
||||||
|
|||||||
@@ -1,19 +1,73 @@
|
|||||||
import { memo, PropsWithChildren } from 'react';
|
import { memo, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { ClockOverview, MetadataTimes, OffsetOverview, ProgressOverview, StartTimes } from './composite/TimeElements';
|
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ClockOverview,
|
||||||
|
MetadataTimes,
|
||||||
|
OffsetOverview,
|
||||||
|
PlanningStats,
|
||||||
|
ProgressOverview,
|
||||||
|
StartTimesPlanning,
|
||||||
|
StartTimesRuntime,
|
||||||
|
} from './composite/TimeElements';
|
||||||
import TitleOverview from './composite/TitleOverview';
|
import TitleOverview from './composite/TitleOverview';
|
||||||
import { OverviewWrapper } from './OverviewWrapper';
|
import { OverviewWrapper } from './OverviewWrapper';
|
||||||
|
|
||||||
|
import style from './Overview.module.scss';
|
||||||
|
|
||||||
export default memo(EditorOverview);
|
export default memo(EditorOverview);
|
||||||
|
|
||||||
function EditorOverview({ children }: PropsWithChildren) {
|
function EditorOverview({ children }: PropsWithChildren) {
|
||||||
|
const { layoutMode } = useEditorLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OverviewWrapper navElements={children}>
|
<OverviewWrapper navElements={children}>
|
||||||
<TitleOverview />
|
{layoutMode === EditorLayoutMode.PLANNING && <OverviewPlanning />}
|
||||||
<StartTimes />
|
{layoutMode === EditorLayoutMode.TRACKING && <OverviewTracking />}
|
||||||
<ProgressOverview />
|
{layoutMode === EditorLayoutMode.CONTROL && <OverviewControl />}
|
||||||
<OffsetOverview />
|
|
||||||
<MetadataTimes />
|
|
||||||
<ClockOverview />
|
|
||||||
</OverviewWrapper>
|
</OverviewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OverviewPlanning() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={style.inline}>
|
||||||
|
<TitleOverview />
|
||||||
|
<StartTimesPlanning />
|
||||||
|
<PlanningStats />
|
||||||
|
</div>
|
||||||
|
<ClockOverview />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewTracking() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={style.inline}>
|
||||||
|
<StartTimesRuntime />
|
||||||
|
<ProgressOverview />
|
||||||
|
<OffsetOverview />
|
||||||
|
</div>
|
||||||
|
<MetadataTimes />
|
||||||
|
<ClockOverview />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewControl() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TitleOverview />
|
||||||
|
<div className={style.inline}>
|
||||||
|
<StartTimesRuntime />
|
||||||
|
<ProgressOverview />
|
||||||
|
<OffsetOverview />
|
||||||
|
</div>
|
||||||
|
<MetadataTimes />
|
||||||
|
<ClockOverview />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,11 +29,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
flex: 1;
|
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
overflow-x: auto;
|
width: max-content;
|
||||||
overflow-y: hidden;
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.infoScroll {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { PropsWithChildren, ReactNode } from 'react';
|
import { PropsWithChildren, ReactNode } from 'react';
|
||||||
import { ErrorBoundary } from '@sentry/react';
|
import { ErrorBoundary } from '@sentry/react';
|
||||||
|
|
||||||
|
import ScrollArea from '../../common/components/scroll-area/ScrollArea';
|
||||||
import { useIsOnline } from '../../common/hooks/useSocket';
|
import { useIsOnline } from '../../common/hooks/useSocket';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
|
|
||||||
@@ -17,7 +18,14 @@ export function OverviewWrapper({ navElements, children }: PropsWithChildren<Ove
|
|||||||
<div className={cx([style.overview, !isOnline && style.isOffline])}>
|
<div className={cx([style.overview, !isOnline && style.isOffline])}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className={style.nav}>{navElements}</div>
|
<div className={style.nav}>{navElements}</div>
|
||||||
<div className={style.info}>{children}</div>
|
<ScrollArea
|
||||||
|
className={style.infoScroll}
|
||||||
|
contentClassName={style.info}
|
||||||
|
contentStyle={{ minWidth: '100%' }}
|
||||||
|
orientation='horizontal'
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollArea>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3rem 9rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.metadataRow {
|
.metadataRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(3rem, 10rem) 8rem 8rem;
|
grid-template-columns: minmax(3rem, 10rem) 8rem 8rem;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
import { useEntry } from '../../../common/hooks-query/useRundown';
|
import { useEntry } from '../../../common/hooks-query/useRundown';
|
||||||
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
|
import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
|
||||||
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
|
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
|
||||||
import { formatTime } from '../../../common/utils/time';
|
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||||
import SuperscriptPeriod from '../../../views/common/superscript-time/SuperscriptPeriod';
|
import SuperscriptPeriod from '../../../views/common/superscript-time/SuperscriptPeriod';
|
||||||
import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils';
|
import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils';
|
||||||
|
|
||||||
@@ -39,30 +39,22 @@ interface OverviewTimeElementsProps {
|
|||||||
shouldFormat?: boolean;
|
shouldFormat?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) {
|
const timeFormatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' };
|
||||||
|
|
||||||
|
function formatTimeValue(time: number | null, shouldFormat: boolean | undefined): string {
|
||||||
|
if (time === null) return timerPlaceholder;
|
||||||
|
if (shouldFormat) return formatTime(time, timeFormatOptions);
|
||||||
|
return millisToString(time, { fallback: timerPlaceholder });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StartTimesRuntime({ shouldFormat }: OverviewTimeElementsProps) {
|
||||||
const { plannedEnd, plannedStart, actualStart } = useStartTimesOverview();
|
const { plannedEnd, plannedStart, actualStart } = useStartTimesOverview();
|
||||||
|
|
||||||
const formatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' };
|
const plannedStartText = formatTimeValue(plannedStart, shouldFormat);
|
||||||
|
const actualStartText = formatTimeValue(actualStart, shouldFormat);
|
||||||
const plannedStartText = (() => {
|
|
||||||
if (plannedStart === null) return timerPlaceholder;
|
|
||||||
if (shouldFormat) return formatTime(plannedStart, formatOptions);
|
|
||||||
return millisToString(plannedStart, { fallback: timerPlaceholder });
|
|
||||||
})();
|
|
||||||
|
|
||||||
const actualStartText = (() => {
|
|
||||||
if (actualStart === null) return timerPlaceholder;
|
|
||||||
if (shouldFormat) return formatTime(actualStart, formatOptions);
|
|
||||||
return millisToString(actualStart, { fallback: timerPlaceholder });
|
|
||||||
})();
|
|
||||||
|
|
||||||
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
||||||
|
const plannedEndText = formatTimeValue(maybePlannedEnd, shouldFormat);
|
||||||
const plannedEndText = (() => {
|
|
||||||
if (maybePlannedEnd === null) return timerPlaceholder;
|
|
||||||
if (shouldFormat) return formatTime(maybePlannedEnd, formatOptions);
|
|
||||||
return millisToString(maybePlannedEnd, { fallback: timerPlaceholder });
|
|
||||||
})();
|
|
||||||
|
|
||||||
const multipleDays = maybePlannedDaySpan > 0;
|
const multipleDays = maybePlannedDaySpan > 0;
|
||||||
const plannedEndTooltip = multipleDays
|
const plannedEndTooltip = multipleDays
|
||||||
@@ -122,6 +114,59 @@ export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StartTimesPlanning({ shouldFormat }: OverviewTimeElementsProps) {
|
||||||
|
const { plannedEnd, plannedStart } = useStartTimesOverview();
|
||||||
|
|
||||||
|
const plannedStartText = formatTimeValue(plannedStart, shouldFormat);
|
||||||
|
|
||||||
|
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
|
||||||
|
const plannedEndText = formatTimeValue(maybePlannedEnd, shouldFormat);
|
||||||
|
|
||||||
|
const multipleDays = maybePlannedDaySpan > 0;
|
||||||
|
const plannedEndTooltip = multipleDays
|
||||||
|
? `Planned end time (rundown spans over ${maybePlannedDaySpan + 1} days)`
|
||||||
|
: 'Planned end time';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.column}>
|
||||||
|
<div className={style.row2}>
|
||||||
|
<span className={style.label}>Start</span>
|
||||||
|
<Tooltip
|
||||||
|
text='Planned start time'
|
||||||
|
render={
|
||||||
|
<div className={style.labelledElement}>
|
||||||
|
<TbCalendarPin className={style.icon} />
|
||||||
|
<SuperscriptPeriod
|
||||||
|
className={cx([style.time, plannedStart === null && style.muted])}
|
||||||
|
time={plannedStartText}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.row2}>
|
||||||
|
<span className={style.label}>End</span>
|
||||||
|
<Tooltip
|
||||||
|
text={plannedEndTooltip}
|
||||||
|
render={
|
||||||
|
<div className={style.labelledElement}>
|
||||||
|
<TbCalendarPin className={style.icon} />
|
||||||
|
<SuperscriptPeriod
|
||||||
|
className={cx([style.time, plannedEnd === null && style.muted])}
|
||||||
|
time={plannedEndText}
|
||||||
|
/>
|
||||||
|
{multipleDays && (
|
||||||
|
<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows the expected end for the rundown
|
* Shows the expected end for the rundown
|
||||||
* Extracted to improve performance as this is a ticking value
|
* Extracted to improve performance as this is a ticking value
|
||||||
@@ -130,11 +175,7 @@ function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) {
|
|||||||
const expectedEnd = useRundownExpectedEnd();
|
const expectedEnd = useRundownExpectedEnd();
|
||||||
|
|
||||||
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
|
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
|
||||||
const maybeExpectedEndText = (() => {
|
const maybeExpectedEndText = formatTimeValue(maybeExpectedEnd, shouldFormat);
|
||||||
if (maybeExpectedEnd === null) return timerPlaceholder;
|
|
||||||
if (shouldFormat) return formatTime(maybeExpectedEnd, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' });
|
|
||||||
return millisToString(maybeExpectedEnd, { fallback: timerPlaceholder });
|
|
||||||
})();
|
|
||||||
|
|
||||||
const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0;
|
const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0;
|
||||||
const tooltip = multipleDays
|
const tooltip = multipleDays
|
||||||
@@ -316,11 +357,27 @@ export function TimerOverview({ className }: { className?: string }) {
|
|||||||
const isWaiting = timer.phase === TimerPhase.Pending;
|
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||||
const title = isWaiting ? 'Count to start' : 'Running timer';
|
const title = isWaiting ? 'Count to start' : 'Running timer';
|
||||||
const display = millisToString(isWaiting ? timer.secondaryTimer : timer.current, { fallback: timerPlaceholder });
|
const display = millisToString(isWaiting ? timer.secondaryTimer : timer.current, { fallback: timerPlaceholder });
|
||||||
const timerState = (() => {
|
|
||||||
|
function getTimerState(): 'waiting' | 'muted' | 'active' {
|
||||||
if (isWaiting) return 'waiting';
|
if (isWaiting) return 'waiting';
|
||||||
if (timer.current === null) return 'muted';
|
if (timer.current === null) return 'muted';
|
||||||
return 'active';
|
return 'active';
|
||||||
})();
|
}
|
||||||
|
|
||||||
return <TimeColumn label={title} value={display} state={timerState} className={className} />;
|
return <TimeColumn label={title} value={display} state={getTimerState()} className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlanningStats() {
|
||||||
|
const { numEvents } = useProgressOverview();
|
||||||
|
const { plannedEnd, plannedStart } = useStartTimesOverview();
|
||||||
|
|
||||||
|
const hasTimes = plannedStart !== null && plannedEnd !== null;
|
||||||
|
const formattedDuration = hasTimes ? formatDuration(plannedEnd - plannedStart) : timerPlaceholder;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TimeColumn label='Total duration' value={formattedDuration} />
|
||||||
|
<TimeColumn label='Events' value={String(numEvents)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rundownContainer {
|
.rundownContainer {
|
||||||
margin-top: 1rem;
|
padding-top: 1rem;
|
||||||
height: 100%;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
:is([data-target='small-device']) & {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
@@ -17,6 +23,7 @@
|
|||||||
:is([data-target='small-device']) & {
|
:is([data-target='small-device']) & {
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
min-width: max-content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ import { TbFlagFilled } from 'react-icons/tb';
|
|||||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
import { closestCenter, DndContext } from '@dnd-kit/core';
|
import { closestCenter, DndContext } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
import {
|
||||||
import { type EntryId, type Rundown, isOntimeEvent, isOntimeGroup, Playback, SupportedEntry } from 'ontime-types';
|
type EntryId,
|
||||||
|
type Rundown as RundownType,
|
||||||
|
isOntimeEvent,
|
||||||
|
isOntimeGroup,
|
||||||
|
Playback,
|
||||||
|
SupportedEntry,
|
||||||
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
|
||||||
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../common/stores/entryCopyStore';
|
||||||
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
|
||||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
import { AppMode } from '../../ontimeConfig';
|
||||||
|
|
||||||
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
|
||||||
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
|
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
|
||||||
@@ -20,6 +26,8 @@ import RundownGroup from './rundown-group/RundownGroup';
|
|||||||
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
|
||||||
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
|
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
|
||||||
import RundownEmpty from './RundownEmpty';
|
import RundownEmpty from './RundownEmpty';
|
||||||
|
import { useCollapsedGroups } from './useCollapsedGroups';
|
||||||
|
import { useEditorFollowMode } from './useEditorFollowMode';
|
||||||
import { useEventSelection } from './useEventSelection';
|
import { useEventSelection } from './useEventSelection';
|
||||||
|
|
||||||
import style from './Rundown.module.scss';
|
import style from './Rundown.module.scss';
|
||||||
@@ -27,10 +35,10 @@ import style from './Rundown.module.scss';
|
|||||||
const RundownEntry = lazy(() => import('./RundownEntry'));
|
const RundownEntry = lazy(() => import('./RundownEntry'));
|
||||||
|
|
||||||
interface RundownProps {
|
interface RundownProps {
|
||||||
entries: Rundown['entries'];
|
entries: RundownType['entries'];
|
||||||
id: Rundown['id'];
|
id: RundownType['id'];
|
||||||
order: Rundown['order'];
|
order: RundownType['order'];
|
||||||
flatOrder: Rundown['flatOrder'];
|
flatOrder: RundownType['flatOrder'];
|
||||||
rundownMetadata: RundownMetadataObject;
|
rundownMetadata: RundownMetadataObject;
|
||||||
featureData: {
|
featureData: {
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
@@ -55,60 +63,36 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
setMetadata(rundownMetadata);
|
setMetadata(rundownMetadata);
|
||||||
}, [order, entries, rundownMetadata]);
|
}, [order, entries, rundownMetadata]);
|
||||||
|
|
||||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
|
||||||
// we ensure that this is unique to the rundown
|
|
||||||
key: `rundown.${id}-editor-collapsed-groups`,
|
|
||||||
defaultValue: [],
|
|
||||||
});
|
|
||||||
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
|
|
||||||
|
|
||||||
const entryActions = useEntryActionsContext();
|
const entryActions = useEntryActionsContext();
|
||||||
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
|
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
|
||||||
|
|
||||||
// cursor
|
// cursor
|
||||||
const [editorMode] = useSessionStorage<AppMode>({
|
const { editorMode } = useEditorFollowMode();
|
||||||
key: sessionKeys.editorMode,
|
|
||||||
defaultValue: AppMode.Edit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
||||||
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
|
|
||||||
const cursor = useEventSelection((state) => state.cursor);
|
const cursor = useEventSelection((state) => state.cursor);
|
||||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
||||||
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
||||||
|
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
|
||||||
|
|
||||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether a group is collapsed
|
|
||||||
*/
|
|
||||||
const getIsCollapsed = useCallback(
|
|
||||||
(groupId: EntryId): boolean => {
|
|
||||||
return collapsedGroupSet.has(groupId);
|
|
||||||
},
|
|
||||||
[collapsedGroupSet],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles logic for collapsing groups
|
* Handles logic for collapsing groups
|
||||||
*/
|
*/
|
||||||
const handleCollapseGroup = useCallback(
|
const handleCollapseGroup = useCallback(
|
||||||
(collapsed: boolean, groupId: EntryId) => {
|
(collapsed: boolean, groupId: EntryId | undefined) => {
|
||||||
setCollapsedGroups((prev) => {
|
if (collapsed) {
|
||||||
const isCollapsed = getIsCollapsed(groupId);
|
collapseGroup(groupId);
|
||||||
if (collapsed && !isCollapsed) {
|
} else {
|
||||||
const newSet = new Set(prev).add(groupId);
|
expandGroup(groupId);
|
||||||
return [...newSet];
|
}
|
||||||
}
|
|
||||||
if (!collapsed && isCollapsed) {
|
|
||||||
return [...prev].filter((id) => id !== groupId);
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[getIsCollapsed, setCollapsedGroups],
|
[collapseGroup, expandGroup],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Commands layer - business logic
|
// Commands layer - business logic
|
||||||
@@ -116,7 +100,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
entries,
|
entries,
|
||||||
flatOrder,
|
flatOrder,
|
||||||
entryActions,
|
entryActions,
|
||||||
setSelectedEvents,
|
selectEntry,
|
||||||
handleCollapseGroup,
|
handleCollapseGroup,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,9 +129,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
return filterVisibleEntries(sortableData, entries, getIsCollapsed);
|
return filterVisibleEntries(sortableData, entries, getIsCollapsed);
|
||||||
}, [sortableData, entries, getIsCollapsed]);
|
}, [sortableData, entries, getIsCollapsed]);
|
||||||
|
|
||||||
// Scroll to a specific entry when requested by keyboard/finder
|
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setScrollHandler('rundown-list', (entryId) => {
|
setScrollHandler((entryId) => {
|
||||||
if (!virtuosoRef.current || dnd.isDraggingRef.current) {
|
if (!virtuosoRef.current || dnd.isDraggingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -164,39 +148,45 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setScrollHandler('rundown-list', null);
|
setScrollHandler(null);
|
||||||
};
|
};
|
||||||
}, [visibleData, dnd.isDraggingRef, setScrollHandler]);
|
}, [visibleData, dnd.isDraggingRef, setScrollHandler]);
|
||||||
|
|
||||||
// Follow-scroll in run mode via the shared scroll handler
|
// Keep selected cursor visible by expanding its parent group in any mode.
|
||||||
|
// Finder can select entries while layout is in Run mode, where we do not follow cursor scroll.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editorMode !== AppMode.Run || dnd.isDraggingRef.current) {
|
if (!cursor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetId = featureData?.selectedEventId;
|
const entry = entries[cursor];
|
||||||
if (!targetId) {
|
if (entry && 'parent' in entry) {
|
||||||
return;
|
expandGroup(entry.parent);
|
||||||
}
|
}
|
||||||
|
}, [cursor, entries, expandGroup]);
|
||||||
|
|
||||||
scrollToEntry(targetId);
|
// Auto-follow behavior in Edit mode: follow the user's cursor
|
||||||
}, [editorMode, featureData?.selectedEventId, visibleData, dnd.isDraggingRef, scrollToEntry]);
|
|
||||||
|
|
||||||
// in run mode, we follow the playback selection and open groups as needed
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) {
|
if (dnd.isDraggingRef.current || editorMode !== AppMode.Edit || !cursor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const index = order.findIndex((id) => id === featureData.selectedEventId);
|
|
||||||
// @ts-expect-error -- but we safely check if the parent property exists
|
scrollToEntry(cursor);
|
||||||
const maybeParent = entries[featureData.selectedEventId]?.parent;
|
}, [editorMode, cursor, scrollToEntry, dnd.isDraggingRef]);
|
||||||
if (maybeParent) {
|
|
||||||
// open the group
|
// Auto-follow behavior in Run mode: follow the currently playing event
|
||||||
setCollapsedGroups((prev) => [...prev].filter((id) => id !== maybeParent));
|
useEffect(() => {
|
||||||
|
if (dnd.isDraggingRef.current || editorMode !== AppMode.Run || !featureData?.selectedEventId) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
|
// Open parent group if the target is inside a collapsed group
|
||||||
}, [editorMode, entries, featureData.selectedEventId, order, setCollapsedGroups, setSelectedEvents]);
|
const entry = entries[featureData.selectedEventId];
|
||||||
|
if (entry && 'parent' in entry) {
|
||||||
|
expandGroup(entry.parent);
|
||||||
|
}
|
||||||
|
scrollToEntry(featureData.selectedEventId);
|
||||||
|
}, [editorMode, featureData?.selectedEventId, entries, expandGroup, scrollToEntry, dnd.isDraggingRef]);
|
||||||
|
|
||||||
// gather presentation options
|
// gather presentation options
|
||||||
const isEditMode = editorMode === AppMode.Edit;
|
const isEditMode = editorMode === AppMode.Edit;
|
||||||
@@ -232,11 +222,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular entry handling - compute all values upfront
|
|
||||||
const entry = entries[entryId];
|
const entry = entries[entryId];
|
||||||
const entryMetadata = metadata[entryId];
|
const entryMetadata = metadata[entryId];
|
||||||
|
|
||||||
// Null check after computing - return null if missing
|
|
||||||
if (!entry || !entryMetadata) {
|
if (!entry || !entryMetadata) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rundownRoot {
|
.rundownRoot {
|
||||||
height: calc(100% - 1.5rem);
|
height: calc(100% - 1rem);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: inherit;
|
height: inherit;
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
box-shadow: $box-shadow-right;
|
box-shadow: $box-shadow-right;
|
||||||
|
|
||||||
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
|
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
|||||||
import { handleLinks } from '../../common/utils/linkUtils';
|
import { handleLinks } from '../../common/utils/linkUtils';
|
||||||
import { cx } from '../../common/utils/styleUtils';
|
import { cx } from '../../common/utils/styleUtils';
|
||||||
import { getIsNavigationLocked } from '../../externals';
|
import { getIsNavigationLocked } from '../../externals';
|
||||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
import { AppMode } from '../../ontimeConfig';
|
||||||
import EditorEditModal from '../../views/cuesheet/cuesheet-edit-modal/EditorEditModal';
|
import EntryEditModal from '../../views/cuesheet/cuesheet-edit-modal/EntryEditModal';
|
||||||
|
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
|
||||||
|
|
||||||
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
|
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
|
||||||
import FinderPlacement from './placements/FinderPlacement';
|
import FinderPlacement from './placements/FinderPlacement';
|
||||||
@@ -20,8 +21,9 @@ import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
|
|||||||
import RundownHeader from './rundown-header/RundownHeader';
|
import RundownHeader from './rundown-header/RundownHeader';
|
||||||
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
|
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
|
||||||
import RundownTable from './rundown-table/RundownTable';
|
import RundownTable from './rundown-table/RundownTable';
|
||||||
|
import { RundownViewMode } from './rundown.options';
|
||||||
import RundownList from './RundownList';
|
import RundownList from './RundownList';
|
||||||
import { DEFAULT_RUNDOWN_VIEW_MODE, RUNDOWN_VIEW_MODE_STORAGE_KEY, RundownViewMode } from './rundownViewMode';
|
import { useEditorFollowMode } from './useEditorFollowMode';
|
||||||
|
|
||||||
import style from './RundownExport.module.scss';
|
import style from './RundownExport.module.scss';
|
||||||
|
|
||||||
@@ -29,13 +31,11 @@ export default memo(RundownExport);
|
|||||||
|
|
||||||
function RundownExport() {
|
function RundownExport() {
|
||||||
const isExtracted = window.location.pathname.includes('/rundown');
|
const isExtracted = window.location.pathname.includes('/rundown');
|
||||||
const [editorMode] = useSessionStorage({
|
const { editorMode } = useEditorFollowMode();
|
||||||
key: sessionKeys.editorMode,
|
const { layoutMode } = useEditorLayout();
|
||||||
defaultValue: AppMode.Edit,
|
|
||||||
});
|
|
||||||
const [viewMode, setViewMode] = useSessionStorage<RundownViewMode>({
|
const [viewMode, setViewMode] = useSessionStorage<RundownViewMode>({
|
||||||
key: RUNDOWN_VIEW_MODE_STORAGE_KEY,
|
key: 'rundown-view-mode',
|
||||||
defaultValue: DEFAULT_RUNDOWN_VIEW_MODE,
|
defaultValue: RundownViewMode.List,
|
||||||
});
|
});
|
||||||
const isSmallDevice = useIsSmallDevice();
|
const isSmallDevice = useIsSmallDevice();
|
||||||
const entryActions = useEntryActions();
|
const entryActions = useEntryActions();
|
||||||
@@ -53,7 +53,7 @@ function RundownExport() {
|
|||||||
<ViewNavigationMenu suppressSettings />
|
<ViewNavigationMenu suppressSettings />
|
||||||
<div className={style.rundown}>
|
<div className={style.rundown}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RundownRoot isSmallDevice viewMode={viewMode} setViewMode={setViewMode} />
|
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
<RundownContextMenu />
|
<RundownContextMenu />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +63,10 @@ function RundownExport() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideSideBar = (isExtracted && editorMode === 'run') || viewMode === 'table';
|
const hideSideBar =
|
||||||
|
layoutMode === EditorLayoutMode.TRACKING ||
|
||||||
|
(isExtracted && editorMode === AppMode.Run) ||
|
||||||
|
viewMode === RundownViewMode.Table;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EntryActionsProvider actions={entryActions}>
|
<EntryActionsProvider actions={entryActions}>
|
||||||
@@ -75,7 +78,7 @@ function RundownExport() {
|
|||||||
<Editor.Panel className={style.list}>
|
<Editor.Panel className={style.list}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
|
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
|
||||||
<RundownRoot viewMode={viewMode} setViewMode={setViewMode} />
|
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
<RundownContextMenu />
|
<RundownContextMenu />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Editor.Panel>
|
</Editor.Panel>
|
||||||
@@ -95,16 +98,21 @@ function RundownExport() {
|
|||||||
|
|
||||||
interface RundownRootProps {
|
interface RundownRootProps {
|
||||||
isSmallDevice?: boolean;
|
isSmallDevice?: boolean;
|
||||||
|
isExtracted?: boolean;
|
||||||
viewMode: RundownViewMode;
|
viewMode: RundownViewMode;
|
||||||
setViewMode: (mode: RundownViewMode) => void;
|
setViewMode: (mode: RundownViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RundownRoot({ isSmallDevice, viewMode, setViewMode }: RundownRootProps) {
|
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
|
||||||
return (
|
return (
|
||||||
<div className={style.rundownRoot}>
|
<div className={style.rundownRoot}>
|
||||||
{isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader viewMode={viewMode} setViewMode={setViewMode} />}
|
{isSmallDevice ? (
|
||||||
{viewMode === 'list' ? <RundownList /> : <RundownTable />}
|
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
<EditorEditModal />
|
) : (
|
||||||
|
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||||
|
)}
|
||||||
|
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
||||||
|
{viewMode === RundownViewMode.Table && <EntryEditModal />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
|
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
|
||||||
|
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
@@ -15,20 +15,14 @@ interface CuesheetEntryEditorProps {
|
|||||||
|
|
||||||
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
|
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
|
||||||
const { data } = useRundown();
|
const { data } = useRundown();
|
||||||
const [entry, setEntry] = useState<OntimeEntry | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const entry = useMemo<OntimeEntry | null>(() => {
|
||||||
if (data.order.length === 0) {
|
if (data.order.length === 0) {
|
||||||
setEntry(null);
|
return null;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = data.entries[entryId];
|
const event = data.entries[entryId];
|
||||||
if (event) {
|
return event ?? null;
|
||||||
setEntry(event);
|
|
||||||
} else {
|
|
||||||
setEntry(null);
|
|
||||||
}
|
|
||||||
}, [entryId, data.order, data.entries]);
|
}, [entryId, data.order, data.entries]);
|
||||||
|
|
||||||
if (isOntimeEvent(entry)) {
|
if (isOntimeEvent(entry)) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.cuesheetEditor,
|
.cuesheetEditor,
|
||||||
.rundownEditor {
|
.rundownEditor {
|
||||||
max-height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import {
|
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
|
||||||
isOntimeDelay,
|
|
||||||
isOntimeEvent,
|
|
||||||
isOntimeGroup,
|
|
||||||
isOntimeMilestone,
|
|
||||||
OntimeEvent,
|
|
||||||
OntimeGroup,
|
|
||||||
OntimeMilestone,
|
|
||||||
} from 'ontime-types';
|
|
||||||
|
|
||||||
import useRundown from '../../../common/hooks-query/useRundown';
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
import { useEventSelection } from '../useEventSelection';
|
import { useEventSelection } from '../useEventSelection';
|
||||||
@@ -24,7 +16,7 @@ export default function RundownEntryEditor() {
|
|||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const { data } = useRundown();
|
const { data } = useRundown();
|
||||||
|
|
||||||
const entry = useMemo<OntimeEvent | OntimeGroup | OntimeMilestone | null>(() => {
|
const entry = useMemo<OntimeEntry | null>(() => {
|
||||||
if (data.order.length === 0) {
|
if (data.order.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -33,12 +25,9 @@ export default function RundownEntryEditor() {
|
|||||||
if (!selectedEventId) {
|
if (!selectedEventId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const event = data.entries[selectedEventId];
|
|
||||||
|
|
||||||
if (event && !isOntimeDelay(event)) {
|
const event = data.entries[selectedEventId];
|
||||||
return event;
|
return event ?? null;
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [data.order.length, data.entries, selectedEvents]);
|
}, [data.order.length, data.entries, selectedEvents]);
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { type EntryId, type OntimeEntry, type Rundown, isOntimeGroup, SupportedEntry } from 'ontime-types';
|
import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'ontime-types';
|
||||||
import {
|
import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils';
|
||||||
getFirstNormal,
|
|
||||||
getLastNormal,
|
|
||||||
getNextGroupNormal,
|
|
||||||
getNextNormal,
|
|
||||||
getPreviousGroupNormal,
|
|
||||||
getPreviousNormal,
|
|
||||||
} from 'ontime-utils';
|
|
||||||
|
|
||||||
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
|
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
|
import { SelectionMode } from '../useEventSelection';
|
||||||
|
|
||||||
type SelectionMode = 'shift' | 'click' | 'ctrl';
|
|
||||||
const PAGE_SIZE = 5;
|
const PAGE_SIZE = 5;
|
||||||
|
|
||||||
interface UseRundownCommandsOptions {
|
interface UseRundownCommandsOptions {
|
||||||
entries: Rundown['entries'];
|
entries: Rundown['entries'];
|
||||||
order: Rundown['order'];
|
flatOrder: EntryId[];
|
||||||
entryActions: ReturnType<typeof useEntryActions>;
|
entryActions: ReturnType<typeof useEntryActions>;
|
||||||
setSelectedEvents: (selection: { id: EntryId; selectMode: SelectionMode; index: number }) => void;
|
selectEntry: (selection: { id: EntryId; selectMode: SelectionMode; index: number }) => void;
|
||||||
handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void;
|
handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,9 +21,9 @@ interface UseRundownCommandsOptions {
|
|||||||
*/
|
*/
|
||||||
export function useRundownCommands({
|
export function useRundownCommands({
|
||||||
entries,
|
entries,
|
||||||
order,
|
flatOrder,
|
||||||
entryActions,
|
entryActions,
|
||||||
setSelectedEvents,
|
selectEntry: applySelection,
|
||||||
handleCollapseGroup,
|
handleCollapseGroup,
|
||||||
}: UseRundownCommandsOptions) {
|
}: UseRundownCommandsOptions) {
|
||||||
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
|
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
|
||||||
@@ -38,13 +31,13 @@ export function useRundownCommands({
|
|||||||
const deleteAtCursor = useCallback(
|
const deleteAtCursor = useCallback(
|
||||||
(cursor: string | null) => {
|
(cursor: string | null) => {
|
||||||
if (!cursor) return;
|
if (!cursor) return;
|
||||||
const { entry, index } = getPreviousNormal(entries, order, cursor);
|
const { entry, index } = getPreviousNormal(entries, flatOrder, cursor);
|
||||||
deleteEntry([cursor]);
|
deleteEntry([cursor]);
|
||||||
if (entry && index !== null) {
|
if (entry && index !== null) {
|
||||||
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
|
applySelection({ id: entry.id, selectMode: 'click', index });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[entries, order, deleteEntry, setSelectedEvents],
|
[entries, flatOrder, deleteEntry, applySelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertCopyAtId = useCallback(
|
const insertCopyAtId = useCallback(
|
||||||
@@ -67,7 +60,7 @@ export function useRundownCommands({
|
|||||||
|
|
||||||
if (entryCopyMode === 'cut') {
|
if (entryCopyMode === 'cut') {
|
||||||
if (!normalisedAtId) {
|
if (!normalisedAtId) {
|
||||||
const firstId = order[0];
|
const firstId = flatOrder[0];
|
||||||
if (!firstId || firstId === entryCopyId) {
|
if (!firstId || firstId === entryCopyId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -92,7 +85,7 @@ export function useRundownCommands({
|
|||||||
before: above ? normalisedAtId ?? undefined : undefined,
|
before: above ? normalisedAtId ?? undefined : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[entries, order, clone, reorderEntry],
|
[entries, flatOrder, clone, reorderEntry],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,70 +104,39 @@ export function useRundownCommands({
|
|||||||
|
|
||||||
const selectGroup = useCallback(
|
const selectGroup = useCallback(
|
||||||
(cursor: EntryId | null, direction: 'up' | 'down') => {
|
(cursor: EntryId | null, direction: 'up' | 'down') => {
|
||||||
if (order.length < 1) {
|
if (flatOrder.length < 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
let newCursor = cursor;
|
|
||||||
if (cursor === null) {
|
|
||||||
// there is no cursor, we select the first or last depending on direction
|
|
||||||
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
|
|
||||||
|
|
||||||
if (isOntimeGroup(selected)) {
|
|
||||||
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
|
|
||||||
return selected.id;
|
|
||||||
}
|
|
||||||
newCursor = selected?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newCursor === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise we select the next or previous
|
|
||||||
const selected =
|
const selected =
|
||||||
direction === 'up'
|
direction === 'up'
|
||||||
? getPreviousGroupNormal(entries, order, newCursor)
|
? getPreviousGroupNormal(entries, flatOrder, cursor)
|
||||||
: getNextGroupNormal(entries, order, newCursor);
|
: getNextGroupNormal(entries, flatOrder, cursor);
|
||||||
|
|
||||||
if (selected.entry !== null && selected.index !== null) {
|
if (selected.entry && selected.index !== null) {
|
||||||
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
applySelection({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
||||||
return selected.entry.id;
|
return selected.entry.id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[order, entries, setSelectedEvents],
|
[flatOrder, entries, applySelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: getPreviousNormal and getNextNormal do not work across group boundaries
|
|
||||||
*/
|
|
||||||
const selectEntry = useCallback(
|
const selectEntry = useCallback(
|
||||||
(cursor: EntryId | null, direction: 'up' | 'down') => {
|
(cursor: EntryId | null, direction: 'up' | 'down') => {
|
||||||
if (order.length < 1) {
|
if (flatOrder.length < 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor === null) {
|
|
||||||
// there is no cursor, we select the first or last depending on direction if it exists
|
|
||||||
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
|
|
||||||
if (selected !== null) {
|
|
||||||
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
|
|
||||||
return selected.id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise we select the next or previous
|
|
||||||
const selected =
|
const selected =
|
||||||
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
|
direction === 'up' ? getPreviousNormal(entries, flatOrder, cursor) : getNextNormal(entries, flatOrder, cursor);
|
||||||
|
|
||||||
if (selected.entry !== null && selected.index !== null) {
|
if (selected.entry && selected.index !== null) {
|
||||||
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
applySelection({ id: selected.entry.id, selectMode: 'click', index: selected.index });
|
||||||
return selected.entry.id;
|
return selected.entry.id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[order, entries, setSelectedEvents],
|
[flatOrder, entries, applySelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const moveEntry = useCallback(
|
const moveEntry = useCallback(
|
||||||
@@ -204,67 +166,52 @@ export function useRundownCommands({
|
|||||||
|
|
||||||
const selectEdge = useCallback(
|
const selectEdge = useCallback(
|
||||||
(direction: 'top' | 'bottom') => {
|
(direction: 'top' | 'bottom') => {
|
||||||
if (order.length < 1) {
|
if (flatOrder.length < 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = direction === 'top' ? getFirstNormal(entries, order) : getLastNormal(entries, order);
|
const index = direction === 'top' ? 0 : flatOrder.length - 1;
|
||||||
if (!selected) {
|
const selectedId = flatOrder[index];
|
||||||
return null;
|
if (!selectedId) return null;
|
||||||
}
|
|
||||||
|
|
||||||
const index = order.indexOf(selected.id);
|
applySelection({ id: selectedId, selectMode: 'click', index });
|
||||||
if (index === -1) {
|
return selectedId;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedEvents({ id: selected.id, selectMode: 'click', index });
|
|
||||||
return selected.id;
|
|
||||||
},
|
},
|
||||||
[entries, order, setSelectedEvents],
|
[flatOrder, applySelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectPage = useCallback(
|
const selectPage = useCallback(
|
||||||
(cursor: EntryId | null, direction: 'up' | 'down') => {
|
(cursor: EntryId | null, direction: 'up' | 'down') => {
|
||||||
if (order.length < 1) {
|
if (flatOrder.length < 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor === null) {
|
let currentIndex = cursor ? flatOrder.indexOf(cursor) : -1;
|
||||||
const selected = direction === 'down' ? getFirstNormal(entries, order) : getLastNormal(entries, order);
|
if (currentIndex === -1) {
|
||||||
if (!selected) {
|
currentIndex = direction === 'down' ? -1 : flatOrder.length;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const index = order.indexOf(selected.id);
|
|
||||||
if (index !== -1) {
|
|
||||||
setSelectedEvents({ id: selected.id, selectMode: 'click', index });
|
|
||||||
return selected.id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextCursor = cursor;
|
let targetIndex = currentIndex;
|
||||||
let target: { entry: OntimeEntry | null; index: number | null } | null = null;
|
let lastValidIndex: number | null = null;
|
||||||
|
|
||||||
for (let step = 0; step < PAGE_SIZE; step += 1) {
|
for (let step = 0; step < PAGE_SIZE; step += 1) {
|
||||||
const next =
|
targetIndex = direction === 'down' ? targetIndex + 1 : targetIndex - 1;
|
||||||
direction === 'down'
|
if (targetIndex < 0 || targetIndex >= flatOrder.length) {
|
||||||
? getNextNormal(entries, order, nextCursor)
|
|
||||||
: getPreviousNormal(entries, order, nextCursor);
|
|
||||||
if (next.entry === null || next.index === null) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
target = next;
|
lastValidIndex = targetIndex;
|
||||||
nextCursor = next.entry.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target?.entry && target.index !== null) {
|
if (lastValidIndex === null) {
|
||||||
setSelectedEvents({ id: target.entry.id, selectMode: 'click', index: target.index });
|
return null;
|
||||||
return target.entry.id;
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
const selectedId = flatOrder[lastValidIndex];
|
||||||
|
if (!selectedId) return null;
|
||||||
|
|
||||||
|
applySelection({ id: selectedId, selectMode: 'click', index: lastValidIndex });
|
||||||
|
return selectedId;
|
||||||
},
|
},
|
||||||
[entries, order, setSelectedEvents],
|
[flatOrder, applySelection],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface UseRundownDndOptions {
|
|||||||
sortableData: EntryId[];
|
sortableData: EntryId[];
|
||||||
setSortableData: Dispatch<SetStateAction<EntryId[]>>;
|
setSortableData: Dispatch<SetStateAction<EntryId[]>>;
|
||||||
getIsCollapsed: (groupId: EntryId) => boolean;
|
getIsCollapsed: (groupId: EntryId) => boolean;
|
||||||
handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void;
|
handleCollapseGroup: (collapsed: boolean, groupId: EntryId | undefined) => void;
|
||||||
entryActions: ReturnType<typeof useEntryActions>;
|
entryActions: ReturnType<typeof useEntryActions>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,13 +132,11 @@ export function useRundownDnd({
|
|||||||
if (event.over?.data.current?.type !== SupportedEntry.Group) {
|
if (event.over?.data.current?.type !== SupportedEntry.Group) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const groupId = event.over?.id as EntryId;
|
|
||||||
const isCollapsed = getIsCollapsed(groupId);
|
const groupId = event.over?.id as EntryId | undefined;
|
||||||
if (isCollapsed) {
|
handleCollapseGroup(false, groupId);
|
||||||
handleCollapseGroup(false, groupId);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[getIsCollapsed, handleCollapseGroup],
|
[handleCollapseGroup],
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ export default function RundownEvent({
|
|||||||
|
|
||||||
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
|
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
|
||||||
const unselect = useEventSelection((state) => state.unselect);
|
const unselect = useEventSelection((state) => state.unselect);
|
||||||
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
|
|
||||||
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
||||||
|
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
|
||||||
|
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||||
@@ -265,7 +265,7 @@ export default function RundownEvent({
|
|||||||
// UI indexes are 1 based
|
// UI indexes are 1 based
|
||||||
const index = eventIndex - 1;
|
const index = eventIndex - 1;
|
||||||
const editMode = getSelectionMode(event);
|
const editMode = getSelectionMode(event);
|
||||||
setSelectedEvents({ id: eventId, index, selectMode: editMode });
|
selectEntry({ id: eventId, index, selectMode: editMode });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPlaying = playback ? isPlaybackActive(playback) : false;
|
const isPlaying = playback ? isPlaybackActive(playback) : false;
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
IoTime,
|
IoTime,
|
||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
|
||||||
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { AppMode, sessionKeys } from '../../../ontimeConfig';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import TitleEditor from '../common/TitleEditor';
|
import TitleEditor from '../common/TitleEditor';
|
||||||
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
|
||||||
|
import { useEditorFollowMode } from '../useEditorFollowMode';
|
||||||
|
|
||||||
import RundownEventChip from './composite/RundownEventChip';
|
import RundownEventChip from './composite/RundownEventChip';
|
||||||
import EventBlockPlayback from './composite/RundownEventPlayback';
|
import EventBlockPlayback from './composite/RundownEventPlayback';
|
||||||
@@ -76,10 +76,7 @@ function RundownEventInner({
|
|||||||
isLinkedToLoaded,
|
isLinkedToLoaded,
|
||||||
hasTriggers,
|
hasTriggers,
|
||||||
}: RundownEventInnerProps) {
|
}: RundownEventInnerProps) {
|
||||||
const [editorMode] = useSessionStorage({
|
const { editorMode } = useEditorFollowMode();
|
||||||
key: sessionKeys.editorMode,
|
|
||||||
defaultValue: AppMode.Edit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const eventIsPlaying = playback === Playback.Play;
|
const eventIsPlaying = playback === Playback.Play;
|
||||||
const eventIsPaused = playback === Playback.Pause;
|
const eventIsPaused = playback === Playback.Pause;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
const { clone, ungroup, deleteEntry } = useEntryActionsContext();
|
const { clone, ungroup, deleteEntry } = useEntryActionsContext();
|
||||||
|
|
||||||
const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection);
|
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UI indexes are 1 based
|
// UI indexes are 1 based
|
||||||
setSingleEntrySelection({ id: data.id });
|
selectSingleEntry({ id: data.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
padding-inline: 1rem 2rem;
|
padding-inline: 1rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
:is([data-target='small-device']) & {
|
:is([data-target='small-device']) & {
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
@@ -57,52 +58,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toggleTooltipTrigger {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.apart {
|
.apart {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator {
|
|
||||||
margin-inline: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column {
|
.column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $gray-400; // Guessing color based on cuesheet
|
|
||||||
}
|
|
||||||
|
|
||||||
.popoverContent {
|
|
||||||
padding: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-self: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $gray-400; // Guessing color
|
|
||||||
}
|
|
||||||
|
|
||||||
.popoverContent {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,25 +2,56 @@ import { memo } from 'react';
|
|||||||
import { Toggle } from '@base-ui/react/toggle';
|
import { Toggle } from '@base-ui/react/toggle';
|
||||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||||
import { Toolbar } from '@base-ui/react/toolbar';
|
import { Toolbar } from '@base-ui/react/toolbar';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
import { OffsetMode } from 'ontime-types';
|
||||||
|
|
||||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { AppMode, sessionKeys } from '../../../ontimeConfig';
|
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
|
||||||
import { RundownViewMode } from '../rundownViewMode';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
|
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
|
||||||
|
import { RundownViewMode } from '../rundown.options';
|
||||||
|
import { useEditorFollowMode } from '../useEditorFollowMode';
|
||||||
|
|
||||||
import RundownMenu from './RundownMenu';
|
import RundownMenu from './RundownMenu';
|
||||||
import RundownSettings from './RundownSettings';
|
|
||||||
|
|
||||||
import style from './RundownHeader.module.scss';
|
import style from './RundownHeader.module.scss';
|
||||||
|
|
||||||
interface RundownHeaderProps {
|
interface RundownHeaderProps {
|
||||||
|
isExtracted?: boolean;
|
||||||
viewMode: RundownViewMode;
|
viewMode: RundownViewMode;
|
||||||
setViewMode: (mode: RundownViewMode) => void;
|
setViewMode: (mode: RundownViewMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HeaderControlsConfig {
|
||||||
|
showRunEditToggle: boolean;
|
||||||
|
showOffsetToggle: boolean;
|
||||||
|
showOverflowMenu: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
|
||||||
|
[EditorLayoutMode.CONTROL]: {
|
||||||
|
showRunEditToggle: true,
|
||||||
|
showOffsetToggle: true,
|
||||||
|
showOverflowMenu: true,
|
||||||
|
},
|
||||||
|
[EditorLayoutMode.PLANNING]: {
|
||||||
|
showRunEditToggle: false,
|
||||||
|
showOffsetToggle: false,
|
||||||
|
showOverflowMenu: true,
|
||||||
|
},
|
||||||
|
[EditorLayoutMode.TRACKING]: {
|
||||||
|
showRunEditToggle: false,
|
||||||
|
showOffsetToggle: true,
|
||||||
|
showOverflowMenu: false,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
export default memo(RundownHeader);
|
export default memo(RundownHeader);
|
||||||
function RundownHeader({ viewMode, setViewMode }: RundownHeaderProps) {
|
function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProps) {
|
||||||
const [editorMode, setEditorMode] = useSessionStorage({ key: sessionKeys.editorMode, defaultValue: AppMode.Edit });
|
const { editorMode, setEditorMode } = useEditorFollowMode();
|
||||||
|
const offsetMode = useOffsetMode();
|
||||||
|
const { layoutMode } = useEditorLayout();
|
||||||
|
|
||||||
|
const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode];
|
||||||
|
|
||||||
const toggleAppMode = (mode: AppMode[]) => {
|
const toggleAppMode = (mode: AppMode[]) => {
|
||||||
// we need to stop user from deselecting a mode
|
// we need to stop user from deselecting a mode
|
||||||
@@ -29,22 +60,70 @@ function RundownHeader({ viewMode, setViewMode }: RundownHeaderProps) {
|
|||||||
setEditorMode(newValue);
|
setEditorMode(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleViewMode = (mode: RundownViewMode[]) => {
|
||||||
|
const newValue = mode.at(0);
|
||||||
|
if (!newValue) return;
|
||||||
|
setViewMode(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOffsetMode = (mode: OffsetMode[]) => {
|
||||||
|
const newValue = mode.at(0);
|
||||||
|
if (!newValue) return;
|
||||||
|
setOffsetMode(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Toolbar.Root className={style.header}>
|
<Toolbar.Root className={style.header}>
|
||||||
<ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}>
|
{showRunEditToggle && (
|
||||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
<ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}>
|
||||||
Run
|
<Tooltip
|
||||||
</Toolbar.Button>
|
text='Live playback view with auto-follow'
|
||||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
render={<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton} />}
|
||||||
Edit
|
>
|
||||||
</Toolbar.Button>
|
Run
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
text='Manual editing without playback automation'
|
||||||
|
render={<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton} />}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Tooltip>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
|
||||||
|
<Tooltip
|
||||||
|
text='View rundown in list mode'
|
||||||
|
render={<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton} />}
|
||||||
|
>
|
||||||
|
List
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
text='View rundown in table mode'
|
||||||
|
render={<Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton} />}
|
||||||
|
>
|
||||||
|
Table
|
||||||
|
</Tooltip>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
|
|
||||||
<Editor.Separator className={style.separator} />
|
{showOffsetToggle && (
|
||||||
|
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
|
||||||
|
<Tooltip
|
||||||
|
text='Offsets use fixed clock time'
|
||||||
|
render={<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton} />}
|
||||||
|
>
|
||||||
|
Absolute
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
text='Offsets follow the rundown relative start'
|
||||||
|
render={<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton} />}
|
||||||
|
>
|
||||||
|
Relative
|
||||||
|
</Tooltip>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
<RundownSettings viewMode={viewMode} setViewMode={setViewMode} />
|
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
|
||||||
|
|
||||||
<RundownMenu />
|
|
||||||
</Toolbar.Root>
|
</Toolbar.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,21 @@ import { memo } from 'react';
|
|||||||
import { Toggle } from '@base-ui/react/toggle';
|
import { Toggle } from '@base-ui/react/toggle';
|
||||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||||
import { Toolbar } from '@base-ui/react/toolbar';
|
import { Toolbar } from '@base-ui/react/toolbar';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
|
||||||
import { OffsetMode } from 'ontime-types';
|
|
||||||
|
|
||||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
|
import { RundownViewMode } from '../rundown.options';
|
||||||
import { AppMode, sessionKeys } from '../../../ontimeConfig';
|
import { useEditorFollowMode } from '../useEditorFollowMode';
|
||||||
|
|
||||||
import style from './RundownHeader.module.scss';
|
import style from './RundownHeader.module.scss';
|
||||||
|
|
||||||
export default memo(RundownHeader);
|
interface RundownHeaderMobileProps {
|
||||||
function RundownHeader() {
|
viewMode: RundownViewMode;
|
||||||
const [editorMode, setEditorMode] = useSessionStorage<AppMode>({
|
setViewMode: (mode: RundownViewMode) => void;
|
||||||
key: sessionKeys.editorMode,
|
}
|
||||||
defaultValue: AppMode.Edit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const offsetMode = useOffsetMode();
|
export default memo(RundownHeaderMobile);
|
||||||
|
function RundownHeaderMobile({ viewMode, setViewMode }: RundownHeaderMobileProps) {
|
||||||
|
const { editorMode, setEditorMode } = useEditorFollowMode();
|
||||||
|
|
||||||
const toggleAppMode = (mode: AppMode[]) => {
|
const toggleAppMode = (mode: AppMode[]) => {
|
||||||
// we need to stop user from deselecting a mode
|
// we need to stop user from deselecting a mode
|
||||||
@@ -27,11 +25,10 @@ function RundownHeader() {
|
|||||||
setEditorMode(newValue);
|
setEditorMode(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleOffsetMode = (mode: OffsetMode[]) => {
|
const toggleViewMode = (mode: RundownViewMode[]) => {
|
||||||
// we need to stop user from deselecting a mode
|
|
||||||
const newValue = mode.at(0);
|
const newValue = mode.at(0);
|
||||||
if (!newValue) return;
|
if (!newValue) return;
|
||||||
setOffsetMode(newValue);
|
setViewMode(newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,19 +37,18 @@ function RundownHeader() {
|
|||||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||||
Run
|
Run
|
||||||
</Toolbar.Button>
|
</Toolbar.Button>
|
||||||
|
|
||||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||||
Edit
|
Edit
|
||||||
</Toolbar.Button>
|
</Toolbar.Button>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
|
|
||||||
<Editor.Separator className={style.separator} />
|
<ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
|
||||||
|
<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton}>
|
||||||
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
|
List
|
||||||
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton}>
|
|
||||||
Absolute
|
|
||||||
</Toolbar.Button>
|
</Toolbar.Button>
|
||||||
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton}>
|
<Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton}>
|
||||||
Relative
|
Table
|
||||||
</Toolbar.Button>
|
</Toolbar.Button>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</Toolbar.Root>
|
</Toolbar.Root>
|
||||||
|
|||||||
@@ -13,8 +13,12 @@ import { useEventSelection } from '../useEventSelection';
|
|||||||
|
|
||||||
import style from './RundownHeader.module.scss';
|
import style from './RundownHeader.module.scss';
|
||||||
|
|
||||||
|
interface RundownMenuProps {
|
||||||
|
allowNavigation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(RundownMenu);
|
export default memo(RundownMenu);
|
||||||
function RundownMenu() {
|
function RundownMenu({ allowNavigation }: RundownMenuProps) {
|
||||||
const [isOpen, handlers] = useDisclosure();
|
const [isOpen, handlers] = useDisclosure();
|
||||||
|
|
||||||
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
||||||
@@ -38,6 +42,7 @@ function RundownMenu() {
|
|||||||
label: 'Manage Rundowns...',
|
label: 'Manage Rundowns...',
|
||||||
icon: IoList,
|
icon: IoList,
|
||||||
onClick: () => setLocation('manage'),
|
onClick: () => setLocation('manage'),
|
||||||
|
disabled: !allowNavigation,
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
import { Toggle } from '@base-ui/react/toggle';
|
|
||||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
|
||||||
import { Toolbar } from '@base-ui/react/toolbar';
|
|
||||||
|
|
||||||
import { RundownViewMode } from '../rundownViewMode';
|
|
||||||
|
|
||||||
import style from './RundownHeader.module.scss';
|
|
||||||
|
|
||||||
interface RundownSettingsProps {
|
|
||||||
viewMode: RundownViewMode;
|
|
||||||
setViewMode: (mode: RundownViewMode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(RundownSettings);
|
|
||||||
function RundownSettings({ viewMode, setViewMode }: RundownSettingsProps) {
|
|
||||||
const toggleViewMode = (mode: RundownViewMode[]) => {
|
|
||||||
const newValue = mode.at(0);
|
|
||||||
if (!newValue) return;
|
|
||||||
setViewMode(newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
|
|
||||||
<Toolbar.Button render={<Toggle />} value='list' className={style.radioButton}>
|
|
||||||
List
|
|
||||||
</Toolbar.Button>
|
|
||||||
<Toolbar.Button render={<Toggle />} value='table' className={style.radioButton}>
|
|
||||||
Table
|
|
||||||
</Toolbar.Button>
|
|
||||||
</ToggleGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -30,7 +30,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
|||||||
const { updateEntry, deleteEntry } = useEntryActionsContext();
|
const { updateEntry, deleteEntry } = useEntryActionsContext();
|
||||||
|
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection);
|
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||||
|
|
||||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
||||||
@@ -69,7 +69,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UI indexes are 1 based
|
// UI indexes are 1 based
|
||||||
setSingleEntrySelection({ id: entryId });
|
selectSingleEntry({ id: entryId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = (field: 'cue' | 'title', value: string) => {
|
const handleUpdate = (field: 'cue' | 'title', value: string) => {
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
import { memo, useMemo } from 'react';
|
import { memo, useEffect, useMemo } from 'react';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
|
||||||
|
|
||||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||||
import { AppMode, sessionKeys } from '../../../ontimeConfig';
|
|
||||||
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
|
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
|
||||||
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
|
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
|
||||||
|
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
|
||||||
|
import { useEditorFollowMode } from '../useEditorFollowMode';
|
||||||
|
|
||||||
import { makeRundownColumns } from './makeRundownColumns';
|
import { makeRundownColumns } from './makeRundownColumns';
|
||||||
|
|
||||||
export default memo(RundownTable);
|
export default memo(RundownTable);
|
||||||
function RundownTable() {
|
function RundownTable() {
|
||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
|
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||||
|
const { editorMode } = useEditorFollowMode();
|
||||||
|
|
||||||
const [editorMode] = useSessionStorage({
|
// Editor always has full permissions
|
||||||
key: sessionKeys.editorMode,
|
useEffect(() => {
|
||||||
defaultValue: AppMode.Edit,
|
setPermissions({
|
||||||
});
|
canChangeMode: true,
|
||||||
|
canCreateEntries: true,
|
||||||
|
canEditEntries: true,
|
||||||
|
canFlag: true,
|
||||||
|
canShare: true,
|
||||||
|
});
|
||||||
|
}, [setPermissions]);
|
||||||
|
|
||||||
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
|
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
|
||||||
|
|
||||||
|
|||||||
@@ -58,3 +58,8 @@ export function makeRundownCustomColumns(customFields: CustomFields) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum RundownViewMode {
|
||||||
|
List = 'list',
|
||||||
|
Table = 'table',
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
import { EntryId, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a sortable list of entries
|
* Creates a sortable list of entries
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export const RUNDOWN_VIEW_MODES = ['list', 'table'] as const;
|
|
||||||
export type RundownViewMode = (typeof RUNDOWN_VIEW_MODES)[number];
|
|
||||||
|
|
||||||
export const DEFAULT_RUNDOWN_VIEW_MODE: RundownViewMode = 'list';
|
|
||||||
export const RUNDOWN_VIEW_MODE_STORAGE_KEY = 'rundown-view-mode';
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useSessionStorage } from '@mantine/hooks';
|
||||||
|
import { EntryId } from 'ontime-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps track of which groups are collapsed
|
||||||
|
* This information is saved in session storage as an array (serializable)
|
||||||
|
* but provides Set-like operations for fast lookups
|
||||||
|
*/
|
||||||
|
export function useCollapsedGroups(rundownId: string) {
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||||
|
key: `rundown.${rundownId}-editor-collapsed-groups`,
|
||||||
|
defaultValue: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
|
||||||
|
|
||||||
|
const getIsCollapsed = useCallback(
|
||||||
|
(groupId: EntryId): boolean => {
|
||||||
|
return collapsedGroupSet.has(groupId);
|
||||||
|
},
|
||||||
|
[collapsedGroupSet],
|
||||||
|
);
|
||||||
|
|
||||||
|
const collapseGroup = useCallback(
|
||||||
|
(groupId: EntryId | null | undefined) => {
|
||||||
|
if (!groupId) return;
|
||||||
|
setCollapsedGroups((prev) => {
|
||||||
|
if (prev.includes(groupId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, groupId];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setCollapsedGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
const expandGroup = useCallback(
|
||||||
|
(groupId: EntryId | null | undefined) => {
|
||||||
|
if (!groupId) return;
|
||||||
|
setCollapsedGroups((prev) => prev.filter((id) => id !== groupId));
|
||||||
|
},
|
||||||
|
[setCollapsedGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { getIsCollapsed, collapseGroup, expandGroup };
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useSessionStorage } from '@mantine/hooks';
|
||||||
|
|
||||||
|
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||||
|
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the editor mode (Edit/Run) derived from layout mode
|
||||||
|
*
|
||||||
|
* Editor mode is automatically determined by the layout:
|
||||||
|
* - PLANNING: Always Edit mode (follows user selection)
|
||||||
|
* - TRACKING: Always Run mode (follows current event)
|
||||||
|
* - CONTROL: User preference (defaults to Run mode), persisted in session storage
|
||||||
|
*
|
||||||
|
* Edit: Manual editing, follows user selection
|
||||||
|
* Run: Live playback view, auto-follows current event
|
||||||
|
*/
|
||||||
|
export function useEditorFollowMode() {
|
||||||
|
const { layoutMode } = useEditorLayout();
|
||||||
|
|
||||||
|
// Only used for CONTROL layout - stores user preference
|
||||||
|
const [controlModePreference, setControlModePreference] = useSessionStorage<AppMode>({
|
||||||
|
key: sessionKeys.editorMode,
|
||||||
|
defaultValue: AppMode.Run,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derive editor mode from layout mode
|
||||||
|
const editorMode = (() => {
|
||||||
|
if (layoutMode === EditorLayoutMode.CONTROL) return controlModePreference;
|
||||||
|
if (layoutMode === EditorLayoutMode.PLANNING) return AppMode.Edit;
|
||||||
|
return AppMode.Run;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// setEditorMode only affects CONTROL layout
|
||||||
|
const setEditorMode = (mode: AppMode) => {
|
||||||
|
if (layoutMode === EditorLayoutMode.CONTROL) {
|
||||||
|
setControlModePreference(mode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { editorMode, setEditorMode };
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { RUNDOWN } from '../../common/api/constants';
|
|||||||
import { ontimeQueryClient } from '../../common/queryClient';
|
import { ontimeQueryClient } from '../../common/queryClient';
|
||||||
import { isMacOS } from '../../common/utils/deviceUtils';
|
import { isMacOS } from '../../common/utils/deviceUtils';
|
||||||
|
|
||||||
type SelectionMode = 'shift' | 'click' | 'ctrl';
|
export type SelectionMode = 'shift' | 'click' | 'ctrl';
|
||||||
|
|
||||||
interface EventSelectionStore {
|
interface EventSelectionStore {
|
||||||
selectedEvents: Set<EntryId>;
|
selectedEvents: Set<EntryId>;
|
||||||
@@ -14,23 +14,25 @@ interface EventSelectionStore {
|
|||||||
cursor: EntryId | null;
|
cursor: EntryId | null;
|
||||||
entryMode: 'event' | 'single' | null;
|
entryMode: 'event' | 'single' | null;
|
||||||
scrollHandler: ((id: EntryId) => void) | null;
|
scrollHandler: ((id: EntryId) => void) | null;
|
||||||
scrollHandlerSource: string | null;
|
|
||||||
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
|
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
|
||||||
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
|
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
|
||||||
clearSelectedEvents: () => void;
|
clearSelectedEvents: () => void;
|
||||||
clearMultiSelect: () => void;
|
clearMultiSelect: () => void;
|
||||||
unselect: (id: EntryId) => void;
|
unselect: (id: EntryId) => void;
|
||||||
setScrollHandler: (source: string, handler: ((id: EntryId) => void) | null) => void;
|
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
|
||||||
scrollToEntry: (id: EntryId) => void;
|
scrollToEntry: (id: EntryId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps track of the selected entries and selection mode
|
||||||
|
* Provides methods to update the selection based on user interactions
|
||||||
|
*/
|
||||||
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||||
selectedEvents: new Set(),
|
selectedEvents: new Set(),
|
||||||
anchoredIndex: null,
|
anchoredIndex: null,
|
||||||
cursor: null,
|
cursor: null,
|
||||||
entryMode: null,
|
entryMode: null,
|
||||||
scrollHandler: null,
|
scrollHandler: null,
|
||||||
scrollHandlerSource: null,
|
|
||||||
setSingleEntrySelection: ({ id }) => {
|
setSingleEntrySelection: ({ id }) => {
|
||||||
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
|
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
|
||||||
},
|
},
|
||||||
@@ -123,16 +125,9 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
|||||||
entryMode: selectedEvents.size === 0 ? null : entryMode,
|
entryMode: selectedEvents.size === 0 ? null : entryMode,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
setScrollHandler: (source, handler) =>
|
// Sets the scroll handler for programmatic scrolling to entries
|
||||||
set((state) => {
|
setScrollHandler: (handler) => set({ scrollHandler: handler }),
|
||||||
if (handler) {
|
// Scrolls to the specified entry using the registered scroll handler
|
||||||
return { scrollHandler: handler, scrollHandlerSource: source };
|
|
||||||
}
|
|
||||||
if (state.scrollHandlerSource !== source) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return { scrollHandler: null, scrollHandlerSource: null };
|
|
||||||
}),
|
|
||||||
scrollToEntry: (id: EntryId) => {
|
scrollToEntry: (id: EntryId) => {
|
||||||
const handler = get().scrollHandler;
|
const handler = get().scrollHandler;
|
||||||
if (handler) {
|
if (handler) {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { EntryId, MaybeString } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useCollapsedGroups } from './useCollapsedGroups';
|
||||||
|
import { useEventSelection } from './useEventSelection';
|
||||||
|
|
||||||
|
type SelectAndRevealOptions = {
|
||||||
|
id: EntryId;
|
||||||
|
index: number;
|
||||||
|
parent?: MaybeString;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSelectAndRevealEntry(rundownId: string) {
|
||||||
|
const { expandGroup } = useCollapsedGroups(rundownId);
|
||||||
|
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
|
||||||
|
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
({ id, index, parent }: SelectAndRevealOptions) => {
|
||||||
|
expandGroup(parent);
|
||||||
|
selectEntry({ id, index, selectMode: 'click' });
|
||||||
|
// Wait one frame so collapsed-group expansion is reflected in rendered/visible entries before scrolling.
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollToEntry(id);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[expandGroup, scrollToEntry, selectEntry],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,8 +49,6 @@ $playback-under: $green-500;
|
|||||||
$bg-container-l1: $gray-1350;
|
$bg-container-l1: $gray-1350;
|
||||||
$bg-container-l2: $gray-1300;
|
$bg-container-l2: $gray-1300;
|
||||||
|
|
||||||
$bg-container-onlight: $gray-100;
|
|
||||||
|
|
||||||
$backdrop-color: rgba(0, 0, 0, 0.7);
|
$backdrop-color: rgba(0, 0, 0, 0.7);
|
||||||
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||||
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
|||||||
import { getIsNavigationLocked } from '../../externals';
|
import { getIsNavigationLocked } from '../../externals';
|
||||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||||
|
|
||||||
import CuesheetEditModal from './cuesheet-edit-modal/CuesheetEditModal';
|
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
||||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||||
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export default function CuesheetPage() {
|
|||||||
return (
|
return (
|
||||||
<EntryActionsProvider actions={entryActions}>
|
<EntryActionsProvider actions={entryActions}>
|
||||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||||
<CuesheetEditModal />
|
<EntryEditModal />
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||||
<CuesheetOverview>
|
<CuesheetOverview>
|
||||||
{!isLocked && (
|
{!isLocked && (
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
|
|
||||||
import Modal from '../../../common/components/modal/Modal';
|
|
||||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
|
||||||
|
|
||||||
import { useCuesheetEditModal } from './useCuesheetEditModal';
|
|
||||||
|
|
||||||
export default memo(CuesheetEditModal);
|
|
||||||
function CuesheetEditModal() {
|
|
||||||
const entryId = useCuesheetEditModal((state) => state.selectedEntryId);
|
|
||||||
const closeModal = useCuesheetEditModal((state) => state.clearSelection);
|
|
||||||
|
|
||||||
if (entryId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen
|
|
||||||
onClose={closeModal}
|
|
||||||
title='Edit entry'
|
|
||||||
showCloseButton
|
|
||||||
bodyElements={<CuesheetEntryEditor entryId={entryId} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+5
-5
@@ -3,12 +3,12 @@ import { memo } from 'react';
|
|||||||
import Modal from '../../../common/components/modal/Modal';
|
import Modal from '../../../common/components/modal/Modal';
|
||||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||||
|
|
||||||
import { useEditorEditModal } from './useEditorEditModal';
|
import { useEditModal } from './useEditModal';
|
||||||
|
|
||||||
export default memo(EditorEditModal);
|
export default memo(EntryEditModal);
|
||||||
function EditorEditModal() {
|
function EntryEditModal() {
|
||||||
const entryId = useEditorEditModal((state) => state.selectedEntryId);
|
const entryId = useEditModal((state) => state.selectedEntryId);
|
||||||
const closeModal = useEditorEditModal((state) => state.clearSelection);
|
const closeModal = useEditModal((state) => state.clearSelection);
|
||||||
|
|
||||||
if (entryId === null) {
|
if (entryId === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { EntryId } from 'ontime-types';
|
|
||||||
import { create } from 'zustand';
|
|
||||||
|
|
||||||
interface SelectedEntryState {
|
|
||||||
selectedEntryId: EntryId | null;
|
|
||||||
setEditableEntry: (entryId: EntryId) => void;
|
|
||||||
clearSelection: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCuesheetEditModal = create<SelectedEntryState>((set) => ({
|
|
||||||
selectedEntryId: null,
|
|
||||||
setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }),
|
|
||||||
clearSelection: () => set({ selectedEntryId: null }),
|
|
||||||
}));
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@ interface SelectedEntryState {
|
|||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditorEditModal = create<SelectedEntryState>((set) => ({
|
export const useEditModal = create<SelectedEntryState>((set) => ({
|
||||||
selectedEntryId: null,
|
selectedEntryId: null,
|
||||||
setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }),
|
setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }),
|
||||||
clearSelection: () => set({ selectedEntryId: null }),
|
clearSelection: () => set({ selectedEntryId: null }),
|
||||||
@@ -11,6 +11,11 @@ $table-header-font-size: calc(1rem - 2px);
|
|||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
padding-bottom: 70vh; // allow focus to reach last elements
|
padding-bottom: 70vh; // allow focus to reach last elements
|
||||||
|
|
||||||
|
:is([data-target='small-device']) & {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
thead {
|
thead {
|
||||||
tr {
|
tr {
|
||||||
&::before {
|
&::before {
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import DelayRow from './cuesheet-table-elements/DelayRow';
|
|||||||
import EventRow from './cuesheet-table-elements/EventRow';
|
import EventRow from './cuesheet-table-elements/EventRow';
|
||||||
import GroupRow from './cuesheet-table-elements/GroupRow';
|
import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||||
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
|
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||||
import EditorTableMenu from './cuesheet-table-menu/EditorTableMenu';
|
|
||||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
|||||||
|
|
||||||
const selectedEventId = useSelectedEventId();
|
const selectedEventId = useSelectedEventId();
|
||||||
const cursor = useEventSelection((state) => state.cursor);
|
const cursor = useEventSelection((state) => state.cursor);
|
||||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
|
||||||
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
||||||
|
|
||||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||||
@@ -116,18 +114,23 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
|||||||
setColumnSizing({});
|
setColumnSizing({});
|
||||||
}, [setColumnSizing]);
|
}, [setColumnSizing]);
|
||||||
|
|
||||||
// Auto-scroll only in run mode, routed through the shared scroll handler
|
// in Run mode, follow the current event
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cuesheetMode !== AppMode.Run || !selectedEventId) {
|
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToEntry(selectedEventId);
|
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
|
||||||
}, [cuesheetMode, data, selectedEventId, scrollToEntry]);
|
if (eventIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||||
|
}, [cuesheetMode, data, selectedEventId]);
|
||||||
|
|
||||||
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setScrollHandler(`cuesheet-table-${tableRoot}`, (entryId) => {
|
const handler = (entryId: string) => {
|
||||||
if (virtuosoRef.current === null) {
|
if (virtuosoRef.current === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -137,13 +140,15 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth', align: 'start', offset: -50 });
|
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||||
});
|
};
|
||||||
|
|
||||||
|
setScrollHandler(handler);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setScrollHandler(`cuesheet-table-${tableRoot}`, null);
|
setScrollHandler(null);
|
||||||
};
|
};
|
||||||
}, [data, setScrollHandler, tableRoot]);
|
}, [data, setScrollHandler]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* To improve performance on resizing, we memoise the column sizes
|
* To improve performance on resizing, we memoise the column sizes
|
||||||
@@ -174,7 +179,6 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
|||||||
|
|
||||||
// control components need different implementations for handling permissions
|
// control components need different implementations for handling permissions
|
||||||
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
|
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
|
||||||
const TableMenu = tableRoot === 'editor' ? EditorTableMenu : CuesheetTableMenu;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -187,6 +191,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
|||||||
<TableVirtuoso
|
<TableVirtuoso
|
||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
data={data}
|
data={data}
|
||||||
|
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
|
||||||
increaseViewportBy={{ top: 100, bottom: 200 }}
|
increaseViewportBy={{ top: 100, bottom: 200 }}
|
||||||
components={{
|
components={{
|
||||||
EmptyPlaceholder: () => <EmptyTableBody text='No data in rundown' />,
|
EmptyPlaceholder: () => <EmptyTableBody text='No data in rundown' />,
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
|
|
||||||
import { SupportedEntry } from 'ontime-types';
|
|
||||||
|
|
||||||
import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
|
||||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
|
||||||
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
|
|
||||||
|
|
||||||
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
|
||||||
|
|
||||||
export default memo(EditorTableMenu);
|
|
||||||
|
|
||||||
function EditorTableMenu() {
|
|
||||||
const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu();
|
|
||||||
const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActionsContext();
|
|
||||||
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PositionedDropdownMenu
|
|
||||||
isOpen
|
|
||||||
onClose={closeMenu}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
type: 'item',
|
|
||||||
label: 'Edit...',
|
|
||||||
onClick: () => showModal(entryId),
|
|
||||||
icon: IoOptions,
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
type: 'item',
|
|
||||||
label: flag ? 'Remove flag' : 'Add flag',
|
|
||||||
onClick: () => updateEntry({ id: entryId, flag: !flag }),
|
|
||||||
icon: IoDuplicateOutline,
|
|
||||||
disabled: flag === null,
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
type: 'item',
|
|
||||||
label: 'Add event above',
|
|
||||||
onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { before: entryId }),
|
|
||||||
icon: IoAdd,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'item',
|
|
||||||
label: 'Add event below',
|
|
||||||
onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { after: entryId }),
|
|
||||||
icon: IoAdd,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'item',
|
|
||||||
label: 'Clone event',
|
|
||||||
onClick: () => clone(entryId),
|
|
||||||
icon: IoDuplicateOutline,
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
type: 'item',
|
|
||||||
label: 'Move up',
|
|
||||||
onClick: () => move(entryId, 'up'),
|
|
||||||
icon: IoArrowUp,
|
|
||||||
disabled: entryIndex < 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'item',
|
|
||||||
label: 'Move down',
|
|
||||||
onClick: () => move(entryId, 'down'),
|
|
||||||
icon: IoArrowDown,
|
|
||||||
},
|
|
||||||
{ type: 'divider' },
|
|
||||||
{
|
|
||||||
type: 'item',
|
|
||||||
label: 'Delete',
|
|
||||||
onClick: () => deleteEntry([entryId]),
|
|
||||||
icon: IoTrash,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
position={position}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+4
-4
@@ -4,17 +4,17 @@ import { SupportedEntry } from 'ontime-types';
|
|||||||
|
|
||||||
import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||||
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
|
import { useEditModal } from '../../cuesheet-edit-modal/useEditModal';
|
||||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||||
|
|
||||||
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
||||||
|
|
||||||
export default memo(CuesheetTableMenu);
|
export default memo(TableMenu);
|
||||||
|
|
||||||
function CuesheetTableMenu() {
|
function TableMenu() {
|
||||||
const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu();
|
const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu();
|
||||||
const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActionsContext();
|
const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActionsContext();
|
||||||
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
|
const showModal = useEditModal((state) => state.setEditableEntry);
|
||||||
const permissions = useCuesheetPermissions();
|
const permissions = useCuesheetPermissions();
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -7,6 +7,13 @@ $max-playback-width: 30rem;
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: $panel-gap;
|
gap: $panel-gap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelContainerTracking {
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left {
|
.left {
|
||||||
@@ -17,3 +24,29 @@ $max-playback-width: 30rem;
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $panel-gap;
|
gap: $panel-gap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rundownLayout {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: $panel-gap;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rundownPanel {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlesPanel {
|
||||||
|
// align rundown start with the + - buttons in the playback controls
|
||||||
|
// start + iconbtn + iconbt + 2 x gap
|
||||||
|
$overview-width: calc(8rem + 5rem + 5rem + 2rem);
|
||||||
|
|
||||||
|
flex: 0 0 $overview-width;
|
||||||
|
min-width: $overview-width;
|
||||||
|
max-width: $overview-width;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
|
||||||
|
import { AppMode } from '../../ontimeConfig';
|
||||||
|
|
||||||
|
import TitleList from './title-list/TitleList';
|
||||||
|
import { EditorLayoutMode, useEditorLayout } from './useEditorLayout';
|
||||||
|
|
||||||
import styles from './Editor.module.scss';
|
import styles from './Editor.module.scss';
|
||||||
|
|
||||||
const Rundown = lazy(() => import('../../features/rundown/RundownExport'));
|
const Rundown = lazy(() => import('../../features/rundown/RundownExport'));
|
||||||
@@ -7,13 +13,46 @@ const TimerControl = lazy(() => import('../../features/control/playback/TimerCon
|
|||||||
const MessageControl = lazy(() => import('../../features/control/message/MessageControlExport'));
|
const MessageControl = lazy(() => import('../../features/control/message/MessageControlExport'));
|
||||||
|
|
||||||
export default function Editor() {
|
export default function Editor() {
|
||||||
|
const { layoutMode } = useEditorLayout();
|
||||||
|
|
||||||
|
if (layoutMode === EditorLayoutMode.CONTROL) {
|
||||||
|
return (
|
||||||
|
<div id='panels' className={styles.panelContainer}>
|
||||||
|
<div className={styles.left}>
|
||||||
|
<TimerControl />
|
||||||
|
<MessageControl />
|
||||||
|
</div>
|
||||||
|
<Rundown />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutMode === EditorLayoutMode.TRACKING) {
|
||||||
|
return (
|
||||||
|
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
|
||||||
|
<div className={styles.rundownLayout}>
|
||||||
|
<div className={styles.titlesPanel}>
|
||||||
|
<TitleList mode={AppMode.Run} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.rundownPanel}>
|
||||||
|
<Rundown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TrackingPlaybackBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id='panels' className={styles.panelContainer}>
|
<div id='panels' className={styles.panelContainer}>
|
||||||
<div className={styles.left}>
|
<div className={styles.rundownLayout}>
|
||||||
<TimerControl />
|
<div className={styles.titlesPanel}>
|
||||||
<MessageControl />
|
<TitleList mode={AppMode.Edit} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.rundownPanel}>
|
||||||
|
<Rundown />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Rundown />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
import { IoCheckmark } from 'react-icons/io5';
|
||||||
|
import { LuLayoutDashboard } from 'react-icons/lu';
|
||||||
|
|
||||||
|
import IconButton from '../../common/components/buttons/IconButton';
|
||||||
|
import { DropdownMenu, DropdownMenuOption } from '../../common/components/dropdown-menu/DropdownMenu';
|
||||||
|
|
||||||
|
import { EditorLayoutMode, useEditorLayout } from './useEditorLayout';
|
||||||
|
|
||||||
|
export default memo(EditorLayoutOptions);
|
||||||
|
function EditorLayoutOptions() {
|
||||||
|
const { layoutMode, setLayoutMode } = useEditorLayout();
|
||||||
|
|
||||||
|
const items = useMemo<DropdownMenuOption[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Planning',
|
||||||
|
description: 'Edit-focused list with planning stats',
|
||||||
|
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
|
||||||
|
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Tracking',
|
||||||
|
description: 'Live timing view with progress and offsets',
|
||||||
|
icon: layoutMode === EditorLayoutMode.TRACKING ? IoCheckmark : undefined,
|
||||||
|
onClick: () => setLayoutMode(EditorLayoutMode.TRACKING),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Control',
|
||||||
|
description: 'All controls and rundown together',
|
||||||
|
icon: layoutMode === EditorLayoutMode.CONTROL ? IoCheckmark : undefined,
|
||||||
|
onClick: () => setLayoutMode(EditorLayoutMode.CONTROL),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[layoutMode, setLayoutMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu render={<IconButton aria-label='Layout mode' variant='subtle-white' size='xlarge' />} items={items}>
|
||||||
|
<LuLayoutDashboard />
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
.group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
padding-inline: 2px;
|
|
||||||
background: $gray-1100;
|
|
||||||
border-radius: $component-border-radius-md;
|
|
||||||
height: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radioButton {
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: calc(2rem - 4px); // padding inline * 2
|
|
||||||
padding-inline: 1em;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: $component-border-radius-md;
|
|
||||||
color: $gray-400;
|
|
||||||
font-size: calc(1rem - 2px);
|
|
||||||
font-weight: 600;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
background: transparent;
|
|
||||||
outline: 1px solid $blue-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
|
||||||
background: $gray-1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background: $gray-1100;
|
|
||||||
}
|
|
||||||
|
|
||||||
&[data-pressed] {
|
|
||||||
background: $blue-700;
|
|
||||||
color: $ui-white;
|
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
|
||||||
background: $blue-600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.popoverContent {
|
|
||||||
padding: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-self: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $gray-400;
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { memo } from 'react';
|
|
||||||
import { LuLayoutDashboard } from 'react-icons/lu';
|
|
||||||
import { Popover } from '@base-ui/react/popover';
|
|
||||||
import { Toggle } from '@base-ui/react/toggle';
|
|
||||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
|
||||||
import { OffsetMode } from 'ontime-types';
|
|
||||||
|
|
||||||
import IconButton from '../../common/components/buttons/IconButton';
|
|
||||||
import * as Editor from '../../common/components/editor-utils/EditorUtils';
|
|
||||||
import PopoverContents from '../../common/components/popover/Popover';
|
|
||||||
import { setOffsetMode, useOffsetMode } from '../../common/hooks/useSocket';
|
|
||||||
|
|
||||||
import style from './EditorViewOptions.module.scss';
|
|
||||||
|
|
||||||
export default memo(EditorViewOptions);
|
|
||||||
|
|
||||||
function EditorViewOptions() {
|
|
||||||
const offsetMode = useOffsetMode();
|
|
||||||
|
|
||||||
const toggleOffsetMode = (mode: OffsetMode[]) => {
|
|
||||||
const newValue = mode.at(0);
|
|
||||||
if (!newValue) return;
|
|
||||||
setOffsetMode(newValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover.Root>
|
|
||||||
<Popover.Trigger
|
|
||||||
render={
|
|
||||||
<IconButton aria-label='View Options' variant='subtle-white' size='xlarge'>
|
|
||||||
<LuLayoutDashboard />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContents title='View Options' className={style.popoverContent} align='end'>
|
|
||||||
<div className={style.column}>
|
|
||||||
<Editor.Label className={style.sectionTitle}>Offset Mode</Editor.Label>
|
|
||||||
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
|
|
||||||
<Toggle value={OffsetMode.Absolute} className={style.radioButton}>
|
|
||||||
Absolute
|
|
||||||
</Toggle>
|
|
||||||
<Toggle value={OffsetMode.Relative} className={style.radioButton}>
|
|
||||||
Relative
|
|
||||||
</Toggle>
|
|
||||||
</ToggleGroup>
|
|
||||||
</div>
|
|
||||||
</PopoverContents>
|
|
||||||
</Popover.Root>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ import EditorOverview from '../../features/overview/EditorOverview';
|
|||||||
|
|
||||||
import WelcomePlacement from './welcome/WelcomePlacement';
|
import WelcomePlacement from './welcome/WelcomePlacement';
|
||||||
import Editor from './Editor';
|
import Editor from './Editor';
|
||||||
import EditorViewOptions from './EditorViewOptions';
|
import EditorLayoutOptions from './EditorLayoutOptions';
|
||||||
|
|
||||||
import styles from './ProtectedEditor.module.scss';
|
import styles from './ProtectedEditor.module.scss';
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ export default function ProtectedEditor() {
|
|||||||
<IconButton aria-label='Toggle settings' variant='subtle-white' size='xlarge' onClick={toggleSettings}>
|
<IconButton aria-label='Toggle settings' variant='subtle-white' size='xlarge' onClick={toggleSettings}>
|
||||||
{isSettingsOpen ? <IoClose /> : <IoSettingsOutline />}
|
{isSettingsOpen ? <IoClose /> : <IoSettingsOutline />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<EditorViewOptions />
|
<EditorLayoutOptions />
|
||||||
</EditorOverview>
|
</EditorOverview>
|
||||||
</div>
|
</div>
|
||||||
</ProtectRoute>
|
</ProtectRoute>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
|
||||||
import { EntryId, isOntimeEvent, isOntimeGroup, isOntimeMilestone, MaybeString, SupportedEntry } from 'ontime-types';
|
import { EntryId, isOntimeEvent, isOntimeGroup, isOntimeMilestone, MaybeString, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
||||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
||||||
|
|
||||||
const maxResults = 12;
|
const maxResults = 12;
|
||||||
|
|
||||||
@@ -44,14 +43,7 @@ export default function useFinder() {
|
|||||||
const [error, setError] = useState<MaybeString>(null);
|
const [error, setError] = useState<MaybeString>(null);
|
||||||
const lastSearchString = useRef('');
|
const lastSearchString = useRef('');
|
||||||
|
|
||||||
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
|
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
||||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
|
||||||
|
|
||||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
|
||||||
// we ensure that this is unique to the rundown
|
|
||||||
key: `rundown.${rundownId}-editor-collapsed-groups`,
|
|
||||||
defaultValue: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Filters the rundown to a given evaluation */
|
/** Filters the rundown to a given evaluation */
|
||||||
const find = useCallback(
|
const find = useCallback(
|
||||||
@@ -224,20 +216,13 @@ export default function useFinder() {
|
|||||||
|
|
||||||
const select = useCallback(
|
const select = useCallback(
|
||||||
(selectedEvent: FilterableEntry) => {
|
(selectedEvent: FilterableEntry) => {
|
||||||
// First expand the parent group if this is an event inside a group
|
selectAndRevealEntry({
|
||||||
if ('parent' in selectedEvent && selectedEvent.parent !== null) {
|
id: selectedEvent.id,
|
||||||
// Try direct state update instead of using callback
|
index: selectedEvent.index,
|
||||||
const currentGroups = [...new Set(collapsedGroups)];
|
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
|
||||||
const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent);
|
});
|
||||||
// Force a direct update
|
|
||||||
setCollapsedGroups(newGroups);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then select the event
|
|
||||||
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
|
|
||||||
scrollToEntry(selectedEvent.id);
|
|
||||||
},
|
},
|
||||||
[collapsedGroups, setCollapsedGroups, setSelectedEvents, scrollToEntry],
|
[selectAndRevealEntry],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** clear results when source data changes */
|
/** clear results when source data changes */
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
@use '../../../theme/ontimeColours' as *;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: $bg-container-l1;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomSpacer {
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 70vh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import ScrollArea from '../../../common/components/scroll-area/ScrollArea';
|
||||||
|
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||||
|
import useRundown from '../../../common/hooks-query/useRundown';
|
||||||
|
import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata';
|
||||||
|
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||||
|
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
||||||
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
|
|
||||||
|
import { getCurrentEventInfo } from './titleList.utils';
|
||||||
|
import TitleListEmpty from './TitleListEmpty';
|
||||||
|
import TitleListItem from './TitleListItem';
|
||||||
|
|
||||||
|
import style from './TitleList.module.scss';
|
||||||
|
|
||||||
|
interface TitleListProps {
|
||||||
|
mode: AppMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TitleList({ mode }: TitleListProps) {
|
||||||
|
const { data: rundown } = useRundown();
|
||||||
|
const selectedEventId = useSelectedEventId();
|
||||||
|
const cursor = useEventSelection((state) => state.cursor);
|
||||||
|
|
||||||
|
// In Run mode, follow the currently playing event
|
||||||
|
// In Edit mode, follow the user's selection
|
||||||
|
const resolvedFollowEventId = useMemo(() => {
|
||||||
|
return mode === AppMode.Run ? selectedEventId : cursor;
|
||||||
|
}, [mode, selectedEventId, cursor]);
|
||||||
|
|
||||||
|
// Filter and memoize event-only data
|
||||||
|
const eventData = useMemo(() => {
|
||||||
|
const flatData = getFlatRundownMetadata(rundown, resolvedFollowEventId);
|
||||||
|
return flatData.filter(isOntimeEvent) as ExtendedEntry<OntimeEvent>[];
|
||||||
|
}, [rundown, resolvedFollowEventId]);
|
||||||
|
|
||||||
|
if (eventData.length === 0) {
|
||||||
|
return <TitleListEmpty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TitleListContent
|
||||||
|
mode={mode}
|
||||||
|
eventData={eventData}
|
||||||
|
selectedEventId={selectedEventId}
|
||||||
|
resolvedFollowEventId={resolvedFollowEventId}
|
||||||
|
rundownId={rundown.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TitleListContentProps {
|
||||||
|
mode: AppMode;
|
||||||
|
eventData: ExtendedEntry<OntimeEvent>[];
|
||||||
|
selectedEventId: string | null;
|
||||||
|
resolvedFollowEventId: string | null;
|
||||||
|
rundownId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleListContent({
|
||||||
|
mode,
|
||||||
|
eventData,
|
||||||
|
selectedEventId,
|
||||||
|
resolvedFollowEventId,
|
||||||
|
rundownId,
|
||||||
|
}: TitleListContentProps) {
|
||||||
|
'use memo';
|
||||||
|
|
||||||
|
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||||
|
const scrollParentRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
||||||
|
|
||||||
|
// Calculate current event info
|
||||||
|
const currentEventInfo = useMemo(() => {
|
||||||
|
return getCurrentEventInfo(eventData);
|
||||||
|
}, [eventData]);
|
||||||
|
|
||||||
|
const followIndex = useMemo(() => {
|
||||||
|
if (!resolvedFollowEventId) return -1;
|
||||||
|
return eventData.findIndex((entry) => entry.id === resolvedFollowEventId);
|
||||||
|
}, [eventData, resolvedFollowEventId]);
|
||||||
|
|
||||||
|
// Stable selection handler
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(params: { id: string; index: number; parent?: string }) => {
|
||||||
|
selectAndRevealEntry(params);
|
||||||
|
},
|
||||||
|
[selectAndRevealEntry],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-scroll to keep current item at sticky position using Virtuoso
|
||||||
|
useEffect(() => {
|
||||||
|
if (!virtuosoRef.current) return;
|
||||||
|
|
||||||
|
const indexToFollow = followIndex !== -1 ? followIndex : currentEventInfo.index;
|
||||||
|
if (indexToFollow === -1) return;
|
||||||
|
|
||||||
|
// In Run mode, always scroll to follow
|
||||||
|
// In Edit mode, only scroll if beyond sticky position (3)
|
||||||
|
if (mode === AppMode.Edit && indexToFollow <= 3) return;
|
||||||
|
|
||||||
|
virtuosoRef.current.scrollToIndex({
|
||||||
|
index: indexToFollow,
|
||||||
|
align: 'start',
|
||||||
|
behavior: 'smooth',
|
||||||
|
offset: -50,
|
||||||
|
});
|
||||||
|
}, [currentEventInfo.index, followIndex, mode]);
|
||||||
|
|
||||||
|
// Virtuoso item renderer
|
||||||
|
const itemContent = useCallback(
|
||||||
|
(index: number, entry: ExtendedEntry<OntimeEvent>) => {
|
||||||
|
const nextEntry = eventData[index + 1];
|
||||||
|
const isGroupEnd = Boolean(entry.parent) && entry.parent !== (nextEntry?.parent ?? null);
|
||||||
|
return (
|
||||||
|
<TitleListItem
|
||||||
|
key={entry.id}
|
||||||
|
entry={entry}
|
||||||
|
currentEventIndex={currentEventInfo.eventIndex}
|
||||||
|
upcomingChipLimit={currentEventInfo.upcomingChipLimit}
|
||||||
|
isRunning={mode === AppMode.Run && selectedEventId !== null}
|
||||||
|
mode={mode}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
isGroupEnd={isGroupEnd}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[eventData, currentEventInfo.eventIndex, currentEventInfo.upcomingChipLimit, mode, selectedEventId, handleSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className={style.container} ref={scrollParentRef}>
|
||||||
|
<Virtuoso
|
||||||
|
ref={virtuosoRef}
|
||||||
|
data={eventData}
|
||||||
|
computeItemKey={(_index, entry) => entry.id}
|
||||||
|
itemContent={itemContent}
|
||||||
|
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||||
|
customScrollParent={scrollParentRef.current ?? undefined}
|
||||||
|
components={{
|
||||||
|
List: VirtuosoListComponent,
|
||||||
|
Footer: TitleListFooter,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Virtuoso list component - extracted to prevent recreation on every render
|
||||||
|
function VirtuosoListComponent({ children, ...props }: React.HTMLAttributes<HTMLUListElement>) {
|
||||||
|
return (
|
||||||
|
<ul {...props} className={style.list}>
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
VirtuosoListComponent.displayName = 'VirtuosoListComponent';
|
||||||
|
|
||||||
|
function TitleListFooter() {
|
||||||
|
return <div className={style.bottomSpacer} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@use '../../../theme/ontimeColours' as *;
|
||||||
|
@use '../../../theme/ontimeStyles' as *;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
height: 100%;
|
||||||
|
background: $bg-container-l1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyMessage {
|
||||||
|
width: min(320px, 100%);
|
||||||
|
text-align: center;
|
||||||
|
color: rgba($gray-200, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyTitle {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: calc(1rem + 2px);
|
||||||
|
font-weight: 400;
|
||||||
|
color: rgba($gray-200, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyBody {
|
||||||
|
margin: 0;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import style from './TitleListEmpty.module.scss';
|
||||||
|
|
||||||
|
export default function TitleListEmpty() {
|
||||||
|
return (
|
||||||
|
<div className={style.container}>
|
||||||
|
<div className={style.emptyState}>
|
||||||
|
<div className={style.emptyMessage}>
|
||||||
|
<h3 className={style.emptyTitle}>No events yet</h3>
|
||||||
|
<p className={style.emptyBody}>Add events in the rundown to populate this list.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
@use '../../../theme/ontimeColours' as *;
|
||||||
|
@use '../../../theme/ontimeStyles' as *;
|
||||||
|
|
||||||
|
$font-size-current: calc(1rem + 2px);
|
||||||
|
$font-size-next: 1rem;
|
||||||
|
$font-size-default: calc(1rem - 2px);
|
||||||
|
|
||||||
|
.item {
|
||||||
|
--color-bar-gap: 0.5rem;
|
||||||
|
--color-bar-width: 4px;
|
||||||
|
// color bar + gap x 2
|
||||||
|
--title-left: calc(4px + 0.5rem + 0.5rem);
|
||||||
|
--indicator-width: 2rem;
|
||||||
|
--chip-width: 3.25rem;
|
||||||
|
--chip-padding: 0.5rem;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 2.5rem;
|
||||||
|
gap: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $white-3;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: $ui-white;
|
||||||
|
font-size: $font-size-current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='past'] {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='running'],
|
||||||
|
&[data-state='selected'] {
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: $white-7;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: $ui-white;
|
||||||
|
font-weight: 500;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: calc(var(--chip-width) + var(--chip-padding));
|
||||||
|
font-size: $font-size-current;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='running'] {
|
||||||
|
background: color-mix(in srgb, $playback-start 22%, $bg-container-l1);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-state='next'] {
|
||||||
|
.title {
|
||||||
|
color: $ui-white;
|
||||||
|
font-size: $font-size-next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-skipped] {
|
||||||
|
opacity: 0.3;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-group-end='true'] {
|
||||||
|
margin-bottom: $panel-gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.colourBar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 4px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding-block: 0.5rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: calc(var(--chip-width) + var(--chip-padding));
|
||||||
|
|
||||||
|
color: $gray-400;
|
||||||
|
font-size: $font-size-default;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipSlot {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--chip-padding);
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
min-width: var(--chip-width);
|
||||||
|
|
||||||
|
&[data-hover-only] {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover .chipSlot[data-hover-only] {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chipText {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
|
color: $label-gray;
|
||||||
|
|
||||||
|
&[data-chip-status='live'] {
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-chip-status='due'] {
|
||||||
|
color: $warning-orange;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { OntimeEvent } from 'ontime-types';
|
||||||
|
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
|
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
|
import { formatDuration, useTimeUntilExpectedStart } from '../../../common/utils/time';
|
||||||
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
|
|
||||||
|
import style from './TitleListItem.module.scss';
|
||||||
|
|
||||||
|
interface TitleListItemProps {
|
||||||
|
entry: ExtendedEntry<OntimeEvent>;
|
||||||
|
currentEventIndex: number;
|
||||||
|
upcomingChipLimit: number;
|
||||||
|
isRunning: boolean;
|
||||||
|
mode: AppMode;
|
||||||
|
isGroupEnd: boolean;
|
||||||
|
onSelect: (params: { id: string; index: number; parent?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(TitleListItem);
|
||||||
|
function TitleListItem({
|
||||||
|
entry,
|
||||||
|
currentEventIndex,
|
||||||
|
upcomingChipLimit,
|
||||||
|
isRunning,
|
||||||
|
mode,
|
||||||
|
isGroupEnd,
|
||||||
|
onSelect,
|
||||||
|
}: TitleListItemProps) {
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onSelect({ id: entry.id, index: entry.eventIndex - 1, parent: entry.parent ?? undefined });
|
||||||
|
}, [onSelect, entry.id, entry.eventIndex, entry.parent]);
|
||||||
|
|
||||||
|
// Show "next" highlight for 2 events after current
|
||||||
|
const isNext =
|
||||||
|
currentEventIndex > 0 && entry.eventIndex > currentEventIndex && entry.eventIndex <= currentEventIndex + 2;
|
||||||
|
const isPast = mode === AppMode.Run && entry.isPast;
|
||||||
|
|
||||||
|
const state = (() => {
|
||||||
|
if (entry.isLoaded) {
|
||||||
|
return mode === AppMode.Run ? 'running' : 'selected';
|
||||||
|
}
|
||||||
|
if (isPast) return 'past';
|
||||||
|
if (isNext) return 'next';
|
||||||
|
return 'default';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const shouldRenderChip = isRunning && !entry.isLoaded && !entry.isPast && !entry.skip;
|
||||||
|
const showChipByDefault = shouldRenderChip && entry.eventIndex <= upcomingChipLimit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-state={state}
|
||||||
|
data-skipped={entry.skip || undefined}
|
||||||
|
data-group-end={isGroupEnd || undefined}
|
||||||
|
className={style.item}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div className={style.colourBar} style={{ backgroundColor: entry.groupColour || 'transparent' }} />
|
||||||
|
<span className={style.title}>{entry.title || 'Untitled'}</span>
|
||||||
|
{shouldRenderChip && (
|
||||||
|
<TitleListTimeUntilChip
|
||||||
|
timeStart={entry.timeStart}
|
||||||
|
delay={entry.delay}
|
||||||
|
dayOffset={entry.dayOffset}
|
||||||
|
totalGap={entry.totalGap}
|
||||||
|
isLinkedToLoaded={entry.isLinkedToLoaded}
|
||||||
|
isLoaded={entry.isLoaded}
|
||||||
|
showOnHover={!showChipByDefault}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TitleListTimeUntilChipProps {
|
||||||
|
timeStart: number;
|
||||||
|
delay: number;
|
||||||
|
dayOffset: number;
|
||||||
|
totalGap: number;
|
||||||
|
isLinkedToLoaded: boolean;
|
||||||
|
isLoaded: boolean;
|
||||||
|
showOnHover: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TitleListTimeUntilChip = memo(TitleListTimeUntilChipImpl);
|
||||||
|
function TitleListTimeUntilChipImpl({
|
||||||
|
timeStart,
|
||||||
|
delay,
|
||||||
|
dayOffset,
|
||||||
|
totalGap,
|
||||||
|
isLinkedToLoaded,
|
||||||
|
isLoaded,
|
||||||
|
showOnHover,
|
||||||
|
}: TitleListTimeUntilChipProps) {
|
||||||
|
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
|
||||||
|
const isDue = !isLoaded && timeUntil < MILLIS_PER_SECOND;
|
||||||
|
|
||||||
|
let timeUntilString = 'LIVE';
|
||||||
|
if (!isLoaded) {
|
||||||
|
timeUntilString = isDue ? 'DUE' : formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chipStatus = isLoaded ? 'live' : isDue ? 'due' : 'pending';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip text='Expected time until start' className={style.chipSlot} data-hover-only={showOnHover || undefined}>
|
||||||
|
<span data-chip-status={chipStatus} className={style.chipText}>
|
||||||
|
{timeUntilString}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
|
|
||||||
|
// Display time chips for current + next 5 upcoming events
|
||||||
|
const UPCOMING_CHIP_COUNT = 5;
|
||||||
|
|
||||||
|
export function getCurrentEventInfo(data: ExtendedEntry<OntimeEvent>[]) {
|
||||||
|
const index = data.findIndex((entry) => entry.isLoaded);
|
||||||
|
const event = index !== -1 ? data[index] : null;
|
||||||
|
const eventIndex = event?.eventIndex ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
id: event?.id ?? null,
|
||||||
|
eventIndex,
|
||||||
|
upcomingChipLimit: eventIndex > 0 ? eventIndex + UPCOMING_CHIP_COUNT : UPCOMING_CHIP_COUNT,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
import { isValueOfEnum } from 'ontime-utils';
|
||||||
|
|
||||||
|
const layoutParam = 'layout';
|
||||||
|
|
||||||
|
export enum EditorLayoutMode {
|
||||||
|
CONTROL = 'control',
|
||||||
|
PLANNING = 'planning',
|
||||||
|
TRACKING = 'tracking',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the current editor layout mode from a nullable string value
|
||||||
|
*/
|
||||||
|
function getEditorLayout(value: string | null): EditorLayoutMode {
|
||||||
|
if (isValueOfEnum(EditorLayoutMode, value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return EditorLayoutMode.CONTROL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorLayout() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const layoutMode = getEditorLayout(searchParams.get(layoutParam));
|
||||||
|
|
||||||
|
const setLayoutMode = (mode: EditorLayoutMode) => {
|
||||||
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
|
nextParams.set(layoutParam, mode);
|
||||||
|
setSearchParams(nextParams, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return { layoutMode, setLayoutMode };
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { expect, test } from '@playwright/test';
|
|||||||
test('show warning when event crosses midnight', async ({ page }) => {
|
test('show warning when event crosses midnight', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
||||||
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
@@ -24,6 +26,8 @@ test('show warning when event crosses midnight', async ({ page }) => {
|
|||||||
test('show warning when event starts next day midnight', async ({ page }) => {
|
test('show warning when event starts next day midnight', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor');
|
await page.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
||||||
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ test('time until absolute', async ({ context }) => {
|
|||||||
await op.goto('/op');
|
await op.goto('/op');
|
||||||
await timeline.goto('/timeline');
|
await timeline.goto('/timeline');
|
||||||
|
|
||||||
|
await editor.getByRole('button', { name: 'Edit' }).click();
|
||||||
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
||||||
await editor.getByRole('button', { name: 'Clear all' }).click();
|
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
await editor.getByRole('button', { name: 'Delete all' }).click();
|
await editor.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
|
||||||
await editor.getByRole('button', { name: 'Create Event' }).click();
|
await editor.getByRole('button', { name: 'Create Event' }).click();
|
||||||
@@ -130,8 +131,9 @@ test('time until relative', async ({ context }) => {
|
|||||||
const editor = await context.newPage();
|
const editor = await context.newPage();
|
||||||
editor.goto('http://localhost:4001/editor');
|
editor.goto('http://localhost:4001/editor');
|
||||||
|
|
||||||
|
await editor.getByRole('button', { name: 'Edit' }).click();
|
||||||
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
||||||
await editor.getByRole('button', { name: 'Clear all' }).click();
|
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
await editor.getByRole('button', { name: 'Delete all' }).click();
|
await editor.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
|
||||||
await editor.getByRole('button', { name: 'Create Event' }).click();
|
await editor.getByRole('button', { name: 'Create Event' }).click();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { test, expect } from '@playwright/test';
|
|||||||
|
|
||||||
test('Rearrange while playing', async ({ page }) => {
|
test('Rearrange while playing', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/rundown');
|
await page.goto('http://localhost:4001/rundown');
|
||||||
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
|
||||||
// clear rundown
|
// clear rundown
|
||||||
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
||||||
@@ -34,6 +35,7 @@ test('Rearrange while playing', async ({ page }) => {
|
|||||||
|
|
||||||
test('flag and unflag an event while playing', async ({ page }) => {
|
test('flag and unflag an event while playing', async ({ page }) => {
|
||||||
await page.goto('http://localhost:4001/editor/');
|
await page.goto('http://localhost:4001/editor/');
|
||||||
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
||||||
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
import type { DatabaseModel } from 'ontime-types';
|
||||||
|
import { EndAction, OntimeView, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
|
export const demoDb: DatabaseModel = {
|
||||||
|
rundowns: {
|
||||||
|
default: {
|
||||||
|
id: 'default',
|
||||||
|
title: 'Demo project',
|
||||||
|
order: ['e2163f', '7eaf99', 'f60403', '6b0edb'],
|
||||||
|
flatOrder: [
|
||||||
|
'e2163f',
|
||||||
|
'7eaf99',
|
||||||
|
'9bf60f',
|
||||||
|
'bf71a2',
|
||||||
|
'c2697f',
|
||||||
|
'fa593e',
|
||||||
|
'a8b0b3',
|
||||||
|
'f60403',
|
||||||
|
'0aaa7d',
|
||||||
|
'6b0edb',
|
||||||
|
'02afca',
|
||||||
|
'75ce86',
|
||||||
|
'e10ed9',
|
||||||
|
'07df89',
|
||||||
|
],
|
||||||
|
entries: {
|
||||||
|
e2163f: {
|
||||||
|
id: 'e2163f',
|
||||||
|
type: SupportedEntry.Milestone,
|
||||||
|
cue: 'Demo',
|
||||||
|
title: 'Clear all, or Create New Project to start fresh',
|
||||||
|
note: 'Moderator - Emma Thompson\n\nSpeakers\n- Liam Carter + Sophia Patel\n- Ethan Brooks\n- Lucas Bennett',
|
||||||
|
colour: '#9d9d9d',
|
||||||
|
custom: {},
|
||||||
|
parent: null,
|
||||||
|
revision: 0,
|
||||||
|
},
|
||||||
|
'7eaf99': {
|
||||||
|
id: '7eaf99',
|
||||||
|
type: SupportedEntry.Group,
|
||||||
|
title: 'Morning Sessions',
|
||||||
|
note: '',
|
||||||
|
entries: ['9bf60f', 'bf71a2', 'c2697f', 'fa593e', 'a8b0b3'],
|
||||||
|
targetDuration: null,
|
||||||
|
colour: '#339E4E',
|
||||||
|
custom: {},
|
||||||
|
revision: 0,
|
||||||
|
timeStart: 36000000,
|
||||||
|
timeEnd: 43200000,
|
||||||
|
duration: 7200000,
|
||||||
|
isFirstLinked: false,
|
||||||
|
},
|
||||||
|
'9bf60f': {
|
||||||
|
id: '9bf60f',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
flag: false,
|
||||||
|
title: 'Pre-show Countdown',
|
||||||
|
timeStart: 36000000,
|
||||||
|
timeEnd: 39600000,
|
||||||
|
duration: 3600000,
|
||||||
|
timeStrategy: TimeStrategy.LockEnd,
|
||||||
|
linkStart: false,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
countToEnd: false,
|
||||||
|
skip: false,
|
||||||
|
note: 'Music plays, holding slide on screens',
|
||||||
|
colour: '#77C785',
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
cue: '1',
|
||||||
|
parent: '7eaf99',
|
||||||
|
revision: 0,
|
||||||
|
timeWarning: 600000,
|
||||||
|
timeDanger: 300000,
|
||||||
|
custom: {
|
||||||
|
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp',
|
||||||
|
Video_Notes: 'Camera + PowerPoint on stream\nPowerPoint on screens',
|
||||||
|
Audio_Notes: '2x Wireless Hand Helds',
|
||||||
|
PowerPoint_Name: 'HoldingSlide.pptx',
|
||||||
|
},
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
bf71a2: {
|
||||||
|
id: 'bf71a2',
|
||||||
|
type: SupportedEntry.Milestone,
|
||||||
|
cue: 'Standby',
|
||||||
|
title: '10:45 - Presenters ready side stage',
|
||||||
|
note: '',
|
||||||
|
colour: '#A790F5',
|
||||||
|
revision: 0,
|
||||||
|
custom: {},
|
||||||
|
parent: '7eaf99',
|
||||||
|
},
|
||||||
|
c2697f: {
|
||||||
|
id: 'c2697f',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
flag: false,
|
||||||
|
title: 'Welcome',
|
||||||
|
timeStart: 39600000,
|
||||||
|
timeEnd: 40200000,
|
||||||
|
duration: 600000,
|
||||||
|
timeStrategy: TimeStrategy.LockDuration,
|
||||||
|
linkStart: true,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
countToEnd: false,
|
||||||
|
skip: false,
|
||||||
|
note: 'Emma Thompson',
|
||||||
|
colour: '#FFCC78',
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
cue: '1.1',
|
||||||
|
parent: '7eaf99',
|
||||||
|
revision: 0,
|
||||||
|
timeWarning: 120000,
|
||||||
|
timeDanger: 60000,
|
||||||
|
custom: {
|
||||||
|
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp',
|
||||||
|
Video_Notes: 'Cameras on stream\nPowerPoint on screens',
|
||||||
|
Audio_Notes: '1x Wireless Hand Held',
|
||||||
|
PowerPoint_Name: 'HoldingSlide.pptx',
|
||||||
|
},
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
fa593e: {
|
||||||
|
id: 'fa593e',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
flag: true,
|
||||||
|
title: 'Session 1',
|
||||||
|
timeStart: 40200000,
|
||||||
|
timeEnd: 43200000,
|
||||||
|
duration: 3000000,
|
||||||
|
timeStrategy: TimeStrategy.LockDuration,
|
||||||
|
linkStart: true,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
countToEnd: false,
|
||||||
|
skip: false,
|
||||||
|
note: 'Liam Carter, Sophia Patel + PowerPoint',
|
||||||
|
colour: '#77C785',
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
cue: '1.2',
|
||||||
|
parent: '7eaf99',
|
||||||
|
revision: 0,
|
||||||
|
timeWarning: 120000,
|
||||||
|
timeDanger: 60000,
|
||||||
|
custom: {
|
||||||
|
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide2.webp',
|
||||||
|
Video_Notes: 'Camera + PowerPoint on stream\nPowerPoint on screens',
|
||||||
|
Audio_Notes: '2x Wireless Hand Helds',
|
||||||
|
PowerPoint_Name: 'Session1.pptx',
|
||||||
|
},
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
a8b0b3: {
|
||||||
|
id: 'a8b0b3',
|
||||||
|
type: SupportedEntry.Milestone,
|
||||||
|
cue: 'House',
|
||||||
|
title: '11:30 - House staff setup lunch in lobby',
|
||||||
|
note: '',
|
||||||
|
colour: '#A790F5',
|
||||||
|
revision: 0,
|
||||||
|
custom: {},
|
||||||
|
parent: '7eaf99',
|
||||||
|
},
|
||||||
|
f60403: {
|
||||||
|
id: 'f60403',
|
||||||
|
type: SupportedEntry.Group,
|
||||||
|
title: 'Lunch',
|
||||||
|
note: '',
|
||||||
|
entries: ['0aaa7d'],
|
||||||
|
targetDuration: null,
|
||||||
|
colour: '#3E75E8',
|
||||||
|
custom: {},
|
||||||
|
revision: 0,
|
||||||
|
timeStart: 43200000,
|
||||||
|
timeEnd: 46800000,
|
||||||
|
duration: 3600000,
|
||||||
|
isFirstLinked: true,
|
||||||
|
},
|
||||||
|
'0aaa7d': {
|
||||||
|
id: '0aaa7d',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
flag: false,
|
||||||
|
title: 'Lunch / Countdown to next session',
|
||||||
|
timeStart: 43200000,
|
||||||
|
timeEnd: 46800000,
|
||||||
|
duration: 3600000,
|
||||||
|
timeStrategy: TimeStrategy.LockDuration,
|
||||||
|
linkStart: true,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
countToEnd: false,
|
||||||
|
skip: false,
|
||||||
|
note: 'Buffet in lobby',
|
||||||
|
colour: '#779BE7',
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
cue: '2.1',
|
||||||
|
parent: 'f60403',
|
||||||
|
revision: 0,
|
||||||
|
timeWarning: 120000,
|
||||||
|
timeDanger: 60000,
|
||||||
|
custom: {
|
||||||
|
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp',
|
||||||
|
Video_Notes: 'Holding slide on screens',
|
||||||
|
Audio_Notes: 'House music',
|
||||||
|
PowerPoint_Name: 'HoldingSlide.pptx',
|
||||||
|
},
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
'6b0edb': {
|
||||||
|
id: '6b0edb',
|
||||||
|
type: SupportedEntry.Group,
|
||||||
|
title: 'Afternoon Sessions',
|
||||||
|
note: '',
|
||||||
|
entries: ['02afca', '75ce86', 'e10ed9', '07df89'],
|
||||||
|
targetDuration: null,
|
||||||
|
colour: '#339E4E',
|
||||||
|
custom: {},
|
||||||
|
revision: 0,
|
||||||
|
timeStart: 46800000,
|
||||||
|
timeEnd: 50400000,
|
||||||
|
duration: 3600000,
|
||||||
|
isFirstLinked: true,
|
||||||
|
},
|
||||||
|
'02afca': {
|
||||||
|
id: '02afca',
|
||||||
|
type: SupportedEntry.Milestone,
|
||||||
|
cue: 'Standby',
|
||||||
|
title: '12:45 - Presenters ready side stage',
|
||||||
|
note: '',
|
||||||
|
colour: '#A790F5',
|
||||||
|
revision: 0,
|
||||||
|
custom: {},
|
||||||
|
parent: '6b0edb',
|
||||||
|
},
|
||||||
|
'75ce86': {
|
||||||
|
id: '75ce86',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
flag: false,
|
||||||
|
title: 'Session 2',
|
||||||
|
timeStart: 46800000,
|
||||||
|
timeEnd: 49800000,
|
||||||
|
duration: 3000000,
|
||||||
|
timeStrategy: TimeStrategy.LockDuration,
|
||||||
|
linkStart: true,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
countToEnd: false,
|
||||||
|
skip: false,
|
||||||
|
note: 'Ethan Brooks + PowerPoint + Video playback',
|
||||||
|
colour: '#77C785',
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
cue: '3.1',
|
||||||
|
parent: '6b0edb',
|
||||||
|
revision: 0,
|
||||||
|
timeWarning: 120000,
|
||||||
|
timeDanger: 60000,
|
||||||
|
custom: {
|
||||||
|
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide3.webp',
|
||||||
|
Video_Notes: 'Camera + PPT + Video on stream\nPowerPoint + Video on screens\n\nVideo file: Session2.mp4',
|
||||||
|
Audio_Notes: '1x Wireless Hand Held\n1x Video with audio',
|
||||||
|
PowerPoint_Name: 'Session2.pptx',
|
||||||
|
},
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
e10ed9: {
|
||||||
|
id: 'e10ed9',
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
flag: false,
|
||||||
|
title: 'Wrap up',
|
||||||
|
timeStart: 49800000,
|
||||||
|
timeEnd: 50400000,
|
||||||
|
duration: 600000,
|
||||||
|
timeStrategy: TimeStrategy.LockDuration,
|
||||||
|
linkStart: false,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
countToEnd: false,
|
||||||
|
skip: false,
|
||||||
|
note: 'Lucas Bennett',
|
||||||
|
colour: '#FFCC78',
|
||||||
|
delay: 0,
|
||||||
|
dayOffset: 0,
|
||||||
|
gap: 0,
|
||||||
|
cue: '3.2',
|
||||||
|
parent: '6b0edb',
|
||||||
|
revision: 0,
|
||||||
|
timeWarning: 120000,
|
||||||
|
timeDanger: 60000,
|
||||||
|
custom: {
|
||||||
|
PowerPoint_Slide: 'https://www.getontime.no/images/aux/demo-slide1.webp',
|
||||||
|
Video_Notes: 'Holding slide on screens',
|
||||||
|
Audio_Notes: '1x Wireless Hand Held',
|
||||||
|
PowerPoint_Name: 'HoldingSlide.pptx',
|
||||||
|
},
|
||||||
|
triggers: [],
|
||||||
|
},
|
||||||
|
'07df89': {
|
||||||
|
id: '07df89',
|
||||||
|
type: SupportedEntry.Milestone,
|
||||||
|
cue: 'Strike',
|
||||||
|
title: '14:00 - AV & Room strike',
|
||||||
|
note: '',
|
||||||
|
colour: '#A790F5',
|
||||||
|
revision: 0,
|
||||||
|
custom: {},
|
||||||
|
parent: '6b0edb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
revision: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
title: 'Ontime Demo Project',
|
||||||
|
description: 'Demo Project to get you started',
|
||||||
|
url: 'https://docs.getontime.no/',
|
||||||
|
info: 'Use Project info to share information to various Ontime views.\nie. Venue info, wifi, staff details, etc.',
|
||||||
|
logo: 'ontime-logo.png',
|
||||||
|
custom: [
|
||||||
|
{
|
||||||
|
title: 'Custom data',
|
||||||
|
value:
|
||||||
|
'Add additional, custom data fields to the project along with optional links to images. \nThe image will be rendered in the views',
|
||||||
|
url: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
version: '-',
|
||||||
|
serverPort: 4001,
|
||||||
|
editorKey: null,
|
||||||
|
operatorKey: null,
|
||||||
|
timeFormat: '24',
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
|
viewSettings: {
|
||||||
|
dangerColor: '#ff7300',
|
||||||
|
normalColor: '#ffffffcc',
|
||||||
|
overrideStyles: false,
|
||||||
|
warningColor: '#ffa528',
|
||||||
|
},
|
||||||
|
urlPresets: [
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
alias: 'clock',
|
||||||
|
target: OntimeView.Timer,
|
||||||
|
search:
|
||||||
|
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
alias: 'minimal',
|
||||||
|
target: OntimeView.Timer,
|
||||||
|
search:
|
||||||
|
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
customFields: {
|
||||||
|
Video_Notes: {
|
||||||
|
type: 'text',
|
||||||
|
colour: '#FFAB33',
|
||||||
|
label: 'Video Notes',
|
||||||
|
},
|
||||||
|
Audio_Notes: {
|
||||||
|
type: 'text',
|
||||||
|
colour: '#339E4E',
|
||||||
|
label: 'Audio Notes',
|
||||||
|
},
|
||||||
|
PowerPoint_Name: {
|
||||||
|
type: 'text',
|
||||||
|
colour: '#3E75E8',
|
||||||
|
label: 'PowerPoint Name',
|
||||||
|
},
|
||||||
|
PowerPoint_Slide: {
|
||||||
|
type: 'image',
|
||||||
|
colour: '#ED3333',
|
||||||
|
label: 'PowerPoint Slide',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
automation: {
|
||||||
|
enabledAutomations: false,
|
||||||
|
enabledOscIn: false,
|
||||||
|
oscPortIn: 8888,
|
||||||
|
triggers: [],
|
||||||
|
automations: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,14 +2,14 @@ import type { OntimeDelay, OntimeEntry, OntimeEvent, OntimeGroup } from 'ontime-
|
|||||||
import { SupportedEntry } from 'ontime-types';
|
import { SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getFirstGroupNormal,
|
||||||
getLastEvent,
|
getLastEvent,
|
||||||
getLastNormal,
|
|
||||||
getLastGroupNormal,
|
getLastGroupNormal,
|
||||||
|
getLastNormal,
|
||||||
getNext,
|
getNext,
|
||||||
getNextEvent,
|
getNextEvent,
|
||||||
getNextGroupNormal,
|
getNextGroupNormal,
|
||||||
getNextNormal,
|
getNextNormal,
|
||||||
getFirstGroupNormal,
|
|
||||||
getPrevious,
|
getPrevious,
|
||||||
getPreviousEvent,
|
getPreviousEvent,
|
||||||
getPreviousGroup,
|
getPreviousGroup,
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
getPreviousNormal,
|
getPreviousNormal,
|
||||||
swapEventData,
|
swapEventData,
|
||||||
} from './rundownUtils';
|
} from './rundownUtils';
|
||||||
import { demoDb } from '../../../../apps/server/src/models/demoProject';
|
import { demoDb } from './rundownUtils.mock';
|
||||||
|
|
||||||
describe('getNext()', () => {
|
describe('getNext()', () => {
|
||||||
it('returns the next event of type event', () => {
|
it('returns the next event of type event', () => {
|
||||||
|
|||||||
@@ -15,5 +15,6 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"src/**/*.test.ts",
|
"src/**/*.test.ts",
|
||||||
"src/**/*.spec.ts",
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.mock.ts",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user