refactor: table mode and context

This commit is contained in:
Carlos Valente
2026-01-10 09:41:40 +01:00
parent 8cfbcd35cd
commit 261cd25bec
64 changed files with 560 additions and 273 deletions
@@ -0,0 +1,24 @@
import { createContext, PropsWithChildren, useContext } from 'react';
import { useEntryActions } from '../hooks/useEntryAction';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
interface EntryActionsProviderProps extends PropsWithChildren {
actions: EntryActionsContextValue;
}
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
}
export function useEntryActionsContext(): EntryActionsContextValue {
const context = useContext(EntryActionsContext);
if (!context) {
throw new Error('useEntryActionsContext must be used within EntryActionsProvider');
}
return context;
}
@@ -5,7 +5,6 @@
.rundownContainer { .rundownContainer {
margin-top: 1rem; margin-top: 1rem;
overflow-y: scroll;
height: 100%; height: 100%;
} }
+2 -2
View File
@@ -38,7 +38,7 @@ import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata'; import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
import { useRundownEntryActions } from './context/RundownActionsContext'; import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons'; import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline'; import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
import RundownGroup from './rundown-group/RundownGroup'; import RundownGroup from './rundown-group/RundownGroup';
@@ -72,7 +72,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
}); });
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]); const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const { addEntry, clone, deleteEntry, move, reorderEntry } = useRundownEntryActions(); const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActionsContext();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId); const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
// cursor // cursor
@@ -1,6 +1,7 @@
.rundownExport { .rundownExport {
height: 100%; height: 100%;
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */ flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
min-width: 0;
&.extracted { &.extracted {
.list { .list {
@@ -20,6 +21,11 @@
height: 100%; height: 100%;
} }
.rundownRoot {
height: calc(100% - 1.5rem);
width: 100%;
}
.list { .list {
display: flex; display: flex;
height: inherit; height: inherit;
@@ -5,18 +5,23 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useEntryActions } from '../../common/hooks/useEntryAction'; import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../externals'; import { getIsNavigationLocked } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode, sessionKeys } from '../../ontimeConfig';
import EditorEditModal from '../../views/cuesheet/cuesheet-edit-modal/EditorEditModal';
import { RundownActionsProvider } from './context/RundownActionsContext';
import RundownEntryEditor from './entry-editor/RundownEntryEditor'; import RundownEntryEditor from './entry-editor/RundownEntryEditor';
import FinderPlacement from './placements/FinderPlacement'; import FinderPlacement from './placements/FinderPlacement';
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu'; import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
import RundownWrapper, { RundownViewMode } from './RundownWrapper'; import RundownHeader from './rundown-header/RundownHeader';
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
import RundownTable from './rundown-table/RundownTable';
import RundownList from './RundownList';
import { DEFAULT_RUNDOWN_VIEW_MODE, RUNDOWN_VIEW_MODE_STORAGE_KEY, RundownViewMode } from './rundownViewMode';
import style from './RundownExport.module.scss'; import style from './RundownExport.module.scss';
@@ -29,15 +34,15 @@ function RundownExport() {
defaultValue: AppMode.Edit, defaultValue: AppMode.Edit,
}); });
const [viewMode, setViewMode] = useSessionStorage<RundownViewMode>({ const [viewMode, setViewMode] = useSessionStorage<RundownViewMode>({
key: 'rundown-view-mode', key: RUNDOWN_VIEW_MODE_STORAGE_KEY,
defaultValue: 'list', defaultValue: DEFAULT_RUNDOWN_VIEW_MODE,
}); });
const isSmallDevice = useIsSmallDevice(); const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions(); const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) { if (isSmallDevice && isExtracted) {
return ( return (
<RundownActionsProvider actions={entryActions}> <EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div <div
className={cx([style.rundownExport, style.extracted])} className={cx([style.rundownExport, style.extracted])}
@@ -48,20 +53,20 @@ function RundownExport() {
<ViewNavigationMenu suppressSettings /> <ViewNavigationMenu suppressSettings />
<div className={style.rundown}> <div className={style.rundown}>
<ErrorBoundary> <ErrorBoundary>
<RundownWrapper isSmallDevice viewMode={viewMode} setViewMode={setViewMode} /> <RundownRoot isSmallDevice viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu /> <RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</div> </div>
</ProtectRoute> </ProtectRoute>
</RundownActionsProvider> </EntryActionsProvider>
); );
} }
const hideSideBar = (isExtracted && editorMode === 'run') || viewMode === 'table'; const hideSideBar = (isExtracted && editorMode === 'run') || viewMode === 'table';
return ( return (
<RundownActionsProvider actions={entryActions}> <EntryActionsProvider actions={entryActions}>
<ProtectRoute permission='editor'> <ProtectRoute permission='editor'>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
<FinderPlacement /> <FinderPlacement />
@@ -70,7 +75,7 @@ function RundownExport() {
<Editor.Panel className={style.list}> <Editor.Panel className={style.list}>
<ErrorBoundary> <ErrorBoundary>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />} {!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownWrapper viewMode={viewMode} setViewMode={setViewMode} /> <RundownRoot viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu /> <RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</Editor.Panel> </Editor.Panel>
@@ -84,6 +89,22 @@ function RundownExport() {
</div> </div>
</div> </div>
</ProtectRoute> </ProtectRoute>
</RundownActionsProvider> </EntryActionsProvider>
);
}
interface RundownRootProps {
isSmallDevice?: boolean;
viewMode: RundownViewMode;
setViewMode: (mode: RundownViewMode) => void;
}
function RundownRoot({ isSmallDevice, viewMode, setViewMode }: RundownRootProps) {
return (
<div className={style.rundownRoot}>
{isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader viewMode={viewMode} setViewMode={setViewMode} />}
{viewMode === 'list' ? <RundownList /> : <RundownTable />}
<EditorEditModal />
</div>
); );
} }
@@ -0,0 +1,29 @@
import { memo } from 'react';
import Empty from '../../common/components/state/Empty';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import Rundown from './Rundown';
export default memo(RundownList);
function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const isLoading = status !== 'success' || !data || !rundownMetadata;
if (isLoading) {
return <Empty text='Connecting to server' />;
}
return (
<Rundown
order={data.order}
entries={data.entries}
id={data.id}
rundownMetadata={rundownMetadata}
featureData={featureData}
/>
);
}
@@ -1,34 +0,0 @@
import Empty from '../../common/components/state/Empty';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import EditorEditModal from '../../views/cuesheet/cuesheet-edit-modal/EditorEditModal';
import RundownHeader from './rundown-header/RundownHeader';
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
import RundownTable from './rundown-table/RundownTable';
import Rundown from './Rundown';
import styles from './Rundown.module.scss';
export type RundownViewMode = 'list' | 'table';
interface RundownWrapperProps {
isSmallDevice?: boolean;
viewMode: RundownViewMode;
setViewMode: (mode: RundownViewMode) => void;
}
export default function RundownWrapper({ isSmallDevice, viewMode, setViewMode }: RundownWrapperProps) {
const { data, status, rundownMetadata } = useRundownWithMetadata();
const isLoading = status !== 'success' || !data || !rundownMetadata;
return (
<div className={styles.rundownWrapper}>
{isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader viewMode={viewMode} setViewMode={setViewMode} />}
{isLoading && <Empty text='Connecting to server' />}
{!isLoading && viewMode === 'list' && <Rundown data={data} rundownMetadata={rundownMetadata} />}
{!isLoading && viewMode === 'table' && <RundownTable />}
<EditorEditModal />
</div>
);
}
@@ -2,8 +2,8 @@ import { useCallback, useRef } from 'react';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { useRundownEntryActions } from '../context/RundownActionsContext';
import style from './TitleEditor.module.scss'; import style from './TitleEditor.module.scss';
@@ -15,7 +15,7 @@ interface TitleEditorProps {
} }
export default function TitleEditor({ title, entryId, placeholder, className }: TitleEditorProps) { export default function TitleEditor({ title, entryId, placeholder, className }: TitleEditorProps) {
const { updateEntry } = useRundownEntryActions(); const { updateEntry } = useEntryActionsContext();
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback( const submitCallback = useCallback(
(text: string) => { (text: string) => {
@@ -1,24 +0,0 @@
import { createContext, PropsWithChildren, useContext } from 'react';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const RundownActionsContext = createContext<EntryActionsContextValue | null>(null);
interface RundownActionsProviderProps extends PropsWithChildren {
actions: EntryActionsContextValue;
}
export function RundownActionsProvider({ children, actions }: RundownActionsProviderProps) {
return <RundownActionsContext.Provider value={actions}>{children}</RundownActionsContext.Provider>;
}
export function useRundownEntryActions(): EntryActionsContextValue {
const context = useContext(RundownActionsContext);
if (!context) {
throw new Error('useRundownEntryActions must be used within RundownActionsProvider');
}
return context;
}
@@ -3,8 +3,8 @@ import { OntimeEvent } from 'ontime-types';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useRundownEntryActions } from '../context/RundownActionsContext';
import EntryEditorCustomFields from './composite/EventEditorCustomFields'; import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventEditorTimes from './composite/EventEditorTimes'; import EventEditorTimes from './composite/EventEditorTimes';
@@ -22,7 +22,7 @@ interface EventEditorProps {
export default function EventEditor({ event }: EventEditorProps) { export default function EventEditor({ event }: EventEditorProps) {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry } = useRundownEntryActions(); const { updateEntry } = useEntryActionsContext();
const isEditor = window.location.pathname.includes('editor'); const isEditor = window.location.pathname.includes('editor');
@@ -5,11 +5,11 @@ import { millisToString } from 'ontime-utils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import TextLikeInput from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput'; import TextLikeInput from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/TextLikeInput';
import { useRundownEntryActions } from '../context/RundownActionsContext';
import EntryEditorCustomFields from './composite/EventEditorCustomFields'; import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventTextArea from './composite/EventTextArea'; import EventTextArea from './composite/EventTextArea';
@@ -28,7 +28,7 @@ interface GroupEditorProps {
export default function GroupEditor({ group }: GroupEditorProps) { export default function GroupEditor({ group }: GroupEditorProps) {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry } = useRundownEntryActions(); const { updateEntry } = useEntryActionsContext();
const handleSubmit = useCallback( const handleSubmit = useCallback(
(field: GroupEditorUpdateTextFields | GroupEditorUpdateMaybeNumberFields, value: string | MaybeNumber) => { (field: GroupEditorUpdateTextFields | GroupEditorUpdateMaybeNumberFields, value: string | MaybeNumber) => {
@@ -5,8 +5,8 @@ import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useRundownEntryActions } from '../context/RundownActionsContext';
import EntryEditorCustomFields from './composite/EventEditorCustomFields'; import EntryEditorCustomFields from './composite/EventEditorCustomFields';
import EventTextArea from './composite/EventTextArea'; import EventTextArea from './composite/EventTextArea';
@@ -22,7 +22,7 @@ interface MilestoneEditorProps {
} }
export default function MilestoneEditor({ milestone }: MilestoneEditorProps) { export default function MilestoneEditor({ milestone }: MilestoneEditorProps) {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry } = useRundownEntryActions(); const { updateEntry } = useEntryActionsContext();
const handleSubmit = useCallback( const handleSubmit = useCallback(
(field: MilestoneEditorUpdateTextFields, value: string) => { (field: MilestoneEditorUpdateTextFields, value: string) => {
@@ -8,8 +8,8 @@ import TimeInput from '../../../../common/components/input/time-input/TimeInput'
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { useRundownEntryActions } from '../../context/RundownActionsContext';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss'; import style from '../EntryEditor.module.scss';
@@ -46,7 +46,7 @@ function EventEditorTimes({
timeWarning, timeWarning,
timeDanger, timeDanger,
}: EventEditorTimesProps) { }: EventEditorTimesProps) {
const { updateEntry } = useRundownEntryActions(); const { updateEntry } = useEntryActionsContext();
const handleSubmit = (field: HandledActions, value: string | boolean) => { const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'countToEnd') { if (field === 'countToEnd') {
@@ -5,7 +5,7 @@ import * as Editor from '../../../../common/components/editor-utils/EditorUtils'
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import { useRundownEntryActions } from '../../context/RundownActionsContext'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import EventTextArea from './EventTextArea'; import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput'; import EntryEditorTextInput from './EventTextInput';
@@ -23,7 +23,7 @@ interface EventEditorTitlesProps {
export default memo(EventEditorTitles); export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) { function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
const { updateEntry } = useRundownEntryActions(); const { updateEntry } = useEntryActionsContext();
const cueSubmitHandler = (_field: string, newValue: string) => { const cueSubmitHandler = (_field: string, newValue: string) => {
updateEntry({ id: eventId, cue: sanitiseCue(newValue) }); updateEntry({ id: eventId, cue: sanitiseCue(newValue) });
@@ -8,8 +8,8 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { useRundownEntryActions } from '../../context/RundownActionsContext';
import { eventTriggerOptions } from './eventTrigger.constants'; import { eventTriggerOptions } from './eventTrigger.constants';
@@ -38,7 +38,7 @@ interface EventTriggerFormProps {
function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) { function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const { updateEntry } = useRundownEntryActions(); const { updateEntry } = useEntryActionsContext();
const [automationId, setAutomationId] = useState<string | undefined>(undefined); const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart); const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
@@ -123,7 +123,7 @@ interface ExistingEventTriggersProps {
} }
function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) { function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) {
const { updateEntry } = useRundownEntryActions(); const { updateEntry } = useEntryActionsContext();
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback( const handleDelete = useCallback(
@@ -2,6 +2,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding-left: 0.5rem;
padding-block: 0.5rem; padding-block: 0.5rem;
background: color-mix(in srgb, transparent 90%, var(--user-bg, transparent) 10%); background: color-mix(in srgb, transparent 90%, var(--user-bg, transparent) 10%);
@@ -4,8 +4,8 @@ import { Toolbar } from '@base-ui/react/toolbar';
import { MaybeString, SupportedEntry } from 'ontime-types'; import { MaybeString, SupportedEntry } from 'ontime-types';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import { useRundownEntryActions } from '../../context/RundownActionsContext';
import style from './QuickAddButtons.module.scss'; import style from './QuickAddButtons.module.scss';
@@ -17,7 +17,7 @@ interface QuickAddButtonsProps {
export default memo(QuickAddButtons); export default memo(QuickAddButtons);
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) { function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
const { addEntry } = useRundownEntryActions(); const { addEntry } = useEntryActionsContext();
const addEvent = () => { const addEvent = () => {
addEntry( addEntry(
@@ -4,7 +4,7 @@ import { MaybeString, SupportedEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import { useRundownEntryActions } from '../../context/RundownActionsContext'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import style from './QuickAddInline.module.scss'; import style from './QuickAddInline.module.scss';
@@ -16,7 +16,7 @@ interface QuickAddInlineProps {
export default memo(QuickAddInline); export default memo(QuickAddInline);
function QuickAddInline({ referenceEntryId, parentGroup, placement }: QuickAddInlineProps) { function QuickAddInline({ referenceEntryId, parentGroup, placement }: QuickAddInlineProps) {
const { addEntry } = useRundownEntryActions(); const { addEntry } = useEntryActionsContext();
const handleAddEntry = (type: SupportedEntry) => { const handleAddEntry = (type: SupportedEntry) => {
if (placement === 'before') { if (placement === 'before') {
@@ -1,8 +1,8 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react'; import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { useRundownEntryActions } from '../../../../features/rundown/context/RundownActionsContext'; import Input from '../../../common/components/input/input/Input';
import Input from '../input/Input'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import BlockRadio from './BlockRadio'; import BlockRadio from './BlockRadio';
@@ -14,7 +14,7 @@ interface DelayInputProps {
} }
export default function DelayInput({ eventId, duration }: DelayInputProps) { export default function DelayInput({ eventId, duration }: DelayInputProps) {
const { updateEntry } = useRundownEntryActions(); const { updateEntry } = useEntryActionsContext();
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -5,9 +5,9 @@ import { CSS } from '@dnd-kit/utilities';
import { OntimeDelay } from 'ontime-types'; import { OntimeDelay } from 'ontime-types';
import Button from '../../../common/components/buttons/Button'; import Button from '../../../common/components/buttons/Button';
import DelayInput from '../../../common/components/input/delay-input/DelayInput'; import DelayInput from './DelayInput';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { useRundownEntryActions } from '../context/RundownActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import style from './RundownDelay.module.scss'; import style from './RundownDelay.module.scss';
@@ -19,7 +19,7 @@ interface RundownDelayProps {
export default function RundownDelay({ data, hasCursor }: RundownDelayProps) { export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
'use memo'; 'use memo';
const { applyDelay, deleteEntry } = useRundownEntryActions(); const { applyDelay, deleteEntry } = useEntryActionsContext();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { const {
@@ -1,4 +1,4 @@
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { MouseEvent, useEffect, useRef } from 'react';
import { import {
IoAdd, IoAdd,
IoDuplicateOutline, IoDuplicateOutline,
@@ -15,9 +15,9 @@ import { CSS } from '@dnd-kit/utilities';
import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils'; import { isPlaybackActive } from 'ontime-utils';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useRundownEntryActions } from '../context/RundownActionsContext';
import { useEventIdSwapping } from '../useEventIdSwapping'; import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection'; import { getSelectionMode, useEventSelection } from '../useEventSelection';
@@ -97,7 +97,7 @@ export default function RundownEvent({
const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId); const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId);
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId); const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useRundownEntryActions(); const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId)); const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
const unselect = useEventSelection((state) => state.unselect); const unselect = useEventSelection((state) => state.unselect);
@@ -107,7 +107,6 @@ export default function RundownEvent({
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => const [onContextMenu] = useContextMenu<HTMLDivElement>(() =>
selectedEvents.size > 1 selectedEvents.size > 1
@@ -236,31 +235,6 @@ export default function RundownEvent({
} }
}, [hasCursor]); }, [hasCursor]);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 1,
},
);
const handleRefCurrent = handleRef.current;
if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
return () => {
if (handleRefCurrent) {
observer.unobserve(handleRefCurrent);
}
};
}, [handleRef]);
const blockClasses = cx([ const blockClasses = cx([
style.rundownEvent, style.rundownEvent,
skip ? style.skip : null, skip ? style.skip : null,
@@ -308,33 +282,31 @@ export default function RundownEvent({
<span className={style.cue}>{cue}</span> <span className={style.cue}>{cue}</span>
</div> </div>
{isVisible && ( <RundownEventInner
<RundownEventInner timeStart={timeStart}
timeStart={timeStart} timeEnd={timeEnd}
timeEnd={timeEnd} duration={duration}
duration={duration} linkStart={linkStart}
linkStart={linkStart} countToEnd={countToEnd}
countToEnd={countToEnd} timeStrategy={timeStrategy}
timeStrategy={timeStrategy} eventId={eventId}
eventId={eventId} eventIndex={eventIndex}
eventIndex={eventIndex} endAction={endAction}
endAction={endAction} timerType={timerType}
timerType={timerType} title={title}
title={title} note={note}
note={note} delay={delay}
delay={delay} isNext={isNext}
isNext={isNext} skip={skip}
skip={skip} loaded={loaded}
loaded={loaded} playback={playback}
playback={playback} isRolling={isRolling}
isRolling={isRolling} dayOffset={dayOffset}
dayOffset={dayOffset} isPast={isPast}
isPast={isPast} totalGap={totalGap}
totalGap={totalGap} isLinkedToLoaded={isLinkedToLoaded}
isLinkedToLoaded={isLinkedToLoaded} hasTriggers={hasTriggers}
hasTriggers={hasTriggers} />
/>
)}
</div> </div>
); );
} }
@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react'; import { memo } from 'react';
import { import {
IoArrowDown, IoArrowDown,
IoArrowUp, IoArrowUp,
@@ -76,17 +76,11 @@ function RundownEventInner({
isLinkedToLoaded, isLinkedToLoaded,
hasTriggers, hasTriggers,
}: RundownEventInnerProps) { }: RundownEventInnerProps) {
const [renderInner, setRenderInner] = useState(false);
const [editorMode] = useSessionStorage({ const [editorMode] = useSessionStorage({
key: sessionKeys.editorMode, key: sessionKeys.editorMode,
defaultValue: AppMode.Edit, defaultValue: AppMode.Edit,
}); });
useEffect(() => {
setRenderInner(true);
}, []);
const eventIsPlaying = playback === Playback.Play; const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause; const eventIsPaused = playback === Playback.Pause;
@@ -97,7 +91,7 @@ function RundownEventInner({
playBtnStyles._hover = {}; playBtnStyles._hover = {};
} }
return !renderInner ? null : ( return (
<> <>
<div className={cx([style.eventTimers, editorMode === AppMode.Edit && style.editMode])}> <div className={cx([style.eventTimers, editorMode === AppMode.Edit && style.editMode])}>
<TimeInputFlow <TimeInputFlow
@@ -161,8 +155,7 @@ function RundownEventInner({
); );
} }
function EndActionIcon(props: { action: EndAction; className: string }) { function EndActionIcon({ action, className }: { action: EndAction; className: string }) {
const { action, className } = props;
const maybeActiveClasses = cx([action !== EndAction.None && style.active, className]); const maybeActiveClasses = cx([action !== EndAction.None && style.active, className]);
if (action === EndAction.LoadNext) { if (action === EndAction.LoadNext) {
@@ -174,8 +167,7 @@ function EndActionIcon(props: { action: EndAction; className: string }) {
return <IoPlay className={className} />; return <IoPlay className={className} />;
} }
function TimerIcon(props: { type: TimerType; className: string }) { function TimerIcon({ type, className }: { type: TimerType; className: string }) {
const { type, className } = props;
if (type === TimerType.CountUp) { if (type === TimerType.CountUp) {
return <IoArrowUp className={className} />; return <IoArrowUp className={className} />;
} }
@@ -3,8 +3,8 @@ import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { setEventPlayback } from '../../../../common/hooks/useSocket'; import { setEventPlayback } from '../../../../common/hooks/useSocket';
import { useRundownEntryActions } from '../../context/RundownActionsContext';
import style from '../RundownEvent.module.scss'; import style from '../RundownEvent.module.scss';
@@ -26,7 +26,7 @@ function RundownEventPlayback({
loaded, loaded,
disablePlayback, disablePlayback,
}: RundownEventPlaybackProps) { }: RundownEventPlaybackProps) {
const { updateEntry } = useRundownEntryActions(); const { updateEntry } = useEntryActionsContext();
const toggleSkip = (event: MouseEvent) => { const toggleSkip = (event: MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -18,7 +18,7 @@ import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
import TitleEditor from '../common/TitleEditor'; import TitleEditor from '../common/TitleEditor';
import { useRundownEntryActions } from '../context/RundownActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { canDrop } from '../rundown.utils'; import { canDrop } from '../rundown.utils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -36,7 +36,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
'use memo'; 'use memo';
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useRundownEntryActions(); const { clone, ungroup, deleteEntry } = useEntryActionsContext();
const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection); const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection);
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
@@ -64,3 +64,45 @@
.separator { .separator {
margin-inline: 1rem; 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;
}
@@ -3,18 +3,16 @@ import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group'; import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar'; import { Toolbar } from '@base-ui/react/toolbar';
import { useSessionStorage } from '@mantine/hooks'; import { useSessionStorage } from '@mantine/hooks';
import { OffsetMode } from 'ontime-types';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode, sessionKeys } from '../../../ontimeConfig'; import { AppMode, sessionKeys } from '../../../ontimeConfig';
import { RundownViewMode } from '../rundownViewMode';
import RundownMenu from './RundownMenu'; import RundownMenu from './RundownMenu';
import RundownSettings from './RundownSettings';
import style from './RundownHeader.module.scss'; import style from './RundownHeader.module.scss';
type RundownViewMode = 'list' | 'table';
interface RundownHeaderProps { interface RundownHeaderProps {
viewMode: RundownViewMode; viewMode: RundownViewMode;
setViewMode: (mode: RundownViewMode) => void; setViewMode: (mode: RundownViewMode) => void;
@@ -24,8 +22,6 @@ export default memo(RundownHeader);
function RundownHeader({ viewMode, setViewMode }: RundownHeaderProps) { function RundownHeader({ viewMode, setViewMode }: RundownHeaderProps) {
const [editorMode, setEditorMode] = useSessionStorage({ key: sessionKeys.editorMode, defaultValue: AppMode.Edit }); const [editorMode, setEditorMode] = useSessionStorage({ key: sessionKeys.editorMode, defaultValue: AppMode.Edit });
const offsetMode = useOffsetMode();
const toggleAppMode = (mode: AppMode[]) => { const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode // we need to stop user from deselecting a mode
const newValue = mode.at(0); const newValue = mode.at(0);
@@ -33,20 +29,6 @@ function RundownHeader({ viewMode, setViewMode }: RundownHeaderProps) {
setEditorMode(newValue); setEditorMode(newValue);
}; };
const toggleOffsetMode = (mode: OffsetMode[]) => {
// we need to stop user from deselecting a mode
const newValue = mode.at(0);
if (!newValue) return;
setOffsetMode(newValue);
};
const toggleViewMode = (mode: RundownViewMode[]) => {
// we need to stop user from deselecting a mode
const newValue = mode.at(0);
if (!newValue) return;
setViewMode(newValue);
};
return ( return (
<Toolbar.Root className={style.header}> <Toolbar.Root className={style.header}>
<ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}> <ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}>
@@ -60,25 +42,7 @@ function RundownHeader({ viewMode, setViewMode }: RundownHeaderProps) {
<Editor.Separator className={style.separator} /> <Editor.Separator className={style.separator} />
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}> <RundownSettings viewMode={viewMode} setViewMode={setViewMode} />
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton}>
Absolute
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton}>
Relative
</Toolbar.Button>
</ToggleGroup>
<Editor.Separator className={style.separator} />
<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>
<RundownMenu /> <RundownMenu />
</Toolbar.Root> </Toolbar.Root>
@@ -1,12 +1,14 @@
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { IoTrash } from 'react-icons/io5'; import { IoEllipsisHorizontal, IoList, IoTrash } from 'react-icons/io5';
import { Toolbar } from '@base-ui/react/toolbar'; import { Toolbar } from '@base-ui/react/toolbar';
import { useDisclosure, useSessionStorage } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import Button from '../../../common/components/buttons/Button'; import Button from '../../../common/components/buttons/Button';
import IconButton from '../../../common/components/buttons/IconButton';
import Dialog from '../../../common/components/dialog/Dialog'; import Dialog from '../../../common/components/dialog/Dialog';
import { AppMode, sessionKeys } from '../../../ontimeConfig'; import { DropdownMenu } from '../../../common/components/dropdown-menu/DropdownMenu';
import { useRundownEntryActions } from '../context/RundownActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useAppSettingsNavigation from '../../app-settings/useAppSettingsNavigation';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
import style from './RundownHeader.module.scss'; import style from './RundownHeader.module.scss';
@@ -16,11 +18,8 @@ function RundownMenu() {
const [isOpen, handlers] = useDisclosure(); const [isOpen, handlers] = useDisclosure();
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const [editorMode] = useSessionStorage({ const { deleteAllEntries } = useEntryActionsContext();
key: sessionKeys.editorMode, const { setLocation } = useAppSettingsNavigation();
defaultValue: AppMode.Edit,
});
const { deleteAllEntries } = useRundownEntryActions();
const deleteAll = useCallback(() => { const deleteAll = useCallback(() => {
deleteAllEntries(); deleteAllEntries();
@@ -30,15 +29,29 @@ function RundownMenu() {
return ( return (
<> <>
<Toolbar.Button <div className={style.apart}>
render={<Button variant='subtle-destructive' />} <DropdownMenu
onClick={handlers.open} render={<Toolbar.Button render={<IconButton variant='subtle-white' aria-label='Rundown menu' />} />}
disabled={editorMode === AppMode.Run} items={[
className={style.apart} {
> type: 'item',
<IoTrash /> label: 'Manage Rundowns...',
Clear all icon: IoList,
</Toolbar.Button> onClick: () => setLocation('manage'),
},
{ type: 'divider' },
{
type: 'destructive',
label: 'Clear all',
icon: IoTrash,
onClick: handlers.open,
},
]}
>
<IoEllipsisHorizontal />
</DropdownMenu>
</div>
<Dialog <Dialog
isOpen={isOpen} isOpen={isOpen}
onClose={handlers.close} onClose={handlers.close}
@@ -0,0 +1,33 @@
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>
);
}
@@ -8,7 +8,7 @@ import Input from '../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useRundownEntryActions } from '../context/RundownActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
import style from './RundownMilestone.module.scss'; import style from './RundownMilestone.module.scss';
@@ -25,7 +25,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
'use memo'; 'use memo';
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const { updateEntry, deleteEntry } = useRundownEntryActions(); const { updateEntry, deleteEntry } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection); const setSingleEntrySelection = useEventSelection((state) => state.setSingleEntrySelection);
@@ -6,7 +6,6 @@ import {
ColumnSettings, ColumnSettings,
ViewSettings, ViewSettings,
} from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings'; } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings';
import { usePersistedRundownOptions } from '../rundown.options'; import { usePersistedRundownOptions } from '../rundown.options';
import style from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss'; import style from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss';
@@ -35,8 +34,6 @@ export default function EditorTableSettings({
handleResetReordering={handleResetReordering} handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles} handleClearToggles={handleClearToggles}
/> />
{/* No mode toggle - already in RundownHeader */}
{/* No share modal - cuesheet only */}
</Toolbar.Root> </Toolbar.Root>
); );
} }
@@ -5,6 +5,9 @@ import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry }
* ------------------------------------ * ------------------------------------
* Due to limitations in dnd-kit we need to flatten the list of entries * Due to limitations in dnd-kit we need to flatten the list of entries
* This list should also be aware of any elements that are sortable (ie: group ends) * This list should also be aware of any elements that are sortable (ie: group ends)
*
* Note: This creates the FULL structure including all entries and pseudo end-group entries.
* For rendering, use filterVisibleEntries() to exclude collapsed items.
*/ */
export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] { export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] {
const flatIds: EntryId[] = []; const flatIds: EntryId[] = [];
@@ -31,6 +34,42 @@ export function makeSortableList(order: EntryId[], entries: RundownEntries): Ent
return flatIds; return flatIds;
} }
/**
* Filters sortable list to only include visible entries based on collapsed state
* ------------------------------------
* Excludes:
* - Children of collapsed groups
* - End-group markers of collapsed groups
*
* This is used by Virtuoso for rendering, while DND-kit uses the full sortableData.
*/
export function filterVisibleEntries(
sortableData: EntryId[],
entries: RundownEntries,
getIsCollapsed: (groupId: EntryId) => boolean,
): EntryId[] {
return sortableData.filter((entryId) => {
// group end pseudo entries are only shown if the group is expanded
if (entryId.startsWith('end-')) {
const parentId = entryId.split('end-')[1];
return !getIsCollapsed(parentId);
}
// retrieve the entry as usual
const entry = entries[entryId];
if (!entry) {
return false;
}
// if entry has a parent and parent is collapsed, filter it out
if (entry.type !== SupportedEntry.Group && 'parent' in entry && entry.parent) {
return !getIsCollapsed(entry.parent);
}
return true;
});
}
/** /**
* Checks whether a drop operation is valid * Checks whether a drop operation is valid
* Currently only used for validating dropping groups * Currently only used for validating dropping groups
@@ -0,0 +1,5 @@
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';
@@ -7,7 +7,7 @@ import IconButton from '../../../common/components/buttons/IconButton';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../common/components/input/time-input/TimeInput';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useRundownEntryActions } from '../context/RundownActionsContext'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import TimeInputGroup from './TimeInputGroup'; import TimeInputGroup from './TimeInputGroup';
@@ -37,7 +37,7 @@ function TimeInputFlow({
delay, delay,
showLabels, showLabels,
}: TimeInputFlowProps) { }: TimeInputFlowProps) {
const { updateEntry, updateTimer } = useRundownEntryActions(); const { updateEntry, updateTimer } = useEntryActionsContext();
// In sync with EventEditorTimes // In sync with EventEditorTimes
const handleSubmit = (field: TimeField, value: string) => { const handleSubmit = (field: TimeField, value: string) => {
@@ -12,9 +12,12 @@ interface EventSelectionStore {
selectedEvents: Set<EntryId>; selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber; anchoredIndex: MaybeNumber;
cursor: MaybeString; cursor: MaybeString;
scrollTargetId: MaybeString;
entryMode: 'event' | 'single' | null; entryMode: 'event' | 'single' | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void; setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void; setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
setScrollTargetId: (id: EntryId | null) => void;
clearScrollTargetId: () => void;
clearSelectedEvents: () => void; clearSelectedEvents: () => void;
clearMultiSelect: () => void; clearMultiSelect: () => void;
unselect: (id: EntryId) => void; unselect: (id: EntryId) => void;
@@ -24,6 +27,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(), selectedEvents: new Set(),
anchoredIndex: null, anchoredIndex: null,
cursor: null, cursor: null,
scrollTargetId: null,
entryMode: null, entryMode: null,
setSingleEntrySelection: ({ id }) => { setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' }); set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
@@ -99,7 +103,10 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
}); });
} }
}, },
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }), setScrollTargetId: (id) => set({ scrollTargetId: id }),
clearScrollTargetId: () => set({ scrollTargetId: null }),
clearSelectedEvents: () =>
set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null, scrollTargetId: null }),
clearMultiSelect: () => { clearMultiSelect: () => {
const { selectedEvents } = get(); const { selectedEvents } = get();
const [firstSelected] = selectedEvents; const [firstSelected] = selectedEvents;
@@ -3,6 +3,8 @@ import { useDisclosure } from '@mantine/hooks';
import IconButton from '../../common/components/buttons/IconButton'; import IconButton from '../../common/components/buttons/IconButton';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { getIsNavigationLocked } from '../../externals'; import { getIsNavigationLocked } from '../../externals';
import CuesheetOverview from '../../features/overview/CuesheetOverview'; import CuesheetOverview from '../../features/overview/CuesheetOverview';
@@ -15,13 +17,14 @@ import styles from './CuesheetPage.module.scss';
export default function CuesheetPage() { export default function CuesheetPage() {
const [isMenuOpen, menuHandler] = useDisclosure(); const [isMenuOpen, menuHandler] = useDisclosure();
const entryActions = useEntryActions();
useWindowTitle('Cuesheet'); useWindowTitle('Cuesheet');
const isLocked = getIsNavigationLocked(); const isLocked = getIsNavigationLocked();
return ( return (
<> <EntryActionsProvider actions={entryActions}>
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} /> <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
<CuesheetEditModal /> <CuesheetEditModal />
<div className={styles.tableWrapper} data-testid='cuesheet'> <div className={styles.tableWrapper} data-testid='cuesheet'>
@@ -35,6 +38,6 @@ export default function CuesheetPage() {
<CuesheetProgress /> <CuesheetProgress />
<CuesheetTableWrapper /> <CuesheetTableWrapper />
</div> </div>
</> </EntryActionsProvider>
); );
} }
@@ -6,12 +6,13 @@ import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField
import EmptyPage from '../../../common/components/state/EmptyPage'; import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody'; import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useSelectedEventId } from '../../../common/hooks/useSocket'; import { useSelectedEventId } from '../../../common/hooks/useSocket';
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown'; import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options'; import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import EditorTableSettings from '../../../features/rundown/rundown-table/EditorTableSettings'; import EditorTableSettings from '../../../features/rundown/rundown-table/EditorTableSettings';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { usePersistedCuesheetOptions } from '../cuesheet.options'; import { usePersistedCuesheetOptions } from '../cuesheet.options';
@@ -35,7 +36,7 @@ interface CuesheetTableProps {
export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cuesheet' }: CuesheetTableProps) { export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cuesheet' }: CuesheetTableProps) {
const { data, status } = useFlatRundownWithMetadata(); const { data, status } = useFlatRundownWithMetadata();
const { updateEntry, updateTimer } = useEntryActions(); const { updateEntry, updateTimer } = useEntryActionsContext();
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions; const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
const showDelayedTimes = useOptions((state) => state.showDelayedTimes); const showDelayedTimes = useOptions((state) => state.showDelayedTimes);
@@ -43,6 +44,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const hideIndexColumn = useOptions((state) => state.hideIndexColumn); const hideIndexColumn = useOptions((state) => state.hideIndexColumn);
const selectedEventId = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const cursor = useEventSelection((state) => state.cursor);
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null); const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const { listeners } = useTableNav(); const { listeners } = useTableNav();
@@ -112,15 +114,22 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
setColumnSizing({}); setColumnSizing({});
}, [setColumnSizing]); }, [setColumnSizing]);
// in run mode, we follow the selected row // in edit mode, we follow the cursor (e.g. from finder)
useEffect(() => { useEffect(() => {
if (cuesheetMode === AppMode.Edit || virtuosoRef.current === null || !selectedEventId) { if (virtuosoRef.current === null) {
return; return;
} }
const eventIndex = data.findIndex((event) => event.id === selectedEventId); const targetId = cuesheetMode === AppMode.Edit ? cursor : selectedEventId;
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth' }); if (!targetId) {
}, [cuesheetMode, data, selectedEventId]); return;
}
const eventIndex = data.findIndex((event) => event.id === targetId);
if (eventIndex !== -1) {
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth', align: 'center' });
}
}, [cuesheetMode, data, selectedEventId, cursor]);
/** /**
* To improve performance on resizing, we memoise the column sizes * To improve performance on resizing, we memoise the column sizes
@@ -184,6 +193,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
const row = rows[rowIndex]; const row = rows[rowIndex];
const key = row.original.id; const key = row.original.id;
const entry = row.original; const entry = row.original;
const hasCursor = entry.id === cursor;
if (isOntimeGroup(entry)) { if (isOntimeGroup(entry)) {
return ( return (
@@ -195,6 +205,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
rowIndex={row.index} rowIndex={row.index}
table={table} table={table}
injectedStyles={injectedStyles} injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps} {...virtuosoProps}
/> />
); );
@@ -202,7 +213,13 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
if (isOntimeDelay(entry)) { if (isOntimeDelay(entry)) {
return ( return (
<DelayRow key={key} duration={entry.duration} injectedStyles={injectedStyles} {...virtuosoProps} /> <DelayRow
key={key}
duration={entry.duration}
injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps}
/>
); );
} }
@@ -219,6 +236,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
rowIndex={rowIndex} rowIndex={rowIndex}
table={table} table={table}
injectedStyles={injectedStyles} injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps} {...virtuosoProps}
/> />
); );
@@ -241,6 +259,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
rowIndex={rowIndex} rowIndex={rowIndex}
table={table} table={table}
injectedStyles={injectedStyles} injectedStyles={injectedStyles}
hasCursor={hasCursor}
{...virtuosoProps} {...virtuosoProps}
/> />
); );
@@ -5,6 +5,12 @@
color: $ontime-delay-text; color: $ontime-delay-text;
border-left: 4px solid transparent; border-left: 4px solid transparent;
&[data-cursor='true'] {
outline: 2px solid $blue-500;
outline-offset: -2px;
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
}
td { td {
width: calc(100% - 4px); width: calc(100% - 4px);
padding-block: 0.5rem; padding-block: 0.5rem;
@@ -8,9 +8,10 @@ import style from './DelayRow.module.scss';
interface DelayRowProps { interface DelayRowProps {
duration: number; duration: number;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean;
} }
function DelayRow({ duration, injectedStyles, ...virtuosoProps }: DelayRowProps) { function DelayRow({ duration, injectedStyles, hasCursor, ...virtuosoProps }: DelayRowProps) {
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays); const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
if (hideDelays || duration === 0) { if (hideDelays || duration === 0) {
@@ -29,7 +30,13 @@ function DelayRow({ duration, injectedStyles, ...virtuosoProps }: DelayRowProps)
const delayTime = millisToDelayString(duration, 'expanded'); const delayTime = millisToDelayString(duration, 'expanded');
return ( return (
<tr className={style.delayRow} data-testid='cuesheet-delay' style={injectedStyles} {...virtuosoProps}> <tr
className={style.delayRow}
data-testid='cuesheet-delay'
style={injectedStyles}
data-cursor={hasCursor}
{...virtuosoProps}
>
<td tabIndex={0}>{delayTime}</td> <td tabIndex={0}>{delayTime}</td>
</tr> </tr>
); );
@@ -12,6 +12,12 @@
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%); background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
} }
&[data-cursor='true'] {
outline: 2px solid $blue-500;
outline-offset: -2px;
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
}
&.firstAfterGroup { &.firstAfterGroup {
margin-top: 2rem; margin-top: 2rem;
} }
@@ -27,6 +27,7 @@ interface EventRowProps {
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry<OntimeEntry>>; table: Table<ExtendedEntry<OntimeEntry>>;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean;
} }
export default function EventRow({ export default function EventRow({
@@ -44,6 +45,7 @@ export default function EventRow({
rowIndex, rowIndex,
table, table,
injectedStyles, injectedStyles,
hasCursor,
...virtuosoProps ...virtuosoProps
}: EventRowProps) { }: EventRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
@@ -87,6 +89,7 @@ export default function EventRow({
opacity: `${isPast ? '0.2' : '1'}`, opacity: `${isPast ? '0.2' : '1'}`,
'--user-bg': groupColour ?? 'transparent', '--user-bg': groupColour ?? 'transparent',
}} }}
data-cursor={hasCursor}
data-testid='cuesheet-event' data-testid='cuesheet-event'
{...virtuosoProps} {...virtuosoProps}
> >
@@ -17,6 +17,12 @@
font-weight: bold; font-weight: bold;
&[data-cursor='true'] {
outline: 2px solid $blue-500;
outline-offset: -2px;
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
}
td { td {
min-height: 3.5rem; min-height: 3.5rem;
padding-top: 0.75rem !important; // fighting styles from cuesheet-table padding-top: 0.75rem !important; // fighting styles from cuesheet-table
@@ -17,6 +17,7 @@ interface GroupRowProps {
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry>; table: Table<ExtendedEntry>;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean;
} }
export default function GroupRow({ export default function GroupRow({
@@ -26,6 +27,7 @@ export default function GroupRow({
rowIndex, rowIndex,
table, table,
injectedStyles, injectedStyles,
hasCursor,
...virtuosoProps ...virtuosoProps
}: GroupRowProps) { }: GroupRowProps) {
const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? { const { cuesheetMode, hideIndexColumn } = table.options.meta?.options ?? {
@@ -40,6 +42,7 @@ export default function GroupRow({
className={style.groupRow} className={style.groupRow}
style={{ ...injectedStyles, '--user-bg': colour }} style={{ ...injectedStyles, '--user-bg': colour }}
data-testid='cuesheet-group' data-testid='cuesheet-group'
data-cursor={hasCursor}
{...virtuosoProps} {...virtuosoProps}
> >
{cuesheetMode === AppMode.Edit && ( {cuesheetMode === AppMode.Edit && (
@@ -13,6 +13,12 @@
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%); background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
} }
&[data-cursor='true'] {
outline: 2px solid $blue-500;
outline-offset: -2px;
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
}
td { td {
background-color: $gray-1250; background-color: $gray-1250;
border-radius: 2px; border-radius: 2px;
@@ -22,6 +22,7 @@ interface MilestoneRowProps {
rowIndex: number; rowIndex: number;
table: Table<ExtendedEntry>; table: Table<ExtendedEntry>;
injectedStyles?: CSSProperties; injectedStyles?: CSSProperties;
hasCursor?: boolean;
} }
export default function MilestoneRow({ export default function MilestoneRow({
@@ -32,6 +33,7 @@ export default function MilestoneRow({
colour, colour,
rowId, rowId,
rowIndex, rowIndex,
hasCursor,
table, table,
injectedStyles, injectedStyles,
...virtuosoProps ...virtuosoProps
@@ -64,6 +66,7 @@ export default function MilestoneRow({
'--user-bg': parentBgColour ?? 'transparent', '--user-bg': parentBgColour ?? 'transparent',
}} }}
data-testid='cuesheet-milestone' data-testid='cuesheet-milestone'
data-cursor={hasCursor}
{...virtuosoProps} {...virtuosoProps}
> >
{cuesheetMode === AppMode.Edit && ( {cuesheetMode === AppMode.Edit && (
@@ -3,7 +3,7 @@ import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash }
import { SupportedEntry } from 'ontime-types'; import { SupportedEntry } from 'ontime-types';
import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal'; import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
import { useCuesheetPermissions } from '../../useTablePermissions'; import { useCuesheetPermissions } from '../../useTablePermissions';
@@ -13,7 +13,7 @@ export default memo(CuesheetTableMenu);
function CuesheetTableMenu() { function CuesheetTableMenu() {
const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu(); const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu();
const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActions(); const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActionsContext();
const showModal = useCuesheetEditModal((state) => state.setEditableEntry); const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
const permissions = useCuesheetPermissions(); const permissions = useCuesheetPermissions();
@@ -3,7 +3,7 @@ import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash }
import { SupportedEntry } from 'ontime-types'; import { SupportedEntry } from 'ontime-types';
import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal'; import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
import { useCuesheetTableMenu } from './useCuesheetTableMenu'; import { useCuesheetTableMenu } from './useCuesheetTableMenu';
@@ -12,7 +12,7 @@ export default memo(EditorTableMenu);
function EditorTableMenu() { function EditorTableMenu() {
const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu(); const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu();
const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActions(); const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActionsContext();
const showModal = useCuesheetEditModal((state) => state.setEditableEntry); const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
if (!isOpen) { if (!isOpen) {
@@ -1,5 +1,5 @@
import { ReactNode, use } from 'react'; import { ReactNode, use } from 'react';
import { IoChevronDown, IoOptions, IoSettingsOutline } from 'react-icons/io5'; import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
import { Popover } from '@base-ui/react/popover'; import { Popover } from '@base-ui/react/popover';
import { Toggle } from '@base-ui/react/toggle'; import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group'; import { ToggleGroup } from '@base-ui/react/toggle-group';
@@ -100,7 +100,7 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
<Toolbar.Button <Toolbar.Button
render={ render={
<Button variant='ghosted-white'> <Button variant='ghosted-white'>
<IoSettingsOutline /> Settings <IoOptions /> Settings
<IoChevronDown /> <IoChevronDown />
</Button> </Button>
} }
@@ -162,7 +162,7 @@ export function ColumnSettings({
<Toolbar.Button <Toolbar.Button
render={ render={
<Button variant='ghosted-white'> <Button variant='ghosted-white'>
<IoOptions /> View <IoBookOutline /> Columns
<IoChevronDown /> <IoChevronDown />
</Button> </Button>
} }
@@ -183,7 +183,6 @@ export function ColumnSettings({
); );
})} })}
</div> </div>
<Editor.Separator orientation='vertical' />
<div className={style.column}> <div className={style.column}>
<Editor.Label className={style.sectionTitle}>Reset Options</Editor.Label> <Editor.Label className={style.sectionTitle}>Reset Options</Editor.Label>
<Button size='small' fluid onClick={handleClearToggles}> <Button size='small' fluid onClick={handleClearToggles}>
@@ -0,0 +1,67 @@
.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;
}
@@ -0,0 +1,50 @@
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,6 +13,7 @@ import EditorOverview from '../../features/overview/EditorOverview';
import WelcomePlacement from './welcome/WelcomePlacement'; import WelcomePlacement from './welcome/WelcomePlacement';
import Editor from './Editor'; import Editor from './Editor';
import EditorViewOptions from './EditorViewOptions';
import styles from './ProtectedEditor.module.scss'; import styles from './ProtectedEditor.module.scss';
@@ -49,6 +50,7 @@ export default function ProtectedEditor() {
<div className={styles.mainContainer} data-testid='event-editor'> <div className={styles.mainContainer} data-testid='event-editor'>
<WelcomePlacement /> <WelcomePlacement />
<NavigationMenu isOpen={isOpen} onClose={handler.close} /> <NavigationMenu isOpen={isOpen} onClose={handler.close} />
{isSettingsOpen ? <AppSettings /> : <Editor />}
<EditorOverview> <EditorOverview>
<IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={handler.open}> <IconButton aria-label='Toggle navigation' variant='subtle-white' size='xlarge' onClick={handler.open}>
<IoApps /> <IoApps />
@@ -56,8 +58,8 @@ export default function ProtectedEditor() {
<IconButton aria-label='Toggle settings' variant='subtle-white' size='xlarge' onClick={toggleSettings}> <IconButton aria-label='Toggle settings' variant='subtle-white' size='xlarge' onClick={toggleSettings}>
{isSettingsOpen ? <IoClose /> : <IoSettingsOutline />} {isSettingsOpen ? <IoClose /> : <IoSettingsOutline />}
</IconButton> </IconButton>
<EditorViewOptions />
</EditorOverview> </EditorOverview>
{isSettingsOpen ? <AppSettings /> : <Editor />}
</div> </div>
</ProtectRoute> </ProtectRoute>
); );
@@ -45,6 +45,7 @@ export default function useFinder() {
const lastSearchString = useRef(''); const lastSearchString = useRef('');
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents); const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const setScrollTargetId = useEventSelection((state) => state.setScrollTargetId);
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({ const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown // we ensure that this is unique to the rundown
@@ -234,8 +235,9 @@ export default function useFinder() {
// Then select the event // Then select the event
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' }); setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
setScrollTargetId(selectedEvent.id);
}, },
[collapsedGroups, setCollapsedGroups, setSelectedEvents], [collapsedGroups, setCollapsedGroups, setSelectedEvents, setScrollTargetId],
); );
/** clear results when source data changes */ /** clear results when source data changes */
+2 -1
View File
@@ -18,7 +18,8 @@ test('project file upload', async ({ page }) => {
} }
await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'toggle settings' }).click(); await page.getByRole('button', { name: 'toggle settings' }).click();
+4 -2
View File
@@ -5,7 +5,8 @@ test('delays add time to events', async ({ page }) => {
// delete all events and add a new one // delete all events and add a new one
await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create event' }).click(); await page.getByRole('button', { name: 'Create event' }).click();
@@ -53,7 +54,8 @@ test('delays are show correctly', async ({ page }) => {
// add a test event // add a test event
await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByRole('button', { name: 'Create Event' }).click();
+2 -1
View File
@@ -4,7 +4,8 @@ test('CRUD operations on the rundown', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0); await expect(page.getByTestId('rundown-event')).toHaveCount(0);
await expect(page.getByTestId('rundown-delay')).toHaveCount(0); await expect(page.getByTestId('rundown-delay')).toHaveCount(0);
+2 -1
View File
@@ -4,7 +4,8 @@ test('smoke test operator', async ({ page }) => {
// make some boilerplate // make some boilerplate
await page.goto('/editor'); await page.goto('/editor');
await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Group' }).click(); await page.getByRole('button', { name: 'Create Group' }).click();
await page.getByTestId('rundown-group').getByTestId('entry__title').click(); await page.getByTestId('rundown-group').getByTestId('entry__title').click();
+2 -1
View File
@@ -121,7 +121,8 @@ test.describe('Sharing from cuesheet', () => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
// we create some elements to test with // we create some elements to test with
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('entry-1').getByTestId('entry__title').click(); await page.getByTestId('entry-1').getByTestId('entry__title').click();
@@ -4,7 +4,8 @@ test('Copy-paste', async ({ page }) => {
await page.goto('http://localhost:4001/rundown'); await page.goto('http://localhost:4001/rundown');
// clear rundown // clear rundown
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
// create event // create event
@@ -35,7 +36,8 @@ test('Move', async ({ page }) => {
await page.goto('http://localhost:4001/rundown'); await page.goto('http://localhost:4001/rundown');
// clear rundown // clear rundown
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
// create events // create events
@@ -63,7 +65,8 @@ test('Add group', async ({ page }) => {
await page.goto('http://localhost:4001/rundown'); await page.goto('http://localhost:4001/rundown');
// clear rundown // clear rundown
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0); await expect(page.getByTestId('rundown-event')).toHaveCount(0);
await expect(page.getByTestId('rundown-group')).toHaveCount(0); await expect(page.getByTestId('rundown-group')).toHaveCount(0);
@@ -96,7 +99,8 @@ test('Add delay', async ({ page }) => {
await page.goto('http://localhost:4001/rundown'); await page.goto('http://localhost:4001/rundown');
// clear rundown // clear rundown
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0); await expect(page.getByTestId('rundown-event')).toHaveCount(0);
await expect(page.getByTestId('rundown-delay')).toHaveCount(0); await expect(page.getByTestId('rundown-delay')).toHaveCount(0);
@@ -125,7 +129,8 @@ test('Add event', async ({ page }) => {
await page.goto('http://localhost:4001/rundown'); await page.goto('http://localhost:4001/rundown');
// clear rundown // clear rundown
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0); await expect(page.getByTestId('rundown-event')).toHaveCount(0);
@@ -151,7 +156,8 @@ test('Delete event', async ({ page }) => {
// clear rundown // clear rundown
await page.goto('http://localhost:4001/rundown'); await page.goto('http://localhost:4001/rundown');
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await expect(page.getByTestId('rundown-event')).toHaveCount(0); await expect(page.getByTestId('rundown-event')).toHaveCount(0);
+4 -2
View File
@@ -3,7 +3,8 @@ import { expect, test } from '@playwright/test';
test('show warning when event crosses midnight', async ({ page }) => { test('show warning when event crosses midnight', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByRole('button', { name: 'Create Event' }).click();
@@ -23,7 +24,8 @@ test('show warning when event crosses midnight', async ({ page }) => {
test('show warning when event starts next day midnight', async ({ page }) => { test('show warning when event starts next day midnight', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByRole('button', { name: 'Create Event' }).click();
@@ -9,6 +9,7 @@ test('time until absolute', async ({ context }) => {
await op.goto('/op'); await op.goto('/op');
await timeline.goto('/timeline'); await timeline.goto('/timeline');
await editor.getByRole('button', { name: 'Rundown menu' }).click();
await editor.getByRole('button', { name: 'Clear all' }).click(); await editor.getByRole('button', { name: 'Clear all' }).click();
await editor.getByRole('button', { name: 'Delete all' }).click(); await editor.getByRole('button', { name: 'Delete all' }).click();
@@ -129,6 +130,7 @@ test('time until relative', async ({ context }) => {
const editor = await context.newPage(); const editor = await context.newPage();
editor.goto('http://localhost:4001/editor'); editor.goto('http://localhost:4001/editor');
await editor.getByRole('button', { name: 'Rundown menu' }).click();
await editor.getByRole('button', { name: 'Clear all' }).click(); await editor.getByRole('button', { name: 'Clear all' }).click();
await editor.getByRole('button', { name: 'Delete all' }).click(); await editor.getByRole('button', { name: 'Delete all' }).click();
@@ -4,7 +4,8 @@ test('Rearrange while playing', async ({ page }) => {
await page.goto('http://localhost:4001/rundown'); await page.goto('http://localhost:4001/rundown');
// clear rundown // clear rundown
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
// create events // create events
@@ -34,7 +35,8 @@ test('Rearrange while playing', async ({ page }) => {
test('flag and unflag an event while playing', async ({ page }) => { test('flag and unflag an event while playing', async ({ page }) => {
await page.goto('http://localhost:4001/editor/'); await page.goto('http://localhost:4001/editor/');
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByRole('button', { name: 'Event' }).nth(4).click(); await page.getByRole('button', { name: 'Event' }).nth(4).click();
@@ -5,7 +5,8 @@ const fileToUpload = 'e2e/tests/fixtures/test-sheet.xlsx';
test('sheet file upload', async ({ page }) => { test('sheet file upload', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Edit' }).click(); await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear all' }).click(); await page.getByRole('button', { name: 'Rundown menu' }).click();
await page.getByRole('menuitem', { name: 'Clear all' }).click();
await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Toggle settings' }).click(); await page.getByRole('button', { name: 'Toggle settings' }).click();