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
@@ -4,8 +4,14 @@
}
.rundownContainer {
margin-top: 1rem;
height: 100%;
padding-top: 1rem;
flex: 1 1 auto;
min-height: 0;
:is([data-target='small-device']) & {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
.list {
@@ -17,6 +23,7 @@
:is([data-target='small-device']) & {
padding-inline: 0;
overflow-x: auto;
min-width: max-content;
}
}
+53 -65
View File
@@ -3,13 +3,19 @@ import { TbFlagFilled } from 'react-icons/tb';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { closestCenter, DndContext } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useSessionStorage } from '@mantine/hooks';
import { type EntryId, type Rundown, isOntimeEvent, isOntimeGroup, Playback, SupportedEntry } from 'ontime-types';
import {
type EntryId,
type Rundown as RundownType,
isOntimeEvent,
isOntimeGroup,
Playback,
SupportedEntry,
} from 'ontime-types';
import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import { AppMode } from '../../ontimeConfig';
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
@@ -20,6 +26,8 @@ import RundownGroup from './rundown-group/RundownGroup';
import RundownGroupEnd from './rundown-group/RundownGroupEnd';
import { filterVisibleEntries, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEditorFollowMode } from './useEditorFollowMode';
import { useEventSelection } from './useEventSelection';
import style from './Rundown.module.scss';
@@ -27,10 +35,10 @@ import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps {
entries: Rundown['entries'];
id: Rundown['id'];
order: Rundown['order'];
flatOrder: Rundown['flatOrder'];
entries: RundownType['entries'];
id: RundownType['id'];
order: RundownType['order'];
flatOrder: RundownType['flatOrder'];
rundownMetadata: RundownMetadataObject;
featureData: {
playback: Playback;
@@ -55,60 +63,36 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
setMetadata(rundownMetadata);
}, [order, entries, rundownMetadata]);
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [],
});
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
// cursor
const [editorMode] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
const { editorMode } = useEditorFollowMode();
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const cursor = useEventSelection((state) => state.cursor);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
/**
* Checks whether a group is collapsed
*/
const getIsCollapsed = useCallback(
(groupId: EntryId): boolean => {
return collapsedGroupSet.has(groupId);
},
[collapsedGroupSet],
);
/**
* Handles logic for collapsing groups
*/
const handleCollapseGroup = useCallback(
(collapsed: boolean, groupId: EntryId) => {
setCollapsedGroups((prev) => {
const isCollapsed = getIsCollapsed(groupId);
if (collapsed && !isCollapsed) {
const newSet = new Set(prev).add(groupId);
return [...newSet];
}
if (!collapsed && isCollapsed) {
return [...prev].filter((id) => id !== groupId);
}
return prev;
});
(collapsed: boolean, groupId: EntryId | undefined) => {
if (collapsed) {
collapseGroup(groupId);
} else {
expandGroup(groupId);
}
},
[getIsCollapsed, setCollapsedGroups],
[collapseGroup, expandGroup],
);
// Commands layer - business logic
@@ -116,7 +100,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
entries,
flatOrder,
entryActions,
setSelectedEvents,
selectEntry,
handleCollapseGroup,
});
@@ -145,9 +129,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
return filterVisibleEntries(sortableData, entries, getIsCollapsed);
}, [sortableData, entries, getIsCollapsed]);
// Scroll to a specific entry when requested by keyboard/finder
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
useEffect(() => {
setScrollHandler('rundown-list', (entryId) => {
setScrollHandler((entryId) => {
if (!virtuosoRef.current || dnd.isDraggingRef.current) {
return;
}
@@ -164,39 +148,45 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
});
return () => {
setScrollHandler('rundown-list', null);
setScrollHandler(null);
};
}, [visibleData, dnd.isDraggingRef, setScrollHandler]);
// Follow-scroll in run mode via the shared scroll handler
// Keep selected cursor visible by expanding its parent group in any mode.
// Finder can select entries while layout is in Run mode, where we do not follow cursor scroll.
useEffect(() => {
if (editorMode !== AppMode.Run || dnd.isDraggingRef.current) {
if (!cursor) {
return;
}
const targetId = featureData?.selectedEventId;
if (!targetId) {
return;
const entry = entries[cursor];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
}, [cursor, entries, expandGroup]);
scrollToEntry(targetId);
}, [editorMode, featureData?.selectedEventId, visibleData, dnd.isDraggingRef, scrollToEntry]);
// in run mode, we follow the playback selection and open groups as needed
// Auto-follow behavior in Edit mode: follow the user's cursor
useEffect(() => {
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) {
if (dnd.isDraggingRef.current || editorMode !== AppMode.Edit || !cursor) {
return;
}
const index = order.findIndex((id) => id === featureData.selectedEventId);
// @ts-expect-error -- but we safely check if the parent property exists
const maybeParent = entries[featureData.selectedEventId]?.parent;
if (maybeParent) {
// open the group
setCollapsedGroups((prev) => [...prev].filter((id) => id !== maybeParent));
scrollToEntry(cursor);
}, [editorMode, cursor, scrollToEntry, dnd.isDraggingRef]);
// Auto-follow behavior in Run mode: follow the currently playing event
useEffect(() => {
if (dnd.isDraggingRef.current || editorMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
}
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [editorMode, entries, featureData.selectedEventId, order, setCollapsedGroups, setSelectedEvents]);
// Open parent group if the target is inside a collapsed group
const entry = entries[featureData.selectedEventId];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
scrollToEntry(featureData.selectedEventId);
}, [editorMode, featureData?.selectedEventId, entries, expandGroup, scrollToEntry, dnd.isDraggingRef]);
// gather presentation options
const isEditMode = editorMode === AppMode.Edit;
@@ -232,11 +222,9 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
);
}
// Regular entry handling - compute all values upfront
const entry = entries[entryId];
const entryMetadata = metadata[entryId];
// Null check after computing - return null if missing
if (!entry || !entryMetadata) {
return null;
}
@@ -22,14 +22,18 @@
}
.rundownRoot {
height: calc(100% - 1.5rem);
height: calc(100% - 1rem);
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.list {
display: flex;
height: inherit;
padding-inline: 0;
padding-bottom: 0;
box-shadow: $box-shadow-right;
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
@@ -11,8 +11,9 @@ import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import EditorEditModal from '../../views/cuesheet/cuesheet-edit-modal/EditorEditModal';
import { AppMode } from '../../ontimeConfig';
import EntryEditModal from '../../views/cuesheet/cuesheet-edit-modal/EntryEditModal';
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
import FinderPlacement from './placements/FinderPlacement';
@@ -20,8 +21,9 @@ import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
import RundownHeader from './rundown-header/RundownHeader';
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
import RundownTable from './rundown-table/RundownTable';
import { RundownViewMode } from './rundown.options';
import RundownList from './RundownList';
import { DEFAULT_RUNDOWN_VIEW_MODE, RUNDOWN_VIEW_MODE_STORAGE_KEY, RundownViewMode } from './rundownViewMode';
import { useEditorFollowMode } from './useEditorFollowMode';
import style from './RundownExport.module.scss';
@@ -29,13 +31,11 @@ export default memo(RundownExport);
function RundownExport() {
const isExtracted = window.location.pathname.includes('/rundown');
const [editorMode] = useSessionStorage({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
const { editorMode } = useEditorFollowMode();
const { layoutMode } = useEditorLayout();
const [viewMode, setViewMode] = useSessionStorage<RundownViewMode>({
key: RUNDOWN_VIEW_MODE_STORAGE_KEY,
defaultValue: DEFAULT_RUNDOWN_VIEW_MODE,
key: 'rundown-view-mode',
defaultValue: RundownViewMode.List,
});
const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
@@ -53,7 +53,7 @@ function RundownExport() {
<ViewNavigationMenu suppressSettings />
<div className={style.rundown}>
<ErrorBoundary>
<RundownRoot isSmallDevice viewMode={viewMode} setViewMode={setViewMode} />
<RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</div>
@@ -63,7 +63,10 @@ function RundownExport() {
);
}
const hideSideBar = (isExtracted && editorMode === 'run') || viewMode === 'table';
const hideSideBar =
layoutMode === EditorLayoutMode.TRACKING ||
(isExtracted && editorMode === AppMode.Run) ||
viewMode === RundownViewMode.Table;
return (
<EntryActionsProvider actions={entryActions}>
@@ -75,7 +78,7 @@ function RundownExport() {
<Editor.Panel className={style.list}>
<ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot viewMode={viewMode} setViewMode={setViewMode} />
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary>
</Editor.Panel>
@@ -95,16 +98,21 @@ function RundownExport() {
interface RundownRootProps {
isSmallDevice?: boolean;
isExtracted?: boolean;
viewMode: RundownViewMode;
setViewMode: (mode: RundownViewMode) => void;
}
function RundownRoot({ isSmallDevice, viewMode, setViewMode }: RundownRootProps) {
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
return (
<div className={style.rundownRoot}>
{isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader viewMode={viewMode} setViewMode={setViewMode} />}
{viewMode === 'list' ? <RundownList /> : <RundownTable />}
<EditorEditModal />
{isSmallDevice ? (
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
) : (
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
</div>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown';
@@ -15,20 +15,14 @@ interface CuesheetEntryEditorProps {
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
const { data } = useRundown();
const [entry, setEntry] = useState<OntimeEntry | null>(null);
useEffect(() => {
const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) {
setEntry(null);
return;
return null;
}
const event = data.entries[entryId];
if (event) {
setEntry(event);
} else {
setEntry(null);
}
return event ?? null;
}, [entryId, data.order, data.entries]);
if (isOntimeEvent(entry)) {
@@ -1,6 +1,6 @@
.cuesheetEditor,
.rundownEditor {
max-height: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow-x: auto;
@@ -1,13 +1,5 @@
import { useMemo } from 'react';
import {
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
isOntimeMilestone,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
} from 'ontime-types';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection';
@@ -24,7 +16,7 @@ export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
const entry = useMemo<OntimeEvent | OntimeGroup | OntimeMilestone | null>(() => {
const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) {
return null;
}
@@ -33,12 +25,9 @@ export default function RundownEntryEditor() {
if (!selectedEventId) {
return null;
}
const event = data.entries[selectedEventId];
if (event && !isOntimeDelay(event)) {
return event;
}
return null;
const event = data.entries[selectedEventId];
return event ?? null;
}, [data.order.length, data.entries, selectedEvents]);
if (!entry) {
@@ -1,25 +1,18 @@
import { useCallback } from 'react';
import { type EntryId, type OntimeEntry, type Rundown, isOntimeGroup, SupportedEntry } from 'ontime-types';
import {
getFirstNormal,
getLastNormal,
getNextGroupNormal,
getNextNormal,
getPreviousGroupNormal,
getPreviousNormal,
} from 'ontime-utils';
import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'ontime-types';
import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { SelectionMode } from '../useEventSelection';
type SelectionMode = 'shift' | 'click' | 'ctrl';
const PAGE_SIZE = 5;
interface UseRundownCommandsOptions {
entries: Rundown['entries'];
order: Rundown['order'];
flatOrder: EntryId[];
entryActions: ReturnType<typeof useEntryActions>;
setSelectedEvents: (selection: { id: EntryId; selectMode: SelectionMode; index: number }) => void;
selectEntry: (selection: { id: EntryId; selectMode: SelectionMode; index: number }) => void;
handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void;
}
@@ -28,9 +21,9 @@ interface UseRundownCommandsOptions {
*/
export function useRundownCommands({
entries,
order,
flatOrder,
entryActions,
setSelectedEvents,
selectEntry: applySelection,
handleCollapseGroup,
}: UseRundownCommandsOptions) {
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
@@ -38,13 +31,13 @@ export function useRundownCommands({
const deleteAtCursor = useCallback(
(cursor: string | null) => {
if (!cursor) return;
const { entry, index } = getPreviousNormal(entries, order, cursor);
const { entry, index } = getPreviousNormal(entries, flatOrder, cursor);
deleteEntry([cursor]);
if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
applySelection({ id: entry.id, selectMode: 'click', index });
}
},
[entries, order, deleteEntry, setSelectedEvents],
[entries, flatOrder, deleteEntry, applySelection],
);
const insertCopyAtId = useCallback(
@@ -67,7 +60,7 @@ export function useRundownCommands({
if (entryCopyMode === 'cut') {
if (!normalisedAtId) {
const firstId = order[0];
const firstId = flatOrder[0];
if (!firstId || firstId === entryCopyId) {
return;
}
@@ -92,7 +85,7 @@ export function useRundownCommands({
before: above ? normalisedAtId ?? undefined : undefined,
});
},
[entries, order, clone, reorderEntry],
[entries, flatOrder, clone, reorderEntry],
);
/**
@@ -111,70 +104,39 @@ export function useRundownCommands({
const selectGroup = useCallback(
(cursor: EntryId | null, direction: 'up' | 'down') => {
if (order.length < 1) {
if (flatOrder.length < 1) {
return null;
}
let newCursor = cursor;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (isOntimeGroup(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
return selected.id;
}
newCursor = selected?.id ?? null;
}
if (newCursor === null) {
return null;
}
// otherwise we select the next or previous
const selected =
direction === 'up'
? getPreviousGroupNormal(entries, order, newCursor)
: getNextGroupNormal(entries, order, newCursor);
? getPreviousGroupNormal(entries, flatOrder, cursor)
: getNextGroupNormal(entries, flatOrder, cursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
if (selected.entry && selected.index !== null) {
applySelection({ id: selected.entry.id, selectMode: 'click', index: selected.index });
return selected.entry.id;
}
return null;
},
[order, entries, setSelectedEvents],
[flatOrder, entries, applySelection],
);
/**
* TODO: getPreviousNormal and getNextNormal do not work across group boundaries
*/
const selectEntry = useCallback(
(cursor: EntryId | null, direction: 'up' | 'down') => {
if (order.length < 1) {
if (flatOrder.length < 1) {
return null;
}
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (selected !== null) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
return selected.id;
}
return null;
}
// otherwise we select the next or previous
const selected =
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
direction === 'up' ? getPreviousNormal(entries, flatOrder, cursor) : getNextNormal(entries, flatOrder, cursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
if (selected.entry && selected.index !== null) {
applySelection({ id: selected.entry.id, selectMode: 'click', index: selected.index });
return selected.entry.id;
}
return null;
},
[order, entries, setSelectedEvents],
[flatOrder, entries, applySelection],
);
const moveEntry = useCallback(
@@ -204,67 +166,52 @@ export function useRundownCommands({
const selectEdge = useCallback(
(direction: 'top' | 'bottom') => {
if (order.length < 1) {
if (flatOrder.length < 1) {
return null;
}
const selected = direction === 'top' ? getFirstNormal(entries, order) : getLastNormal(entries, order);
if (!selected) {
return null;
}
const index = direction === 'top' ? 0 : flatOrder.length - 1;
const selectedId = flatOrder[index];
if (!selectedId) return null;
const index = order.indexOf(selected.id);
if (index === -1) {
return null;
}
setSelectedEvents({ id: selected.id, selectMode: 'click', index });
return selected.id;
applySelection({ id: selectedId, selectMode: 'click', index });
return selectedId;
},
[entries, order, setSelectedEvents],
[flatOrder, applySelection],
);
const selectPage = useCallback(
(cursor: EntryId | null, direction: 'up' | 'down') => {
if (order.length < 1) {
if (flatOrder.length < 1) {
return null;
}
if (cursor === null) {
const selected = direction === 'down' ? getFirstNormal(entries, order) : getLastNormal(entries, order);
if (!selected) {
return null;
}
const index = order.indexOf(selected.id);
if (index !== -1) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index });
return selected.id;
}
return null;
let currentIndex = cursor ? flatOrder.indexOf(cursor) : -1;
if (currentIndex === -1) {
currentIndex = direction === 'down' ? -1 : flatOrder.length;
}
let nextCursor = cursor;
let target: { entry: OntimeEntry | null; index: number | null } | null = null;
let targetIndex = currentIndex;
let lastValidIndex: number | null = null;
for (let step = 0; step < PAGE_SIZE; step += 1) {
const next =
direction === 'down'
? getNextNormal(entries, order, nextCursor)
: getPreviousNormal(entries, order, nextCursor);
if (next.entry === null || next.index === null) {
targetIndex = direction === 'down' ? targetIndex + 1 : targetIndex - 1;
if (targetIndex < 0 || targetIndex >= flatOrder.length) {
break;
}
target = next;
nextCursor = next.entry.id;
lastValidIndex = targetIndex;
}
if (target?.entry && target.index !== null) {
setSelectedEvents({ id: target.entry.id, selectMode: 'click', index: target.index });
return target.entry.id;
if (lastValidIndex === null) {
return null;
}
return null;
const selectedId = flatOrder[lastValidIndex];
if (!selectedId) return null;
applySelection({ id: selectedId, selectMode: 'click', index: lastValidIndex });
return selectedId;
},
[entries, order, setSelectedEvents],
[flatOrder, applySelection],
);
return {
@@ -11,7 +11,7 @@ interface UseRundownDndOptions {
sortableData: EntryId[];
setSortableData: Dispatch<SetStateAction<EntryId[]>>;
getIsCollapsed: (groupId: EntryId) => boolean;
handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void;
handleCollapseGroup: (collapsed: boolean, groupId: EntryId | undefined) => void;
entryActions: ReturnType<typeof useEntryActions>;
}
@@ -132,13 +132,11 @@ export function useRundownDnd({
if (event.over?.data.current?.type !== SupportedEntry.Group) {
return;
}
const groupId = event.over?.id as EntryId;
const isCollapsed = getIsCollapsed(groupId);
if (isCollapsed) {
handleCollapseGroup(false, groupId);
}
const groupId = event.over?.id as EntryId | undefined;
handleCollapseGroup(false, groupId);
},
[getIsCollapsed, handleCollapseGroup],
[handleCollapseGroup],
);
return useMemo(
@@ -103,8 +103,8 @@ export default function RundownEvent({
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
const unselect = useEventSelection((state) => state.unselect);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
@@ -265,7 +265,7 @@ export default function RundownEvent({
// UI indexes are 1 based
const index = eventIndex - 1;
const editMode = getSelectionMode(event);
setSelectedEvents({ id: eventId, index, selectMode: editMode });
selectEntry({ id: eventId, index, selectMode: editMode });
};
const isPlaying = playback ? isPlaybackActive(playback) : false;
@@ -10,14 +10,14 @@ import {
IoTime,
} from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu';
import { useSessionStorage } from '@mantine/hooks';
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { cx } from '../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../ontimeConfig';
import { AppMode } from '../../../ontimeConfig';
import TitleEditor from '../common/TitleEditor';
import TimeInputFlow from '../time-input-flow/TimeInputFlow';
import { useEditorFollowMode } from '../useEditorFollowMode';
import RundownEventChip from './composite/RundownEventChip';
import EventBlockPlayback from './composite/RundownEventPlayback';
@@ -76,10 +76,7 @@ function RundownEventInner({
isLinkedToLoaded,
hasTriggers,
}: RundownEventInnerProps) {
const [editorMode] = useSessionStorage({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
const { editorMode } = useEditorFollowMode();
const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause;
@@ -40,7 +40,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActionsContext();
const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection);
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
@@ -97,7 +97,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
}
// UI indexes are 1 based
setSingleEntrySelection({ id: data.id });
selectSingleEntry({ id: data.id });
};
const binderColours = data.colour && getAccessibleColour(data.colour);
@@ -2,6 +2,7 @@
padding-inline: 1rem 2rem;
display: flex;
align-items: center;
gap: 1rem;
:is([data-target='small-device']) & {
padding-inline: 0;
@@ -57,52 +58,17 @@
}
}
.toggleTooltipTrigger {
display: flex;
}
.apart {
margin-left: auto;
}
.separator {
margin-inline: 1rem;
}
.column {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: start;
}
.sectionTitle {
text-transform: uppercase;
font-weight: 600;
font-size: 0.75rem;
color: $gray-400; // Guessing color based on cuesheet
}
.popoverContent {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 200px;
}
.column {
display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: start;
}
.sectionTitle {
text-transform: uppercase;
font-weight: 600;
font-size: 0.75rem;
color: $gray-400; // Guessing color
}
.popoverContent {
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -2,25 +2,56 @@ import { memo } from 'react';
import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar';
import { useSessionStorage } from '@mantine/hooks';
import { OffsetMode } from 'ontime-types';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import { AppMode, sessionKeys } from '../../../ontimeConfig';
import { RundownViewMode } from '../rundownViewMode';
import Tooltip from '../../../common/components/tooltip/Tooltip';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode } from '../../../ontimeConfig';
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
import { RundownViewMode } from '../rundown.options';
import { useEditorFollowMode } from '../useEditorFollowMode';
import RundownMenu from './RundownMenu';
import RundownSettings from './RundownSettings';
import style from './RundownHeader.module.scss';
interface RundownHeaderProps {
isExtracted?: boolean;
viewMode: RundownViewMode;
setViewMode: (mode: RundownViewMode) => void;
}
interface HeaderControlsConfig {
showRunEditToggle: boolean;
showOffsetToggle: boolean;
showOverflowMenu: boolean;
}
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
[EditorLayoutMode.CONTROL]: {
showRunEditToggle: true,
showOffsetToggle: true,
showOverflowMenu: true,
},
[EditorLayoutMode.PLANNING]: {
showRunEditToggle: false,
showOffsetToggle: false,
showOverflowMenu: true,
},
[EditorLayoutMode.TRACKING]: {
showRunEditToggle: false,
showOffsetToggle: true,
showOverflowMenu: false,
},
} as const;
export default memo(RundownHeader);
function RundownHeader({ viewMode, setViewMode }: RundownHeaderProps) {
const [editorMode, setEditorMode] = useSessionStorage({ key: sessionKeys.editorMode, defaultValue: AppMode.Edit });
function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProps) {
const { editorMode, setEditorMode } = useEditorFollowMode();
const offsetMode = useOffsetMode();
const { layoutMode } = useEditorLayout();
const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode];
const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode
@@ -29,22 +60,70 @@ function RundownHeader({ viewMode, setViewMode }: RundownHeaderProps) {
setEditorMode(newValue);
};
const toggleViewMode = (mode: RundownViewMode[]) => {
const newValue = mode.at(0);
if (!newValue) return;
setViewMode(newValue);
};
const toggleOffsetMode = (mode: OffsetMode[]) => {
const newValue = mode.at(0);
if (!newValue) return;
setOffsetMode(newValue);
};
return (
<Toolbar.Root className={style.header}>
<ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}>
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
{showRunEditToggle && (
<ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}>
<Tooltip
text='Live playback view with auto-follow'
render={<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton} />}
>
Run
</Tooltip>
<Tooltip
text='Manual editing without playback automation'
render={<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton} />}
>
Edit
</Tooltip>
</ToggleGroup>
)}
<ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
<Tooltip
text='View rundown in list mode'
render={<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton} />}
>
List
</Tooltip>
<Tooltip
text='View rundown in table mode'
render={<Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton} />}
>
Table
</Tooltip>
</ToggleGroup>
<Editor.Separator className={style.separator} />
{showOffsetToggle && (
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
<Tooltip
text='Offsets use fixed clock time'
render={<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton} />}
>
Absolute
</Tooltip>
<Tooltip
text='Offsets follow the rundown relative start'
render={<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton} />}
>
Relative
</Tooltip>
</ToggleGroup>
)}
<RundownSettings viewMode={viewMode} setViewMode={setViewMode} />
<RundownMenu />
{showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
</Toolbar.Root>
);
}
@@ -2,23 +2,21 @@ import { memo } from 'react';
import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar';
import { useSessionStorage } from '@mantine/hooks';
import { OffsetMode } from 'ontime-types';
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode, sessionKeys } from '../../../ontimeConfig';
import { AppMode } from '../../../ontimeConfig';
import { RundownViewMode } from '../rundown.options';
import { useEditorFollowMode } from '../useEditorFollowMode';
import style from './RundownHeader.module.scss';
export default memo(RundownHeader);
function RundownHeader() {
const [editorMode, setEditorMode] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
interface RundownHeaderMobileProps {
viewMode: RundownViewMode;
setViewMode: (mode: RundownViewMode) => void;
}
const offsetMode = useOffsetMode();
export default memo(RundownHeaderMobile);
function RundownHeaderMobile({ viewMode, setViewMode }: RundownHeaderMobileProps) {
const { editorMode, setEditorMode } = useEditorFollowMode();
const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode
@@ -27,11 +25,10 @@ function RundownHeader() {
setEditorMode(newValue);
};
const toggleOffsetMode = (mode: OffsetMode[]) => {
// we need to stop user from deselecting a mode
const toggleViewMode = (mode: RundownViewMode[]) => {
const newValue = mode.at(0);
if (!newValue) return;
setOffsetMode(newValue);
setViewMode(newValue);
};
return (
@@ -40,19 +37,18 @@ function RundownHeader() {
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit
</Toolbar.Button>
</ToggleGroup>
<Editor.Separator className={style.separator} />
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton}>
Absolute
<ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton}>
List
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton}>
Relative
<Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton}>
Table
</Toolbar.Button>
</ToggleGroup>
</Toolbar.Root>
@@ -13,8 +13,12 @@ import { useEventSelection } from '../useEventSelection';
import style from './RundownHeader.module.scss';
interface RundownMenuProps {
allowNavigation?: boolean;
}
export default memo(RundownMenu);
function RundownMenu() {
function RundownMenu({ allowNavigation }: RundownMenuProps) {
const [isOpen, handlers] = useDisclosure();
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
@@ -38,6 +42,7 @@ function RundownMenu() {
label: 'Manage Rundowns...',
icon: IoList,
onClick: () => setLocation('manage'),
disabled: !allowNavigation,
},
{ type: 'divider' },
{
@@ -1,33 +0,0 @@
import { memo } from 'react';
import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar';
import { RundownViewMode } from '../rundownViewMode';
import style from './RundownHeader.module.scss';
interface RundownSettingsProps {
viewMode: RundownViewMode;
setViewMode: (mode: RundownViewMode) => void;
}
export default memo(RundownSettings);
function RundownSettings({ viewMode, setViewMode }: RundownSettingsProps) {
const toggleViewMode = (mode: RundownViewMode[]) => {
const newValue = mode.at(0);
if (!newValue) return;
setViewMode(newValue);
};
return (
<ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
<Toolbar.Button render={<Toggle />} value='list' className={style.radioButton}>
List
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value='table' className={style.radioButton}>
Table
</Toolbar.Button>
</ToggleGroup>
);
}
@@ -30,7 +30,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
const { updateEntry, deleteEntry } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection);
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
@@ -69,7 +69,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
}
// UI indexes are 1 based
setSingleEntrySelection({ id: entryId });
selectSingleEntry({ id: entryId });
};
const handleUpdate = (field: 'cue' | 'title', value: string) => {
@@ -1,22 +1,30 @@
import { memo, useMemo } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { AppMode, sessionKeys } from '../../../ontimeConfig';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
import { useEditorFollowMode } from '../useEditorFollowMode';
import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable);
function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
const [editorMode] = useSessionStorage({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
// Editor always has full permissions
useEffect(() => {
setPermissions({
canChangeMode: true,
canCreateEntries: true,
canEditEntries: true,
canFlag: true,
canShare: true,
});
}, [setPermissions]);
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
@@ -58,3 +58,8 @@ export function makeRundownCustomColumns(customFields: CustomFields) {
};
});
}
export enum RundownViewMode {
List = 'list',
Table = 'table',
}
@@ -1,4 +1,4 @@
import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
import { EntryId, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
/**
* Creates a sortable list of entries
@@ -1,5 +0,0 @@
export const RUNDOWN_VIEW_MODES = ['list', 'table'] as const;
export type RundownViewMode = (typeof RUNDOWN_VIEW_MODES)[number];
export const DEFAULT_RUNDOWN_VIEW_MODE: RundownViewMode = 'list';
export const RUNDOWN_VIEW_MODE_STORAGE_KEY = 'rundown-view-mode';
@@ -0,0 +1,47 @@
import { useCallback, useMemo } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { EntryId } from 'ontime-types';
/**
* Keeps track of which groups are collapsed
* This information is saved in session storage as an array (serializable)
* but provides Set-like operations for fast lookups
*/
export function useCollapsedGroups(rundownId: string) {
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
key: `rundown.${rundownId}-editor-collapsed-groups`,
defaultValue: [],
});
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const getIsCollapsed = useCallback(
(groupId: EntryId): boolean => {
return collapsedGroupSet.has(groupId);
},
[collapsedGroupSet],
);
const collapseGroup = useCallback(
(groupId: EntryId | null | undefined) => {
if (!groupId) return;
setCollapsedGroups((prev) => {
if (prev.includes(groupId)) {
return prev;
}
return [...prev, groupId];
});
},
[setCollapsedGroups],
);
const expandGroup = useCallback(
(groupId: EntryId | null | undefined) => {
if (!groupId) return;
setCollapsedGroups((prev) => prev.filter((id) => id !== groupId));
},
[setCollapsedGroups],
);
return { getIsCollapsed, collapseGroup, expandGroup };
}
@@ -0,0 +1,41 @@
import { useSessionStorage } from '@mantine/hooks';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
/**
* Manages the editor mode (Edit/Run) derived from layout mode
*
* Editor mode is automatically determined by the layout:
* - PLANNING: Always Edit mode (follows user selection)
* - TRACKING: Always Run mode (follows current event)
* - CONTROL: User preference (defaults to Run mode), persisted in session storage
*
* Edit: Manual editing, follows user selection
* Run: Live playback view, auto-follows current event
*/
export function useEditorFollowMode() {
const { layoutMode } = useEditorLayout();
// Only used for CONTROL layout - stores user preference
const [controlModePreference, setControlModePreference] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode,
defaultValue: AppMode.Run,
});
// Derive editor mode from layout mode
const editorMode = (() => {
if (layoutMode === EditorLayoutMode.CONTROL) return controlModePreference;
if (layoutMode === EditorLayoutMode.PLANNING) return AppMode.Edit;
return AppMode.Run;
})();
// setEditorMode only affects CONTROL layout
const setEditorMode = (mode: AppMode) => {
if (layoutMode === EditorLayoutMode.CONTROL) {
setControlModePreference(mode);
}
};
return { editorMode, setEditorMode };
}
@@ -6,7 +6,7 @@ import { RUNDOWN } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
type SelectionMode = 'shift' | 'click' | 'ctrl';
export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<EntryId>;
@@ -14,23 +14,25 @@ interface EventSelectionStore {
cursor: EntryId | null;
entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
scrollHandlerSource: string | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
setScrollHandler: (source: string, handler: ((id: EntryId) => void) | null) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
}
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*/
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
entryMode: null,
scrollHandler: null,
scrollHandlerSource: null,
setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
},
@@ -123,16 +125,9 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
entryMode: selectedEvents.size === 0 ? null : entryMode,
});
},
setScrollHandler: (source, handler) =>
set((state) => {
if (handler) {
return { scrollHandler: handler, scrollHandlerSource: source };
}
if (state.scrollHandlerSource !== source) {
return state;
}
return { scrollHandler: null, scrollHandlerSource: null };
}),
// Sets the scroll handler for programmatic scrolling to entries
setScrollHandler: (handler) => set({ scrollHandler: handler }),
// Scrolls to the specified entry using the registered scroll handler
scrollToEntry: (id: EntryId) => {
const handler = get().scrollHandler;
if (handler) {
@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { EntryId, MaybeString } from 'ontime-types';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEventSelection } from './useEventSelection';
type SelectAndRevealOptions = {
id: EntryId;
index: number;
parent?: MaybeString;
};
export function useSelectAndRevealEntry(rundownId: string) {
const { expandGroup } = useCollapsedGroups(rundownId);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
return useCallback(
({ id, index, parent }: SelectAndRevealOptions) => {
expandGroup(parent);
selectEntry({ id, index, selectMode: 'click' });
// Wait one frame so collapsed-group expansion is reflected in rendered/visible entries before scrolling.
requestAnimationFrame(() => {
scrollToEntry(id);
});
},
[expandGroup, scrollToEntry, selectEntry],
);
}