feat: editor layout

This commit is contained in:
Carlos Valente
2026-01-21 19:56:52 +01:00
committed by Carlos Valente
parent 967e92e2c8
commit da02ecd113
77 changed files with 2267 additions and 788 deletions
@@ -9,7 +9,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals';
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 CuesheetTableWrapper from './CuesheetTableWrapper';
@@ -26,7 +26,7 @@ export default function CuesheetPage() {
return (
<EntryActionsProvider actions={entryActions}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<CuesheetEditModal />
<EntryEditModal />
<div className={styles.tableWrapper} data-testid='cuesheet'>
<CuesheetOverview>
{!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} />}
/>
);
}
@@ -3,12 +3,12 @@ import { memo } from 'react';
import Modal from '../../../common/components/modal/Modal';
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
import { useEditorEditModal } from './useEditorEditModal';
import { useEditModal } from './useEditModal';
export default memo(EditorEditModal);
function EditorEditModal() {
const entryId = useEditorEditModal((state) => state.selectedEntryId);
const closeModal = useEditorEditModal((state) => state.clearSelection);
export default memo(EntryEditModal);
function EntryEditModal() {
const entryId = useEditModal((state) => state.selectedEntryId);
const closeModal = useEditModal((state) => state.clearSelection);
if (entryId === 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 }),
}));
@@ -7,7 +7,7 @@ interface SelectedEntryState {
clearSelection: () => void;
}
export const useEditorEditModal = create<SelectedEntryState>((set) => ({
export const useEditModal = create<SelectedEntryState>((set) => ({
selectedEntryId: null,
setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }),
clearSelection: () => set({ selectedEntryId: null }),
@@ -11,6 +11,11 @@ $table-header-font-size: calc(1rem - 2px);
color: $ui-white;
padding-bottom: 70vh; // allow focus to reach last elements
:is([data-target='small-device']) & {
width: max-content;
min-width: 100%;
}
thead {
tr {
&::before {
@@ -21,8 +21,7 @@ import DelayRow from './cuesheet-table-elements/DelayRow';
import EventRow from './cuesheet-table-elements/EventRow';
import GroupRow from './cuesheet-table-elements/GroupRow';
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
import EditorTableMenu from './cuesheet-table-menu/EditorTableMenu';
import TableMenu from './cuesheet-table-menu/TableMenu';
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
@@ -45,7 +44,6 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const selectedEventId = useSelectedEventId();
const cursor = useEventSelection((state) => state.cursor);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
@@ -116,18 +114,23 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
setColumnSizing({});
}, [setColumnSizing]);
// Auto-scroll only in run mode, routed through the shared scroll handler
// in Run mode, follow the current event
useEffect(() => {
if (cuesheetMode !== AppMode.Run || !selectedEventId) {
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
return;
}
scrollToEntry(selectedEventId);
}, [cuesheetMode, data, selectedEventId, scrollToEntry]);
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
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)
useEffect(() => {
setScrollHandler(`cuesheet-table-${tableRoot}`, (entryId) => {
const handler = (entryId: string) => {
if (virtuosoRef.current === null) {
return;
}
@@ -137,13 +140,15 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
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 () => {
setScrollHandler(`cuesheet-table-${tableRoot}`, null);
setScrollHandler(null);
};
}, [data, setScrollHandler, tableRoot]);
}, [data, setScrollHandler]);
/**
* 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
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
const TableMenu = tableRoot === 'editor' ? EditorTableMenu : CuesheetTableMenu;
return (
<>
@@ -187,6 +191,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
<TableVirtuoso
ref={virtuosoRef}
data={data}
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
increaseViewportBy={{ top: 100, bottom: 200 }}
components={{
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,17 +4,17 @@ 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 { useEditModal } from '../../cuesheet-edit-modal/useEditModal';
import { useCuesheetPermissions } from '../../useTablePermissions';
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 { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActionsContext();
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
const showModal = useEditModal((state) => state.setEditableEntry);
const permissions = useCuesheetPermissions();
if (!isOpen) {
@@ -7,6 +7,13 @@ $max-playback-width: 30rem;
display: flex;
gap: $panel-gap;
overflow: hidden;
height: 100%;
min-height: 0;
}
.panelContainerTracking {
flex-direction: column;
min-height: 0;
}
.left {
@@ -17,3 +24,29 @@ $max-playback-width: 30rem;
flex-direction: column;
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;
}
+43 -4
View File
@@ -1,5 +1,11 @@
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';
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'));
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 (
<div id='panels' className={styles.panelContainer}>
<div className={styles.left}>
<TimerControl />
<MessageControl />
<div className={styles.rundownLayout}>
<div className={styles.titlesPanel}>
<TitleList mode={AppMode.Edit} />
</div>
<div className={styles.rundownPanel}>
<Rundown />
</div>
</div>
<Rundown />
</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 Editor from './Editor';
import EditorViewOptions from './EditorViewOptions';
import EditorLayoutOptions from './EditorLayoutOptions';
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}>
{isSettingsOpen ? <IoClose /> : <IoSettingsOutline />}
</IconButton>
<EditorViewOptions />
<EditorLayoutOptions />
</EditorOverview>
</div>
</ProtectRoute>
@@ -1,9 +1,8 @@
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { EntryId, isOntimeEvent, isOntimeGroup, isOntimeMilestone, MaybeString, SupportedEntry } from 'ontime-types';
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
const maxResults = 12;
@@ -44,14 +43,7 @@ export default function useFinder() {
const [error, setError] = useState<MaybeString>(null);
const lastSearchString = useRef('');
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
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: [],
});
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
/** Filters the rundown to a given evaluation */
const find = useCallback(
@@ -224,20 +216,13 @@ export default function useFinder() {
const select = useCallback(
(selectedEvent: FilterableEntry) => {
// First expand the parent group if this is an event inside a group
if ('parent' in selectedEvent && selectedEvent.parent !== null) {
// Try direct state update instead of using callback
const currentGroups = [...new Set(collapsedGroups)];
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);
selectAndRevealEntry({
id: selectedEvent.id,
index: selectedEvent.index,
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
});
},
[collapsedGroups, setCollapsedGroups, setSelectedEvents, scrollToEntry],
[selectAndRevealEntry],
);
/** 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 };
}