refactor: cuesheet design review

fix: parsing of custom fields for blocks
refactor: extract cuesheet settings
refactor: cuesheet actions
refactor: improve cuesheet performance on resizing
This commit is contained in:
Carlos Valente
2025-06-29 08:51:38 +02:00
committed by Carlos Valente
parent 0726c04f20
commit 8ad260d28a
61 changed files with 1421 additions and 734 deletions
@@ -79,3 +79,22 @@
border-color: $gray-1250;
}
}
.ghosted {
background: transparent;
color: $ui-white;
&:hover:not(:disabled):not(:active) {
background: $gray-1000;
color: $ui-white;
}
&:active:not(:disabled) {
background: $gray-1100;
border-color: $gray-1250;
}
&:disabled {
opacity: $opacity-disabled;
}
}
@@ -1,20 +1,21 @@
import { ButtonHTMLAttributes } from 'react';
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cx } from '../../utils/styleUtils';
import style from './Button.module.scss';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive';
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive' | 'ghosted';
size?: 'small' | 'medium' | 'large' | 'xlarge';
fluid?: boolean;
}
export default function Button(props: ButtonProps) {
const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
const { className, children, variant = 'subtle', size = 'medium', fluid, ...buttonProps } = props;
return (
<button
ref={ref}
className={cx([style.baseButton, style[variant], style[size], fluid && style.fluid, className])}
type='button'
{...buttonProps}
@@ -22,4 +23,8 @@ export default function Button(props: ButtonProps) {
{children}
</button>
);
}
});
Button.displayName = 'Button';
export default Button;
@@ -7,7 +7,7 @@
place-content: center;
border: 1px solid transparent;
border-radius: 3px;
border-radius: $component-border-radius-md;
cursor: pointer;
@@ -17,6 +17,11 @@
}
}
.small {
height: 1.5rem;
font-size: calc(1rem - 3px);
}
.medium {
height: 2rem;
width: 2rem;
@@ -5,8 +5,8 @@ import { cx } from '../../utils/styleUtils';
import style from './IconButton.module.scss';
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive';
size?: 'medium' | 'large' | 'xlarge';
variant?: 'primary' | 'subtle' | 'subtle-white' | 'destructive' | 'subtle-destructive' | 'ghosted';
size?: 'small' | 'medium' | 'large' | 'xlarge';
}
export default function IconButton({
@@ -0,0 +1,6 @@
import { IconBaseProps } from 'react-icons';
import { IoLink } from 'react-icons/io5';
export default function RotatedLink(linkProps: IconBaseProps) {
return <IoLink style={{ transform: 'rotate(-45deg)' }} {...linkProps} />;
}
@@ -9,7 +9,7 @@ $input-font-size: 15px;
.inputField {
font-size: $input-font-size;
letter-spacing: 1px;
letter-spacing: 0.5px;
max-width: 7em;
padding-left: 16px;
color: $ontime-delay-text
@@ -1,6 +1,6 @@
.timeInput {
width: 100%;
max-width: 7.5em;
letter-spacing: 1px;
letter-spacing: 0.5px;
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,58 @@
.modal {
position: fixed;
top: 50%;
left: 50%;
z-index: $zindex-dialog;
transform: translate(-50%, -50%);
padding-inline: 1rem;
min-width: min(680px, 90vw);
min-height: min(200px, 10vh);
background-color: $gray-1250;
color: $ui-white;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 1px solid $gray-1200;
}
.backdrop {
position: fixed;
inset: 0;
z-index: $zindex-backdrop;
background-color: $backdrop-color;
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
}
}
.title {
font-size: 1rem;
font-weight: 600;
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.body {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: min(80vh, 600px);
overflow-y: auto;
}
.footer {
padding-block: 1rem;
display: flex;
align-items: center;
justify-content: end;
gap: 1rem;
}
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
import { IoClose } from 'react-icons/io5';
import { Dialog as BaseDialog } from '@base-ui-components/react/dialog';
import IconButton from '../buttons/IconButton';
import style from './Modal.module.scss';
interface ModalProps {
isOpen: boolean;
title: string;
showCloseButton?: boolean;
showBackdrop?: boolean;
bodyElements: ReactNode;
footerElements?: ReactNode;
onClose: () => void;
}
export default function Modal({
isOpen,
title,
showCloseButton,
showBackdrop,
bodyElements,
footerElements,
onClose,
}: ModalProps) {
return (
<BaseDialog.Root
open={isOpen}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
dismissible={false}
>
<BaseDialog.Portal>
{showBackdrop && <BaseDialog.Backdrop className={style.backdrop} />}
<BaseDialog.Popup className={style.modal}>
<div className={style.title}>
{title}
{showCloseButton && (
<IconButton variant='subtle-white' onClick={onClose}>
<IoClose />
</IconButton>
)}
</div>
<div className={style.body}>{bodyElements}</div>
<div className={style.footer}>{footerElements}</div>
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}
@@ -6,9 +6,9 @@ import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
export default function EditorNavigation() {
const navigate = useNavigate();
const isSmallDevide = useIsSmallDevice();
const isSmallDevice = useIsSmallDevice();
if (!isSmallDevide) {
if (!isSmallDevice) {
return (
<NavigationMenuItem active={location.pathname === '/editor'} onClick={() => navigate('/editor')}>
<IoLockClosedOutline />
@@ -0,0 +1,32 @@
.popup {
box-sizing: border-box;
padding: 1rem 1.5rem;
z-index: $zindex-dialog;
color: $ui-white;
background-color: $gray-1250;
border-radius: 3px;
box-shadow: $box-shadow-l1;
border: 2px solid $gray-1200;
//width: 32rem;
max-width: 90vw;
transform-origin: var(--transform-origin);
transition:
transform 150ms,
opacity 150ms;
&[data-starting-style],
&[data-ending-style] {
opacity: 0;
transform: scale(0.9);
}
}
.title {
font-size: 1rem;
margin-bottom: 0.5rem;
}
@@ -0,0 +1,26 @@
import { PropsWithChildren } from 'react';
import { Popover } from '@base-ui-components/react/popover';
import style from './Popover.module.scss';
interface PopoverContentsProps extends Popover.Positioner.Props {
title?: string;
className?: string;
}
export default function PopoverContents({
title,
className,
children,
...popoverProps
}: PropsWithChildren<PopoverContentsProps>) {
return (
<Popover.Portal>
<Popover.Positioner sideOffset={8} {...popoverProps}>
<Popover.Popup className={style.popup}>
{title && <Popover.Title className={style.title}>{title}</Popover.Title>}
<Popover.Description className={className}>{children}</Popover.Description>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
);
}
@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { useOs, useViewportSize } from '@mantine/hooks';
export function useIsMobile(): boolean {
export function useIsMobileDevice(): boolean {
const { width } = useViewportSize();
const os = useOs();
@@ -0,0 +1,8 @@
import { useMemo } from 'react';
import { useViewportSize } from '@mantine/hooks';
export function useIsMobileScreen(): boolean {
const { width } = useViewportSize();
return useMemo(() => width < 800, [width]);
}
@@ -2,7 +2,7 @@ import { PropsWithChildren } from 'react';
import ProtectRoute from '../common/components/protect-route/ProtectRoute';
import style from './FeatureWrapper.module.scss';
import style from './EditorFeatureWrapper.module.scss';
export default function EditorFeatureWrapper({ children }: PropsWithChildren) {
return (
@@ -0,0 +1,74 @@
import { memo, PropsWithChildren, useMemo } from 'react';
import { useIsMobileScreen } from '../../common/hooks/useIsMobileScreen';
import { useRuntimeOverview } from '../../common/hooks/useSocket';
import { CurrentBlockOverview, OverviewWrapper, RuntimeOverview, TimerOverview } from './composite/OverviewWrapper';
import { TimeRow } from './composite/TimeLayout';
import { calculateEndAndDaySpan, formatedTime } from './overviewUtils';
import style from './Overview.module.scss';
export default memo(CuesheetOverview);
function CuesheetOverview({ children }: PropsWithChildren) {
const isMobileScreen = useIsMobileScreen();
if (isMobileScreen) return <CuesheetMobile>{children}</CuesheetMobile>;
else return <CuesheetDesktop>{children}</CuesheetDesktop>;
}
function CuesheetMobile({ children }: PropsWithChildren) {
return (
<OverviewWrapper navElements={children}>
<TimerOverview />
<RuntimeOverview />
</OverviewWrapper>
);
}
function CuesheetDesktop({ children }: PropsWithChildren) {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatedTime(maybePlannedEnd);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const expectedEndText = formatedTime(maybeExpectedEnd);
return (
<OverviewWrapper navElements={children}>
<div>
<TimeRow
label='Planned start'
value={formatedTime(plannedStart)}
className={style.start}
muted={plannedStart === null}
/>
<TimeRow
label='Actual start'
value={formatedTime(actualStart)}
className={style.start}
muted={actualStart === null}
/>
</div>
<TimerOverview />
<CurrentBlockOverview />
<RuntimeOverview />
<div>
<TimeRow
label='Planned end'
value={plannedEndText}
className={style.end}
daySpan={maybePlannedDaySpan}
muted={maybePlannedEnd === null}
/>
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
muted={maybeExpectedEnd === null}
/>
</div>
</OverviewWrapper>
);
}
@@ -0,0 +1,65 @@
import { memo, PropsWithChildren, useMemo } from 'react';
import { useRuntimeOverview } from '../../common/hooks/useSocket';
import {
CurrentBlockOverview,
OverviewWrapper,
ProgressOverview,
RuntimeOverview,
TitlesOverview,
} from './composite/OverviewWrapper';
import { TimeRow } from './composite/TimeLayout';
import { calculateEndAndDaySpan, formatedTime } from './overviewUtils';
import style from './Overview.module.scss';
export default memo(EditorOverview);
function EditorOverview({ children }: PropsWithChildren) {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatedTime(maybePlannedEnd);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const expectedEndText = formatedTime(maybeExpectedEnd);
return (
<OverviewWrapper navElements={children}>
<TitlesOverview />
<div>
<TimeRow
label='Planned start'
value={formatedTime(plannedStart)}
className={style.start}
muted={plannedStart === null}
/>
<TimeRow
label='Actual start'
value={formatedTime(actualStart)}
className={style.start}
muted={actualStart === null}
/>
</div>
<ProgressOverview />
<CurrentBlockOverview />
<RuntimeOverview />
<div>
<TimeRow
label='Planned end'
value={plannedEndText}
className={style.end}
daySpan={maybePlannedDaySpan}
muted={maybePlannedEnd === null}
/>
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
muted={maybeExpectedEnd === null}
/>
</div>
</OverviewWrapper>
);
}
@@ -1,171 +0,0 @@
import { memo, PropsWithChildren, ReactNode, useMemo } from 'react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useIsOnline, useRuntimeOverview, useRuntimePlaybackOverview, useTimer } from '../../common/hooks/useSocket';
import useProjectData from '../../common/hooks-query/useProjectData';
import { cx, enDash, timerPlaceholder } from '../../common/utils/styleUtils';
import { TimeColumn, TimeRow } from './composite/TimeLayout';
import { calculateEndAndDaySpan, formatedTime, getOffsetText } from './overviewUtils';
import style from './Overview.module.scss';
export const EditorOverview = memo(_EditorOverview);
function _EditorOverview({ children }: PropsWithChildren) {
const { plannedEnd, plannedStart, actualStart, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatedTime(maybePlannedEnd);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const expectedEndText = formatedTime(maybeExpectedEnd);
return (
<OverviewWrapper navElements={children}>
<TitlesOverview />
<div>
<TimeRow
label='Planned start'
value={formatedTime(plannedStart)}
className={style.start}
muted={plannedStart === null}
/>
<TimeRow
label='Actual start'
value={formatedTime(actualStart)}
className={style.start}
muted={actualStart === null}
/>
</div>
<ProgressOverview />
<CurrentBlockOverview />
<RuntimeOverview />
<div>
<TimeRow
label='Planned end'
value={plannedEndText}
className={style.end}
daySpan={maybePlannedDaySpan}
muted={maybePlannedEnd === null}
/>
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
muted={maybeExpectedEnd === null}
/>
</div>
</OverviewWrapper>
);
}
export const CuesheetOverview = memo(_CuesheetOverview);
function _CuesheetOverview({ children }: PropsWithChildren) {
const { plannedEnd, expectedEnd } = useRuntimeOverview();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatedTime(maybePlannedEnd);
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const expectedEndText = formatedTime(maybeExpectedEnd);
return (
<OverviewWrapper navElements={children}>
<TitlesOverview />
<TimerOverview />
<RuntimeOverview />
<div>
<TimeRow
label='Planned end'
value={plannedEndText}
className={style.end}
daySpan={maybePlannedDaySpan}
muted={maybePlannedEnd === null}
/>
<TimeRow
label='Expected end'
value={expectedEndText}
className={style.end}
daySpan={maybeExpectedDaySpan}
muted={maybeExpectedEnd === null}
/>
</div>
</OverviewWrapper>
);
}
interface OverviewWrapperProps {
navElements: ReactNode;
}
function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) {
const { isOnline } = useIsOnline();
return (
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary>
<div className={style.nav}>{navElements}</div>
<div className={style.info}>{children}</div>
</ErrorBoundary>
</div>
);
}
function TitlesOverview() {
const { data } = useProjectData();
if (!data.title && !data.description) {
return null;
}
return (
<div>
<div className={style.title}>{data.title}</div>
<div className={style.description}>{data.description}</div>
</div>
);
}
function CurrentBlockOverview() {
const { blockStartedAt: blockStartAt, clock } = useRuntimePlaybackOverview();
const timeInBlock = formatedTime(blockStartAt ? clock - blockStartAt : null);
return <TimeColumn label='Time in block' value={timeInBlock} className={style.clock} muted={blockStartAt === null} />;
}
function TimerOverview() {
const { current } = useTimer();
const display = millisToString(current, { fallback: timerPlaceholder });
return <TimeColumn label='Running timer' value={display} muted={current === null} />;
}
function ProgressOverview() {
const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview();
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
const ofTotal = numEvents || enDash;
const progressText = numEvents ? `${current} of ${ofTotal}` : '-';
return <TimeColumn label='Progress' value={progressText} />;
}
function RuntimeOverview() {
const { clock, offset, playback } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset);
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
return (
<>
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
<TimeColumn label='Time now' value={formatedTime(clock)} />
</>
);
}
@@ -0,0 +1,84 @@
import { PropsWithChildren, ReactNode } from 'react';
import { ErrorBoundary } from '@sentry/react';
import { Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useIsOnline, useRuntimePlaybackOverview, useTimer } from '../../../common/hooks/useSocket';
import useProjectData from '../../../common/hooks-query/useProjectData';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatedTime, getOffsetText } from '../overviewUtils';
import { TimeColumn } from './TimeLayout';
import style from '../Overview.module.scss';
interface OverviewWrapperProps {
navElements: ReactNode;
}
export function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) {
const { isOnline } = useIsOnline();
return (
<div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary>
<div className={style.nav}>{navElements}</div>
<div className={style.info}>{children}</div>
</ErrorBoundary>
</div>
);
}
export function TitlesOverview() {
const { data } = useProjectData();
if (!data.title && !data.description) {
return null;
}
return (
<div>
<div className={style.title}>{data.title}</div>
<div className={style.description}>{data.description}</div>
</div>
);
}
export function CurrentBlockOverview() {
const { blockStartedAt: blockStartAt, clock } = useRuntimePlaybackOverview();
const timeInBlock = formatedTime(blockStartAt ? clock - blockStartAt : null);
return <TimeColumn label='Time in block' value={timeInBlock} className={style.clock} muted={blockStartAt === null} />;
}
export function TimerOverview() {
const { current } = useTimer();
const display = millisToString(current, { fallback: timerPlaceholder });
return <TimeColumn label='Running timer' value={display} muted={current === null} />;
}
export function ProgressOverview() {
const { numEvents, selectedEventIndex } = useRuntimePlaybackOverview();
const current = selectedEventIndex !== null ? selectedEventIndex + 1 : enDash;
const ofTotal = numEvents || enDash;
const progressText = numEvents ? `${current} of ${ofTotal}` : '-';
return <TimeColumn label='Progress' value={progressText} />;
}
export function RuntimeOverview() {
const { clock, offset, playback } = useRuntimePlaybackOverview();
const offsetText = getOffsetText(offset);
const offsetClasses = cx([style.offset, playback !== Playback.Stop && (offset < 0 ? style.behind : style.ahead)]);
return (
<>
<TimeColumn label='Offset' value={offsetText} className={offsetClasses} testId='offset' />
<TimeColumn label='Time now' value={formatedTime(clock)} />
</>
);
}
@@ -1,43 +1,50 @@
import { useEffect, useState } from 'react';
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
import { isOntimeBlock, isOntimeEvent, OntimeEntry } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown';
import { cx } from '../../../common/utils/styleUtils';
import BlockEditor from './BlockEditor';
import EventEditor from './EventEditor';
import style from './EntryEditor.module.scss';
interface CuesheetEventEditorProps {
eventId: string;
interface CuesheetEntryEditorProps {
entryId: string;
}
export default function CuesheetEventEditor({ eventId }: CuesheetEventEditorProps) {
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
const { data } = useRundown();
const [event, setEvent] = useState<OntimeEvent | null>(null);
const [entry, setEntry] = useState<OntimeEntry | null>(null);
useEffect(() => {
if (data.order.length === 0) {
setEvent(null);
setEntry(null);
return;
}
const event = data.entries[eventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
const event = data.entries[entryId];
if (event) {
setEntry(event);
} else {
setEvent(null);
setEntry(null);
}
}, [eventId, data.order, data.entries]);
}, [entryId, data.order, data.entries]);
if (!event) {
return null;
if (isOntimeEvent(entry)) {
return (
<div className={style.entryEditor} data-testid='editor-container'>
<EventEditor event={entry} />
</div>
);
}
return (
<div className={cx([style.entryEditor, style.inModal])} data-testid='editor-container'>
<EventEditor event={event} />
</div>
);
if (isOntimeBlock(entry)) {
return (
<div className={style.inModal} data-testid='editor-container'>
<BlockEditor block={entry} />
</div>
);
}
return null;
}
@@ -3,10 +3,6 @@
display: flex;
flex-direction: column;
overflow-x: auto;
&.inModal {
max-height: 80vh;
}
}
.content {
+1
View File
@@ -73,6 +73,7 @@ html,
-webkit-user-select: none;
user-select: none;
-webkit-app-region: drag;
isolation: isolate;
}
/* smaller root size for MacOS laptops */
-1
View File
@@ -41,7 +41,6 @@ $text-black: $gray-1350;
// interface panels
$bg-container-l1: $gray-1350;
$bg-container-l2: $gray-1300;
$bg-container-l3: $gray-1350;
$bg-container-onlight: $gray-100;
@@ -6,12 +6,11 @@
padding-block: 0.5rem 1rem;
display: grid;
grid-template-rows: 3rem auto auto 1fr;
grid-template-rows: 3.5rem auto auto 1fr;
grid-template-areas:
'overview'
'progress'
'settings'
'table';
gap: 1rem;
color: $ui-white;
}
+15 -58
View File
@@ -1,93 +1,50 @@
import { useCallback, useMemo, useState } from 'react';
import { IoApps, IoSettingsOutline } from 'react-icons/io5';
import { Modal, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { useMemo } from 'react';
import { IoApps } from 'react-icons/io5';
import { useDisclosure } from '@mantine/hooks';
import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import useViewEditor from '../../common/components/navigation-menu/useViewEditor';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import { useFlatRundown } from '../../common/hooks-query/useRundown';
import { CuesheetOverview } from '../../features/overview/Overview';
import CuesheetEventEditor from '../../features/rundown/entry-editor/CuesheetEventEditor';
import CuesheetOverview from '../../features/overview/CuesheetOverview';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import CuesheetEditModal from './cuesheet-edit-modal/CuesheetEditModal';
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetCols';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
import { cuesheetOptions } from './cuesheet.options';
import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() {
const { data: flatRundown, status: rundownStatus } = useFlatRundown();
const { data: customFields, status: customFieldStatus } = useCustomFields();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable: true });
const { isViewLocked } = useViewEditor({ isLockable: true });
const [isMenuOpen, menuHandler] = useDisclosure();
const [isEventEditorOpen, eventEditorHandler] = useDisclosure();
const [eventId, setEventId] = useState<string | null>(null);
const columns = useMemo(() => makeCuesheetColumns(customFields), [customFields]);
useWindowTitle('Cuesheet');
/**
* Handles setting the edit modal target and visibility
*/
const setShowModal = useCallback(
(eventId: string | null) => {
if (eventId) {
setEventId(eventId);
eventEditorHandler.open();
} else {
setEventId(null);
eventEditorHandler.close();
}
},
[eventEditorHandler],
);
if (!customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending') {
return <EmptyPage text='Loading...' />;
}
const isLoading = !customFields || !flatRundown || rundownStatus === 'pending' || customFieldStatus === 'pending';
return (
<>
<Modal isOpen={isEventEditorOpen} onClose={eventEditorHandler.close} variant='ontime'>
<ModalOverlay />
<ModalContent maxWidth='max(640px, 40vw)' padding='1rem'>
<CuesheetEventEditor eventId={eventId!} />
</ModalContent>
</Modal>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<CuesheetEditModal />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<ViewParamsEditor viewOptions={cuesheetOptions} />
<CuesheetOverview>
<IconButton
aria-label='Toggle navigation'
variant='subtle-white'
size='xlarge'
onClick={menuHandler.open}
disabled={isViewLocked}
>
<IoApps />
</IconButton>
<IconButton
aria-label='Toggle settings'
variant='subtle-white'
size='xlarge'
onClick={showEditFormDrawer}
disabled={isViewLocked}
>
<IoSettingsOutline />
</IconButton>
{!isViewLocked && (
<IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={menuHandler.open}>
<IoApps />
</IconButton>
)}
</CuesheetOverview>
<CuesheetProgress />
<CuesheetDnd columns={columns}>
<CuesheetTable data={flatRundown} columns={columns} showModal={setShowModal} />
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable data={flatRundown} columns={columns} />}
</CuesheetDnd>
</div>
</>
@@ -0,0 +1,26 @@
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} />}
/>
);
}
@@ -0,0 +1,14 @@
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,4 +1,5 @@
.progressOverride {
margin-top: 0.5rem;
height: 1rem;
grid-area: progress;
}
@@ -8,25 +8,41 @@ $table-header-font-size: calc(1rem - 2px);
width: 100%;
height: 100%;
overflow: auto;
padding-bottom: 640px; // allow focus to reach last elements
padding-bottom: 70vh; // allow focus to reach last elements
}
.cuesheet {
font-size: $table-font-size;
font-weight: 400;
color: $ui-white;
tr {
display: flex;
}
th {
background-color: $gray-1300;
padding-left: 0.25rem;
font-weight: 400;
&:hover {
.resizer {
width: 0.5rem;
}
}
&:first-of-type {
margin-left: 4px; // compensate left border
}
}
th,
td {
margin: 1px;
font-weight: inherit;
font-size: inherit;
text-align: left;
position: relative;
@include ellipsis-overflow;
padding: 0.25rem;
}
th:focus,
@@ -35,102 +51,44 @@ $table-header-font-size: calc(1rem - 2px);
}
}
.tableHeader,
.eventRow {
.actionColumn {
width: calc(2rem + 0.5rem); // sm button size (--chakra-sizes-8) + 2 * padding
background-color: transparent;
}
.actionColumn {
padding-inline: 0.5rem;
background-color: transparent;
display: flex;
align-items: start;
justify-content: center;
line-height: 2rem; // match input height
}
.indexColumn {
display: flex;
align-items: center;
justify-content: end;
.indexColumn {
display: flex;
align-items: start;
justify-content: end;
min-width: 3em; // allow for 3-digit numbers
font-size: $table-header-font-size;
background-color: $gray-1300; // will be overridden inline
}
min-width: 3em; // allow for 3-digit numbers
font-size: $table-header-font-size;
line-height: 2rem; // match input height
background-color: $gray-1300; // will be overridden inline
font-weight: 600;;
}
.tableHeader {
line-height: 2rem;
position: sticky;
top: 0px;
z-index: $zindex-floating;
background-color: $ui-black;
font-size: $table-header-font-size;
color: $label-gray;
}
th {
background-color: $gray-1300;
padding-left: 0.25rem;
&:hover {
.resizer {
width: 0.5rem;
}
}
}
.eventRow {
vertical-align: top;
&:hover {
outline: 1px solid $blue-700;
outline-offset: -1px;
}
td {
.actionColumn {
background-color: $gray-1250;
border-radius: 2px;
padding: 0.25rem;
width: calc(2rem + 1px); // button + padding + margin
}
&.skip {
text-decoration: line-through;
opacity: $opacity-disabled !important; // fighting inline styles
.indexColumn {
width: 3.5em;
}
}
.blockRow {
width: 100%;
background-color: $gray-1350;
font-size: 1rem;
height: 2.5rem;
td {
align-self: flex-end;
position: sticky;
left: 1rem;
padding: 0.25rem 0;
}
}
.delayRow {
width: 100%;
color: $ontime-delay-text;
td {
position: sticky;
left: 47.5%; // center of the screen, ish
padding: 0.5rem 0;
&:first-letter {
text-transform: uppercase;
}
}
}
.check {
font-size: 1.5rem;
margin: 0 auto;
}
.delayedTime {
color: $ontime-delay-text;
font-size: calc(1rem - 2px);
}
.resizer {
cursor: col-resize;
opacity: $opacity-disabled;
@@ -1,11 +1,11 @@
import { useCallback, useRef } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent, TimeField } from 'ontime-types';
import { OntimeEntry, TimeField } from 'ontime-types';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useFollowComponent from '../../../common/hooks/useFollowComponent';
import { useCuesheetOptions } from '../cuesheet.options';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import CuesheetBody from './cuesheet-table-elements/CuesheetBody';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
@@ -18,21 +18,59 @@ import style from './CuesheetTable.module.scss';
interface CuesheetTableProps {
data: OntimeEntry[];
columns: ColumnDef<OntimeEntry>[];
showModal: (eventId: MaybeString) => void;
}
export default function CuesheetTable({ data, columns, showModal }: CuesheetTableProps) {
export default function CuesheetTable({ data, columns }: CuesheetTableProps) {
const { updateEntry, updateTimer } = useEntryActions();
const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
useColumnManager(columns);
const followPlayback = usePersistedCuesheetOptions((state) => state.followPlayback);
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followPlayback });
const { listeners } = useTableNav();
const meta = useMemo(
() => ({
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
// check if value is the same
const event = data[rowIndex];
if (!event) {
return;
}
// skip if there is no value change
const key = accessor as keyof OntimeEntry;
const previousValue = event[key];
if (previousValue === payload) {
return;
}
if (isCustom) {
updateEntry({ id: event.id, custom: { [accessor]: payload } });
return;
}
updateEntry({ id: event.id, [accessor]: payload });
},
handleUpdateTimer: (eventId: string, field: TimeField, payload: string) => {
// the timer element already contains logic to avoid submitting a unchanged value
updateTimer(eventId, field, payload, true);
},
options: {
showDelayedTimes,
hideTableSeconds,
},
}),
[data, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
);
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
useColumnManager(columns);
const table = useReactTable({
data,
columns,
@@ -45,52 +83,36 @@ export default function CuesheetTable({ data, columns, showModal }: CuesheetTabl
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
meta: {
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
// check if value is the same
const event = data[rowIndex];
if (!event || !isOntimeEvent(event)) {
return;
}
// skip if there is no value change
const key = accessor as keyof OntimeEvent;
const previousValue = event[key];
if (previousValue === payload) {
return;
}
if (isCustom) {
updateEntry({ id: event.id, custom: { [accessor]: payload } });
return;
}
updateEntry({ id: event.id, [accessor]: payload });
},
handleUpdateTimer: (eventId: string, field: TimeField, payload) => {
// the timer element already contains logic to avoid submitting a unchanged value
updateTimer(eventId, field, payload, true);
},
options: {
showDelayedTimes,
hideTableSeconds,
},
},
meta,
});
const setAllVisible = useCallback(() => {
table.toggleAllColumnsVisible(true);
}, []);
}, [table]);
const resetColumnResizing = useCallback(() => {
setColumnSizing({});
}, []);
}, [setColumnSizing]);
const headerGroups = table.getHeaderGroups();
const rowModel = table.getRowModel();
const allLeafColumns = table.getAllLeafColumns();
/**
* To improve performance on resizing, we memoise the column sizes
* and pass them as CSS variables to the table container.
*/
const columnSizeVars = useMemo(() => {
const headers = table.getFlatHeaders();
const colSizes: { [key: string]: number } = {};
for (let i = 0; i < headers.length; i++) {
const header = headers[i]!;
colSizes[`--header-${header.id}-size`] = header.getSize();
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
}
return colSizes;
}, [table.getState().columnSizingInfo, table.getState().columnSizing]);
return (
<>
<CuesheetTableSettings
@@ -100,12 +122,24 @@ export default function CuesheetTable({ data, columns, showModal }: CuesheetTabl
handleClearToggles={setAllVisible}
/>
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet} id='cuesheet' {...listeners}>
<table className={style.cuesheet} id='cuesheet' style={{ ...columnSizeVars }} {...listeners}>
<CuesheetHeader headerGroups={headerGroups} />
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
{table.getState().columnSizingInfo.isResizingColumn ? (
<MemoisedBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
) : (
<CuesheetBody rowModel={rowModel} selectedRef={selectedRef} table={table} />
)}
</table>
</div>
<CuesheetTableMenu showModal={showModal} />
<CuesheetTableMenu />
</>
);
}
/**
* While dragging, we avoid re-rendering the body by render
*/
const MemoisedBody = memo(
CuesheetBody,
(prev, next) => prev.table.options.data === next.table.options.data,
) as typeof CuesheetBody;
@@ -0,0 +1,35 @@
@import '../CuesheetTable.module.scss';
.blockRow {
margin-top: 1rem;
width: 100%;
display: flex;
align-items: start;
font-size: 1rem;
border-radius: 2px 0 0 0;
background-color: $gray-1300;
border-left: 4px solid var(--user-bg, $gray-500);
background: color-mix(in srgb, transparent 90%, var(--user-bg, $gray-500) 10%);
position: relative;
line-height: 1em;
td {
min-height: 3.5rem;
padding-top: 0.75rem !important; // fighting styles from cuesheet-table
}
.indexColumn {
background-color: transparent;
}
.title {
font-weight: 600;
}
&:hover {
outline: 1px solid $blue-500;
outline-offset: -1px;
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
}
}
@@ -1,47 +1,70 @@
import { memo, useRef } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table';
import { EntryId, OntimeEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton';
import { useCurrentBlockId } from '../../../../common/hooks/useSocket';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import style from '../CuesheetTable.module.scss';
import style from './BlockRow.module.scss';
interface BlockRowProps {
blockId: EntryId;
colour: string;
hidePast: boolean;
title: string;
columnCount: number;
rowId: string;
rowIndex: number;
table: Table<OntimeEntry>;
}
function BlockRow({ hidePast, title, columnCount }: BlockRowProps) {
export default function BlockRow({ blockId, colour, hidePast, rowId, rowIndex, table }: BlockRowProps) {
const { currentBlockId } = useCurrentBlockId();
const firstCellRef = useRef<null | HTMLTableCellElement>(null);
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
if (hidePast && !currentBlockId) {
return null;
}
// guard the use case where user has hidden all columns
const fillColumns = Math.min(columnCount, 1);
const paddingRows = new Array(fillColumns).fill(null);
return (
<tr className={style.blockRow}>
<td tabIndex={-1} role='cell' ref={firstCellRef}>
{title}
</td>
{paddingRows.map((_value, index) => {
return (
<td
key={index}
tabIndex={-1}
role='cell'
onFocus={() => {
firstCellRef.current?.focus();
<tr className={style.blockRow} style={{ '--user-bg': colour }}>
{showActionMenu && (
<td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton
aria-label='Options'
variant='subtle-white'
size='small'
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const yPos = 8 + rect.y + rect.height / 2;
openMenu({ x: rect.x, y: yPos }, blockId, rowIndex);
}}
/>
);
})}
>
<IoEllipsisHorizontal />
</IconButton>
</td>
)}
{!hideIndexColumn && <td className={style.indexColumn} tabIndex={-1} role='cell' />}
{table
.getRow(rowId)
.getVisibleCells()
.map((cell) => {
return (
<td
key={cell.id}
tabIndex={-1}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
}}
role='cell'
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
}
export default memo(BlockRow);
@@ -1,16 +1,19 @@
import { MutableRefObject } from 'react';
import { MutableRefObject, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { RowModel, Table } from '@tanstack/react-table';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeEntry } from 'ontime-types';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types';
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import { RUNDOWN } from '../../../../common/api/constants';
import { useSelectedEventId } from '../../../../common/hooks/useSocket';
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../../../cuesheet/cuesheet.options';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import BlockRow from './BlockRow';
import DelayRow from './DelayRow';
import EventRow from './EventRow';
import { useVisibleRowsStore } from './visibleRowsStore';
interface CuesheetBodyProps {
rowModel: RowModel<OntimeEntry>;
@@ -19,8 +22,29 @@ interface CuesheetBodyProps {
}
export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetBodyProps) {
const queryClient = useQueryClient();
const { selectedEventId } = useSelectedEventId();
const { hideDelays, hidePast } = useCuesheetOptions();
const hidePast = usePersistedCuesheetOptions((state) => state.hidePast);
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
const { addVisibleRow, removeVisibleRow } = useVisibleRowsStore();
const observer = useMemo(
() =>
new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
addVisibleRow(entry.target.id);
} else {
removeVisibleRow(entry.target.id);
}
}
},
{ rootMargin: '400px' },
),
[addVisibleRow, removeVisibleRow],
);
const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
const getColumnHash = lazyEvaluate(() => {
@@ -36,6 +60,7 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
let eventIndex = 0;
// for the first event, it will be past if there is something selected
let isPast = Boolean(selectedEventId);
let hadBlock = false;
return (
<tbody>
{rowModel.rows.map((row, index) => {
@@ -47,8 +72,17 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
}
if (isOntimeBlock(entry)) {
const columnCount = getVisibleColumns().length;
return <BlockRow columnCount={columnCount} key={key} title={entry.title} hidePast={isPast && hidePast} />;
return (
<BlockRow
key={key}
blockId={entry.id}
colour={entry.colour}
hidePast={isPast && hidePast}
rowId={row.id}
rowIndex={row.index}
table={table}
/>
);
}
if (isOntimeDelay(entry)) {
if (isPast && hidePast) {
@@ -59,20 +93,27 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
return null;
}
return <DelayRow key={key} duration={delayVal} />;
let parentBgColour: string | null = null;
if (entry.parent) {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
const parentEntry = rundown?.entries[entry.parent];
parentBgColour = (parentEntry as OntimeBlock).colour ?? null;
}
return <DelayRow key={key} duration={delayVal} parentBgColour={parentBgColour} />;
}
if (isOntimeEvent(entry)) {
eventIndex++;
const isSelected = key === selectedEventId;
const columnHash = getColumnHash();
if (isPast && hidePast) {
return null;
}
let rowBgColour: string | undefined;
if (isSelected) {
rowBgColour = '#D20300'; // $red-700
rowBgColour = '#087A27'; // $active-green
} else if (entry.colour) {
// the colour is user defined and might be invalid
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(entry.colour).backgroundColor);
@@ -84,6 +125,19 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
}
}
let parentBgColour: string | undefined;
let firstAfterBlock = false;
if (entry.parent) {
const rundown = queryClient.getQueryData<Rundown>(RUNDOWN);
const parentEntry = rundown?.entries[entry.parent];
parentBgColour = (parentEntry as OntimeBlock).colour;
hadBlock = true;
} else if (hadBlock) {
firstAfterBlock = true;
hadBlock = false;
}
return (
<EventRow
key={row.id}
@@ -94,8 +148,11 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
rowBgColour={rowBgColour}
parentBgColour={parentBgColour}
table={table}
firstAfterBlock={firstAfterBlock}
columnHash={columnHash}
observer={observer}
/>
);
}
@@ -3,7 +3,7 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../../cuesheet.options';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { SortableCell } from './SortableCell';
@@ -14,7 +14,8 @@ interface CuesheetHeaderProps {
}
export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
return (
<thead className={style.tableHeader}>
@@ -31,7 +32,6 @@ export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
)}
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
// @ts-expect-error -- we inject this into react-table
const customBackground = header.column.columnDef?.meta?.colour;
@@ -42,7 +42,11 @@ export default function CuesheetHeader({ headerGroups }: CuesheetHeaderProps) {
}
return (
<SortableCell key={header.column.columnDef.id} header={header} style={{ width, ...customStyles }}>
<SortableCell
key={header.column.columnDef.id}
header={header}
injectedStyles={{ width: `calc(var(--header-${header?.id}-size) * 1px)`, ...customStyles }}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</SortableCell>
);
@@ -0,0 +1,18 @@
@import '../CuesheetTable.module.scss';
.delayRow {
width: calc(100vw - 2rem);
color: $ontime-delay-text;
border-left: 4px solid var(--user-bg);
td {
width: 100%;
padding-block: 0.5rem;
text-align: center;
transform: translateX(45%);
&:first-letter {
text-transform: uppercase;
}
}
}
@@ -2,17 +2,23 @@ import { memo } from 'react';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import style from '../CuesheetTable.module.scss';
import style from './DelayRow.module.scss';
interface DelayRowProps {
duration: number;
parentBgColour: string | null;
}
function DelayRow({ duration }: DelayRowProps) {
function DelayRow({ duration, parentBgColour }: DelayRowProps) {
const delayTime = millisToDelayString(duration, 'expanded');
return (
<tr className={style.delayRow}>
<tr
className={style.delayRow}
style={{
'--user-bg': parentBgColour ?? 'transparent',
}}
>
<td tabIndex={0} role='cell'>
{delayTime}
</td>
@@ -1,3 +1,12 @@
.imageInput {
&::placeholder {
opacity: 0.2;
}
&:hover::placeholder {
opacity: 1;
}
}
.imageCell {
position: relative;
min-height: 2rem;
@@ -6,6 +15,8 @@
.overlay {
display: grid;
place-content: center;
justify-items: center;
gap: 1rem;
background-color: $black-60;
}
}
@@ -1,5 +1,6 @@
import { memo } from 'react';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import style from './EditableImage.module.scss';
@@ -22,10 +23,17 @@ function EditableImage({ initialValue, updateValue }: EditableImageProps) {
updateValue(newValue);
};
const openInNewTab = () => {
if (initialValue) {
window.open(initialValue, '_blank', 'noopener,noreferrer');
}
};
if (!initialValue) {
return (
<Input
variant='ghosted'
className={style.imageInput}
fluid
placeholder='Paste image URL'
onBlur={(event) => handleUpdate(event.currentTarget.value)}
@@ -42,7 +50,12 @@ function EditableImage({ initialValue, updateValue }: EditableImageProps) {
return (
<div className={style.imageCell}>
<div className={style.overlay}>
<button onClick={() => handleUpdate('')}>Delete</button>
<Button variant='subtle-white' onClick={openInNewTab}>
Preview
</Button>
<Button variant='subtle-destructive' onClick={() => handleUpdate('')}>
Delete
</Button>
</div>
<img loading='lazy' src={initialValue} className={style.image} />
</div>
@@ -0,0 +1,40 @@
@import "../CuesheetTable.module.scss";
.eventRow {
vertical-align: top;
background: color-mix(in srgb, transparent 92%, var(--user-bg, $gray-500) 8%);
border-left: 4px solid var(--user-bg, $gray-500);
&:hover {
outline: 1px solid $blue-500;
outline-offset: -1px;
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
}
&.firstAfterBlock {
margin-top: 1rem;
}
&.skip {
position: relative;
&::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
-45deg,
rgba(255, 255, 255, 0.03),
rgba(255, 255, 255, 0.03) 10px,
rgba(255, 255, 255, 0.08) 10px,
rgba(255, 255, 255, 0.08) 20px
);
}
}
td {
background-color: $gray-1200;
border-radius: 2px;
}
}
@@ -1,4 +1,4 @@
import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react';
import { memo, MutableRefObject, useLayoutEffect, useRef } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table';
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types';
@@ -6,10 +6,12 @@ import { colourToHex, cssOrHexToColour } from 'ontime-utils';
import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../../cuesheet.options';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import style from '../CuesheetTable.module.scss';
import { useVisibleRowsStore } from './visibleRowsStore';
import style from './EventRow.module.scss';
interface EventRowProps {
rowId: string;
@@ -21,9 +23,12 @@ interface EventRowProps {
skip?: boolean;
colour?: string;
rowBgColour?: string;
parentBgColour?: string;
table: Table<OntimeEntry>;
/** hack to force re-rendering of the row when the column sizes change */
columnHash: string;
observer: IntersectionObserver;
firstAfterBlock: boolean;
}
export default memo(EventRow, (prevProps, nextProps) => {
@@ -35,34 +40,36 @@ export default memo(EventRow, (prevProps, nextProps) => {
prevProps.isPast === nextProps.isPast &&
prevProps.selectedRef === nextProps.selectedRef &&
prevProps.rowBgColour === nextProps.rowBgColour &&
prevProps.parentBgColour === nextProps.parentBgColour &&
prevProps.columnHash === nextProps.columnHash
);
});
function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, rowBgColour, table }: EventRowProps) {
const { hideIndexColumn, showActionMenu } = useCuesheetOptions();
function EventRow({
rowId,
event,
eventIndex,
rowIndex,
isPast,
selectedRef,
rowBgColour,
parentBgColour,
table,
observer,
firstAfterBlock,
}: EventRowProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
const showActionMenu = usePersistedCuesheetOptions((state) => state.showActionMenu);
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const isVisible = useVisibleRowsStore((state) => state.visibleRows.has(rowId));
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
// store a reference of the row in the observer
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 0.01,
},
);
const handleRefCurrent = ownRef.current;
if (selectedRef) {
setIsVisible(true);
} else if (handleRefCurrent) {
if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
@@ -71,22 +78,28 @@ function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, row
observer.unobserve(handleRefCurrent);
}
};
}, [ownRef, selectedRef]);
}, [observer]);
const { color, backgroundColor } = getAccessibleColour(event.colour);
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.6 });
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
return (
<tr
className={cx([style.eventRow, event.skip ?? style.skip])}
style={{ opacity: `${isPast ? '0.2' : '1'}` }}
id={rowId}
className={cx([style.eventRow, event.skip && style.skip, firstAfterBlock && style.firstAfterBlock, Boolean(parentBgColour) && style.hasParent])}
style={{
opacity: `${isPast ? '0.2' : '1'}`,
'--user-bg': parentBgColour ?? 'transparent',
}}
ref={selectedRef ?? ownRef}
>
{showActionMenu && (
<td className={style.actionColumn} tabIndex={-1} role='cell'>
<IconButton
aria-label='Options'
variant='subtle-white'
size='small'
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const yPos = 8 + rect.y + rect.height / 2;
@@ -105,12 +118,15 @@ function EventRow({ rowId, event, eventIndex, rowIndex, isPast, selectedRef, row
{isVisible
? table
.getRow(rowId)
?.getVisibleCells()
.getVisibleCells()
.map((cell) => {
return (
<td
key={cell.id}
style={{ width: cell.column.getSize(), backgroundColor: rowBgColour }}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
backgroundColor: rowBgColour,
}}
tabIndex={-1}
role='cell'
>
@@ -0,0 +1,9 @@
.muted {
opacity: 0.4; // same as the time input with muted text
line-height: 2rem; // input height
}
.numeric {
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
import { cx } from '../../../../common/utils/styleUtils';
import style from './MutedText.module.scss';
interface MutedTextProps {
numeric?: boolean;
}
export default function MutedText({ numeric, children }: PropsWithChildren<MutedTextProps>) {
return <span className={cx([style.muted, numeric && style.numeric])}>{children}</span>;
}
@@ -4,15 +4,15 @@ import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import styles from '../CuesheetTable.module.scss';
import style from '../CuesheetTable.module.scss';
interface SortableCellProps {
header: Header<OntimeEntry, unknown>;
style: CSSProperties;
injectedStyles: CSSProperties;
children: ReactNode;
}
export function SortableCell({ header, style, children }: SortableCellProps) {
export function SortableCell({ header, injectedStyles, children }: SortableCellProps) {
const { column, colSpan } = header;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
@@ -21,7 +21,7 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
// build drag styles
const dragStyle = {
...style,
...injectedStyles,
opacity: isDragging ? 0.5 : 1,
transform: CSS.Translate.toString(transform),
transition,
@@ -34,10 +34,11 @@ export function SortableCell({ header, style, children }: SortableCellProps) {
</div>
<div
{...{
onDoubleClick: () => header.column.resetSize(),
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
}}
className={styles.resizer}
className={style.resizer}
/>
</th>
);
@@ -7,10 +7,12 @@
display: flex;
align-items: center;
gap: 0.25rem;
letter-spacing: 1px;
letter-spacing: 0.5px;
font-size: 1rem;
font-variant-numeric: tabular-nums;
overflow: hidden;
&.delayed {
color: $ontime-delay-text;
}
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
import { CustomFields, isOntimeEvent, OntimeEntry, OntimeEvent, TimeStrategy } from 'ontime-types';
import { CustomFields, isOntimeDelay, isOntimeEvent, OntimeEntry, TimeStrategy } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
@@ -9,6 +9,7 @@ import { formatDuration, formatTime } from '../../../../common/utils/time';
import DurationInput from './DurationInput';
import EditableImage from './EditableImage';
import MultiLineCell from './MultiLineCell';
import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
@@ -17,24 +18,29 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>)
return null;
}
const { handleUpdateTimer } = table.options.meta;
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
const event = row.original;
if (!isOntimeEvent(event)) {
return <MutedText numeric>{formatTime(getValue() as number)}</MutedText>;
}
const { handleUpdateTimer } = table.options.meta;
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeStart', newValue);
const startTime = getValue() as number;
const isStartLocked = !(row.original as OntimeEvent).linkStart;
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
const isStartLocked = !event.linkStart;
const displayTime = showDelayedTimes ? startTime + delayValue : startTime;
const displayTime = showDelayedTimes ? startTime + event.delay : startTime;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
const formattedTime = formatTime(displayTime, formatOpts);
return (
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={delayValue !== 0}>
<TimeInput initialValue={startTime} onSubmit={update} lockedValue={isStartLocked} delayed={event.delay !== 0}>
{formattedTime}
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(startTime)} />
<DelayIndicator delayValue={event.delay} tooltipPrefix={millisToString(startTime)} />
</TimeInput>
);
}
@@ -44,24 +50,29 @@ function MakeEnd({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
return null;
}
const { handleUpdateTimer } = table.options.meta;
const { showDelayedTimes, hideTableSeconds } = table.options.meta.options;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
const event = row.original;
if (!isOntimeEvent(event)) {
return <MutedText numeric>{formatTime(getValue() as number, formatOpts)}</MutedText>;
}
const { handleUpdateTimer } = table.options.meta;
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'timeEnd', newValue);
const endTime = getValue() as number;
const isEndLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockEnd;
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
const isEndLocked = event.timeStrategy === TimeStrategy.LockEnd;
const displayTime = showDelayedTimes ? endTime + delayValue : endTime;
const displayTime = showDelayedTimes ? endTime + event.delay : endTime;
const formatOpts = hideTableSeconds ? { format12: 'hh:mm a', format24: 'HH:mm' } : undefined;
const formattedTime = formatTime(displayTime, formatOpts);
return (
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={delayValue !== 0}>
<TimeInput initialValue={endTime} onSubmit={update} lockedValue={isEndLocked} delayed={event.delay !== 0}>
{formattedTime}
<DelayIndicator delayValue={delayValue} tooltipPrefix={millisToString(endTime)} />
<DelayIndicator delayValue={event.delay} tooltipPrefix={millisToString(endTime)} />
</TimeInput>
);
}
@@ -71,13 +82,19 @@ function MakeDuration({ getValue, row, table }: CellContext<OntimeEntry, unknown
return null;
}
const { hideTableSeconds } = table.options.meta.options;
const event = row.original;
if (!isOntimeEvent(event)) {
return <MutedText numeric>{formatDuration(getValue() as number, hideTableSeconds)}</MutedText>;
}
const { handleUpdateTimer } = table.options.meta;
const update = (newValue: string) => handleUpdateTimer(row.original.id, 'duration', newValue);
const duration = getValue() as number;
const isDurationLocked = (row.original as OntimeEvent).timeStrategy === TimeStrategy.LockDuration;
const formattedDuration = formatDuration(duration, false);
const isDurationLocked = event.timeStrategy === TimeStrategy.LockDuration;
const formattedDuration = formatDuration(duration, hideTableSeconds);
return (
<DurationInput initialValue={duration} onSubmit={update} lockedValue={isDurationLocked}>
@@ -91,17 +108,15 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unk
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
[column.id, row.index, table.options.meta],
);
const event = row.original;
if (!isOntimeEvent(event)) {
// not all entries have all properties (eg blocks)
const initialValue = row.original[column.id as keyof OntimeEntry];
if (initialValue === undefined) {
return null;
}
const initialValue = event[column.id as keyof OntimeEntry] ?? '';
return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
@@ -110,12 +125,11 @@ function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
[column.id, row.index, table.options.meta],
);
const event = row.original;
if (!isOntimeEvent(event)) {
if (isOntimeDelay(event)) {
return null;
}
@@ -128,17 +142,15 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, un
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
[column.id, row.index, table.options.meta],
);
const event = row.original;
if (!isOntimeEvent(event)) {
// not all entries have all properties (eg blocks)
const initialValue = row.original[column.id as keyof OntimeEntry];
if (initialValue === undefined) {
return null;
}
const initialValue = event[column.id as keyof OntimeEntry] ?? '';
return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
}
@@ -147,20 +159,25 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknow
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- we skip table.options.meta since the reference seems unstable
[column.id, row.index],
[column.id, row.index, table.options.meta],
);
const event = row.original;
if (!isOntimeEvent(event)) {
if (isOntimeDelay(event)) {
return null;
}
// fields will not contain the field if there is no value set by the user
// event if there is no initial value, we still render the cell
const initialValue = event.custom[column.id] ?? '';
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
}
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeEntry>[] {
/**
* we cant use the createColumnHelper() because we have custom logic for rendering the cells
* This means that the display columns: index and action are added inline by the row components
*/
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
accessorKey: key,
id: key,
@@ -0,0 +1,18 @@
import { create } from 'zustand';
interface VisibleRowsStore {
visibleRows: Set<string>;
addVisibleRow: (id: string) => void;
removeVisibleRow: (id: string) => void;
}
export const useVisibleRowsStore = create<VisibleRowsStore>((set) => ({
visibleRows: new Set(),
addVisibleRow: (id) => set((state) => ({ visibleRows: new Set(state.visibleRows).add(id) })),
removeVisibleRow: (id) =>
set((state) => {
const newSet = new Set(state.visibleRows);
newSet.delete(id);
return { visibleRows: newSet };
}),
}));
@@ -1,17 +1,38 @@
import { memo } from 'react';
import { Menu, MenuButton, Portal } from '@chakra-ui/react';
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
import { Menu, MenuButton, MenuDivider, MenuItem, MenuList, Portal } from '@chakra-ui/react';
import { isOntimeEvent, SupportedEntry } from 'ontime-types';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { cloneEvent } from '../../../../common/utils/clone';
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
import CuesheetTableMenuActionsProps from './CuesheetTableMenuActions';
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
interface CuesheetTableMenuProps {
showModal: (eventId: string) => void;
}
export default memo(CuesheetTableMenu);
function CuesheetTableMenu({ showModal }: CuesheetTableMenuProps) {
function CuesheetTableMenu() {
const { isOpen, eventId, entryIndex, position, closeMenu } = useCuesheetTableMenu();
const { addEntry, getEntryById, move, deleteEntry } = useEntryActions();
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
const handleCloneEvent = () => {
if (!eventId) {
return;
}
const currentEvent = getEntryById(eventId);
if (!currentEvent || !isOntimeEvent(currentEvent)) {
return;
}
const newEvent = cloneEvent(currentEvent);
try {
addEntry(newEvent, { after: eventId });
} catch (_error) {
// we do not handle errors here
}
};
return (
<Portal>
@@ -26,7 +47,31 @@ function CuesheetTableMenu({ showModal }: CuesheetTableMenuProps) {
w={1}
h={1}
/>
<CuesheetTableMenuActionsProps eventId={eventId} entryIndex={entryIndex} showModal={showModal} />
<MenuList>
<MenuItem icon={<IoOptions />} onClick={() => showModal(eventId)}>
Edit ...
</MenuItem>
<MenuDivider />
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}>
Add event above
</MenuItem>
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}>
Add event below
</MenuItem>
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
Clone event
</MenuItem>
<MenuDivider />
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(eventId, 'up')}>
Move up
</MenuItem>
<MenuItem icon={<IoArrowDown />} onClick={() => move(eventId, 'down')}>
Move down
</MenuItem>
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([eventId])}>
Delete
</MenuItem>
</MenuList>
</Menu>
)}
</Portal>
@@ -1,58 +0,0 @@
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { isOntimeEvent, SupportedEntry } from 'ontime-types';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { cloneEvent } from '../../../../common/utils/clone';
interface CuesheetTableMenuActionsProps {
eventId: string;
entryIndex: number;
showModal: (entryId: string) => void;
}
export default function CuesheetTableMenuActions({ eventId, entryIndex, showModal }: CuesheetTableMenuActionsProps) {
const { addEntry, getEntryById, move, deleteEntry } = useEntryActions();
const handleCloneEvent = () => {
const currentEvent = getEntryById(eventId);
if (!currentEvent || !isOntimeEvent(currentEvent)) {
return;
}
const newEvent = cloneEvent(currentEvent);
try {
addEntry(newEvent, { after: eventId });
} catch (_error) {
// we do not handle errors here
}
};
return (
<MenuList>
<MenuItem icon={<IoOptions />} onClick={() => showModal(eventId)}>
Edit ...
</MenuItem>
<MenuDivider />
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}>
Add event above
</MenuItem>
<MenuItem icon={<IoAdd />} onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}>
Add event below
</MenuItem>
<MenuItem icon={<IoDuplicateOutline />} onClick={handleCloneEvent}>
Clone event
</MenuItem>
<MenuDivider />
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(eventId, 'up')}>
Move up
</MenuItem>
<MenuItem icon={<IoArrowDown />} onClick={() => move(eventId, 'down')}>
Move down
</MenuItem>
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([eventId])}>
Delete
</MenuItem>
</MenuList>
);
}
@@ -1,13 +1,13 @@
.tableSettings {
margin-top: 1rem;
grid-area: settings;
padding-inline: 0.5rem;
background-color: $gray-1250;
padding: 0.5rem 1rem;
display: flex;
gap: 5rem;
align-items: center;
justify-content: space-between;
font-size: $inner-section-text-size;
@media (max-width: $small-screen) {
gap: 1rem;
}
border-radius: 3px 3px 0 0;
}
.sectionTitle {
@@ -27,3 +27,16 @@
align-items: center;
gap: 0.5rem;
}
.column {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: start;
}
.inline {
display: flex;
align-items: center;
gap: 1rem;
}
@@ -1,10 +1,15 @@
import { memo, ReactNode } from 'react';
import { Column } from '@tanstack/react-table';
import { IoChevronDown, IoLocate, IoOptions, IoSettingsOutline } from 'react-icons/io5';
import { Popover } from '@base-ui-components/react/popover';
import type { Column } from '@tanstack/react-table';
import { OntimeEntry } from 'ontime-types';
import Button from '../../../../common/components/buttons/Button';
import Checkbox from '../../../../common/components/checkbox/Checkbox';
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
import RotatedLink from '../../../../common/components/icons/RotatedLink';
import PopoverContents from '../../../../common/components/popover/Popover';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import style from './CuesheetTableSettings.module.scss';
@@ -15,6 +20,7 @@ interface CuesheetTableSettingsProps {
handleClearToggles: () => void;
}
export default memo(CuesheetTableSettings);
function CuesheetTableSettings({
columns,
handleResetResizing,
@@ -23,9 +29,126 @@ function CuesheetTableSettings({
}: CuesheetTableSettingsProps) {
return (
<div className={style.tableSettings}>
<div>
<Editor.Label className={style.sectionTitle}>Toggle column visibility</Editor.Label>
<div className={style.row}>
<div className={style.inline}>
<ViewSettings />
<ColumnSettings
columns={columns}
handleResetResizing={handleResetResizing}
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
</div>
<div className={style.inline}>
<ViewSettingsFollowButton />
<Editor.Separator orientation='vertical' />
<Button variant='subtle'>
<RotatedLink />
Share...
</Button>
</div>
</div>
);
}
function ViewSettingsFollowButton() {
const followPlayback = usePersistedCuesheetOptions((state) => state.followPlayback);
const toggle = usePersistedCuesheetOptions((state) => state.toggleOption);
return (
<Button variant={followPlayback ? 'primary' : 'subtle'} onClick={() => toggle('followPlayback')}>
<IoLocate />
{followPlayback ? 'Following playback' : 'Follow playback'}
</Button>
);
}
function ViewSettings() {
const options = usePersistedCuesheetOptions();
return (
<Popover.Root>
<Popover.Trigger
render={
<Button variant='ghosted'>
<IoSettingsOutline /> Settings
<IoChevronDown />
</Button>
}
/>
<PopoverContents align='start' className={style.column}>
<Editor.Label className={style.sectionTitle}>Element visibility</Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.showActionMenu}
onCheckedChange={(checked) => options.setOption('showActionMenu', checked)}
/>
Show action menu
</Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.hideTableSeconds}
onCheckedChange={(checked) => options.setOption('hideTableSeconds', checked)}
/>
Hide seconds in table
</Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.hidePast}
onCheckedChange={(checked) => options.setOption('hidePast', checked)}
/>
Hide past events
</Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.hideIndexColumn}
onCheckedChange={(checked) => options.setOption('hideIndexColumn', checked)}
/>
Hide index column
</Editor.Label>
<Editor.Label className={style.sectionTitle}>Table Behaviour</Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.showDelayedTimes}
onCheckedChange={(checked) => options.setOption('showDelayedTimes', checked)}
/>
Show delayed times
</Editor.Label>
<Editor.Label className={style.option}>
<Checkbox
defaultChecked={options.hideDelays}
onCheckedChange={(checked) => options.setOption('hideDelays', checked)}
/>
Hide delay entries
</Editor.Label>
</PopoverContents>
</Popover.Root>
);
}
function ColumnSettings({
columns,
handleResetResizing,
handleResetReordering,
handleClearToggles,
}: CuesheetTableSettingsProps) {
return (
<Popover.Root>
<Popover.Trigger
render={
<Button variant='ghosted'>
<IoOptions /> View
<IoChevronDown />
</Button>
}
/>
<PopoverContents align='start' className={style.inline}>
<div className={style.column}>
<Editor.Label className={style.sectionTitle}>Column visibility</Editor.Label>
{columns.map((column) => {
const columnHeader = column.columnDef.header;
const visible = column.getIsVisible();
@@ -37,23 +160,20 @@ function CuesheetTableSettings({
);
})}
</div>
</div>
<div className={style.column}>
<Editor.Label className={style.sectionTitle}>Reset Options</Editor.Label>
<div className={style.row}>
<Button size='small' variant='subtle' onClick={handleClearToggles}>
<Editor.Separator orientation='vertical' />
<div className={style.column}>
<Editor.Label className={style.sectionTitle}>Reset Options</Editor.Label>
<Button size='small' fluid onClick={handleClearToggles}>
Show All
</Button>
<Button size='small' variant='subtle' onClick={handleResetResizing}>
<Button size='small' fluid onClick={handleResetResizing}>
Reset Resizing
</Button>
<Button size='small' variant='subtle' onClick={handleResetReordering}>
<Button size='small' fluid onClick={handleResetReordering}>
Reset Reordering
</Button>
</div>
</div>
</div>
</PopoverContents>
</Popover.Root>
);
}
export default memo(CuesheetTableSettings);
@@ -11,7 +11,7 @@ export default function useColumnManager(columns: ColumnDef<OntimeEntry>[]) {
});
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
// if the columns change, we update the dataset
// if the columns order changes, we update the dataset
useEffect(() => {
let shouldReplace = false;
const newColumns: string[] = [];
@@ -1,110 +1,46 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/viewParams.types';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
/**
* In the specific case of the cuesheet options
* we save the user preferences in the local storage
*/
export const cuesheetOptions: ViewOption[] = [
{
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'showActionMenu',
title: 'Show action menu',
description: 'Whether to show the action menu for every row in the table',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideTableSeconds',
title: 'Hide seconds in table',
description: 'Whether to hide seconds in the time fields displayed in the table',
type: 'boolean',
defaultValue: false,
},
{
id: 'followSelected',
title: 'Follow selected event',
description: 'Whether the view should automatically scroll to the selected event',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideIndexColumn',
title: 'Hide index column',
description: 'Whether the hide the event indexes in the table',
type: 'boolean',
defaultValue: false,
},
],
},
{
title: OptionTitle.BehaviourOptions,
collapsible: true,
options: [
{
id: 'showDelayedTimes',
title: 'Show delayed times',
description: 'Whether the time fields should include delays',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideDelays',
title: 'Hide delays',
description: 'Whether to hide the rows containing scheduled delays',
type: 'boolean',
defaultValue: false,
},
],
},
];
type CuesheetOptions = {
type OptionValues = {
showActionMenu: boolean;
hideTableSeconds: boolean;
followSelected: boolean;
followPlayback: boolean;
hidePast: boolean;
hideIndexColumn: boolean;
showDelayedTimes: boolean;
hideDelays: boolean;
};
/**
* Utility extract the view options from URL Params
* the names and fallbacks are manually matched with cuesheetOptions
*/
function getOptionsFromParams(searchParams: URLSearchParams): CuesheetOptions {
// we manually make an object that matches the key above
return {
showActionMenu: isStringBoolean(searchParams.get('showActionMenu')),
hideTableSeconds: isStringBoolean(searchParams.get('hideTableSeconds')),
followSelected: isStringBoolean(searchParams.get('followSelected')),
hidePast: isStringBoolean(searchParams.get('hidePast')),
hideIndexColumn: isStringBoolean(searchParams.get('hideIndexColumn')),
showDelayedTimes: isStringBoolean(searchParams.get('showDelayedTimes')),
hideDelays: isStringBoolean(searchParams.get('hideDelays')),
};
const defaultOptions: OptionValues = {
showActionMenu: false,
hideTableSeconds: false,
followPlayback: false,
hidePast: false,
hideIndexColumn: false,
showDelayedTimes: false,
hideDelays: false,
};
export type CuesheetOptionKeys = keyof OptionValues;
export interface CuesheetOptions extends OptionValues {
setOption: <K extends CuesheetOptionKeys>(key: K, value: OptionValues[K]) => void;
toggleOption: (key: CuesheetOptionKeys) => void;
resetOptions: () => void;
}
/**
* Hook exposes the cuesheet view options
*/
export function useCuesheetOptions(): CuesheetOptions {
const [searchParams] = useSearchParams();
const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]);
return options;
}
export const usePersistedCuesheetOptions = create<CuesheetOptions>()(
persist(
(set) => {
return {
...defaultOptions,
setOption: (key, value) => set((state) => ({ ...state, [key]: value })),
toggleOption: (key) => set((state) => ({ ...state, [key]: !state[key] })),
resetOptions: () => set(defaultOptions),
};
},
{
name: 'cuesheet-options',
},
),
);
+1 -1
View File
@@ -8,7 +8,7 @@ import { useElectronListener } from '../../common/hooks/useElectronEvent';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import AppSettings from '../../features/app-settings/AppSettings';
import useAppSettingsNavigation from '../../features/app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../../features/overview/Overview';
import EditorOverview from '../../features/overview/EditorOverview';
import WelcomePlacement from './welcome/WelcomePlacement';
+2 -2
View File
@@ -1,4 +1,4 @@
import { useIsMobile } from '../../common/hooks/useIsMobile';
import { useIsMobileDevice } from '../../common/hooks/useIsMobileDevice';
import { cx } from '../../common/utils/styleUtils';
import { formatTime } from '../../common/utils/time';
import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime';
@@ -17,7 +17,7 @@ interface StudioClockProps {
}
export default function StudioClock({ onAir, clock, hideCards }: StudioClockProps) {
const isMobile = useIsMobile();
const isMobile = useIsMobileDevice();
// if we are on mobile and have to show the cards
if (isMobile && !hideCards) {
@@ -12,6 +12,7 @@ interface LegacyData extends Partial<DatabaseModel> {
}
export function parseAutomationSettings(data: LegacyData, emitError?: ErrorEmitter): AutomationSettings {
// TODO(v4): move to migration script
/**
* Leaving a path for migrating users to the new automations
* This should be removed after a few releases
@@ -213,6 +213,64 @@ describe('parseRundown()', () => {
expect((parsedRundown.entries['2'] as OntimeEvent).custom).toStrictEqual({ sound: 'loud' });
});
it('removes empty custom fields', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2'],
flatOrder: ['1', '2', '21'],
entries: {
'1': makeOntimeEvent({ id: '1', custom: { lighting: 'yes' } }),
'2': makeOntimeBlock({ id: '2', entries: ['21'], custom: { lighting: '' } }),
'21': makeOntimeEvent({ id: '21', custom: { lighting: '' } }),
},
revision: 1,
} as Rundown;
const customFields: CustomFields = {
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
};
const parsedRundown = parseRundown(rundown, customFields);
expect((parsedRundown.entries['1'] as OntimeEvent).custom).toStrictEqual({ lighting: 'yes' });
expect((parsedRundown.entries['2'] as OntimeBlock).custom).not.toHaveProperty('lighting');
expect((parsedRundown.entries['21'] as OntimeEvent).custom).not.toHaveProperty('lighting');
});
it('parses data in blocks', () => {
const rundown = {
id: 'test',
title: '',
order: ['block'],
flatOrder: ['block'],
isNextDay: false,
entries: {
block: makeOntimeBlock({
id: 'block',
title: 'block-title',
note: 'block-note',
colour: 'red',
entries: ['1', '2'],
}),
'1': makeOntimeEvent({ id: '1' }),
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(1);
expect(parsedRundown.entries.block).toMatchObject({
title: 'block-title',
note: 'block-note',
colour: 'red',
entries: ['1'],
});
});
it('parses events nested in blocks', () => {
const rundown = {
id: 'test',
@@ -19,10 +19,10 @@ import {
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
import { defaultRundown } from '../../models/dataModel.js';
import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
import { delay as delayDef } from '../../models/eventsDefinition.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { calculateDayOffset, createEvent } from './rundown.utils.js';
import { calculateDayOffset, cleanupCustomFields, createBlock, createEvent } from './rundown.utils.js';
import { RundownMetadata } from './rundown.types.js';
/**
@@ -104,14 +104,7 @@ export function parseRundown(
continue;
}
// for every field in custom, check that a key exists in customfields
for (const field in newEvent.custom) {
if (!Object.hasOwn(parsedCustomFields, field)) {
emitError?.(`Custom field ${field} not found`);
delete newEvent.custom[field];
}
}
cleanupCustomFields(newEvent.custom, parsedCustomFields);
eventIndex += 1;
} else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id };
@@ -128,14 +121,7 @@ export function parseRundown(
continue;
}
// for every field in custom, check that a key exists in customfields
for (const field in newNestedEvent.custom) {
if (!Object.hasOwn(parsedCustomFields, field)) {
emitError?.(`Custom field ${field} not found`);
delete newNestedEvent.custom[field];
}
}
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
eventIndex += 1;
if (newNestedEvent) {
@@ -145,16 +131,13 @@ export function parseRundown(
}
}
newEvent = {
...blockDef,
title: event.title,
note: event.note,
entries: event.entries?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [],
isNextDay: event.isNextDay,
colour: event.colour,
custom: { ...event.custom },
id,
};
newEvent = createBlock({ ...structuredClone(event), id });
// ensure entries exist
if (event.entries?.length > 0) {
newEvent.entries = event.entries.filter((eventId) => Object.hasOwn(rundown.entries, eventId));
}
// ensure custom fields are valid
cleanupCustomFields(newEvent.custom, parsedCustomFields);
} else {
emitError?.('Unknown event type, skipping');
continue;
@@ -1,4 +1,6 @@
import {
CustomFields,
EntryCustomFields,
EntryId,
isOntimeBlock,
isOntimeDelay,
@@ -60,7 +62,7 @@ export function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDel
throw new Error('Invalid event type');
}
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
export function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
if (Object.keys(patchEvent).length === 0) {
return originalEvent;
}
@@ -101,18 +103,52 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
};
}
export function createBlockPatch(originalBlock: OntimeBlock, patchBlock: Partial<OntimeBlock>): OntimeBlock {
if (Object.keys(patchBlock).length === 0) {
return originalBlock;
}
const maybeTargetDuration = () => {
if (typeof patchBlock.targetDuration === 'number') {
return patchBlock.targetDuration;
}
if (patchBlock.targetDuration === null || patchBlock.targetDuration === '') {
return null;
}
return originalBlock.targetDuration;
};
return {
id: originalBlock.id,
type: SupportedEntry.Block,
title: makeString(patchBlock.title, originalBlock.title),
note: makeString(patchBlock.note, originalBlock.note),
entries: patchBlock.entries ?? originalBlock.entries,
isNextDay: typeof patchBlock.isNextDay === 'boolean' ? patchBlock.isNextDay : originalBlock.isNextDay,
targetDuration: maybeTargetDuration(),
colour: makeString(patchBlock.colour, originalBlock.colour),
revision: originalBlock.revision,
timeStart: originalBlock.timeStart,
timeEnd: originalBlock.timeEnd,
duration: originalBlock.duration,
isFirstLinked: originalBlock.isFirstLinked,
custom: { ...originalBlock.custom, ...patchBlock.custom },
};
}
/**
* Utility function for patching an existing event with new data
* Increments the revision of the event when applying the patch
*/
export function applyPatchToEntry<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T {
if (isOntimeEvent(eventFromRundown)) {
const newEvent = createPatch(eventFromRundown, patch as Partial<OntimeEvent>);
const newEvent = createEventPatch(eventFromRundown, patch as Partial<OntimeEvent>);
newEvent.revision++;
return newEvent as T;
}
if (isOntimeBlock(eventFromRundown)) {
const newBlock: OntimeBlock = { ...eventFromRundown, ...patch };
const newBlock: OntimeBlock = createBlockPatch(eventFromRundown, patch as Partial<OntimeBlock>);
newBlock.revision++;
return newBlock as T;
}
@@ -139,7 +175,7 @@ export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number
cue,
...eventDef,
};
const event = createPatch(baseEvent, eventArgs);
const event = createEventPatch(baseEvent, eventArgs);
return event;
};
@@ -359,6 +395,22 @@ export function getInsertAfterId(rundown: Rundown, afterId?: EntryId, beforeId?:
return null;
}
/**
* Sanitises custom fields in an entry by removing fields
* - if it does not exist in the project
* - if the value is empty string
* Mutates the entryCustomFields object
*/
export function cleanupCustomFields(entryCustomFields: EntryCustomFields, projectCustomFields: CustomFields) {
for (const field in entryCustomFields) {
if (!Object.hasOwn(projectCustomFields, field)) {
delete entryCustomFields[field];
} else if (entryCustomFields[field] === '') {
delete entryCustomFields[field];
}
}
}
/**
* converts an index from the timedEventOrder to an index in the playableEventOrder
* or returns null if it can not be found
-1
View File
@@ -69,7 +69,6 @@ export function loadRoll(
}
// in case we were unable to find anything, we load the first event
console.log('returning first event');
return { event: rundown.entries[firstEventId] as PlayableEvent, index: 0, isPending: true };
}
-1
View File
@@ -3,7 +3,6 @@ import { expect, test } from '@playwright/test';
test('cuesheet displays events', async ({ page }) => {
// same elements in cuesheet
await page.goto('http://localhost:4001/cuesheet');
await expect(page.getByText('Eurovision Song Contest')).toBeVisible();
await expect(page.getByRole('row', { name: 'Lunch break' })).toBeVisible();
await expect(page.getByRole('row', { name: 'Afternoon break' })).toBeVisible();