diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts
index aa0551931..878408121 100644
--- a/apps/client/src/common/api/constants.ts
+++ b/apps/client/src/common/api/constants.ts
@@ -22,6 +22,14 @@ export const CLIENT_LIST = ['clientList'];
export const REPORT = ['report'];
export const TRANSLATION = ['translation'];
+/**
+ * Cache key holding the data for a rundown.
+ * Before the loaded rundown is known there is no id to key by,
+ * and the data lives under the bootstrap alias.
+ */
+export const getRundownCacheKey = (rundownId: string) =>
+ rundownId ? getRundownQueryKey(rundownId) : CURRENT_RUNDOWN_QUERY_KEY;
+
// API URLs
export const apiEntryUrl = `${serverURL}/data`;
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..1578b21b0
--- /dev/null
+++ b/apps/client/src/common/context/RundownScopeContext.tsx
@@ -0,0 +1,77 @@
+import { MaybeString, Rundown } from 'ontime-types';
+import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useRef } from 'react';
+
+import { getRundownCacheKey } 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: MaybeString;
+}
+
+/**
+ * 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);
+
+ const selectionStoreRef = useRef(null);
+ if (selectionStoreRef.current === null) {
+ selectionStoreRef.current = createEventSelectionStore(() =>
+ ontimeQueryClient.getQueryData(getRundownCacheKey(targetIdRef.current)),
+ );
+ }
+ const selectionStore = selectionStoreRef.current;
+
+ // a selection refers to entries of a single rundown, it cannot survive a change of target
+ useEffect(() => {
+ targetIdRef.current = targetId;
+ selectionStore.getState().clearSelectedEvents();
+ }, [selectionStore, targetId]);
+
+ const value = useMemo(
+ (): RundownScopeValue => ({
+ rundownId: targetId,
+ // an unresolved target is not the loaded rundown, it is not yet any rundown
+ isLoaded: Boolean(loaded) && 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/useLoadedRundown.ts b/apps/client/src/common/hooks-query/useLoadedRundown.ts
new file mode 100644
index 000000000..80f658e5d
--- /dev/null
+++ b/apps/client/src/common/hooks-query/useLoadedRundown.ts
@@ -0,0 +1,81 @@
+import { EntryId, OntimeEntry } from 'ontime-types';
+import { useMemo } from 'react';
+
+import { useSelectedEventId } from '../hooks/useSocket';
+import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
+import { useProjectRundowns } from './useProjectRundowns';
+import { flattenRundown, useRundownById } from './useRundownById';
+
+/**
+ * Rundown data for surfaces which only ever work against the rundown being played:
+ * the viewers, the operator, app settings and the runtime overview.
+ *
+ * These take no part in a rundown scope, they resolve the loaded rundown directly.
+ * Anything which can be pointed at a background rundown reads from its scope instead.
+ */
+export function useLoadedRundown() {
+ const {
+ data: { loaded },
+ } = useProjectRundowns();
+ return useRundownById(loaded);
+}
+
+export function useLoadedRundownWithMetadata() {
+ const { data, status } = useLoadedRundown();
+ const selectedEventId = useSelectedEventId();
+ const { entries, flatOrder } = data;
+ const rundownMetadata = useMemo(
+ () => getRundownMetadata({ entries, flatOrder }, selectedEventId),
+ [entries, flatOrder, selectedEventId],
+ );
+ return { data, status, rundownMetadata };
+}
+
+export function useLoadedFlatRundown() {
+ const { data, status } = useLoadedRundown();
+ const flatRundown = useMemo(() => flattenRundown(data), [data]);
+ return { data: flatRundown, rundownId: data.id, status };
+}
+
+export function useLoadedFlatRundownWithMetadata() {
+ const { data, status } = useLoadedRundown();
+ const selectedEventId = useSelectedEventId();
+ const { entries, flatOrder } = data;
+ const rundownWithMetadata = useMemo(
+ () => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
+ [entries, flatOrder, selectedEventId],
+ );
+ return { data: rundownWithMetadata, status };
+}
+
+/**
+ * Provides access to a partial rundown based on a filter callback
+ *
+ * Callers MUST memoize the callback with useCallback to prevent
+ * re-filtering on every render.
+ */
+export function useLoadedPartialRundown(cb: (event: ExtendedEntry) => boolean) {
+ const { data, status } = useLoadedFlatRundownWithMetadata();
+ const filteredData = useMemo(() => data.filter(cb), [data, cb]);
+ return { data: filteredData, status };
+}
+
+/**
+ * Hook to get a specific entry by ID from the loaded rundown.
+ * Runtime ids (the playing event, its group, the next flag) only exist here.
+ */
+export function useLoadedEntry(entryId: EntryId | null): OntimeEntry | null {
+ const { data: rundown } = useLoadedRundown();
+
+ if (entryId === null) return null;
+ return rundown.entries[entryId] ?? null;
+}
+
+export function useLoadedRundownAuxData() {
+ const { data, status } = useLoadedRundown();
+ const filteredData = useMemo(() => {
+ const { title, id } = data;
+ return { title, id };
+ }, [data]);
+ return { data: filteredData, status };
+}
diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts
index 4912f688b..cef300a65 100644
--- a/apps/client/src/common/hooks-query/useRundown.ts
+++ b/apps/client/src/common/hooks-query/useRundown.ts
@@ -1,62 +1,38 @@
-import { useQuery, useQueryClient } from '@tanstack/react-query';
-import { EntryId, OntimeEntry, Rundown } from 'ontime-types';
-import { useEffect, useMemo } from 'react';
+import { EntryId, OntimeEntry } from 'ontime-types';
+import { 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 = {
- id: 'default',
- title: '',
- order: [],
- flatOrder: [],
- entries: {},
- revision: -1,
-};
+import { getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
+import { flattenRundown, useRundownById } from './useRundownById';
/**
- * Normalised rundown data for the currently loaded rundown.
- *
- * 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`.
+ * Normalised rundown data for the rundown of the enclosing scope
*/
export default function useRundown() {
- const queryClient = useQueryClient();
- const {
- data: { loaded: loadedRundownId },
- } = useProjectRundowns();
+ const { rundownId } = useRundownScope();
+ return useRundownById(rundownId);
+}
- const { data, status, isError, refetch, isFetching } = useQuery({
- queryKey: loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY,
- queryFn: ({ signal }) => fetchCurrentRundown({ signal }),
- refetchInterval: queryRefetchIntervalSlow,
- });
-
- // Seed the id-keyed cache when fetching via the bootstrap alias
- useEffect(() => {
- if (!data || loadedRundownId) return;
- queryClient.setQueryData(getRundownQueryKey(data.id), data);
- }, [data, loadedRundownId, queryClient]);
-
- // Once we have the ID, drop the temporary current cache
- useEffect(() => {
- if (!loadedRundownId) return;
- queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
- }, [loadedRundownId, queryClient]);
-
- return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
+/**
+ * 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 rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
+ const selectedEventId = useScopedSelectedEventId();
+ // key on the fields the derivation reads, a revision only change must not churn the list
+ const { entries, flatOrder } = data;
+ const rundownMetadata = useMemo(
+ () => getRundownMetadata({ entries, flatOrder }, selectedEventId),
+ [entries, flatOrder, selectedEventId],
+ );
return { data, status, rundownMetadata };
}
@@ -66,41 +42,23 @@ export function useRundownWithMetadata() {
*/
export function useFlatRundown() {
const { data, status } = useRundown();
-
- const flatRundown = useMemo(() => {
- if (data.revision === -1) {
- return [];
- }
- return data.flatOrder.map((id) => data.entries[id]).filter((entry): entry is OntimeEntry => entry !== undefined);
- }, [data]);
+ const flatRundown = useMemo(() => flattenRundown(data), [data]);
return { data: flatRundown, rundownId: data.id, status };
}
export function useFlatRundownWithMetadata() {
const { data, status } = useRundown();
- const selectedEventId = useSelectedEventId();
+ const selectedEventId = useScopedSelectedEventId();
- const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
+ const { entries, flatOrder } = data;
+ const rundownWithMetadata = useMemo(
+ () => getFlatRundownMetadata({ entries, flatOrder }, selectedEventId),
+ [entries, flatOrder, selectedEventId],
+ );
return { data: rundownWithMetadata, status };
}
-/**
- * Provides access to a partial rundown based on a filter callback
- *
- * Callers MUST memoize the callback with useCallback to prevent
- * re-filtering on every render.
- *
- */
-export function usePartialRundown(cb: (event: ExtendedEntry) => boolean) {
- const { data, status } = useFlatRundownWithMetadata();
- const filteredData = useMemo(() => {
- return data.filter(cb);
- }, [data, cb]);
-
- return { data: filteredData, status };
-}
-
/**
* Hook to get a specific entry by ID from the rundown
*/
@@ -110,30 +68,3 @@ export function useEntry(entryId: EntryId | null): OntimeEntry | null {
if (entryId === null) return null;
return rundown.entries[entryId] ?? null;
}
-
-export function useRundownAuxData() {
- const { data, status } = useRundown();
- const filteredData = useMemo(() => {
- const { title, id } = data;
- return { title, id };
- }, [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/useRundownById.ts b/apps/client/src/common/hooks-query/useRundownById.ts
new file mode 100644
index 000000000..6725b0090
--- /dev/null
+++ b/apps/client/src/common/hooks-query/useRundownById.ts
@@ -0,0 +1,65 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { OntimeEntry, Rundown } from 'ontime-types';
+import { useEffect, useRef } from 'react';
+
+import { queryRefetchIntervalSlow } from '../../ontimeConfig';
+import { CURRENT_RUNDOWN_QUERY_KEY, getRundownCacheKey, getRundownQueryKey } from '../api/constants';
+import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
+
+// revision is -1 so that the remote revision is higher
+const cachedRundownPlaceholder: Rundown = {
+ id: 'default',
+ title: '',
+ order: [],
+ flatOrder: [],
+ entries: {},
+ revision: -1,
+};
+
+/**
+ * Provides access to a specific rundown by ID.
+ *
+ * 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 function useRundownById(rundownId: string | null | undefined) {
+ const queryClient = useQueryClient();
+ const id = rundownId ?? '';
+ const isBootstrap = id === '';
+
+ const { data, status, isError, refetch, isFetching } = useQuery({
+ queryKey: getRundownCacheKey(id),
+ queryFn: ({ signal }) => (isBootstrap ? fetchCurrentRundown({ signal }) : fetchRundown(id, { signal })),
+ refetchInterval: queryRefetchIntervalSlow,
+ });
+
+ // Seed the id-keyed cache when fetching via the bootstrap alias
+ useEffect(() => {
+ if (!data || !isBootstrap) return;
+ queryClient.setQueryData(getRundownQueryKey(data.id), data);
+ }, [data, isBootstrap, queryClient]);
+
+ // Once we have the ID, drop the temporary current cache.
+ // Only the reader which bootstrapped may do so, others are still relying on it.
+ const didBootstrap = useRef(isBootstrap);
+ useEffect(() => {
+ if (isBootstrap || !didBootstrap.current) return;
+ didBootstrap.current = false;
+ queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
+ }, [isBootstrap, queryClient]);
+
+ return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
+}
+
+/**
+ * Builds a flat rundown from the order and entries fields
+ */
+export function flattenRundown(rundown: Rundown): OntimeEntry[] {
+ if (rundown.revision === -1) {
+ return [];
+ }
+ return rundown.flatOrder
+ .map((id) => rundown.entries[id])
+ .filter((entry): entry is OntimeEntry => entry !== undefined);
+}
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/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts
index a561e416e..7f785a172 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,
@@ -32,9 +31,10 @@ import {
swapEventData,
} from 'ontime-utils';
import { useCallback, useMemo } from 'react';
+import isEqual from 'react-fast-compare';
import { moveDown, moveUp, orderEntries } from '../../features/rundown/rundown.utils';
-import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../api/constants';
+import { getRundownCacheKey } from '../api/constants';
import {
ReorderEntry,
deleteEntries,
@@ -52,6 +52,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<{
@@ -64,22 +65,38 @@ export type EventOptions = Partial<{
lastEventId: MaybeString;
}>;
+/**
+ * Applies a patch the way the server does, revision included.
+ *
+ * An entry revision advances on every change to that entry, which makes it a
+ * cheap marker for whether an entry has moved on. The optimistic entry has to
+ * carry the revision the server will return, otherwise the two disagree and the
+ * refetch resolves to a different object for no reason.
+ * Mirrors applyPatchToEntry in the rundown service: delays carry no revision.
+ */
+function patchEntry(entry: OntimeEntry, patch: Partial): OntimeEntry {
+ if (isOntimeEvent(entry) || isOntimeGroup(entry) || isOntimeMilestone(entry)) {
+ return { ...entry, ...patch, revision: entry.revision + 1 } as OntimeEntry;
+ }
+ return { ...entry, ...patch } as OntimeEntry;
+}
+
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/**
- * 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 ?? '');
+export const useScopedEntryActions = (rundownId: MaybeString) => useEntryActionsForRundown(rundownId ?? '');
-function useEntryActionsForRundown(scopedRundownId: string | undefined) {
+function useEntryActionsForRundown(scopedRundownId: string) {
const queryClient = useQueryClient();
const {
linkPrevious,
@@ -93,12 +110,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
} = useEditorSettings();
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 getRundownCacheKey(scopedRundownId);
+ }, [scopedRundownId]);
/**
* Returns the currently loaded rundown
@@ -325,8 +338,10 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (previousData && eventId) {
// optimistically update object
const newRundown = { ...previousData.entries };
- // @ts-expect-error -- we expect the events to be of same type
- newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
+ const previousEntry = newRundown[eventId];
+ if (previousEntry) {
+ newRundown[eventId] = patchEntry(previousEntry, newEvent);
+ }
queryClient.setQueryData(queryKey, {
id: previousData.id,
title: previousData.title,
@@ -340,6 +355,23 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// Return a context with the previous and new events
return { previousData, newEvent, queryKey };
},
+ // the server is the authority on the applied patch, it may normalise what we sent
+ onSuccess: (response, _variables, context) => {
+ const serverEntry = response.data;
+ if (!serverEntry || !context?.queryKey) return;
+
+ const cachedRundown = queryClient.getQueryData(context.queryKey);
+ if (!cachedRundown) return;
+
+ // our optimistic entry usually describes the change exactly, writing an
+ // identical entry would discard the cached reference for nothing
+ if (isEqual(cachedRundown.entries[serverEntry.id], serverEntry)) return;
+
+ queryClient.setQueryData(context.queryKey, {
+ ...cachedRundown,
+ entries: { ...cachedRundown.entries, [serverEntry.id]: serverEntry },
+ });
+ },
// Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => {
if (context?.previousData && context?.queryKey) {
@@ -512,10 +544,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
if (Object.hasOwn(newRundown, eventId)) {
const event = newRundown[eventId];
if (isOntimeEvent(event)) {
- newRundown[eventId] = {
- ...event,
- ...data,
- };
+ newRundown[eventId] = patchEntry(event, data.data);
}
}
});
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..1aa309fee
--- /dev/null
+++ b/apps/client/src/common/stores/__tests__/eventSelectionStore.test.ts
@@ -0,0 +1,64 @@
+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('replaces the selection set so subscribers on the set re-render', () => {
+ const store = createEventSelectionStore(() => makeRundown('rundown-a', ['a1', 'a2']));
+
+ store.getState().setSelectedEvents({ id: 'a1', index: 0, selectMode: 'click' });
+ const afterClick = store.getState().selectedEvents;
+
+ store.getState().setSelectedEvents({ id: 'a2', index: 1, selectMode: 'ctrl' });
+ const afterAdd = store.getState().selectedEvents;
+ expect(afterAdd).not.toBe(afterClick);
+ expect(afterAdd).toEqual(new Set(['a1', 'a2']));
+
+ store.getState().unselect('a1');
+ const afterUnselect = store.getState().selectedEvents;
+ expect(afterUnselect).not.toBe(afterAdd);
+ expect(afterUnselect).toEqual(new Set(['a2']));
+ });
+
+ 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/eventSelectionStore.ts b/apps/client/src/common/stores/eventSelectionStore.ts
new file mode 100644
index 000000000..0db39c67e
--- /dev/null
+++ b/apps/client/src/common/stores/eventSelectionStore.ts
@@ -0,0 +1,158 @@
+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: new Set(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
+ const withoutId = new Set(selectedEvents);
+ withoutId.delete(id);
+
+ const nextIndex = rundownData.order.findIndex(
+ (eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && withoutId.has(eventId),
+ );
+
+ // if we didnt find anything after, set the anchor to the last event
+ return set({
+ selectedEvents: withoutId,
+ 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();
+ const remaining = new Set(selectedEvents);
+ remaining.delete(id);
+ set({
+ selectedEvents: remaining,
+ entryMode: remaining.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';
+}