mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 19:03:47 +00:00
feat: editor layout
This commit is contained in:
committed by
Carlos Valente
parent
967e92e2c8
commit
da02ecd113
@@ -9,7 +9,7 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { getIsNavigationLocked } from '../../externals';
|
||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||
|
||||
import CuesheetEditModal from './cuesheet-edit-modal/CuesheetEditModal';
|
||||
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function CuesheetPage() {
|
||||
return (
|
||||
<EntryActionsProvider actions={entryActions}>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
<CuesheetEditModal />
|
||||
<EntryEditModal />
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetOverview>
|
||||
{!isLocked && (
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||
|
||||
import { useCuesheetEditModal } from './useCuesheetEditModal';
|
||||
|
||||
export default memo(CuesheetEditModal);
|
||||
function CuesheetEditModal() {
|
||||
const entryId = useCuesheetEditModal((state) => state.selectedEntryId);
|
||||
const closeModal = useCuesheetEditModal((state) => state.clearSelection);
|
||||
|
||||
if (entryId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={closeModal}
|
||||
title='Edit entry'
|
||||
showCloseButton
|
||||
bodyElements={<CuesheetEntryEditor entryId={entryId} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+5
-5
@@ -3,12 +3,12 @@ import { memo } from 'react';
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||
|
||||
import { useEditorEditModal } from './useEditorEditModal';
|
||||
import { useEditModal } from './useEditModal';
|
||||
|
||||
export default memo(EditorEditModal);
|
||||
function EditorEditModal() {
|
||||
const entryId = useEditorEditModal((state) => state.selectedEntryId);
|
||||
const closeModal = useEditorEditModal((state) => state.clearSelection);
|
||||
export default memo(EntryEditModal);
|
||||
function EntryEditModal() {
|
||||
const entryId = useEditModal((state) => state.selectedEntryId);
|
||||
const closeModal = useEditModal((state) => state.clearSelection);
|
||||
|
||||
if (entryId === null) {
|
||||
return null;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { EntryId } from 'ontime-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface SelectedEntryState {
|
||||
selectedEntryId: EntryId | null;
|
||||
setEditableEntry: (entryId: EntryId) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
export const useCuesheetEditModal = create<SelectedEntryState>((set) => ({
|
||||
selectedEntryId: null,
|
||||
setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }),
|
||||
clearSelection: () => set({ selectedEntryId: null }),
|
||||
}));
|
||||
+1
-1
@@ -7,7 +7,7 @@ interface SelectedEntryState {
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
export const useEditorEditModal = create<SelectedEntryState>((set) => ({
|
||||
export const useEditModal = create<SelectedEntryState>((set) => ({
|
||||
selectedEntryId: null,
|
||||
setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }),
|
||||
clearSelection: () => set({ selectedEntryId: null }),
|
||||
@@ -11,6 +11,11 @@ $table-header-font-size: calc(1rem - 2px);
|
||||
color: $ui-white;
|
||||
padding-bottom: 70vh; // allow focus to reach last elements
|
||||
|
||||
:is([data-target='small-device']) & {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
thead {
|
||||
tr {
|
||||
&::before {
|
||||
|
||||
@@ -21,8 +21,7 @@ import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||
import EventRow from './cuesheet-table-elements/EventRow';
|
||||
import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
|
||||
import EditorTableMenu from './cuesheet-table-menu/EditorTableMenu';
|
||||
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||
|
||||
@@ -45,7 +44,6 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
||||
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const cursor = useEventSelection((state) => state.cursor);
|
||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
||||
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
||||
|
||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||
@@ -116,18 +114,23 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
||||
setColumnSizing({});
|
||||
}, [setColumnSizing]);
|
||||
|
||||
// Auto-scroll only in run mode, routed through the shared scroll handler
|
||||
// in Run mode, follow the current event
|
||||
useEffect(() => {
|
||||
if (cuesheetMode !== AppMode.Run || !selectedEventId) {
|
||||
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollToEntry(selectedEventId);
|
||||
}, [cuesheetMode, data, selectedEventId, scrollToEntry]);
|
||||
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
|
||||
if (eventIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||
}, [cuesheetMode, data, selectedEventId]);
|
||||
|
||||
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
||||
useEffect(() => {
|
||||
setScrollHandler(`cuesheet-table-${tableRoot}`, (entryId) => {
|
||||
const handler = (entryId: string) => {
|
||||
if (virtuosoRef.current === null) {
|
||||
return;
|
||||
}
|
||||
@@ -137,13 +140,15 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
||||
return;
|
||||
}
|
||||
|
||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth', align: 'start', offset: -50 });
|
||||
});
|
||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||
};
|
||||
|
||||
setScrollHandler(handler);
|
||||
|
||||
return () => {
|
||||
setScrollHandler(`cuesheet-table-${tableRoot}`, null);
|
||||
setScrollHandler(null);
|
||||
};
|
||||
}, [data, setScrollHandler, tableRoot]);
|
||||
}, [data, setScrollHandler]);
|
||||
|
||||
/**
|
||||
* To improve performance on resizing, we memoise the column sizes
|
||||
@@ -174,7 +179,6 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
||||
|
||||
// control components need different implementations for handling permissions
|
||||
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
|
||||
const TableMenu = tableRoot === 'editor' ? EditorTableMenu : CuesheetTableMenu;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -187,6 +191,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
|
||||
increaseViewportBy={{ top: 100, bottom: 200 }}
|
||||
components={{
|
||||
EmptyPlaceholder: () => <EmptyTableBody text='No data in rundown' />,
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
|
||||
import { SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
|
||||
|
||||
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
||||
|
||||
export default memo(EditorTableMenu);
|
||||
|
||||
function EditorTableMenu() {
|
||||
const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu();
|
||||
const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActionsContext();
|
||||
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PositionedDropdownMenu
|
||||
isOpen
|
||||
onClose={closeMenu}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Edit...',
|
||||
onClick: () => showModal(entryId),
|
||||
icon: IoOptions,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: flag ? 'Remove flag' : 'Add flag',
|
||||
onClick: () => updateEntry({ id: entryId, flag: !flag }),
|
||||
icon: IoDuplicateOutline,
|
||||
disabled: flag === null,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add event above',
|
||||
onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { before: entryId }),
|
||||
icon: IoAdd,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Add event below',
|
||||
onClick: () => addEntry({ type: SupportedEntry.Event, parent: parentId }, { after: entryId }),
|
||||
icon: IoAdd,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Clone event',
|
||||
onClick: () => clone(entryId),
|
||||
icon: IoDuplicateOutline,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Move up',
|
||||
onClick: () => move(entryId, 'up'),
|
||||
icon: IoArrowUp,
|
||||
disabled: entryIndex < 1,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Move down',
|
||||
onClick: () => move(entryId, 'down'),
|
||||
icon: IoArrowDown,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
onClick: () => deleteEntry([entryId]),
|
||||
icon: IoTrash,
|
||||
},
|
||||
]}
|
||||
position={position}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -4,17 +4,17 @@ import { SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { PositionedDropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||
import { useCuesheetEditModal } from '../../cuesheet-edit-modal/useCuesheetEditModal';
|
||||
import { useEditModal } from '../../cuesheet-edit-modal/useEditModal';
|
||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||
|
||||
import { useCuesheetTableMenu } from './useCuesheetTableMenu';
|
||||
|
||||
export default memo(CuesheetTableMenu);
|
||||
export default memo(TableMenu);
|
||||
|
||||
function CuesheetTableMenu() {
|
||||
function TableMenu() {
|
||||
const { isOpen, entryId, entryIndex, parentId, flag, position, closeMenu } = useCuesheetTableMenu();
|
||||
const { addEntry, clone, deleteEntry, move, updateEntry } = useEntryActionsContext();
|
||||
const showModal = useCuesheetEditModal((state) => state.setEditableEntry);
|
||||
const showModal = useEditModal((state) => state.setEditableEntry);
|
||||
const permissions = useCuesheetPermissions();
|
||||
|
||||
if (!isOpen) {
|
||||
@@ -7,6 +7,13 @@ $max-playback-width: 30rem;
|
||||
display: flex;
|
||||
gap: $panel-gap;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panelContainerTracking {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left {
|
||||
@@ -17,3 +24,29 @@ $max-playback-width: 30rem;
|
||||
flex-direction: column;
|
||||
gap: $panel-gap;
|
||||
}
|
||||
|
||||
.rundownLayout {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
gap: $panel-gap;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.rundownPanel {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.titlesPanel {
|
||||
// align rundown start with the + - buttons in the playback controls
|
||||
// start + iconbtn + iconbt + 2 x gap
|
||||
$overview-width: calc(8rem + 5rem + 5rem + 2rem);
|
||||
|
||||
flex: 0 0 $overview-width;
|
||||
min-width: $overview-width;
|
||||
max-width: $overview-width;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar';
|
||||
import { AppMode } from '../../ontimeConfig';
|
||||
|
||||
import TitleList from './title-list/TitleList';
|
||||
import { EditorLayoutMode, useEditorLayout } from './useEditorLayout';
|
||||
|
||||
import styles from './Editor.module.scss';
|
||||
|
||||
const Rundown = lazy(() => import('../../features/rundown/RundownExport'));
|
||||
@@ -7,13 +13,46 @@ const TimerControl = lazy(() => import('../../features/control/playback/TimerCon
|
||||
const MessageControl = lazy(() => import('../../features/control/message/MessageControlExport'));
|
||||
|
||||
export default function Editor() {
|
||||
const { layoutMode } = useEditorLayout();
|
||||
|
||||
if (layoutMode === EditorLayoutMode.CONTROL) {
|
||||
return (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
<div className={styles.left}>
|
||||
<TimerControl />
|
||||
<MessageControl />
|
||||
</div>
|
||||
<Rundown />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (layoutMode === EditorLayoutMode.TRACKING) {
|
||||
return (
|
||||
<div id='panels' className={`${styles.panelContainer} ${styles.panelContainerTracking}`}>
|
||||
<div className={styles.rundownLayout}>
|
||||
<div className={styles.titlesPanel}>
|
||||
<TitleList mode={AppMode.Run} />
|
||||
</div>
|
||||
<div className={styles.rundownPanel}>
|
||||
<Rundown />
|
||||
</div>
|
||||
</div>
|
||||
<TrackingPlaybackBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div id='panels' className={styles.panelContainer}>
|
||||
<div className={styles.left}>
|
||||
<TimerControl />
|
||||
<MessageControl />
|
||||
<div className={styles.rundownLayout}>
|
||||
<div className={styles.titlesPanel}>
|
||||
<TitleList mode={AppMode.Edit} />
|
||||
</div>
|
||||
<div className={styles.rundownPanel}>
|
||||
<Rundown />
|
||||
</div>
|
||||
</div>
|
||||
<Rundown />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { LuLayoutDashboard } from 'react-icons/lu';
|
||||
|
||||
import IconButton from '../../common/components/buttons/IconButton';
|
||||
import { DropdownMenu, DropdownMenuOption } from '../../common/components/dropdown-menu/DropdownMenu';
|
||||
|
||||
import { EditorLayoutMode, useEditorLayout } from './useEditorLayout';
|
||||
|
||||
export default memo(EditorLayoutOptions);
|
||||
function EditorLayoutOptions() {
|
||||
const { layoutMode, setLayoutMode } = useEditorLayout();
|
||||
|
||||
const items = useMemo<DropdownMenuOption[]>(
|
||||
() => [
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Planning',
|
||||
description: 'Edit-focused list with planning stats',
|
||||
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
|
||||
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Tracking',
|
||||
description: 'Live timing view with progress and offsets',
|
||||
icon: layoutMode === EditorLayoutMode.TRACKING ? IoCheckmark : undefined,
|
||||
onClick: () => setLayoutMode(EditorLayoutMode.TRACKING),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Control',
|
||||
description: 'All controls and rundown together',
|
||||
icon: layoutMode === EditorLayoutMode.CONTROL ? IoCheckmark : undefined,
|
||||
onClick: () => setLayoutMode(EditorLayoutMode.CONTROL),
|
||||
},
|
||||
],
|
||||
[layoutMode, setLayoutMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu render={<IconButton aria-label='Layout mode' variant='subtle-white' size='xlarge' />} items={items}>
|
||||
<LuLayoutDashboard />
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
.group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-inline: 2px;
|
||||
background: $gray-1100;
|
||||
border-radius: $component-border-radius-md;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.radioButton {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: calc(2rem - 4px); // padding inline * 2
|
||||
padding-inline: 1em;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $component-border-radius-md;
|
||||
color: $gray-400;
|
||||
font-size: calc(1rem - 2px);
|
||||
font-weight: 600;
|
||||
|
||||
&:focus-visible {
|
||||
background: transparent;
|
||||
outline: 1px solid $blue-500;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $gray-1100;
|
||||
}
|
||||
|
||||
&[data-pressed] {
|
||||
background: $blue-700;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $blue-600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.popoverContent {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: $gray-400;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { LuLayoutDashboard } from 'react-icons/lu';
|
||||
import { Popover } from '@base-ui/react/popover';
|
||||
import { Toggle } from '@base-ui/react/toggle';
|
||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||
import { OffsetMode } from 'ontime-types';
|
||||
|
||||
import IconButton from '../../common/components/buttons/IconButton';
|
||||
import * as Editor from '../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../common/components/popover/Popover';
|
||||
import { setOffsetMode, useOffsetMode } from '../../common/hooks/useSocket';
|
||||
|
||||
import style from './EditorViewOptions.module.scss';
|
||||
|
||||
export default memo(EditorViewOptions);
|
||||
|
||||
function EditorViewOptions() {
|
||||
const offsetMode = useOffsetMode();
|
||||
|
||||
const toggleOffsetMode = (mode: OffsetMode[]) => {
|
||||
const newValue = mode.at(0);
|
||||
if (!newValue) return;
|
||||
setOffsetMode(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<IconButton aria-label='View Options' variant='subtle-white' size='xlarge'>
|
||||
<LuLayoutDashboard />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<PopoverContents title='View Options' className={style.popoverContent} align='end'>
|
||||
<div className={style.column}>
|
||||
<Editor.Label className={style.sectionTitle}>Offset Mode</Editor.Label>
|
||||
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
|
||||
<Toggle value={OffsetMode.Absolute} className={style.radioButton}>
|
||||
Absolute
|
||||
</Toggle>
|
||||
<Toggle value={OffsetMode.Relative} className={style.radioButton}>
|
||||
Relative
|
||||
</Toggle>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</PopoverContents>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import EditorOverview from '../../features/overview/EditorOverview';
|
||||
|
||||
import WelcomePlacement from './welcome/WelcomePlacement';
|
||||
import Editor from './Editor';
|
||||
import EditorViewOptions from './EditorViewOptions';
|
||||
import EditorLayoutOptions from './EditorLayoutOptions';
|
||||
|
||||
import styles from './ProtectedEditor.module.scss';
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function ProtectedEditor() {
|
||||
<IconButton aria-label='Toggle settings' variant='subtle-white' size='xlarge' onClick={toggleSettings}>
|
||||
{isSettingsOpen ? <IoClose /> : <IoSettingsOutline />}
|
||||
</IconButton>
|
||||
<EditorViewOptions />
|
||||
<EditorLayoutOptions />
|
||||
</EditorOverview>
|
||||
</div>
|
||||
</ProtectRoute>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { EntryId, isOntimeEvent, isOntimeGroup, isOntimeMilestone, MaybeString, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { useFlatRundown } from '../../../common/hooks-query/useRundown';
|
||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
||||
|
||||
const maxResults = 12;
|
||||
|
||||
@@ -44,14 +43,7 @@ export default function useFinder() {
|
||||
const [error, setError] = useState<MaybeString>(null);
|
||||
const lastSearchString = useRef('');
|
||||
|
||||
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
|
||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
||||
|
||||
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
|
||||
// we ensure that this is unique to the rundown
|
||||
key: `rundown.${rundownId}-editor-collapsed-groups`,
|
||||
defaultValue: [],
|
||||
});
|
||||
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
||||
|
||||
/** Filters the rundown to a given evaluation */
|
||||
const find = useCallback(
|
||||
@@ -224,20 +216,13 @@ export default function useFinder() {
|
||||
|
||||
const select = useCallback(
|
||||
(selectedEvent: FilterableEntry) => {
|
||||
// First expand the parent group if this is an event inside a group
|
||||
if ('parent' in selectedEvent && selectedEvent.parent !== null) {
|
||||
// Try direct state update instead of using callback
|
||||
const currentGroups = [...new Set(collapsedGroups)];
|
||||
const newGroups = currentGroups.filter((id) => id !== selectedEvent.parent);
|
||||
// Force a direct update
|
||||
setCollapsedGroups(newGroups);
|
||||
}
|
||||
|
||||
// Then select the event
|
||||
setSelectedEvents({ id: selectedEvent.id, index: selectedEvent.index, selectMode: 'click' });
|
||||
scrollToEntry(selectedEvent.id);
|
||||
selectAndRevealEntry({
|
||||
id: selectedEvent.id,
|
||||
index: selectedEvent.index,
|
||||
parent: 'parent' in selectedEvent ? selectedEvent.parent : null,
|
||||
});
|
||||
},
|
||||
[collapsedGroups, setCollapsedGroups, setSelectedEvents, scrollToEntry],
|
||||
[selectAndRevealEntry],
|
||||
);
|
||||
|
||||
/** clear results when source data changes */
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: $bg-container-l1;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bottomSpacer {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
height: 70vh;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
|
||||
import ScrollArea from '../../../common/components/scroll-area/ScrollArea';
|
||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import { ExtendedEntry, getFlatRundownMetadata } from '../../../common/utils/rundownMetadata';
|
||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||
import { useSelectAndRevealEntry } from '../../../features/rundown/useSelectAndRevealEntry';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
|
||||
import { getCurrentEventInfo } from './titleList.utils';
|
||||
import TitleListEmpty from './TitleListEmpty';
|
||||
import TitleListItem from './TitleListItem';
|
||||
|
||||
import style from './TitleList.module.scss';
|
||||
|
||||
interface TitleListProps {
|
||||
mode: AppMode;
|
||||
}
|
||||
|
||||
export default function TitleList({ mode }: TitleListProps) {
|
||||
const { data: rundown } = useRundown();
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const cursor = useEventSelection((state) => state.cursor);
|
||||
|
||||
// In Run mode, follow the currently playing event
|
||||
// In Edit mode, follow the user's selection
|
||||
const resolvedFollowEventId = useMemo(() => {
|
||||
return mode === AppMode.Run ? selectedEventId : cursor;
|
||||
}, [mode, selectedEventId, cursor]);
|
||||
|
||||
// Filter and memoize event-only data
|
||||
const eventData = useMemo(() => {
|
||||
const flatData = getFlatRundownMetadata(rundown, resolvedFollowEventId);
|
||||
return flatData.filter(isOntimeEvent) as ExtendedEntry<OntimeEvent>[];
|
||||
}, [rundown, resolvedFollowEventId]);
|
||||
|
||||
if (eventData.length === 0) {
|
||||
return <TitleListEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TitleListContent
|
||||
mode={mode}
|
||||
eventData={eventData}
|
||||
selectedEventId={selectedEventId}
|
||||
resolvedFollowEventId={resolvedFollowEventId}
|
||||
rundownId={rundown.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface TitleListContentProps {
|
||||
mode: AppMode;
|
||||
eventData: ExtendedEntry<OntimeEvent>[];
|
||||
selectedEventId: string | null;
|
||||
resolvedFollowEventId: string | null;
|
||||
rundownId: string;
|
||||
}
|
||||
|
||||
function TitleListContent({
|
||||
mode,
|
||||
eventData,
|
||||
selectedEventId,
|
||||
resolvedFollowEventId,
|
||||
rundownId,
|
||||
}: TitleListContentProps) {
|
||||
'use memo';
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||
const scrollParentRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectAndRevealEntry = useSelectAndRevealEntry(rundownId);
|
||||
|
||||
// Calculate current event info
|
||||
const currentEventInfo = useMemo(() => {
|
||||
return getCurrentEventInfo(eventData);
|
||||
}, [eventData]);
|
||||
|
||||
const followIndex = useMemo(() => {
|
||||
if (!resolvedFollowEventId) return -1;
|
||||
return eventData.findIndex((entry) => entry.id === resolvedFollowEventId);
|
||||
}, [eventData, resolvedFollowEventId]);
|
||||
|
||||
// Stable selection handler
|
||||
const handleSelect = useCallback(
|
||||
(params: { id: string; index: number; parent?: string }) => {
|
||||
selectAndRevealEntry(params);
|
||||
},
|
||||
[selectAndRevealEntry],
|
||||
);
|
||||
|
||||
// Auto-scroll to keep current item at sticky position using Virtuoso
|
||||
useEffect(() => {
|
||||
if (!virtuosoRef.current) return;
|
||||
|
||||
const indexToFollow = followIndex !== -1 ? followIndex : currentEventInfo.index;
|
||||
if (indexToFollow === -1) return;
|
||||
|
||||
// In Run mode, always scroll to follow
|
||||
// In Edit mode, only scroll if beyond sticky position (3)
|
||||
if (mode === AppMode.Edit && indexToFollow <= 3) return;
|
||||
|
||||
virtuosoRef.current.scrollToIndex({
|
||||
index: indexToFollow,
|
||||
align: 'start',
|
||||
behavior: 'smooth',
|
||||
offset: -50,
|
||||
});
|
||||
}, [currentEventInfo.index, followIndex, mode]);
|
||||
|
||||
// Virtuoso item renderer
|
||||
const itemContent = useCallback(
|
||||
(index: number, entry: ExtendedEntry<OntimeEvent>) => {
|
||||
const nextEntry = eventData[index + 1];
|
||||
const isGroupEnd = Boolean(entry.parent) && entry.parent !== (nextEntry?.parent ?? null);
|
||||
return (
|
||||
<TitleListItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
currentEventIndex={currentEventInfo.eventIndex}
|
||||
upcomingChipLimit={currentEventInfo.upcomingChipLimit}
|
||||
isRunning={mode === AppMode.Run && selectedEventId !== null}
|
||||
mode={mode}
|
||||
onSelect={handleSelect}
|
||||
isGroupEnd={isGroupEnd}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[eventData, currentEventInfo.eventIndex, currentEventInfo.upcomingChipLimit, mode, selectedEventId, handleSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className={style.container} ref={scrollParentRef}>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={eventData}
|
||||
computeItemKey={(_index, entry) => entry.id}
|
||||
itemContent={itemContent}
|
||||
increaseViewportBy={{ top: 200, bottom: 200 }}
|
||||
customScrollParent={scrollParentRef.current ?? undefined}
|
||||
components={{
|
||||
List: VirtuosoListComponent,
|
||||
Footer: TitleListFooter,
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
// Virtuoso list component - extracted to prevent recreation on every render
|
||||
function VirtuosoListComponent({ children, ...props }: React.HTMLAttributes<HTMLUListElement>) {
|
||||
return (
|
||||
<ul {...props} className={style.list}>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
VirtuosoListComponent.displayName = 'VirtuosoListComponent';
|
||||
|
||||
function TitleListFooter() {
|
||||
return <div className={style.bottomSpacer} />;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use '../../../theme/ontimeStyles' as *;
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
background: $bg-container-l1;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.emptyMessage {
|
||||
width: min(320px, 100%);
|
||||
text-align: center;
|
||||
color: rgba($gray-200, 0.55);
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: calc(1rem + 2px);
|
||||
font-weight: 400;
|
||||
color: rgba($gray-200, 0.72);
|
||||
}
|
||||
|
||||
.emptyBody {
|
||||
margin: 0;
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import style from './TitleListEmpty.module.scss';
|
||||
|
||||
export default function TitleListEmpty() {
|
||||
return (
|
||||
<div className={style.container}>
|
||||
<div className={style.emptyState}>
|
||||
<div className={style.emptyMessage}>
|
||||
<h3 className={style.emptyTitle}>No events yet</h3>
|
||||
<p className={style.emptyBody}>Add events in the rundown to populate this list.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
@use '../../../theme/ontimeColours' as *;
|
||||
@use '../../../theme/ontimeStyles' as *;
|
||||
|
||||
$font-size-current: calc(1rem + 2px);
|
||||
$font-size-next: 1rem;
|
||||
$font-size-default: calc(1rem - 2px);
|
||||
|
||||
.item {
|
||||
--color-bar-gap: 0.5rem;
|
||||
--color-bar-width: 4px;
|
||||
// color bar + gap x 2
|
||||
--title-left: calc(4px + 0.5rem + 0.5rem);
|
||||
--indicator-width: 2rem;
|
||||
--chip-width: 3.25rem;
|
||||
--chip-padding: 0.5rem;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 2.5rem;
|
||||
gap: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: $white-3;
|
||||
border-radius: $component-border-radius-md;
|
||||
|
||||
.title {
|
||||
color: $ui-white;
|
||||
font-size: $font-size-current;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state='past'] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&[data-state='running'],
|
||||
&[data-state='selected'] {
|
||||
gap: 0.25rem;
|
||||
background: $white-7;
|
||||
border-radius: $component-border-radius-md;
|
||||
|
||||
.title {
|
||||
color: $ui-white;
|
||||
font-weight: 500;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: calc(var(--chip-width) + var(--chip-padding));
|
||||
font-size: $font-size-current;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state='running'] {
|
||||
background: color-mix(in srgb, $playback-start 22%, $bg-container-l1);
|
||||
|
||||
.title {
|
||||
color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state='next'] {
|
||||
.title {
|
||||
color: $ui-white;
|
||||
font-size: $font-size-next;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-skipped] {
|
||||
opacity: 0.3;
|
||||
|
||||
.title {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-group-end='true'] {
|
||||
margin-bottom: $panel-gap;
|
||||
}
|
||||
}
|
||||
|
||||
.colourBar {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-block: 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: calc(var(--chip-width) + var(--chip-padding));
|
||||
|
||||
color: $gray-400;
|
||||
font-size: $font-size-default;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.chipSlot {
|
||||
position: absolute;
|
||||
right: var(--chip-padding);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: var(--chip-width);
|
||||
|
||||
&[data-hover-only] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.item:hover .chipSlot[data-hover-only] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.chipText {
|
||||
white-space: nowrap;
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
|
||||
&[data-chip-status='live'] {
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
&[data-chip-status='due'] {
|
||||
color: $warning-orange;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { memo, useCallback } from 'react';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import { formatDuration, useTimeUntilExpectedStart } from '../../../common/utils/time';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
|
||||
import style from './TitleListItem.module.scss';
|
||||
|
||||
interface TitleListItemProps {
|
||||
entry: ExtendedEntry<OntimeEvent>;
|
||||
currentEventIndex: number;
|
||||
upcomingChipLimit: number;
|
||||
isRunning: boolean;
|
||||
mode: AppMode;
|
||||
isGroupEnd: boolean;
|
||||
onSelect: (params: { id: string; index: number; parent?: string }) => void;
|
||||
}
|
||||
|
||||
export default memo(TitleListItem);
|
||||
function TitleListItem({
|
||||
entry,
|
||||
currentEventIndex,
|
||||
upcomingChipLimit,
|
||||
isRunning,
|
||||
mode,
|
||||
isGroupEnd,
|
||||
onSelect,
|
||||
}: TitleListItemProps) {
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect({ id: entry.id, index: entry.eventIndex - 1, parent: entry.parent ?? undefined });
|
||||
}, [onSelect, entry.id, entry.eventIndex, entry.parent]);
|
||||
|
||||
// Show "next" highlight for 2 events after current
|
||||
const isNext =
|
||||
currentEventIndex > 0 && entry.eventIndex > currentEventIndex && entry.eventIndex <= currentEventIndex + 2;
|
||||
const isPast = mode === AppMode.Run && entry.isPast;
|
||||
|
||||
const state = (() => {
|
||||
if (entry.isLoaded) {
|
||||
return mode === AppMode.Run ? 'running' : 'selected';
|
||||
}
|
||||
if (isPast) return 'past';
|
||||
if (isNext) return 'next';
|
||||
return 'default';
|
||||
})();
|
||||
|
||||
const shouldRenderChip = isRunning && !entry.isLoaded && !entry.isPast && !entry.skip;
|
||||
const showChipByDefault = shouldRenderChip && entry.eventIndex <= upcomingChipLimit;
|
||||
|
||||
return (
|
||||
<li
|
||||
data-state={state}
|
||||
data-skipped={entry.skip || undefined}
|
||||
data-group-end={isGroupEnd || undefined}
|
||||
className={style.item}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className={style.colourBar} style={{ backgroundColor: entry.groupColour || 'transparent' }} />
|
||||
<span className={style.title}>{entry.title || 'Untitled'}</span>
|
||||
{shouldRenderChip && (
|
||||
<TitleListTimeUntilChip
|
||||
timeStart={entry.timeStart}
|
||||
delay={entry.delay}
|
||||
dayOffset={entry.dayOffset}
|
||||
totalGap={entry.totalGap}
|
||||
isLinkedToLoaded={entry.isLinkedToLoaded}
|
||||
isLoaded={entry.isLoaded}
|
||||
showOnHover={!showChipByDefault}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface TitleListTimeUntilChipProps {
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
isLoaded: boolean;
|
||||
showOnHover: boolean;
|
||||
}
|
||||
|
||||
const TitleListTimeUntilChip = memo(TitleListTimeUntilChipImpl);
|
||||
function TitleListTimeUntilChipImpl({
|
||||
timeStart,
|
||||
delay,
|
||||
dayOffset,
|
||||
totalGap,
|
||||
isLinkedToLoaded,
|
||||
isLoaded,
|
||||
showOnHover,
|
||||
}: TitleListTimeUntilChipProps) {
|
||||
const timeUntil = useTimeUntilExpectedStart({ timeStart, delay, dayOffset }, { totalGap, isLinkedToLoaded });
|
||||
const isDue = !isLoaded && timeUntil < MILLIS_PER_SECOND;
|
||||
|
||||
let timeUntilString = 'LIVE';
|
||||
if (!isLoaded) {
|
||||
timeUntilString = isDue ? 'DUE' : formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE);
|
||||
}
|
||||
|
||||
const chipStatus = isLoaded ? 'live' : isDue ? 'due' : 'pending';
|
||||
|
||||
return (
|
||||
<Tooltip text='Expected time until start' className={style.chipSlot} data-hover-only={showOnHover || undefined}>
|
||||
<span data-chip-status={chipStatus} className={style.chipText}>
|
||||
{timeUntilString}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
|
||||
// Display time chips for current + next 5 upcoming events
|
||||
const UPCOMING_CHIP_COUNT = 5;
|
||||
|
||||
export function getCurrentEventInfo(data: ExtendedEntry<OntimeEvent>[]) {
|
||||
const index = data.findIndex((entry) => entry.isLoaded);
|
||||
const event = index !== -1 ? data[index] : null;
|
||||
const eventIndex = event?.eventIndex ?? 0;
|
||||
|
||||
return {
|
||||
index,
|
||||
id: event?.id ?? null,
|
||||
eventIndex,
|
||||
upcomingChipLimit: eventIndex > 0 ? eventIndex + UPCOMING_CHIP_COUNT : UPCOMING_CHIP_COUNT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { isValueOfEnum } from 'ontime-utils';
|
||||
|
||||
const layoutParam = 'layout';
|
||||
|
||||
export enum EditorLayoutMode {
|
||||
CONTROL = 'control',
|
||||
PLANNING = 'planning',
|
||||
TRACKING = 'tracking',
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current editor layout mode from a nullable string value
|
||||
*/
|
||||
function getEditorLayout(value: string | null): EditorLayoutMode {
|
||||
if (isValueOfEnum(EditorLayoutMode, value)) {
|
||||
return value;
|
||||
}
|
||||
return EditorLayoutMode.CONTROL;
|
||||
}
|
||||
|
||||
export function useEditorLayout() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const layoutMode = getEditorLayout(searchParams.get(layoutParam));
|
||||
|
||||
const setLayoutMode = (mode: EditorLayoutMode) => {
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.set(layoutParam, mode);
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
};
|
||||
|
||||
return { layoutMode, setLayoutMode };
|
||||
}
|
||||
Reference in New Issue
Block a user