diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index b723476bf..28b5498dd 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -9,6 +9,7 @@ import AppRouter from './AppRouter'; import ErrorBoundary from './common/components/error-boundary/ErrorBoundary'; import IdentifyOverlay from './common/components/identify-overlay/IdentifyOverlay'; import { AppContextProvider } from './common/context/AppContext'; +import { RundownScopeProvider } from './common/context/RundownScopeContext'; import { ontimeQueryClient } from './common/queryClient'; import { connectSocket } from './common/utils/socket'; import { baseURI } from './externals'; @@ -28,7 +29,10 @@ function App() { - + {/* the app follows the loaded rundown, panels nest a scope to point elsewhere */} + + + diff --git a/apps/client/src/AppRouter.tsx b/apps/client/src/AppRouter.tsx index 34d21b950..3650b777a 100644 --- a/apps/client/src/AppRouter.tsx +++ b/apps/client/src/AppRouter.tsx @@ -3,6 +3,7 @@ import { ComponentType, Suspense, lazy, useEffect, useMemo } from 'react'; import { Navigate, Route, useLocation, useNavigate, useParams } from 'react-router'; import ViewNavigationMenu from './common/components/navigation-menu/ViewNavigationMenu'; +import { EditableRundownScopeProvider } from './common/context/EditableRundownScopeProvider'; import { PresetContext } from './common/context/PresetContext'; import useUrlPresets from './common/hooks-query/useUrlPresets'; import { useClientPath } from './common/hooks/useClientPath'; @@ -112,7 +113,9 @@ export default function AppRouter() { path='rundown' element={ - + + + } /> diff --git a/apps/client/src/common/context/EditableRundownScopeProvider.tsx b/apps/client/src/common/context/EditableRundownScopeProvider.tsx new file mode 100644 index 000000000..bf376d51e --- /dev/null +++ b/apps/client/src/common/context/EditableRundownScopeProvider.tsx @@ -0,0 +1,24 @@ +import { PropsWithChildren } from 'react'; + +import { useScopedEntryActions } from '../hooks/useEntryAction'; +import { EntryActionsProvider } from './EntryActionsContext'; +import { RundownScopeProvider, useRundownScope, type RundownScopeProviderProps } from './RundownScopeContext'; + +/** + * Rundown scope for subtrees which mutate entries. + * Actions are bound to the same rundown as the data, so the two cannot disagree. + */ +export function EditableRundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) { + return ( + + {children} + + ); +} + +function ScopedEntryActions({ children }: PropsWithChildren) { + const { rundownId } = useRundownScope(); + const actions = useScopedEntryActions(rundownId); + + return {children}; +} diff --git a/apps/client/src/common/context/RundownScopeContext.tsx b/apps/client/src/common/context/RundownScopeContext.tsx new file mode 100644 index 000000000..fa1d8bc6a --- /dev/null +++ b/apps/client/src/common/context/RundownScopeContext.tsx @@ -0,0 +1,76 @@ +import { Rundown } from 'ontime-types'; +import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react'; + +import { getRundownQueryKey } from '../api/constants'; +import { useProjectRundowns } from '../hooks-query/useProjectRundowns'; +import { ontimeQueryClient } from '../queryClient'; +import { createEventSelectionStore, type EventSelectionStoreApi } from '../stores/eventSelectionStore'; + +export type RundownScopeValue = { + /** the rundown this subtree operates on */ + rundownId: string; + /** whether this scope targets the rundown the runtime is playing */ + isLoaded: boolean; + /** selection and cursor, scoped to this rundown */ + selectionStore: EventSelectionStoreApi; +}; + +const RundownScopeContext = createContext(null); + +export interface RundownScopeProviderProps extends PropsWithChildren { + /** rundown to operate on, null follows the loaded rundown */ + rundownId: string | null; +} + +/** + * Declares which rundown a subtree reads from. + * + * Data hooks resolve their rundown from here, so components never need to know + * which rundown they operate on. Nest a provider to point part of the tree at a + * different rundown; the app mounts one at the root that follows the loaded rundown. + */ +export function RundownScopeProvider({ children, rundownId }: RundownScopeProviderProps) { + const { + data: { loaded }, + } = useProjectRundowns(); + + const targetId = rundownId ?? loaded; + + // the store reads the rundown lazily, the ref keeps it pointing at the current target + const targetIdRef = useRef(targetId); + targetIdRef.current = targetId; + + const selectionStoreRef = useRef(null); + if (selectionStoreRef.current === null) { + selectionStoreRef.current = createEventSelectionStore(() => + ontimeQueryClient.getQueryData(getRundownQueryKey(targetIdRef.current)), + ); + } + const selectionStore = selectionStoreRef.current; + + // a selection refers to entries of a single rundown, it cannot survive a change of target + useEffect(() => { + selectionStore.getState().clearSelectedEvents(); + }, [selectionStore, targetId]); + + const value = useMemo( + (): RundownScopeValue => ({ + rundownId: targetId, + isLoaded: targetId === loaded, + selectionStore, + }), + [targetId, loaded, selectionStore], + ); + + return {children}; +} + +export function useRundownScope(): RundownScopeValue { + const context = useContext(RundownScopeContext); + + if (!context) { + throw new Error('useRundownScope must be used within a RundownScopeProvider'); + } + + return context; +} diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index 4912f688b..13f6495d2 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -5,9 +5,9 @@ import { useEffect, useMemo } from 'react'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; import { fetchCurrentRundown, fetchRundown } from '../api/rundown'; +import { useRundownScope } from '../context/RundownScopeContext'; import { useSelectedEventId } from '../hooks/useSocket'; import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata'; -import { useProjectRundowns } from './useProjectRundowns'; // revision is -1 so that the remote revision is higher const cachedRundownPlaceholder: Rundown = { @@ -20,42 +20,60 @@ const cachedRundownPlaceholder: Rundown = { }; /** - * Normalised rundown data for the currently loaded rundown. + * Provides access to a specific rundown by ID. * - * Bootstraps via the `/current` alias so the first paint is a single round-trip, - * independent of the project rundown list. Once the loaded id is known, the - * query key swaps to the id-keyed cache that is shared with `useRundownById`. + * Without an ID we do not yet know which rundown is loaded, so we bootstrap via + * the `/current` alias to keep the first paint to a single round-trip, then seed + * the id-keyed cache that every other reader shares. */ -export default function useRundown() { +export function useRundownById(rundownId: string | null | undefined) { const queryClient = useQueryClient(); - const { - data: { loaded: loadedRundownId }, - } = useProjectRundowns(); + const id = rundownId ?? ''; + const isBootstrap = id === ''; const { data, status, isError, refetch, isFetching } = useQuery({ - queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY, - queryFn: ({ signal }) => fetchCurrentRundown({ signal }), + queryKey: isBootstrap ? CURRENT_RUNDOWN_QUERY_KEY : getRundownQueryKey(id), + queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })), + placeholderData: (previousData, _previousQuery) => previousData, refetchInterval: queryRefetchIntervalSlow, }); // Seed the id-keyed cache when fetching via the bootstrap alias useEffect(() => { - if (!data || loadedRundownId) return; + if (!data || !isBootstrap) return; queryClient.setQueryData(getRundownQueryKey(data.id), data); - }, [data, loadedRundownId, queryClient]); + }, [data, isBootstrap, queryClient]); // Once we have the ID, drop the temporary current cache useEffect(() => { - if (!loadedRundownId) return; + if (isBootstrap) return; queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true }); - }, [loadedRundownId, queryClient]); + }, [isBootstrap, queryClient]); return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; } +/** + * Normalised rundown data for the rundown of the enclosing scope + */ +export default function useRundown() { + const { rundownId } = useRundownScope(); + return useRundownById(rundownId); +} + +/** + * Runtime state only describes the loaded rundown, + * a scope pointed elsewhere must not show a playing event + */ +export function useScopedSelectedEventId(): EntryId | null { + const { isLoaded } = useRundownScope(); + const selectedEventId = useSelectedEventId(); + return isLoaded ? selectedEventId : null; +} + export function useRundownWithMetadata() { const { data, status } = useRundown(); - const selectedEventId = useSelectedEventId(); + const selectedEventId = useScopedSelectedEventId(); const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); return { data, status, rundownMetadata }; } @@ -79,7 +97,7 @@ export function useFlatRundown() { export function useFlatRundownWithMetadata() { const { data, status } = useRundown(); - const selectedEventId = useSelectedEventId(); + const selectedEventId = useScopedSelectedEventId(); const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); return { data: rundownWithMetadata, status }; @@ -119,21 +137,3 @@ export function useRundownAuxData() { }, [data]); return { data: filteredData, status }; } - -/** - * Provides access to a specific rundown by ID. - * When rundownId is null/undefined the query is disabled and returns the placeholder. - */ -export function useRundownById(rundownId: string | null | undefined) { - const enabled = Boolean(rundownId); - - const { data, status, isError, refetch, isFetching } = useQuery({ - queryKey: getRundownQueryKey(rundownId ?? ''), - queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }), - enabled, - placeholderData: (previousData, _previousQuery) => previousData, - refetchInterval: queryRefetchIntervalSlow, - }); - - return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; -} diff --git a/apps/client/src/common/hooks-query/useScopedRundown.ts b/apps/client/src/common/hooks-query/useScopedRundown.ts deleted file mode 100644 index a14707d7f..000000000 --- a/apps/client/src/common/hooks-query/useScopedRundown.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { EntryId, Rundown } from 'ontime-types'; -import { useMemo } from 'react'; - -import { useSelectedEventId } from '../hooks/useSocket'; -import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata'; -import { useProjectRundowns } from './useProjectRundowns'; -import { useRundownById } from './useRundown'; - -export type RundownSource = { - rundownId: string | null; - rundown: Rundown; - flatRundown: ExtendedEntry[]; - status: string; - selectedEventId: EntryId | null; -}; - -/** - * Explicitly scoped rundown data for views that may operate on a non-loaded rundown. - */ -export function useScopedRundown(rundownId: string | null): RundownSource { - const { data: projectRundowns } = useProjectRundowns(); - return useRundownSource(rundownId, projectRundowns.loaded || null); -} - -/** - * Loaded-rundown source for views that must follow the active runtime rundown. - */ -export function useLoadedRundownSource(): RundownSource { - const { data: projectRundowns } = useProjectRundowns(); - const loadedRundownId = projectRundowns.loaded || null; - return useRundownSource(loadedRundownId, loadedRundownId); -} - -function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource { - const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId; - const runtimeSelectedEventId = useSelectedEventId(); - const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null; - const { data: rundown, status } = useRundownById(rundownId); - const flatRundown = useMemo( - () => getFlatRundownMetadata(rundown, effectiveSelectedEventId), - [effectiveSelectedEventId, rundown], - ); - - return useMemo( - () => ({ - rundownId, - rundown, - flatRundown, - status, - selectedEventId: effectiveSelectedEventId, - }), - [effectiveSelectedEventId, flatRundown, rundown, rundownId, status], - ); -} diff --git a/apps/client/src/views/cuesheet/__tests__/useCuesheetRundownSelection.test.ts b/apps/client/src/common/hooks/__tests__/useRundownSelection.test.ts similarity index 57% rename from apps/client/src/views/cuesheet/__tests__/useCuesheetRundownSelection.test.ts rename to apps/client/src/common/hooks/__tests__/useRundownSelection.test.ts index 4baaeb2b4..9544f1149 100644 --- a/apps/client/src/views/cuesheet/__tests__/useCuesheetRundownSelection.test.ts +++ b/apps/client/src/common/hooks/__tests__/useRundownSelection.test.ts @@ -1,13 +1,13 @@ import { FOLLOW_LOADED_RUNDOWN_ID, - getCuesheetRundownStorageKey, + getRundownSelectionStorageKey, resolveSelectedRundownId, -} from '../useCuesheetRundownSelection'; +} from '../useRundownSelection'; -describe('useCuesheetRundownSelection helpers', () => { - it('builds a project-scoped storage key', () => { - expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe( - 'cuesheet-selected-rundown:http://localhost:4001:My Project', +describe('useRundownSelection helpers', () => { + it('builds a namespace and project scoped storage key', () => { + expect(getRundownSelectionStorageKey('cuesheet', 'http://localhost:4001', 'My Project')).toBe( + 'rundown-selection:cuesheet:http://localhost:4001:My Project', ); }); diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index a561e416e..53020c75d 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -8,7 +8,6 @@ import { OntimeGroup, OntimeMilestone, PatchWithId, - ProjectRundownsList, Rundown, SupportedEntry, TimeField, @@ -34,7 +33,7 @@ import { import { useCallback, useMemo } from 'react'; import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils'; -import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants'; +import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants'; import { ReorderEntry, deleteEntries, @@ -52,6 +51,7 @@ import { requestFitGroupTarget, } from '../api/rundown'; import { logAxiosError } from '../api/utils'; +import { useRundownScope } from '../context/RundownScopeContext'; import { useEditorSettings } from '../stores/editorSettings'; export type EventOptions = Partial<{ @@ -70,16 +70,16 @@ type ClientInsertOptions = { }; /** - * Gather utilities for actions on entries in the loaded rundown. + * Gather utilities for actions on entries in the rundown of the enclosing scope. */ -export const useEntryActions = () => useEntryActionsForRundown(undefined); +export const useEntryActions = () => useEntryActionsForRundown(useRundownScope().rundownId); /** * Gather utilities for actions on entries in an explicitly selected rundown. */ export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? ''); -function useEntryActionsForRundown(scopedRundownId: string | undefined) { +function useEntryActionsForRundown(scopedRundownId: string) { const queryClient = useQueryClient(); const { linkPrevious, @@ -92,13 +92,10 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) { inheritGroupColour, } = useEditorSettings(); + // an empty id means the loaded rundown has not resolved yet, the bootstrap alias holds the data const resolveCurrentRundownQueryKey = useCallback(() => { - if (scopedRundownId !== undefined) { - return getRundownQueryKey(scopedRundownId); - } - const loadedRundownId = queryClient.getQueryData(PROJECT_RUNDOWNS)?.loaded; - return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY; - }, [queryClient, scopedRundownId]); + return scopedRundownId ? getRundownQueryKey(scopedRundownId) : CURRENT_RUNDOWN_QUERY_KEY; + }, [scopedRundownId]); /** * Returns the currently loaded rundown diff --git a/apps/client/src/views/cuesheet/useCuesheetRundownSelection.ts b/apps/client/src/common/hooks/useRundownSelection.ts similarity index 61% rename from apps/client/src/views/cuesheet/useCuesheetRundownSelection.ts rename to apps/client/src/common/hooks/useRundownSelection.ts index 73ae2de38..b10ede650 100644 --- a/apps/client/src/views/cuesheet/useCuesheetRundownSelection.ts +++ b/apps/client/src/common/hooks/useRundownSelection.ts @@ -2,14 +2,21 @@ import { useSessionStorage } from '@mantine/hooks'; import { startTransition, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router'; -import { useOrderedProjectList } from '../../common/hooks-query/useProjectList'; -import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns'; import { serverURL } from '../../externals'; +import { useOrderedProjectList } from '../hooks-query/useProjectList'; +import { useProjectRundowns } from '../hooks-query/useProjectRundowns'; export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const; -export function getCuesheetRundownStorageKey(server: string, projectFilename: string) { - return `cuesheet-selected-rundown:${server}:${projectFilename}`; +/** each surface keeps its own selection, so panels can point at different rundowns */ +export type RundownSelectionNamespace = 'cuesheet'; + +export function getRundownSelectionStorageKey( + namespace: RundownSelectionNamespace, + server: string, + projectFilename: string, +) { + return `rundown-selection:${namespace}:${server}:${projectFilename}`; } export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set) { @@ -17,14 +24,22 @@ export function resolveSelectedRundownId(storedSelectedRundownId: string | null, return FOLLOW_LOADED_RUNDOWN_ID; } -export function useCuesheetRundownSelection() { +/** + * Persisted choice of which rundown a surface operates on. + * The resolved id is meant to be handed to a rundown scope provider, + * where null means follow whichever rundown is loaded. + */ +export function useRundownSelection(namespace: RundownSelectionNamespace) { 'use memo'; const { data: projectRundowns } = useProjectRundowns(); const { data: { lastLoadedProject }, } = useOrderedProjectList(); - const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]); + const storageKey = useMemo( + () => getRundownSelectionStorageKey(namespace, serverURL, lastLoadedProject), + [namespace, lastLoadedProject], + ); const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage({ key: storageKey, defaultValue: FOLLOW_LOADED_RUNDOWN_ID, @@ -38,6 +53,8 @@ export function useCuesheetRundownSelection() { return { loadedRundownId, selectedRundownId, + /** id for the rundown scope, null follows the loaded rundown */ + scopedRundownId: selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? null : selectedRundownId, projectRundowns: projectRundowns.rundowns, setSelectedRundownId: (rundownId: string) => { startTransition(() => { @@ -52,7 +69,7 @@ export function useDirectLinkToBackgroundEdit() { data: { lastLoadedProject }, } = useOrderedProjectList(); const navigate = useNavigate(); - const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject); + const storageKey = getRundownSelectionStorageKey('cuesheet', serverURL, lastLoadedProject); const [_, setStoredSelectedRundownId] = useSessionStorage({ key: storageKey, defaultValue: null }); return useCallback( diff --git a/apps/client/src/common/stores/__tests__/eventSelectionStore.test.ts b/apps/client/src/common/stores/__tests__/eventSelectionStore.test.ts new file mode 100644 index 000000000..5ff026683 --- /dev/null +++ b/apps/client/src/common/stores/__tests__/eventSelectionStore.test.ts @@ -0,0 +1,47 @@ +import { Rundown, SupportedEntry } from 'ontime-types'; + +import { createEventSelectionStore } from '../eventSelectionStore'; + +function makeRundown(id: string, eventIds: string[]): Rundown { + return { + id, + title: id, + order: eventIds, + flatOrder: eventIds, + entries: Object.fromEntries(eventIds.map((entryId) => [entryId, { id: entryId, type: SupportedEntry.Event }])), + revision: 1, + } as unknown as Rundown; +} + +describe('createEventSelectionStore', () => { + it('keeps selections of separate instances independent', () => { + const first = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2'])); + const second = createEventSelectionStore(() => makeRundown('rundown-b', ['b1', 'b2'])); + + first.getState().setSingleEntrySelection({ id: 'a1' }); + second.getState().setSingleEntrySelection({ id: 'b2' }); + + expect(first.getState().cursor).toBe('a1'); + expect(second.getState().cursor).toBe('b2'); + expect(first.getState().selectedEvents).toEqual(new Set(['a1'])); + expect(second.getState().selectedEvents).toEqual(new Set(['b2'])); + }); + + it('resolves a shift range against the injected rundown', () => { + const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2', 'a3'])); + + store.getState().setSelectedEvents({ id: 'a3', index: 2, selectMode: 'shift' }); + + // without an anchor the range runs from the top up to the clicked index + expect(store.getState().selectedEvents).toEqual(new Set(['a1', 'a2'])); + expect(store.getState().anchoredIndex).toBe(2); + }); + + it('does not select when the rundown is unavailable', () => { + const store = createEventSelectionStore(() => undefined); + + store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'shift' }); + + expect(store.getState().selectedEvents).toEqual(new Set()); + }); +}); diff --git a/apps/client/src/common/stores/entryCopyStore.ts b/apps/client/src/common/stores/entryCopyStore.ts index 4ccc2de7b..33963265f 100644 --- a/apps/client/src/common/stores/entryCopyStore.ts +++ b/apps/client/src/common/stores/entryCopyStore.ts @@ -2,13 +2,19 @@ import { create } from 'zustand'; type EntryCopyStore = { entryCopyId: string | null; + /** rundown the copied entry belongs to, so a paste knows whether it crosses rundowns */ + entryCopyRundownId: string | null; entryCopyMode: 'copy' | 'cut'; - setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void; + setEntryCopyId: (eventId: string | null, rundownId: string | null, mode?: 'copy' | 'cut') => void; }; +/** + * The clipboard is shared across rundowns so entries can be moved between them + */ export const useEntryCopy = create()((set) => ({ entryCopyId: null, + entryCopyRundownId: null, entryCopyMode: 'copy', - setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') => - set({ entryCopyId, entryCopyMode: mode }), + setEntryCopyId: (entryCopyId: string | null, entryCopyRundownId: string | null, mode: 'copy' | 'cut' = 'copy') => + set({ entryCopyId, entryCopyRundownId, entryCopyMode: mode }), })); diff --git a/apps/client/src/common/stores/eventSelectionStore.ts b/apps/client/src/common/stores/eventSelectionStore.ts new file mode 100644 index 000000000..5acb6a1ae --- /dev/null +++ b/apps/client/src/common/stores/eventSelectionStore.ts @@ -0,0 +1,156 @@ +import { EntryId, MaybeNumber, Rundown, isOntimeEvent } from 'ontime-types'; +import { MouseEvent } from 'react'; +import { StoreApi } from 'zustand'; +import { createStore } from 'zustand/vanilla'; + +import { isMacOS } from '../utils/deviceUtils'; + +export type SelectionMode = 'shift' | 'click' | 'ctrl'; + +export interface EventSelectionStore { + selectedEvents: Set; + anchoredIndex: MaybeNumber; + cursor: EntryId | null; + entryMode: 'event' | 'single' | null; + scrollHandler: ((id: EntryId) => void) | null; + setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void; + setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void; + clearSelectedEvents: () => void; + clearMultiSelect: () => void; + unselect: (id: EntryId) => void; + setScrollHandler: (handler: ((id: EntryId) => void) | null) => void; + scrollToEntry: (id: EntryId) => void; +} + +export type EventSelectionStoreApi = StoreApi; + +/** + * Keeps track of the selected entries and selection mode + * Provides methods to update the selection based on user interactions + * + * One store instance exists per rundown scope, so panels showing different + * rundowns keep independent selections. The rundown the selection refers to is + * injected as `getRundown` rather than resolved from the loaded rundown. + */ +export function createEventSelectionStore(getRundown: () => Rundown | undefined): EventSelectionStoreApi { + return createStore()((set, get) => ({ + selectedEvents: new Set(), + anchoredIndex: null, + cursor: null, + entryMode: null, + scrollHandler: null, + setSingleEntrySelection: ({ id }) => { + set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' }); + }, + setSelectedEvents: ({ id, index, selectMode }) => { + const { selectedEvents, anchoredIndex, entryMode } = get(); + + // if we are in single mode, we replace the selection and change the mode + if (entryMode === 'single') { + return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' }); + } + + // on click, we replace selection with event + if (selectMode === 'click') { + return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' }); + } + + // on ctrl + click, we toggle the selection of that event + if (selectMode === 'ctrl') { + const rundownData = getRundown(); + if (!rundownData) return; + + // if it doesnt exist, simply add to the list and set an anchor + if (!selectedEvents.has(id)) { + return set({ + selectedEvents: selectedEvents.add(id), + anchoredIndex: index, + cursor: id, + entryMode: 'event', + }); + } + + // if event is already selected, we remove it from selection + // and set the anchor to the event after + selectedEvents.delete(id); + + const nextIndex = rundownData.order.findIndex( + (eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId), + ); + + // if we didnt find anything after, set the anchor to the last event + return set({ + selectedEvents, + anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex, + entryMode: 'event', + }); + } + + // on shift + click, we select a range of events up to the clicked event + if (selectMode === 'shift') { + const rundownData = getRundown(); + if (!rundownData) return; + + // get list of rundown with only ontime events + const eventIds: EntryId[] = []; + rundownData.flatOrder.forEach((eventId) => { + const event = rundownData.entries[eventId]; + if (isOntimeEvent(event)) { + eventIds.push(event.id); + } + }); + + const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index); + const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1); + + // create new set with range of ids from start to end + const selectedEventIds = eventIds.slice(start, end); + + return set({ + selectedEvents: new Set([...selectedEvents, ...selectedEventIds]), + anchoredIndex: index, + entryMode: 'event', + }); + } + }, + clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }), + clearMultiSelect: () => { + const { selectedEvents } = get(); + const [firstSelected] = selectedEvents; + set({ + selectedEvents: new Set(firstSelected ? [firstSelected] : []), + anchoredIndex: null, + entryMode: null, + }); + }, + unselect: (id: string) => { + const { entryMode, selectedEvents } = get(); + selectedEvents.delete(id); + set({ + selectedEvents, + entryMode: selectedEvents.size === 0 ? null : entryMode, + }); + }, + // Sets the scroll handler for programmatic scrolling to entries + setScrollHandler: (handler) => set({ scrollHandler: handler }), + // Scrolls to the specified entry using the registered scroll handler + scrollToEntry: (id: EntryId) => { + const handler = get().scrollHandler; + if (handler) { + handler(id); + } + }, + })); +} + +export function getSelectionMode(event: MouseEvent): SelectionMode { + if ((isMacOS() && event.metaKey) || event.ctrlKey) { + return 'ctrl'; + } + + if (event.shiftKey) { + return 'shift'; + } + + return 'click'; +} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx index 2e0965e54..03329853e 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/ManageRundowns.tsx @@ -19,8 +19,8 @@ import Dialog from '../../../../common/components/dialog/Dialog'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import Tag from '../../../../common/components/tag/Tag'; import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns'; +import { useDirectLinkToBackgroundEdit } from '../../../../common/hooks/useRundownSelection'; import { cx } from '../../../../common/utils/styleUtils'; -import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection'; import * as Panel from '../../panel-utils/PanelUtils'; import RundownRenameForm from './composite/RundownRenameForm'; import { ManageRundownForm } from './ManageRundownForm'; diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 9b96debce..a833d74d1 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -67,7 +67,11 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id); const entryActions = useEntryActionsContext(); - const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId); + const setEntryCopy = useEntryCopy((state) => state.setEntryCopyId); + const setEntryCopyId = useCallback( + (entryId: EntryId | null, mode?: 'copy' | 'cut') => setEntryCopy(entryId, id, mode), + [setEntryCopy, id], + ); // cursor const { editorMode } = useEditorFollowMode(); diff --git a/apps/client/src/features/rundown/RundownExport.tsx b/apps/client/src/features/rundown/RundownExport.tsx index 0fcebf415..9450e1ab2 100644 --- a/apps/client/src/features/rundown/RundownExport.tsx +++ b/apps/client/src/features/rundown/RundownExport.tsx @@ -5,9 +5,6 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; -import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; -import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown'; -import { useEntryActions } from '../../common/hooks/useEntryAction'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { handleLinks } from '../../common/utils/linkUtils'; import { cx } from '../../common/utils/styleUtils'; @@ -39,28 +36,25 @@ function RundownExport() { defaultValue: RundownViewMode.List, }); const isSmallDevice = useIsSmallDevice(); - const entryActions = useEntryActions(); if (isSmallDevice && isExtracted) { return ( - - -
- - -
- - - - -
+ +
+ + +
+ + + +
- - +
+
); } @@ -70,30 +64,28 @@ function RundownExport() { viewMode === RundownViewMode.Table; return ( - - -
- - {isExtracted && } -
- + +
+ + {isExtracted && } +
+ + + {!isExtracted && handleLinks('rundown', event)} />} + + + + + {!hideSideBar && ( +
- {!isExtracted && handleLinks('rundown', event)} />} - - + - - {!hideSideBar && ( -
- - - -
- )} -
+
+ )}
-
- +
+ ); } @@ -105,8 +97,6 @@ interface RundownRootProps { } function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) { - const source = useLoadedRundownSource(); - return (
{isSmallDevice ? ( @@ -115,7 +105,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund )} {viewMode === RundownViewMode.List ? : } - {viewMode === RundownViewMode.Table && } + {viewMode === RundownViewMode.Table && }
); diff --git a/apps/client/src/features/rundown/RundownList.tsx b/apps/client/src/features/rundown/RundownList.tsx index 86d80906b..f6c3113a5 100644 --- a/apps/client/src/features/rundown/RundownList.tsx +++ b/apps/client/src/features/rundown/RundownList.tsx @@ -1,15 +1,22 @@ +import { Playback } from 'ontime-types'; import { memo } from 'react'; import EmptyFill from '../../common/components/state/EmptyFill'; +import { useRundownScope } from '../../common/context/RundownScopeContext'; import { useRundownWithMetadata } from '../../common/hooks-query/useRundown'; import { useRundownEditor } from '../../common/hooks/useSocket'; import { useTranslation } from '../../translation/TranslationProvider'; import Rundown from './Rundown'; +/** a rundown which is not loaded has no playback to follow */ +const idleFeatureData = { playback: Playback.Stop, selectedEventId: null, nextEventId: null }; + export default memo(RundownList); function RundownList() { + const { isLoaded } = useRundownScope(); const { data, status, rundownMetadata } = useRundownWithMetadata(); - const featureData = useRundownEditor(); + const runtimeFeatureData = useRundownEditor(); + const featureData = isLoaded ? runtimeFeatureData : idleFeatureData; const { getLocalizedString } = useTranslation(); // avoid showing the editable empty state before we know whether the rundown is actually empty diff --git a/apps/client/src/features/rundown/hooks/useRundownCommands.ts b/apps/client/src/features/rundown/hooks/useRundownCommands.ts index e036aa1ce..e6fd1375c 100644 --- a/apps/client/src/features/rundown/hooks/useRundownCommands.ts +++ b/apps/client/src/features/rundown/hooks/useRundownCommands.ts @@ -2,6 +2,7 @@ import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'on import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils'; import { useCallback } from 'react'; +import { useRundownScope } from '../../../common/context/RundownScopeContext'; import type { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { SelectionMode } from '../useEventSelection'; @@ -26,6 +27,7 @@ export function useRundownCommands({ selectEntry: applySelection, handleCollapseGroup, }: UseRundownCommandsOptions) { + const { rundownId } = useRundownScope(); const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions; const deleteAtCursor = useCallback( @@ -43,12 +45,17 @@ export function useRundownCommands({ const insertCopyAtId = useCallback( (atId: EntryId | null, above = false) => { // lazily get the value from the store - const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState(); + const { entryCopyId, entryCopyRundownId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState(); if (entryCopyId === null || !entries[entryCopyId]) { // we cant clone without selection return; } + // pasting into a different rundown needs the entry payload, the server clones within a rundown + if (entryCopyRundownId !== null && entryCopyRundownId !== rundownId) { + return; + } + let normalisedAtId = atId; const elementToCopy = entries[entryCopyId]; @@ -65,7 +72,7 @@ export function useRundownCommands({ return; } reorderEntry(entryCopyId, firstId, 'before') - .then(() => setEntryCopyId(null)) + .then(() => setEntryCopyId(null, null)) .catch(() => {}); return; } @@ -74,7 +81,7 @@ export function useRundownCommands({ } const placement = above ? 'before' : 'after'; reorderEntry(entryCopyId, normalisedAtId, placement) - .then(() => setEntryCopyId(null)) + .then(() => setEntryCopyId(null, null)) .catch(() => {}); return; } @@ -85,7 +92,7 @@ export function useRundownCommands({ before: above ? (normalisedAtId ?? undefined) : undefined, }); }, - [entries, flatOrder, clone, reorderEntry], + [entries, flatOrder, clone, reorderEntry, rundownId], ); /** diff --git a/apps/client/src/features/rundown/rundown-table/RundownTable.tsx b/apps/client/src/features/rundown/rundown-table/RundownTable.tsx index 03f28a277..1c3dd7df7 100644 --- a/apps/client/src/features/rundown/rundown-table/RundownTable.tsx +++ b/apps/client/src/features/rundown/rundown-table/RundownTable.tsx @@ -1,9 +1,6 @@ import { memo, useEffect, useMemo } from 'react'; -import { EntryActionsProvider } from '../../../common/context/EntryActionsContext'; import useCustomFields from '../../../common/hooks-query/useCustomFields'; -import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown'; -import { useEntryActions } from '../../../common/hooks/useEntryAction'; import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd'; import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable'; import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions'; @@ -15,8 +12,6 @@ function RundownTable() { const { data: customFields } = useCustomFields(); const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const { editorMode } = useEditorFollowMode(); - const source = useLoadedRundownSource(); - const actions = useEntryActions(); // Editor always has full permissions useEffect(() => { @@ -32,10 +27,8 @@ function RundownTable() { const columns = useMemo(() => makeRundownColumns(customFields), [customFields]); return ( - - - - - + + + ); } diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index 180f525f2..c5c60eabe 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -1,155 +1,15 @@ -import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } from 'ontime-types'; -import { MouseEvent } from 'react'; -import { create } from 'zustand'; +import { useStore } from 'zustand'; -import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants'; -import { ontimeQueryClient } from '../../common/queryClient'; -import { isMacOS } from '../../common/utils/deviceUtils'; +import { useRundownScope } from '../../common/context/RundownScopeContext'; +import type { EventSelectionStore } from '../../common/stores/eventSelectionStore'; -export type SelectionMode = 'shift' | 'click' | 'ctrl'; - -interface EventSelectionStore { - selectedEvents: Set; - anchoredIndex: MaybeNumber; - cursor: EntryId | null; - entryMode: 'event' | 'single' | null; - scrollHandler: ((id: EntryId) => void) | null; - setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void; - setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void; - clearSelectedEvents: () => void; - clearMultiSelect: () => void; - unselect: (id: EntryId) => void; - setScrollHandler: (handler: ((id: EntryId) => void) | null) => void; - scrollToEntry: (id: EntryId) => void; -} +export { getSelectionMode } from '../../common/stores/eventSelectionStore'; +export type { SelectionMode } from '../../common/stores/eventSelectionStore'; /** - * Keeps track of the selected entries and selection mode - * Provides methods to update the selection based on user interactions + * Selection and cursor for the rundown of the enclosing scope */ -export const useEventSelection = create()((set, get) => ({ - selectedEvents: new Set(), - anchoredIndex: null, - cursor: null, - entryMode: null, - scrollHandler: null, - setSingleEntrySelection: ({ id }) => { - set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' }); - }, - setSelectedEvents: ({ id, index, selectMode }) => { - const { selectedEvents, anchoredIndex, entryMode } = get(); - - // if we are in single mode, we replace the selection and change the mode - if (entryMode === 'single') { - return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' }); - } - - // on click, we replace selection with event - if (selectMode === 'click') { - return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id, entryMode: 'event' }); - } - - // on ctrl + click, we toggle the selection of that event - if (selectMode === 'ctrl') { - const rundownData = getLoadedRundownData(); - if (!rundownData) return; - - // if it doesnt exist, simply add to the list and set an anchor - if (!selectedEvents.has(id)) { - return set({ - selectedEvents: selectedEvents.add(id), - anchoredIndex: index, - cursor: id, - entryMode: 'event', - }); - } - - // if event is already selected, we remove it from selection - // and set the anchor to the event after - selectedEvents.delete(id); - - const nextIndex = rundownData.order.findIndex( - (eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId), - ); - - // if we didnt find anything after, set the anchor to the last event - return set({ - selectedEvents, - anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex, - entryMode: 'event', - }); - } - - // on shift + click, we select a range of events up to the clicked event - if (selectMode === 'shift') { - const rundownData = getLoadedRundownData(); - if (!rundownData) return; - - // get list of rundown with only ontime events - const eventIds: EntryId[] = []; - rundownData.flatOrder.forEach((eventId) => { - const event = rundownData.entries[eventId]; - if (isOntimeEvent(event)) { - eventIds.push(event.id); - } - }); - - const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index); - const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1); - - // create new set with range of ids from start to end - const selectedEventIds = eventIds.slice(start, end); - - return set({ - selectedEvents: new Set([...selectedEvents, ...selectedEventIds]), - anchoredIndex: index, - entryMode: 'event', - }); - } - }, - clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null, entryMode: null }), - clearMultiSelect: () => { - const { selectedEvents } = get(); - const [firstSelected] = selectedEvents; - set({ - selectedEvents: new Set(firstSelected ? [firstSelected] : []), - anchoredIndex: null, - entryMode: null, - }); - }, - unselect: (id: string) => { - const { entryMode, selectedEvents } = get(); - selectedEvents.delete(id); - set({ - selectedEvents, - entryMode: selectedEvents.size === 0 ? null : entryMode, - }); - }, - // Sets the scroll handler for programmatic scrolling to entries - setScrollHandler: (handler) => set({ scrollHandler: handler }), - // Scrolls to the specified entry using the registered scroll handler - scrollToEntry: (id: EntryId) => { - const handler = get().scrollHandler; - if (handler) { - handler(id); - } - }, -})); - -function getLoadedRundownData() { - const rundownId = ontimeQueryClient.getQueryData(PROJECT_RUNDOWNS)?.loaded; - if (!rundownId) return undefined; - return ontimeQueryClient.getQueryData(getRundownQueryKey(rundownId)); -} - -export function getSelectionMode(event: MouseEvent): SelectionMode { - if ((isMacOS() && event.metaKey) || event.ctrlKey) { - return 'ctrl'; - } - - if (event.shiftKey) { - return 'shift'; - } - - return 'click'; +export function useEventSelection(selector: (state: EventSelectionStore) => T): T { + const { selectionStore } = useRundownScope(); + return useStore(selectionStore, selector); } diff --git a/apps/client/src/views/cuesheet/CuesheetPage.tsx b/apps/client/src/views/cuesheet/CuesheetPage.tsx index d2febeb17..7be760bc5 100644 --- a/apps/client/src/views/cuesheet/CuesheetPage.tsx +++ b/apps/client/src/views/cuesheet/CuesheetPage.tsx @@ -3,35 +3,31 @@ import { IoApps } from 'react-icons/io5'; import IconButton from '../../common/components/buttons/IconButton'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; -import { EntryActionsProvider } from '../../common/context/EntryActionsContext'; -import { useScopedRundown } from '../../common/hooks-query/useScopedRundown'; -import { useScopedEntryActions } from '../../common/hooks/useEntryAction'; +import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider'; +import { useRundownSelection } from '../../common/hooks/useRundownSelection'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { getIsNavigationLocked } from '../../externals'; import CuesheetOverview from '../../features/overview/CuesheetOverview'; import EntryEditModal from './cuesheet-edit-modal/EntryEditModal'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetTableWrapper from './CuesheetTableWrapper'; -import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection'; import styles from './CuesheetPage.module.scss'; export default function CuesheetPage() { 'use memo'; const [isMenuOpen, menuHandler] = useDisclosure(); - const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection(); - const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId); - - const actions = useScopedEntryActions(source.rundownId); + const { scopedRundownId, selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = + useRundownSelection('cuesheet'); useWindowTitle('Cuesheet'); const isLocked = getIsNavigationLocked(); return ( - + - +
{!isLocked && ( @@ -42,13 +38,12 @@ export default function CuesheetPage() {
-
+ ); } diff --git a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx index 048f8f1c6..86f7bcea2 100644 --- a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx +++ b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx @@ -3,19 +3,18 @@ import { memo, use, useMemo } from 'react'; import Select from '../../common/components/select/Select'; import { PresetContext } from '../../common/context/PresetContext'; +import { useRundownScope } from '../../common/context/RundownScopeContext'; import useCustomFields from '../../common/hooks-query/useCustomFields'; -import type { RundownSource } from '../../common/hooks-query/useScopedRundown'; +import { FOLLOW_LOADED_RUNDOWN_ID } from '../../common/hooks/useRundownSelection'; import { AppMode } from '../../ontimeConfig'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import CuesheetTable from './cuesheet-table/CuesheetTable'; import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy'; -import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection'; import styles from './CuesheetPage.module.scss'; interface CuesheetTableWrapperProps { - source: RundownSource; selectedRundownId: MaybeString; loadedRundownId: string; setSelectedRundownId: (rundownId: string) => void; @@ -24,15 +23,14 @@ interface CuesheetTableWrapperProps { export default memo(CuesheetTableWrapper); function CuesheetTableWrapper({ - source, selectedRundownId, setSelectedRundownId, loadedRundownId, projectRundowns, }: CuesheetTableWrapperProps) { const preset = use(PresetContext); - const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId; - const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown }); + const { isLoaded } = useRundownScope(); + const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isLoaded }); const { data: customFields } = useCustomFields(); const columns = useMemo( @@ -44,11 +42,9 @@ function CuesheetTableWrapper({ state.selectedEntryId); const closeModal = useEditModal((state) => state.clearSelection); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index 76031ae67..6a562ae16 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -14,7 +14,8 @@ import { import EmptyFill from '../../../common/components/state/EmptyFill'; import EmptyTableBody from '../../../common/components/state/EmptyTableBody'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; -import type { RundownSource } from '../../../common/hooks-query/useScopedRundown'; +import { useRundownScope } from '../../../common/context/RundownScopeContext'; +import { useFlatRundownWithMetadata, useScopedSelectedEventId } from '../../../common/hooks-query/useRundown'; import type { ExtendedEntry } from '../../../common/utils/rundownMetadata'; import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options'; import { useEventSelection } from '../../../features/rundown/useEventSelection'; @@ -41,20 +42,17 @@ import style from './CuesheetTable.module.scss'; type CuesheetTableBaseProps = { columns: CuesheetColumnDef[]; cuesheetMode: AppMode; - source: RundownSource; insertElement?: ReactNode; }; type EditorCuesheetTableProps = CuesheetTableBaseProps & { tableRoot: 'editor'; setCuesheetMode?: undefined; - isCurrentRundown?: undefined; }; type ViewCuesheetTableProps = CuesheetTableBaseProps & { tableRoot: 'cuesheet'; setCuesheetMode: (mode: AppMode) => void; - isCurrentRundown?: boolean; }; type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps; @@ -62,13 +60,13 @@ type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps; export default function CuesheetTable({ columns, cuesheetMode, - source, tableRoot, setCuesheetMode, - isCurrentRundown, insertElement, }: CuesheetTableProps) { - const { flatRundown, status, selectedEventId } = source; + const { isLoaded: isCurrentRundown } = useRundownScope(); + const { data: flatRundown, status } = useFlatRundownWithMetadata(); + const selectedEventId = useScopedSelectedEventId(); const { updateEntry, updateTimer, addEntry } = useEntryActionsContext(); const { getLocalizedString } = useTranslation(); const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit; diff --git a/apps/client/src/views/editor/Editor.tsx b/apps/client/src/views/editor/Editor.tsx index 66d848c65..94ae7d3d9 100644 --- a/apps/client/src/views/editor/Editor.tsx +++ b/apps/client/src/views/editor/Editor.tsx @@ -1,5 +1,6 @@ import { lazy } from 'react'; +import { EditableRundownScopeProvider } from '../../common/context/EditableRundownScopeProvider'; import TrackingPlaybackBar from '../../features/control/playback/tracking-playback-bar/TrackingPlaybackBar'; import { AppMode } from '../../ontimeConfig'; import TitleList from './title-list/TitleList'; @@ -21,7 +22,9 @@ export default function Editor() {
- + + +
); } @@ -29,14 +32,17 @@ export default function Editor() { if (layoutMode === EditorLayoutMode.TRACKING) { return (
-
-
- + {/* the titles and the rundown share a scope, so they follow one cursor */} + +
+
+ +
+
+ +
-
- -
-
+
); @@ -44,14 +50,16 @@ export default function Editor() { return (
-
-
- + +
+
+ +
+
+ +
-
- -
-
+
); }