Compare commits

..

25 Commits

Author SHA1 Message Date
google-labs-jules[bot] fbbbaa5922 fix: stabilize cuesheet table components to prevent focus and data loss
This commit addresses the issue where focusing cells or inputs in the cuesheet table was lost during re-renders.

Changes:
- Extracted Virtuoso components (Table, TableRow, TableHead, EmptyPlaceholder) into stable, memoized components outside the main CuesheetTable component.
- Used Virtuoso's context API to pass dynamic state to the extracted components.
- Stabilized the 'meta' object of TanStack Table by using a ref for the data, reducing unnecessary re-renders of cell components.
- Modified useReactiveTextInput to skip synchronizing the 'text' state with 'initialText' when the input is focused, preventing user input from being overwritten by external changes.

These changes ensure that focus is maintained even when the table re-renders due to concurrent edits or submitting changes, and typed content is preserved.

Co-authored-by: cpvalente <34649812+cpvalente@users.noreply.github.com>
2026-02-08 11:24:48 +00:00
Carlos Valente aecf9575b1 refactor: link to settings section 2026-02-08 11:20:09 +01:00
Carlos Valente 773229ce4c refactor: align inputs with group, refs #1857 2026-02-08 11:20:09 +01:00
Carlos Valente da02ecd113 feat: editor layout 2026-02-08 11:20:09 +01:00
Carlos Valente 967e92e2c8 feat: extend rundown shortcuts 2026-02-08 11:20:09 +01:00
Carlos Valente d7af73d9ed feat: unify rundown features 2026-02-08 11:20:09 +01:00
Carlos Valente c5045ad82e refactor: table mode and context 2026-02-08 11:20:09 +01:00
Carlos Valente 8c22968bc9 fix: tsconfig references for utility tests 2026-02-08 11:20:09 +01:00
Carlos Valente 8aad271ffe refactor: create utility for enums 2026-02-08 11:20:09 +01:00
Carlos Valente b28573455a feat: create reusable scroll area 2026-02-08 11:20:09 +01:00
Carlos Valente e25725ea8b refactor: expose actions through context 2026-02-08 11:20:09 +01:00
Carlos Valente 1f8065143a refactor: improve context menu performance
- extract menu from rundown tree
- lazy create actions
2026-02-08 11:20:09 +01:00
Carlos Valente 8e32314667 refactor: improve performance in rundown
- leverage compiler
- correct subscriptions from zustand
2026-02-08 11:20:09 +01:00
Carlos Valente 0de55e74a8 feat: table mode in editor 2026-02-08 11:20:09 +01:00
Carlos Valente e5c5ec18d3 fix: automation form validation 2026-02-08 11:20:09 +01:00
Carlos Valente f9f6cb74ad chore: update npm link 2026-02-01 18:03:56 +01:00
Alex Christoffer Rasmussen 026e24bf84 Feat: unify view search params (#1947)
* feat: main src in backstage view

* feat: main src in timeline view

* feat: main src in countdown view

* feat: main src in studio view

* feat: hide past events in countdown view

* notes can not be set as main src

* feat: show group title as secondary src

* chore: spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2026-01-27 20:42:07 +01:00
Joel Wetzell ec1890c275 update docker actions and pin to commit hash 2026-01-26 14:10:09 +01:00
Joel Wetzell 046f1176eb add docker metadata action to setup tags 2026-01-26 14:10:09 +01:00
Alex Christoffer Rasmussen 04c5b67475 fix: missing demo logo (#1954) 2026-01-26 13:52:29 +01:00
google-labs-jules[bot] 7b5f797c95 refactor: move welcome modal logic to AppStateService
The logic for deciding whether to show the welcome modal has been moved from `app.ts` to `AppStateService.ts`. The `getShowWelcomeDialog` function now accepts a boolean indicating whether a restore point exists and returns `false` if it does. This keeps the business logic out of the main application file and in the relevant service.
2026-01-24 07:45:56 +01:00
google-labs-jules[bot] 14f9472250 fix: hide welcome modal on restore
The welcome modal is now hidden if the application is started from a restore point.
2026-01-24 07:45:56 +01:00
Alex Christoffer Rasmussen efede83271 Fix: automation (#1943)
* Fix(automation): Handles undefined values in conditions (#1933)

* Fix(automation): Handles undefined values in conditions

Addresses an issue where conditions with the 'not_equals' operator incorrectly evaluated undefined values. This change ensures that empty string comparisons correctly identify missing values in automation rules.

* Fixes automation "not equals" logic

Simplifies the 'not_equals' condition evaluation in automations by reusing the 'equals' condition, improving code readability and consistency.

Fixes #1932

* Test(automation): Expanding filter condition testing

Expanding test cases to cover various data types and edge case for each operators.
Unexpected behavior have been mark with a TO_DO.

* Fix(automation): Handles default filter case

- Ensures that their is a default scenario.
- Fixing Deepsource issue "No default cases in switch statements JS-0047"

* Test(automation): Remove abstraction in test

* move to test.each

* extract a isEquivalent function

* lowercase contains test

---------

Co-authored-by: Philippe Allard-Rousse <philrousse@gmail.com>
2026-01-21 16:07:21 +01:00
Carlos Valente 904db95f59 docs: update linux links 2026-01-16 07:52:56 +01:00
Carlos Valente 525999440a fix: inline elements should not wrap by default 2026-01-14 17:12:03 +01:00
180 changed files with 5234 additions and 1511 deletions
+15 -16
View File
@@ -37,32 +37,31 @@ jobs:
run: pnpm build:docker run: pnpm build:docker
- name: Docker Login - name: Docker Login
uses: docker/login-action@v2.1.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Setup Buildx - name: Docker Setup Buildx
uses: docker/setup-buildx-action@v2.5.0 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Setup Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/ontime
tags: |
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=raw,value=nightly,enable=${{ github.event.release.prerelease == true }}
- name: Build and push stable release - name: Build and push stable release
if: github.event.release.prerelease == false uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@v4.0.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry # Push is a shorthand for --output=type=registry
push: true push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push pre-release
if: github.event.release.prerelease == true
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+6 -4
View File
@@ -10,13 +10,15 @@
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a> <a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">macOS (Apple Silicon)</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a> - Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">macOS (Intel)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a> - Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-x86_64.AppImage">Linux (Intel / AMD 64-bit)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-arm64.AppImage">Linux (ARM 64-bit, Raspberry Pi 4+)</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux-armv7l.AppImage">Linux (ARM 32-bit, older Raspberry Pi)</a>
... or ... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a> - Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a> - Install from <a href="https://www.npmjs.com/package/@getontime/cli">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a> - Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help? ## Need help?
@@ -31,11 +31,12 @@
outline: 0; outline: 0;
cursor: default; cursor: default;
padding-block: 0.5rem; padding-block: 0.5rem;
padding-inline: 1rem 2rem; padding-inline: 1rem;
display: flex; display: flex;
gap: 0.5rem; justify-content: space-between;
line-height: 1em; align-items: flex-start;
gap: 1rem;
svg { svg {
color: $gray-500; color: $gray-500;
@@ -69,6 +70,35 @@
} }
} }
.item .iconStrong {
color: $ui-white;
}
.content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
line-height: 1.2;
}
.description {
color: $gray-400;
font-size: calc(1rem - 2px);
line-height: 1.3;
}
.shortcut {
color: $gray-500;
font-size: calc(1rem - 4px);
letter-spacing: 0.02em;
}
.separator { .separator {
margin: 0.25rem 0.75rem; margin: 0.25rem 0.75rem;
height: 1px; height: 1px;
@@ -8,9 +8,11 @@ type DropdownMenuItemDivider = { type: 'divider' };
type DropdownMenuItem = { type DropdownMenuItem = {
type: 'item' | 'destructive'; type: 'item' | 'destructive';
label: string; label: string;
description?: string;
icon?: IconType; icon?: IconType;
disabled?: boolean; disabled?: boolean;
onClick: () => void; onClick: () => void;
shortcut?: string;
}; };
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem; export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
@@ -38,8 +40,14 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
disabled={item.disabled} disabled={item.disabled}
data-type={item.type} data-type={item.type}
> >
{item.icon && <item.icon />} <span className={style.content}>
{item.label} <span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.description && <span className={style.description}>{item.description}</span>}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item> </BaseMenu.Item>
); );
})} })}
@@ -75,8 +83,14 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
} }
return ( return (
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}> <BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
{item.icon && <item.icon />} <span className={style.content}>
{item.label} <span className={style.label}>
{item.icon && <item.icon />}
{item.label}
</span>
{item.description && <span className={style.description}>{item.description}</span>}
</span>
{item.shortcut && <span className={style.shortcut}>{item.shortcut}</span>}
</BaseMenu.Item> </BaseMenu.Item>
); );
})} })}
@@ -25,12 +25,15 @@ export default function useReactiveTextInput(
const isKeyboardSubmitting = useRef(false); const isKeyboardSubmitting = useRef(false);
useEffect(() => { useEffect(() => {
const isFocused = document.activeElement === ref.current;
if (isFocused) return;
if (typeof initialText === 'undefined') { if (typeof initialText === 'undefined') {
setText(''); setText('');
} else { } else {
setText(initialText); setText(initialText);
} }
}, [initialText]); }, [initialText, ref]);
/** /**
* @description Handles Input value change * @description Handles Input value change
@@ -0,0 +1,56 @@
@use '../../../theme/ontimeColours' as *;
@use '../../../theme/ontimeStyles' as *;
// copied from index.scss
$track-color: $white-1;
$thumb-color: $white-20;
$thumb-color-hover: $white-60;
.root {
position: relative;
overflow: hidden;
}
.viewport {
height: 100%;
width: 100%;
overscroll-behavior: contain;
}
.scrollbar {
background: transparent;
opacity: 0;
transition: opacity 150ms;
&[data-orientation='vertical'] {
width: 6px;
}
&[data-orientation='horizontal'] {
height: 6px;
}
&[data-orientation='horizontal'][data-has-overflow-x] {
opacity: 1;
pointer-events: auto;
}
&[data-scrolling] {
transition-duration: 0ms;
}
&[data-hovering],
&[data-scrolling] {
opacity: 1;
pointer-events: auto;
}
}
.thumb {
background: $thumb-color;
border-radius: 2px;
&:hover {
background: $thumb-color-hover;
}
}
@@ -0,0 +1,38 @@
import { CSSProperties, PropsWithChildren, Ref } from 'react';
import { ScrollArea } from '@base-ui/react/scroll-area';
import { cx } from '../../utils/styleUtils';
import style from './ScrollArea.module.scss';
interface ScrollAreaProps {
className?: string;
viewportClassName?: string;
contentClassName?: string;
contentStyle?: CSSProperties;
ref?: Ref<HTMLDivElement>;
orientation?: 'vertical' | 'horizontal';
}
export default function StyledScrollArea({
className,
viewportClassName,
contentClassName,
contentStyle,
children,
ref,
orientation = 'vertical',
}: PropsWithChildren<ScrollAreaProps>) {
return (
<ScrollArea.Root className={cx([style.root, className])}>
<ScrollArea.Viewport ref={ref} className={cx([style.viewport, viewportClassName])}>
<ScrollArea.Content className={contentClassName} style={contentStyle}>
{children}
</ScrollArea.Content>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar className={style.scrollbar} orientation={orientation}>
<ScrollArea.Thumb className={style.thumb} />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
);
}
@@ -1,7 +1,11 @@
import { ReactNode } from 'react'; import { PropsWithChildren } from 'react';
import style from './Tag.module.scss'; import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) { interface TagProps {
return <span className={style.tag}>{children}</span>; className?: string;
}
export default function Tag({ className, children }: PropsWithChildren<TagProps>) {
return <span className={`${style.tag} ${className || ''}`}>{children}</span>;
} }
@@ -16,6 +16,7 @@
.title-card__title { .title-card__title {
color: var(--color-override, $viewer-color); color: var(--color-override, $viewer-color);
padding-right: 1em; padding-right: 1em;
min-height: 1.2em;
} }
.title-card__placeholder { .title-card__placeholder {
@@ -0,0 +1,24 @@
import { createContext, PropsWithChildren, useContext } from 'react';
import { useEntryActions } from '../hooks/useEntryAction';
type EntryActionsContextValue = ReturnType<typeof useEntryActions>;
const EntryActionsContext = createContext<EntryActionsContextValue | null>(null);
interface EntryActionsProviderProps extends PropsWithChildren {
actions: EntryActionsContextValue;
}
export function EntryActionsProvider({ children, actions }: EntryActionsProviderProps) {
return <EntryActionsContext.Provider value={actions}>{children}</EntryActionsContext.Provider>;
}
export function useEntryActionsContext(): EntryActionsContextValue {
const context = useContext(EntryActionsContext);
if (!context) {
throw new Error('useEntryActionsContext must be used within EntryActionsProvider');
}
return context;
}
@@ -33,7 +33,7 @@ export default function useRundown() {
export function useRundownWithMetadata() { export function useRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownMetadata = useMemo(() => getRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data, status, rundownMetadata }; return { data, status, rundownMetadata };
} }
@@ -57,7 +57,7 @@ export function useFlatRundown() {
export function useFlatRundownWithMetadata() { export function useFlatRundownWithMetadata() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { selectedEventId } = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]); const rundownWithMetadata = useMemo(() => getFlatRundownMetadata(data, selectedEventId), [data, selectedEventId]);
return { data: rundownWithMetadata, status }; return { data: rundownWithMetadata, status };
@@ -1,18 +1,24 @@
import { MouseEvent } from 'react'; import { MouseEvent, useCallback } from 'react';
import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu'; import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu';
import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu'; import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu';
export const useContextMenu = <T extends HTMLElement>(options: DropdownMenuOption[]) => { type ContextMenuOptions = () => DropdownMenuOption[];
export const useContextMenu = <T extends HTMLElement>(options: ContextMenuOptions) => {
const setContextMenu = useContextMenuStore((state) => state.setContextMenu); const setContextMenu = useContextMenuStore((state) => state.setContextMenu);
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => { const localCreateContextMenu = useCallback(
// prevent browser default context menu from showing up (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
contextMenuEvent.preventDefault(); // prevent browser default context menu from showing up
contextMenuEvent.preventDefault();
const { pageX, pageY } = contextMenuEvent; const { pageX, pageY } = contextMenuEvent;
return setContextMenu({ x: pageX, y: pageY }, options); const menuOptions = options();
}; return setContextMenu({ x: pageX, y: pageY }, menuOptions);
},
[options, setContextMenu],
);
return [localCreateContextMenu]; return [localCreateContextMenu];
}; };
+35 -17
View File
@@ -1,4 +1,4 @@
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
EntryId, EntryId,
@@ -840,22 +840,40 @@ export const useEntryActions = () => {
[getCurrentRundownData, swapEventsMutation], [getCurrentRundownData, swapEventsMutation],
); );
return { return useMemo(
addEntry, () => ({
applyDelay, addEntry,
batchUpdateEvents, applyDelay,
clone, batchUpdateEvents,
deleteEntry, clone,
deleteAllEntries, deleteEntry,
ungroup, deleteAllEntries,
getEntryById, ungroup,
groupEntries, getEntryById,
move, groupEntries,
reorderEntry, move,
swapEvents, reorderEntry,
updateEntry, swapEvents,
updateTimer, updateEntry,
}; updateTimer,
}),
[
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
updateTimer,
],
);
}; };
/** /**
+8 -28
View File
@@ -128,13 +128,9 @@ export const setAuxTimer = {
setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }), setDuration: (index: number, time: number) => sendSocket('auxtimer', { [index]: { duration: time } }),
}; };
export const useSelectedEventId = createSelector((state: RuntimeStore) => ({ export const useSelectedEventId = createSelector((state: RuntimeStore) => state.eventNow?.id ?? null);
selectedEventId: state.eventNow?.id ?? null,
}));
export const useCurrentGroupId = createSelector((state: RuntimeStore) => ({ export const useCurrentGroupId = createSelector((state: RuntimeStore) => state.groupNow?.id ?? null);
currentGroupId: state.groupNow?.id ?? null,
}));
export const setEventPlayback = { export const setEventPlayback = {
loadEvent: (id: string) => sendSocket('load', { id }), loadEvent: (id: string) => sendSocket('load', { id }),
@@ -147,9 +143,7 @@ export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer, ...state.timer,
})); }));
export const useClock = createSelector((state: RuntimeStore) => ({ export const useClock = createSelector((state: RuntimeStore) => state.clock);
clock: state.clock,
}));
export const useNextFlag = createSelector((state: RuntimeStore) => ({ export const useNextFlag = createSelector((state: RuntimeStore) => ({
id: state.eventFlag?.id ?? null, id: state.eventFlag?.id ?? null,
@@ -173,28 +167,16 @@ export const useExpectedStartData = createSelector((state: RuntimeStore) => ({
clock: state.clock, clock: state.clock,
})); }));
export const usePing = createSelector((state: RuntimeStore) => ({ export const usePing = createSelector((state: RuntimeStore) => state.ping);
ping: state.ping,
}));
/** convert ping into a derived value which changes less often */ /** convert ping into a derived value which changes less often */
export const useIsOnline = createSelector((state: RuntimeStore) => ({ export const useIsOnline = createSelector((state: RuntimeStore) => state.ping > 0);
isOnline: state.ping > 0,
}));
export const useOffsetMode = createSelector((state: RuntimeStore) => ({ export const useOffsetMode = createSelector((state: RuntimeStore) => state.offset.mode);
offsetMode: state.offset.mode,
}));
export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload); export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload);
export const usePlayback = () => { export const usePlayback = createSelector((state: RuntimeStore) => state.timer.playback);
const featureSelector = (state: RuntimeStore) => ({
playback: state.timer.playback,
});
return useRuntimeStore(featureSelector);
};
/* ======================= Overview data subscriptions ======================= */ /* ======================= Overview data subscriptions ======================= */
@@ -204,9 +186,7 @@ export const useStartTimesOverview = createSelector((state: RuntimeStore) => ({
plannedEnd: state.rundown.plannedEnd, plannedEnd: state.rundown.plannedEnd,
})); }));
export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => ({ export const useRundownExpectedEnd = createSelector((state: RuntimeStore) => state.offset.expectedRundownEnd);
expectedEnd: state.offset.expectedRundownEnd,
}));
export const useProgressOverview = createSelector((state: RuntimeStore) => ({ export const useProgressOverview = createSelector((state: RuntimeStore) => ({
numEvents: state.rundown.numEvents, numEvents: state.rundown.numEvents,
@@ -2,10 +2,13 @@ import { create } from 'zustand';
type EntryCopyStore = { type EntryCopyStore = {
entryCopyId: string | null; entryCopyId: string | null;
setEntryCopyId: (eventId: string | null) => void; entryCopyMode: 'copy' | 'cut';
setEntryCopyId: (eventId: string | null, mode?: 'copy' | 'cut') => void;
}; };
export const useEntryCopy = create<EntryCopyStore>()((set) => ({ export const useEntryCopy = create<EntryCopyStore>()((set) => ({
entryCopyId: null, entryCopyId: null,
setEntryCopyId: (entryCopyId: string | null) => set({ entryCopyId }), entryCopyMode: 'copy',
setEntryCopyId: (entryCopyId: string | null, mode: 'copy' | 'cut' = 'copy') =>
set({ entryCopyId, entryCopyMode: mode }),
})); }));
@@ -196,7 +196,6 @@ $inner-padding: 1rem;
.inlineElements { .inlineElements {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
&.inner { &.inner {
gap: 0.5rem; gap: 0.5rem;
@@ -213,6 +212,11 @@ $inner-padding: 1rem;
&.apart { &.apart {
justify-content: space-between; justify-content: space-between;
} }
&.wrap {
flex-wrap: wrap;
row-gap: 0.5rem;
}
} }
@keyframes animloader { @keyframes animloader {
@@ -107,7 +107,7 @@ export function BlockQuote({ children }: { children: ReactNode }) {
return <blockquote className={style.blockquote}>{children}</blockquote>; return <blockquote className={style.blockquote}>{children}</blockquote>;
} }
export function Error({ children, className }: { children: ReactNode } & React.JSX.IntrinsicElements['div']) { export function Error({ children, className }: PropsWithChildren & React.JSX.IntrinsicElements['div']) {
return <div className={cx([style.fieldError, className])}>{children}</div>; return <div className={cx([style.fieldError, className])}>{children}</div>;
} }
@@ -131,16 +131,25 @@ type InlineProps<C extends AllowedInlineTags> = {
as?: C; as?: C;
relation?: 'inner' | 'component' | 'section'; relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end' | 'apart'; align?: 'start' | 'end' | 'apart';
className?: string; wrap?: 'wrap' | 'nowrap';
}; } & Omit<React.JSX.IntrinsicElements[C], 'align'>;
export function InlineElements<C extends AllowedInlineTags = 'div'>({ export function InlineElements<C extends AllowedInlineTags = 'div'>({
children, children,
as, as,
relation = 'component', relation = 'component',
align = 'start', align = 'start',
wrap = 'nowrap',
className, className,
...elementProps
}: PropsWithChildren<InlineProps<C>>) { }: PropsWithChildren<InlineProps<C>>) {
const Element = as ?? 'div'; const Element = as ?? 'div';
return <Element className={cx([style.inlineElements, style[relation], style[align], className])}>{children}</Element>; return (
<Element
{...(elementProps as HTMLAttributes<HTMLElement>)}
className={cx([style.inlineElements, style[relation], style[align], style[wrap], className])}
>
{children}
</Element>
);
} }
@@ -130,7 +130,7 @@ export default function OntimeActionForm({
}} }}
value={watch(`outputs.${index}.secondarySource`)} value={watch(`outputs.${index}.secondarySource`)}
options={[ options={[
{ value: null, label: 'Select secondary source', disabled: true }, { value: null, label: 'Select secondary source' },
{ value: 'aux1', label: 'Auxiliary timer 1' }, { value: 'aux1', label: 'Auxiliary timer 1' },
{ value: 'aux2', label: 'Auxiliary timer 2' }, { value: 'aux2', label: 'Auxiliary timer 2' },
{ value: 'aux3', label: 'Auxiliary timer 3' }, { value: 'aux3', label: 'Auxiliary timer 3' },
@@ -14,7 +14,7 @@ export default function InfoNif() {
const handleClick = (address: string) => openLink(address); const handleClick = (address: string) => openLink(address);
return ( return (
<Panel.InlineElements> <Panel.InlineElements wrap='wrap'>
{data.networkInterfaces.map((nif) => { {data.networkInterfaces.map((nif) => {
// interfaces outside localhost wont have access // interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null; if (nif.name === 'localhost' && !isLocalhost) return null;
@@ -34,7 +34,7 @@ export default function NetworkLogPanel({ location }: PanelBaseProps) {
} }
function OntimeCloudStats() { function OntimeCloudStats() {
const { ping } = usePing(); const ping = usePing();
/** /**
* Send immediate ping request, and keep sending on an interval * Send immediate ping request, and keep sending on an interval
@@ -90,7 +90,10 @@ const staticOptions = [
}, },
] as const; ] as const;
export type SettingsOptionId = (typeof staticOptions)[number]['id']; // a child of navigation or a child of secondary navigation
export type SettingsOptionId =
| (typeof staticOptions)[number]['id']
| Extract<(typeof staticOptions)[number], { secondary: object }>['secondary'][number]['id'];
export function useAppSettingsMenu() { export function useAppSettingsMenu() {
const { data } = useAppVersion(); const { data } = useAppVersion();
@@ -41,5 +41,4 @@
padding-inline: 0.5em; padding-inline: 0.5em;
outline: none; outline: none;
}
}
@@ -1,9 +1,8 @@
import { useMemo } from 'react';
import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop } from 'react-icons/io5'; import { IoPause, IoPlay, IoPlaySkipBack, IoPlaySkipForward, IoReload, IoStop } from 'react-icons/io5';
import { Playback, TimerPhase } from 'ontime-types'; import { Playback, TimerPhase } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../../common/hooks/useSocket'; import { setPlayback } from '../../../../common/hooks/useSocket';
import { getPlaybackControlState } from '../playbackControl.utils';
import TapButton from '../tap-button/TapButton'; import TapButton from '../tap-button/TapButton';
import style from './PlaybackButtons.module.scss'; import style from './PlaybackButtons.module.scss';
@@ -15,44 +14,32 @@ interface PlaybackButtonsProps {
timerPhase: TimerPhase; timerPhase: TimerPhase;
} }
export default function PlaybackButtons(props: PlaybackButtonsProps) { export default function PlaybackButtons({ playback, numEvents, selectedEventIndex, timerPhase }: PlaybackButtonsProps) {
const { playback, numEvents, selectedEventIndex, timerPhase } = props; const {
isPlaying,
const isRolling = playback === Playback.Roll; isPaused,
const isPlaying = playback === Playback.Play; isRolling,
const isPaused = playback === Playback.Pause; disableGo,
const isArmed = playback === Playback.Armed; disableNext,
disablePrev,
const isFirst = selectedEventIndex === 0; disableStart,
const isLast = selectedEventIndex === numEvents - 1; disablePause,
const noEvents = numEvents === 0; disableRoll,
disableStop,
const disableGo = isRolling || noEvents; disableReload,
const disableNext = isRolling || noEvents || isLast; goAction,
const disablePrev = isRolling || noEvents || isFirst; goLabel,
} = getPlaybackControlState({
const playbackCan = validatePlayback(playback, timerPhase); playback,
const disableStart = !playbackCan.start; numEvents,
const disablePause = !playbackCan.pause; selectedEventIndex,
const disableRoll = !playbackCan.roll || noEvents; timerPhase,
const disableStop = !playbackCan.stop; });
const disableReload = !playbackCan.reload;
const [goModeAction, goModeText] = useMemo(() => {
if (isArmed) {
return [setPlayback.start, 'Start'];
} else if (isLast) {
return [setPlayback.stop, 'Finish'];
} else if (selectedEventIndex === null) {
return [setPlayback.startNext, 'Start'];
}
return [setPlayback.startNext, 'Next'];
}, [isArmed, isLast, selectedEventIndex]);
return ( return (
<div className={style.buttonContainer}> <div className={style.buttonContainer}>
<TapButton disabled={disableGo} onClick={goModeAction} aspect='fill' className={style.go}> <TapButton disabled={disableGo} onClick={goAction} aspect='fill' className={style.go}>
{goModeText} {goLabel}
</TapButton> </TapButton>
<div className={style.playbackContainer}> <div className={style.playbackContainer}>
<TapButton onClick={setPlayback.start} disabled={disableStart} theme={Playback.Play} active={isPlaying}> <TapButton onClick={setPlayback.start} disabled={disableStart} theme={Playback.Play} active={isPlaying}>
@@ -8,6 +8,13 @@
gap: $element-inner-spacing; gap: $element-inner-spacing;
} }
.timerDisplay {
grid-area: timer;
max-width: 18.75rem;
min-width: 5em;
text-align: center;
}
// ---------> INDICATORS // ---------> INDICATORS
.indicators { .indicators {
@@ -42,7 +42,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
<div className={style.indicatorNegative} data-active={isOvertime} /> <div className={style.indicatorNegative} data-active={isOvertime} />
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} /> <Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
</div> </div>
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} /> <TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} phase={timer.phase} />
<div className={style.status}> <div className={style.status}>
{isWaiting ? ( {isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span> <span className={style.rolltag}>Roll: Countdown to start</span>
@@ -0,0 +1,95 @@
import { Playback, TimerPhase } from 'ontime-types';
import { validatePlayback } from 'ontime-utils';
import { setPlayback } from '../../../common/hooks/useSocket';
export interface PlaybackControlInput {
playback: Playback;
numEvents: number;
selectedEventIndex: number | null;
timerPhase: TimerPhase;
}
export interface PlaybackControlState {
// Playback states
isPlaying: boolean;
isPaused: boolean;
isRolling: boolean;
isArmed: boolean;
isStopped: boolean;
// Position states
isFirst: boolean;
isLast: boolean;
noEvents: boolean;
// Disable flags
disableGo: boolean;
disableNext: boolean;
disablePrev: boolean;
disableStart: boolean;
disablePause: boolean;
disableRoll: boolean;
disableStop: boolean;
disableReload: boolean;
disableAddTime: boolean;
// Go button configuration
goAction: () => void;
goLabel: 'Start' | 'Next' | 'Finish';
}
/**
* Centralized playback control state calculator.
* Consolidates all playback logic, disable states, and derived values in one place.
*/
export function getPlaybackControlState({
playback,
numEvents,
selectedEventIndex,
timerPhase,
}: PlaybackControlInput): PlaybackControlState {
const isFirst = selectedEventIndex === 0;
const isLast = selectedEventIndex === numEvents - 1;
const noEvents = numEvents === 0;
const isRolling = playback === Playback.Roll;
const playbackCan = validatePlayback(playback, timerPhase);
const { action: goAction, label: goLabel } = getGoAction(playback, selectedEventIndex, isLast);
return {
isPlaying: playback === Playback.Play,
isPaused: playback === Playback.Pause,
isRolling,
isArmed: playback === Playback.Armed,
isStopped: playback === Playback.Stop,
isFirst,
isLast,
noEvents,
disableGo: isRolling || noEvents,
disableNext: isRolling || noEvents || isLast,
disablePrev: isRolling || noEvents || isFirst,
disableStart: !playbackCan.start,
disablePause: !playbackCan.pause,
disableRoll: !playbackCan.roll || noEvents,
disableStop: !playbackCan.stop,
disableReload: !playbackCan.reload,
disableAddTime: playback !== Playback.Play && playback !== Playback.Pause,
goAction,
goLabel,
};
}
/**
* Determines the action and label for the "Go" button based on playback state.
*/
function getGoAction(
playback: Playback,
selectedEventIndex: number | null,
isLast: boolean,
): { action: () => void; label: 'Start' | 'Next' | 'Finish' } {
if (playback === Playback.Armed) return { action: setPlayback.start, label: 'Start' };
if (isLast) return { action: setPlayback.stop, label: 'Finish' };
if (selectedEventIndex === null) return { action: setPlayback.startNext, label: 'Start' };
return { action: setPlayback.startNext, label: 'Next' };
}
@@ -1,23 +1,34 @@
@use '@/theme/viewerDefs' as *; @use '@/theme/viewerDefs' as *;
.timer { .timer {
grid-area: timer;
white-space: nowrap; white-space: nowrap;
max-width: 18.75rem;
min-width: 5em;
color: $timer-color; line-height: 0.9;
line-height: 0.9em;
text-align: center;
letter-spacing: 0.1em; letter-spacing: 0.1em;
font-weight: 600; font-weight: 600;
font-size: 3.5rem; font-size: 3.5rem;
&.finished { &[data-phase='default'] {
color: $timer-finished-color; color: $timer-color;
} }
&.muted { &[data-phase='warning'] {
color: $warning-orange;
}
&[data-phase='danger'] {
color: $error-red;
}
&[data-phase='overtime'] {
color: $playback-negative;
}
&[data-phase='pending'] {
color: $ontime-roll;
}
&[data-phase='none'] {
color: $muted-gray; color: $muted-gray;
} }
} }
@@ -1,4 +1,4 @@
import { MaybeNumber } from 'ontime-types'; import { MaybeNumber, TimerPhase } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils'; import { cx, timerPlaceholder } from '../../../../common/utils/styleUtils';
@@ -7,19 +7,20 @@ import style from './TimerDisplay.module.scss';
interface TimerDisplayProps { interface TimerDisplayProps {
time: MaybeNumber; time: MaybeNumber;
phase: TimerPhase;
className?: string;
} }
/** /**
* Displays time in ms in formatted timetag * Displays time in ms in formatted timetag
* Used in editor * Used in editor
*/ */
export default function TimerDisplay(props: TimerDisplayProps) { export default function TimerDisplay({ time, phase, className }: TimerDisplayProps) {
const { time } = props; const display = millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
const isNegative = (time ?? 0) < 0; return (
const display = <div className={cx([style.timer, className])} data-phase={phase}>
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', ''); {display}
const classes = cx([style.timer, isNegative ? style.finished : null, time === null && style.muted]); </div>
);
return <div className={classes}>{display}</div>;
} }
@@ -0,0 +1,146 @@
@use '../../../../theme/ontimeColours' as *;
@use '../../../../theme/ontimeStyles' as *;
$element-height: 4.5rem; // matches go button height in PlaybackButtons
.container {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
gap: 1rem;
padding: $panel-gap $section-spacing;
background: $bg-container-l2;
border-radius: $panel-border-radius;
}
.itemGroup {
display: flex;
align-items: center;
gap: $element-spacing;
flex-shrink: 0;
}
.goButton,
.iconButtonWithLabel,
.iconButton {
width: $element-height;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
svg {
font-size: 1.25rem;
}
}
.goButton {
height: $element-height;
min-width: 8rem;
line-height: 1.1;
}
.goLabel {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.2;
}
.shortcutHint {
font-size: calc(1rem - 4px);
opacity: 0.6;
}
.addSection {
display: contents;
}
.separator {
width: 1px;
height: 1.5rem;
background: $white-10;
margin-inline: 0.25rem;
}
.timerSection {
display: flex;
justify-content: center;
min-width: 0;
gap: 0.5rem;
}
.negativeIndicator {
font-size: 2.25rem;
font-weight: 700;
line-height: 1;
color: $playback-negative;
opacity: 0;
&[data-active='true'] {
opacity: 1;
}
}
// -----> Aux timer section (right)
.auxSection {
justify-self: end;
display: flex;
align-items: center;
gap: $element-inner-spacing;
flex-shrink: 0;
}
.auxControls {
display: flex;
gap: $element-inner-spacing;
}
.auxTimeInput,
.auxTimeDisplay {
width: 6.5rem;
min-width: 6.5rem;
max-width: 6.5rem;
height: $element-height;
text-align: center;
background: $gray-1050;
border-radius: $component-border-radius-md;
color: $ui-white;
}
.auxTimeDisplay {
display: grid;
place-content: center;
font-size: 1rem;
font-weight: 400;
color: $gray-200;
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
padding-inline: 0.5em;
outline: none;
}
// hardcoded value where the timer is over the buttons
@media (max-width: 1048px) {
.container {
grid-template-columns: 1fr 1fr;
}
.timerSection {
justify-self: end;
}
.auxSection {
display: none;
}
}
// hardcoded value where the timer is over the buttons
@media (max-width: 728px) {
.addSection {
display: none;
}
}
@@ -0,0 +1,184 @@
import { IoAdd, IoArrowDown, IoArrowUp, IoPause, IoPlay, IoPlaySkipForward, IoRemove, IoStop } from 'react-icons/io5';
import { useHotkeys, useLocalStorage } from '@mantine/hooks';
import { Playback, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import {
setAuxTimer,
setPlayback,
useAuxTimerControl,
useAuxTimerTime,
usePlaybackControl,
useTimer,
} from '../../../../common/hooks/useSocket';
import { enDash } from '../../../../common/utils/styleUtils';
import { formatDuration } from '../../../../common/utils/time';
import { getPlaybackControlState } from '../playbackControl.utils';
import TapButton from '../tap-button/TapButton';
import TimerDisplay from '../timer-display/TimerDisplay';
import style from './TrackingPlaybackBar.module.scss';
export default function TrackingPlaybackBar() {
const timer = useTimer();
const { playback, numEvents, selectedEventIndex } = usePlaybackControl();
const { playback: auxPlayback, direction: auxDirection } = useAuxTimerControl(1);
const auxTime = useAuxTimerTime(1);
const [addTimeInMs] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 });
const { disableGo, disableNext, disableAddTime, isPlaying, goAction, goLabel } = getPlaybackControlState({
playback,
numEvents,
selectedEventIndex,
timerPhase: timer.phase,
});
const disableAddTimeWithAmount = disableAddTime || addTimeInMs === 0;
const handleAddTime = (direction: 'add' | 'remove') => {
if (disableAddTimeWithAmount) return;
if (direction === 'add') {
setPlayback.addTime(addTimeInMs);
} else {
setPlayback.addTime(-1 * addTimeInMs);
}
};
const handleAuxPlayPause = () => {
if (auxPlayback === SimplePlayback.Start) {
setAuxTimer.pause(1);
} else {
setAuxTimer.start(1);
}
};
const handleAuxStop = () => {
setAuxTimer.stop(1);
};
const handleAuxDirectionToggle = () => {
const newDirection =
auxDirection === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
setAuxTimer.setDirection(1, newDirection);
};
const handleAuxTimeChange = (_field: string, value: string) => {
const newTimeInMs = parseUserTime(value);
setAuxTimer.setDuration(1, newTimeInMs);
};
useHotkeys([
['Space', () => !disableGo && goAction(), { preventDefault: true }],
['N', () => !disableNext && setPlayback.next(), { preventDefault: true }],
['Escape', () => playback !== Playback.Stop && setPlayback.stop(), { preventDefault: true }],
]);
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const displayTime = isWaiting ? timer.secondaryTimer : timer.current;
const addTimeLabel = formatDuration(addTimeInMs);
return (
<div className={style.container}>
<div className={style.itemGroup}>
<TapButton
onClick={goAction}
disabled={disableGo}
theme={Playback.Play}
active={isPlaying}
className={style.goButton}
>
<span className={style.goLabel}>{goLabel}</span>
<span className={style.shortcutHint}>[space]</span>
</TapButton>
<TapButton
onClick={setPlayback.next}
disabled={disableNext}
className={style.iconButtonWithLabel}
aspect='square'
>
<IoPlaySkipForward />
<span className={style.shortcutHint}>[n]</span>
</TapButton>
<TapButton
onClick={setPlayback.stop}
disabled={playback === Playback.Stop}
className={style.iconButtonWithLabel}
aspect='square'
>
<IoStop />
<span className={style.shortcutHint}>[esc]</span>
</TapButton>
<div className={style.addSection}>
<div className={style.separator} />
<TapButton
onClick={() => handleAddTime('remove')}
disabled={disableAddTimeWithAmount}
className={style.iconButtonWithLabel}
aspect='square'
>
<IoRemove />
{addTimeLabel}
</TapButton>
<TapButton
onClick={() => handleAddTime('add')}
disabled={disableAddTimeWithAmount}
className={style.iconButtonWithLabel}
aspect='square'
>
<IoAdd />
{addTimeLabel}
</TapButton>
</div>
</div>
<div className={style.timerSection}>
<span className={style.negativeIndicator} data-active={isOvertime}>
{enDash}
</span>
<TimerDisplay time={displayTime} phase={timer.phase} />
</div>
<div className={style.auxSection}>
<TapButton onClick={handleAuxPlayPause} className={style.iconButton} theme={Playback.Play} aspect='square'>
{auxPlayback === SimplePlayback.Start ? <IoPause /> : <IoPlay />}
</TapButton>
<TapButton
onClick={handleAuxStop}
disabled={auxPlayback === SimplePlayback.Stop}
className={style.iconButton}
theme={Playback.Stop}
aspect='square'
>
<IoStop />
</TapButton>
{auxPlayback !== SimplePlayback.Stop ? (
<div className={style.auxTimeDisplay}>{millisToString(auxTime)}</div>
) : (
<TimeInput
name='aux1-tracking'
submitHandler={handleAuxTimeChange}
time={auxTime}
className={style.auxTimeInput}
/>
)}
<TapButton
onClick={handleAuxDirectionToggle}
disabled={auxPlayback !== SimplePlayback.Stop}
className={style.iconButton}
aspect='square'
>
{auxDirection === SimpleDirection.CountDown ? <IoArrowDown /> : <IoArrowUp />}
</TapButton>
</div>
</div>
);
}
@@ -12,7 +12,7 @@ import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals'; import { isTouchDevice } from '../../externals';
import Loader from '../../views/common/loader/Loader'; import Loader from '../../views/common/loader/Loader';
import EditModal from './edit-modal/EditModal'; import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton'; import FollowButton from './follow-button/FollowButton';
import OperatorEvent from './operator-event/OperatorEvent'; import OperatorEvent from './operator-event/OperatorEvent';
import OperatorGroup from './operator-group/OperatorGroup'; import OperatorGroup from './operator-group/OperatorGroup';
@@ -43,7 +43,7 @@ export default function OperatorLoader() {
} }
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) { function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
const { selectedEventId } = useSelectedEventId(); const selectedEventId = useSelectedEventId();
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions(); const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
const [showEditPrompt, setShowEditPrompt] = useState(false); const [showEditPrompt, setShowEditPrompt] = useState(false);
@@ -118,7 +118,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
return ( return (
<div className={style.operatorContainer} data-testid='operator-view'> <div className={style.operatorContainer} data-testid='operator-view'>
<ViewParamsEditor target={OntimeView.Operator} viewOptions={operatorOptions} /> <ViewParamsEditor target={OntimeView.Operator} viewOptions={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />} {editEvent && <CustomFieldEditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar /> <StatusBar />
@@ -9,14 +9,14 @@ import Textarea from '../../../common/components/input/textarea/Textarea';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { EditEvent } from '../operator.types'; import { EditEvent } from '../operator.types';
import style from './EditModal.module.scss'; import style from './CustomFieldEditModal.module.scss';
interface EditModalProps { interface CustomFieldEditModalProps {
event: EditEvent; event: EditEvent;
onClose: () => void; onClose: () => void;
} }
export default function EditModal(props: EditModalProps) { export default function CustomFieldEditModal(props: CustomFieldEditModalProps) {
const { event, onClose } = props; const { event, onClose } = props;
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActions();
@@ -2,7 +2,13 @@ import { memo, PropsWithChildren } from 'react';
import { useIsMobileScreen } from '../../common/hooks/useIsMobileScreen'; import { useIsMobileScreen } from '../../common/hooks/useIsMobileScreen';
import { ClockOverview, MetadataTimes, OffsetOverview, StartTimes, TimerOverview } from './composite/TimeElements'; import {
ClockOverview,
MetadataTimes,
OffsetOverview,
StartTimesRuntime,
TimerOverview,
} from './composite/TimeElements';
import TitleOverview from './composite/TitleOverview'; import TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper'; import { OverviewWrapper } from './OverviewWrapper';
@@ -10,8 +16,10 @@ export default memo(CuesheetOverview);
function CuesheetOverview({ children }: PropsWithChildren) { function CuesheetOverview({ children }: PropsWithChildren) {
const isMobileScreen = useIsMobileScreen(); const isMobileScreen = useIsMobileScreen();
if (isMobileScreen) return <CuesheetMobile>{children}</CuesheetMobile>; if (isMobileScreen) {
else return <CuesheetDesktop>{children}</CuesheetDesktop>; return <CuesheetMobile>{children}</CuesheetMobile>;
}
return <CuesheetDesktop>{children}</CuesheetDesktop>;
} }
function CuesheetMobile({ children }: PropsWithChildren) { function CuesheetMobile({ children }: PropsWithChildren) {
@@ -27,7 +35,7 @@ function CuesheetDesktop({ children }: PropsWithChildren) {
return ( return (
<OverviewWrapper navElements={children}> <OverviewWrapper navElements={children}>
<TitleOverview /> <TitleOverview />
<StartTimes shouldFormat /> <StartTimesRuntime shouldFormat />
<TimerOverview /> <TimerOverview />
<OffsetOverview /> <OffsetOverview />
<MetadataTimes /> <MetadataTimes />
@@ -1,19 +1,73 @@
import { memo, PropsWithChildren } from 'react'; import { memo, PropsWithChildren } from 'react';
import { ClockOverview, MetadataTimes, OffsetOverview, ProgressOverview, StartTimes } from './composite/TimeElements'; import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
import {
ClockOverview,
MetadataTimes,
OffsetOverview,
PlanningStats,
ProgressOverview,
StartTimesPlanning,
StartTimesRuntime,
} from './composite/TimeElements';
import TitleOverview from './composite/TitleOverview'; import TitleOverview from './composite/TitleOverview';
import { OverviewWrapper } from './OverviewWrapper'; import { OverviewWrapper } from './OverviewWrapper';
import style from './Overview.module.scss';
export default memo(EditorOverview); export default memo(EditorOverview);
function EditorOverview({ children }: PropsWithChildren) { function EditorOverview({ children }: PropsWithChildren) {
const { layoutMode } = useEditorLayout();
return ( return (
<OverviewWrapper navElements={children}> <OverviewWrapper navElements={children}>
<TitleOverview /> {layoutMode === EditorLayoutMode.PLANNING && <OverviewPlanning />}
<StartTimes /> {layoutMode === EditorLayoutMode.TRACKING && <OverviewTracking />}
<ProgressOverview /> {layoutMode === EditorLayoutMode.CONTROL && <OverviewControl />}
<OffsetOverview />
<MetadataTimes />
<ClockOverview />
</OverviewWrapper> </OverviewWrapper>
); );
} }
function OverviewPlanning() {
return (
<>
<div className={style.inline}>
<TitleOverview />
<StartTimesPlanning />
<PlanningStats />
</div>
<ClockOverview />
</>
);
}
function OverviewTracking() {
return (
<>
<div className={style.inline}>
<StartTimesRuntime />
<ProgressOverview />
<OffsetOverview />
</div>
<MetadataTimes />
<ClockOverview />
</>
);
}
function OverviewControl() {
return (
<>
<TitleOverview />
<div className={style.inline}>
<StartTimesRuntime />
<ProgressOverview />
<OffsetOverview />
</div>
<MetadataTimes />
<ClockOverview />
</>
);
}
@@ -29,11 +29,22 @@
} }
.info { .info {
flex: 1;
padding-left: 1rem; padding-left: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
overflow-x: auto; width: max-content;
overflow-y: hidden; height: 100%;
}
.infoScroll {
flex: 1;
min-width: 0;
height: 100%;
}
.inline {
display: flex;
align-items: center;
gap: 1rem;
} }
@@ -1,6 +1,7 @@
import { PropsWithChildren, ReactNode } from 'react'; import { PropsWithChildren, ReactNode } from 'react';
import { ErrorBoundary } from '@sentry/react'; import { ErrorBoundary } from '@sentry/react';
import ScrollArea from '../../common/components/scroll-area/ScrollArea';
import { useIsOnline } from '../../common/hooks/useSocket'; import { useIsOnline } from '../../common/hooks/useSocket';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
@@ -11,12 +12,20 @@ interface OverviewWrapperProps {
} }
export function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) { export function OverviewWrapper({ navElements, children }: PropsWithChildren<OverviewWrapperProps>) {
const { isOnline } = useIsOnline(); const isOnline = useIsOnline();
return ( return (
<div className={cx([style.overview, !isOnline && style.isOffline])}> <div className={cx([style.overview, !isOnline && style.isOffline])}>
<ErrorBoundary> <ErrorBoundary>
<div className={style.nav}>{navElements}</div> <div className={style.nav}>{navElements}</div>
<div className={style.info}>{children}</div> <ScrollArea
className={style.infoScroll}
contentClassName={style.info}
contentStyle={{ minWidth: '100%' }}
orientation='horizontal'
>
{children}
</ScrollArea>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
); );
@@ -14,6 +14,13 @@
gap: 0.5rem; gap: 0.5rem;
} }
.row2 {
display: grid;
grid-template-columns: 3rem 9rem;
align-items: center;
gap: 0.5rem;
}
.metadataRow { .metadataRow {
display: grid; display: grid;
grid-template-columns: minmax(3rem, 10rem) 8rem 8rem; grid-template-columns: minmax(3rem, 10rem) 8rem 8rem;
@@ -27,7 +27,7 @@ import {
import { useEntry } from '../../../common/hooks-query/useRundown'; import { useEntry } from '../../../common/hooks-query/useRundown';
import { getOffsetState, getOffsetText } from '../../../common/utils/offset'; import { getOffsetState, getOffsetText } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time'; import { formatDuration, formatTime } from '../../../common/utils/time';
import SuperscriptPeriod from '../../../views/common/superscript-time/SuperscriptPeriod'; import SuperscriptPeriod from '../../../views/common/superscript-time/SuperscriptPeriod';
import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils'; import { calculateEndAndDaySpan, formatDueTime } from '../overview.utils';
@@ -39,30 +39,22 @@ interface OverviewTimeElementsProps {
shouldFormat?: boolean; shouldFormat?: boolean;
} }
export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) { const timeFormatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' };
function formatTimeValue(time: number | null, shouldFormat: boolean | undefined): string {
if (time === null) return timerPlaceholder;
if (shouldFormat) return formatTime(time, timeFormatOptions);
return millisToString(time, { fallback: timerPlaceholder });
}
export function StartTimesRuntime({ shouldFormat }: OverviewTimeElementsProps) {
const { plannedEnd, plannedStart, actualStart } = useStartTimesOverview(); const { plannedEnd, plannedStart, actualStart } = useStartTimesOverview();
const formatOptions = { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' }; const plannedStartText = formatTimeValue(plannedStart, shouldFormat);
const actualStartText = formatTimeValue(actualStart, shouldFormat);
const plannedStartText = (() => {
if (plannedStart === null) return timerPlaceholder;
if (shouldFormat) return formatTime(plannedStart, formatOptions);
return millisToString(plannedStart, { fallback: timerPlaceholder });
})();
const actualStartText = (() => {
if (actualStart === null) return timerPlaceholder;
if (shouldFormat) return formatTime(actualStart, formatOptions);
return millisToString(actualStart, { fallback: timerPlaceholder });
})();
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]); const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatTimeValue(maybePlannedEnd, shouldFormat);
const plannedEndText = (() => {
if (maybePlannedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybePlannedEnd, formatOptions);
return millisToString(maybePlannedEnd, { fallback: timerPlaceholder });
})();
const multipleDays = maybePlannedDaySpan > 0; const multipleDays = maybePlannedDaySpan > 0;
const plannedEndTooltip = multipleDays const plannedEndTooltip = multipleDays
@@ -122,19 +114,68 @@ export function StartTimes({ shouldFormat }: OverviewTimeElementsProps) {
); );
} }
export function StartTimesPlanning({ shouldFormat }: OverviewTimeElementsProps) {
const { plannedEnd, plannedStart } = useStartTimesOverview();
const plannedStartText = formatTimeValue(plannedStart, shouldFormat);
const [maybePlannedEnd, maybePlannedDaySpan] = useMemo(() => calculateEndAndDaySpan(plannedEnd), [plannedEnd]);
const plannedEndText = formatTimeValue(maybePlannedEnd, shouldFormat);
const multipleDays = maybePlannedDaySpan > 0;
const plannedEndTooltip = multipleDays
? `Planned end time (rundown spans over ${maybePlannedDaySpan + 1} days)`
: 'Planned end time';
return (
<div className={style.column}>
<div className={style.row2}>
<span className={style.label}>Start</span>
<Tooltip
text='Planned start time'
render={
<div className={style.labelledElement}>
<TbCalendarPin className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, plannedStart === null && style.muted])}
time={plannedStartText}
/>
</div>
}
/>
</div>
<div className={style.row2}>
<span className={style.label}>End</span>
<Tooltip
text={plannedEndTooltip}
render={
<div className={style.labelledElement}>
<TbCalendarPin className={style.icon} />
<SuperscriptPeriod
className={cx([style.time, plannedEnd === null && style.muted])}
time={plannedEndText}
/>
{multipleDays && (
<span className={cx([style.time, style.daySpan])} data-day-offset={maybePlannedDaySpan} />
)}
</div>
}
/>
</div>
</div>
);
}
/** /**
* Shows the expected end for the rundown * Shows the expected end for the rundown
* Extracted to improve performance as this is a ticking value * Extracted to improve performance as this is a ticking value
*/ */
function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) { function RundownExpectedEnd({ shouldFormat }: OverviewTimeElementsProps) {
const { expectedEnd } = useRundownExpectedEnd(); const expectedEnd = useRundownExpectedEnd();
const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]); const [maybeExpectedEnd, maybeExpectedDaySpan] = useMemo(() => calculateEndAndDaySpan(expectedEnd), [expectedEnd]);
const maybeExpectedEndText = (() => { const maybeExpectedEndText = formatTimeValue(maybeExpectedEnd, shouldFormat);
if (maybeExpectedEnd === null) return timerPlaceholder;
if (shouldFormat) return formatTime(maybeExpectedEnd, { format12: 'hh:mm:ss a', format24: 'HH:mm:ss' });
return millisToString(maybeExpectedEnd, { fallback: timerPlaceholder });
})();
const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0; const multipleDays = maybeExpectedEnd !== null && maybeExpectedDaySpan > 0;
const tooltip = multipleDays const tooltip = multipleDays
@@ -169,7 +210,7 @@ export function MetadataTimes() {
function GroupTimes() { function GroupTimes() {
const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView(); const { clock, mode, groupExpectedEnd, actualGroupStart, currentDay, playback } = useGroupTimerOverView();
const { currentGroupId } = useCurrentGroupId(); const currentGroupId = useCurrentGroupId();
const group = useEntry(currentGroupId) as OntimeGroup | null; const group = useEntry(currentGroupId) as OntimeGroup | null;
const active = isPlaybackActive(playback); const active = isPlaybackActive(playback);
@@ -298,7 +339,7 @@ export function OffsetOverview() {
} }
export function ClockOverview({ shouldFormat, className }: OverviewTimeElementsProps & { className?: string }) { export function ClockOverview({ shouldFormat, className }: OverviewTimeElementsProps & { className?: string }) {
const { clock } = useClock(); const clock = useClock();
const formattedClock = shouldFormat ? formatTime(clock) : millisToString(clock); const formattedClock = shouldFormat ? formatTime(clock) : millisToString(clock);
return ( return (
@@ -316,11 +357,27 @@ export function TimerOverview({ className }: { className?: string }) {
const isWaiting = timer.phase === TimerPhase.Pending; const isWaiting = timer.phase === TimerPhase.Pending;
const title = isWaiting ? 'Count to start' : 'Running timer'; const title = isWaiting ? 'Count to start' : 'Running timer';
const display = millisToString(isWaiting ? timer.secondaryTimer : timer.current, { fallback: timerPlaceholder }); const display = millisToString(isWaiting ? timer.secondaryTimer : timer.current, { fallback: timerPlaceholder });
const timerState = (() => {
function getTimerState(): 'waiting' | 'muted' | 'active' {
if (isWaiting) return 'waiting'; if (isWaiting) return 'waiting';
if (timer.current === null) return 'muted'; if (timer.current === null) return 'muted';
return 'active'; return 'active';
})(); }
return <TimeColumn label={title} value={display} state={timerState} className={className} />; return <TimeColumn label={title} value={display} state={getTimerState()} className={className} />;
}
export function PlanningStats() {
const { numEvents } = useProgressOverview();
const { plannedEnd, plannedStart } = useStartTimesOverview();
const hasTimes = plannedStart !== null && plannedEnd !== null;
const formattedDuration = hasTimes ? formatDuration(plannedEnd - plannedStart) : timerPlaceholder;
return (
<>
<TimeColumn label='Total duration' value={formattedDuration} />
<TimeColumn label='Events' value={String(numEvents)} />
</>
);
} }
@@ -4,9 +4,14 @@
} }
.rundownContainer { .rundownContainer {
margin-top: 1rem; padding-top: 1rem;
overflow-y: scroll; flex: 1 1 auto;
height: 100%; min-height: 0;
:is([data-target='small-device']) & {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
} }
.list { .list {
@@ -18,6 +23,7 @@
:is([data-target='small-device']) & { :is([data-target='small-device']) & {
padding-inline: 0; padding-inline: 0;
overflow-x: auto; overflow-x: auto;
min-width: max-content;
} }
} }
+269 -540
View File
@@ -1,50 +1,33 @@
import { Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type HTMLProps, forwardRef, Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TbFlagFilled } from 'react-icons/tb'; import { TbFlagFilled } from 'react-icons/tb';
import { import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
closestCenter, import { closestCenter, DndContext } from '@dnd-kit/core';
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useHotkeys, useSessionStorage } from '@mantine/hooks';
import { import {
type EntryId, type EntryId,
type MaybeString, type Rundown as RundownType,
type Rundown,
isOntimeEvent, isOntimeEvent,
isOntimeGroup, isOntimeGroup,
OntimeEntry,
Playback, Playback,
SupportedEntry, SupportedEntry,
} from 'ontime-types'; } from 'ontime-types';
import {
getFirstNormal,
getLastNormal,
getNextGroupNormal,
getNextNormal,
getPreviousGroupNormal,
getPreviousNormal,
reorderArray,
} from 'ontime-utils';
import { useEntryActions } from '../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../common/context/EntryActionsContext';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata'; import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode } from '../../ontimeConfig';
import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons'; import QuickAddButtons from './entry-editor/quick-add-buttons/QuickAddButtons';
import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline'; import QuickAddInline from './entry-editor/quick-add-cursor/QuickAddInline';
import { useRundownCommands } from './hooks/useRundownCommands';
import { useRundownDnd } from './hooks/useRundownDnd';
import { useRundownKeyboard } from './hooks/useRundownKeyboard';
import RundownGroup from './rundown-group/RundownGroup'; import RundownGroup from './rundown-group/RundownGroup';
import RundownGroupEnd from './rundown-group/RundownGroupEnd'; import RundownGroupEnd from './rundown-group/RundownGroupEnd';
import { canDrop, makeSortableList } from './rundown.utils'; import { filterVisibleEntries, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty'; import RundownEmpty from './RundownEmpty';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEditorFollowMode } from './useEditorFollowMode';
import { useEventSelection } from './useEventSelection'; import { useEventSelection } from './useEventSelection';
import style from './Rundown.module.scss'; import style from './Rundown.module.scss';
@@ -52,401 +35,158 @@ import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry')); const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps { interface RundownProps {
data: Rundown; entries: RundownType['entries'];
id: RundownType['id'];
order: RundownType['order'];
flatOrder: RundownType['flatOrder'];
rundownMetadata: RundownMetadataObject; rundownMetadata: RundownMetadataObject;
featureData: {
playback: Playback;
selectedEventId: EntryId | null;
nextEventId: EntryId | null;
};
} }
export default function Rundown({ data, rundownMetadata }: RundownProps) { export default function Rundown({ order, flatOrder, entries, id, rundownMetadata, featureData }: RundownProps) {
// invoke the compiler for the component
'use memo'; 'use memo';
const { order, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor();
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries)); const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [metadata, setMetadata] = useState(rundownMetadata); const [metadata, setMetadata] = useState<RundownMetadataObject>(rundownMetadata);
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [],
});
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActions();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
// cursor
const [editorMode] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const cursor = useEventSelection((state) => state.cursor);
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({
followRef: cursorRef,
scrollRef,
doFollow: true,
followTrigger: editorMode === AppMode.Edit ? cursor : featureData?.selectedEventId,
});
// DND KIT
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
const deleteAtCursor = useCallback(
(cursor: string | null) => {
if (!cursor) return;
const { entry, index } = getPreviousNormal(entries, order, cursor);
deleteEntry([cursor]);
if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
}
},
[entries, order, deleteEntry, setSelectedEvents],
);
const insertCopyAtId = useCallback(
(atId: string | null, above = false) => {
// lazily get the value from the store
const { entryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
return;
}
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
const refElement = atId ? entries[atId] : undefined;
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
normalisedAtId = refElement.parent;
}
clone(entryCopyId, {
after: above ? undefined : normalisedAtId ?? undefined,
// if we don't have a cursor add the new event on top
before: above ? normalisedAtId ?? undefined : undefined,
});
},
[entries, clone],
);
/** /**
* Add a new item referring to an existing one * We need to keep a copy of the events we receive to
* workaround issues with dnd-kit optimistic updates
*/ */
const insertAtId = useCallback(
(patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: MaybeString, above = false) => {
addEntry(patch, {
after: id && !above ? id : undefined,
before: id && above ? id : undefined,
lastEventId: !above && id ? id : undefined,
});
},
[addEntry],
);
const selectGroup = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) {
return;
}
let newCursor = cursor;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (isOntimeGroup(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
return;
}
newCursor = selected?.id ?? null;
}
if (newCursor === null) {
return;
}
// otherwise we select the next or previous
const selected =
direction === 'up'
? getPreviousGroupNormal(entries, order, newCursor)
: getNextGroupNormal(entries, order, newCursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, entries, setSelectedEvents],
);
const selectEntry = useCallback(
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 1) {
return;
}
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists
const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order);
if (selected !== null) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 });
}
return;
}
// otherwise we select the next or previous
const selected =
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
}
},
[order, entries, setSelectedEvents],
);
/**
* Checks whether a group is collapsed
*/
const getIsCollapsed = useCallback(
(groupId: EntryId): boolean => {
return collapsedGroupSet.has(groupId);
},
[collapsedGroupSet],
);
/**
* Handles logic for collapsing groups
*/
const handleCollapseGroup = useCallback(
(collapsed: boolean, groupId: EntryId) => {
setCollapsedGroups((prev) => {
const isCollapsed = getIsCollapsed(groupId);
if (collapsed && !isCollapsed) {
const newSet = new Set(prev).add(groupId);
return [...newSet];
}
if (!collapsed && isCollapsed) {
return [...prev].filter((id) => id !== groupId);
}
return prev;
});
},
[getIsCollapsed, setCollapsedGroups],
);
const moveEntry = useCallback(
async (cursor: EntryId | null, direction: 'up' | 'down') => {
if (cursor == null) {
return;
}
const movedIntoGroupId = await move(cursor, direction);
// if we are moving into a group, we need to make sure it is expanded
if (movedIntoGroupId) {
handleCollapseGroup(false, movedIntoGroupId);
}
},
[handleCollapseGroup, move],
);
// shortcuts
useHotkeys([
['alt + ArrowDown', () => selectEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
['alt + ArrowUp', () => selectEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
['alt + shift + ArrowDown', () => selectGroup(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
['alt + shift + ArrowUp', () => selectGroup(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true, usePhysicalKeys: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
['Escape', () => clearSelectedEvents(), { preventDefault: true, usePhysicalKeys: true }],
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
[
'alt + E',
() => insertAtId({ type: SupportedEntry.Event }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + E',
() => insertAtId({ type: SupportedEntry.Event }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + G',
() => insertAtId({ type: SupportedEntry.Group }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + G',
() => insertAtId({ type: SupportedEntry.Group }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + D',
() => insertAtId({ type: SupportedEntry.Delay }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + D',
() => insertAtId({ type: SupportedEntry.Delay }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + M',
() => insertAtId({ type: SupportedEntry.Milestone }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + M',
() => insertAtId({ type: SupportedEntry.Milestone }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor)],
['mod + shift + V', () => insertCopyAtId(cursor, true), { preventDefault: true, usePhysicalKeys: true }],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
]);
// we copy the state from the store here
// to workaround async updates on the drag mutations
useEffect(() => { useEffect(() => {
setSortableData(makeSortableList(order, entries)); setSortableData(makeSortableList(order, entries));
setMetadata(rundownMetadata); setMetadata(rundownMetadata);
}, [order, entries, rundownMetadata]); }, [order, entries, rundownMetadata]);
// in run mode, we follow the playback selection and open groups as needed const { getIsCollapsed, collapseGroup, expandGroup } = useCollapsedGroups(id);
const entryActions = useEntryActionsContext();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
// cursor
const { editorMode } = useEditorFollowMode();
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const cursor = useEventSelection((state) => state.cursor);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
/**
* Handles logic for collapsing groups
*/
const handleCollapseGroup = useCallback(
(collapsed: boolean, groupId: EntryId | undefined) => {
if (collapsed) {
collapseGroup(groupId);
} else {
expandGroup(groupId);
}
},
[collapseGroup, expandGroup],
);
// Commands layer - business logic
const commands = useRundownCommands({
entries,
flatOrder,
entryActions,
selectEntry,
handleCollapseGroup,
});
// Keyboard shortcuts
useRundownKeyboard({
cursor,
commands,
clearSelectedEvents,
setEntryCopyId,
});
// DND handlers
const dnd = useRundownDnd({
entries,
sortableData,
setSortableData,
getIsCollapsed,
handleCollapseGroup,
entryActions,
});
// Filter visible data to exclude collapsed items
// DND-kit (SortableContext) needs the full sortableData for drag calculations
// Virtuoso only renders visibleData to avoid null items that reserve space
const visibleData = useMemo(() => {
return filterVisibleEntries(sortableData, entries, getIsCollapsed);
}, [sortableData, entries, getIsCollapsed]);
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
useEffect(() => { useEffect(() => {
if (editorMode !== AppMode.Run || !featureData?.selectedEventId) { setScrollHandler((entryId) => {
return; if (!virtuosoRef.current || dnd.isDraggingRef.current) {
} return;
const index = order.findIndex((id) => id === featureData.selectedEventId);
// @ts-expect-error -- but we safely check if the parent property exists
const maybeParent = entries[featureData.selectedEventId]?.parent;
if (maybeParent) {
// open the group
setCollapsedGroups((prev) => [...prev].filter((id) => id !== maybeParent));
}
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [editorMode, entries, featureData.selectedEventId, order, setCollapsedGroups, setSelectedEvents]);
/**
* On drag end, we reorder the events
*/
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over?.id || active.id === over.id) {
return;
}
if (!active.data.current || !over.data.current) {
return;
}
const fromIndex: number = active.data.current.sortable.index;
const toIndex: number = over.data.current.sortable.index;
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
let destinationId = over.id as EntryId;
const isDraggingGroup = active.data.current?.type === SupportedEntry.Group;
// prevent dropping a group inside another
if (
isDraggingGroup &&
!canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId))
) {
return;
}
/**
* We need to specially handle the end-group
* Dragging before a end-group will add the entry to the end of the group
* Dragging after a end-group will add the event after the group itself
* Dragging to the top of a group either place before first entry or if no entries do insert
*/
if (destinationId.startsWith('end-')) {
destinationId = destinationId.replace('end-', '');
// if we are moving before the end, we use the insert operation
if (placement === 'before') {
placement = 'insert';
} }
} else { const index = visibleData.indexOf(entryId);
const group = data.entries[destinationId]; if (index === -1) {
// if dragging into a group return;
if (isOntimeGroup(group) && placement === 'after') {
if (isDraggingGroup) {
// ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before
// so we can safely push the dragged event after the group
destinationId = group.id;
} else if (group.entries.length === 0) {
// ... and the group is entry, we insert
destinationId = group.id;
placement = 'insert';
} else {
// otherwise we add it to before the first group child
destinationId = group.entries[0];
placement = 'before';
}
} }
} virtuosoRef.current.scrollToIndex({
index,
// keep copy of the current state in case we need to revert align: 'start',
const currentEntries = [...sortableData]; behavior: 'smooth',
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates offset: -100, // show the previous entry for context
setSortableData((currentEntries) => { });
return reorderArray(currentEntries, fromIndex, toIndex);
}); });
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
setSortableData(currentEntries);
});
};
/** return () => {
* When we drag a group, we force collapse it setScrollHandler(null);
* This avoids strange scenarios like dropping a group inside itself };
*/ }, [visibleData, dnd.isDraggingRef, setScrollHandler]);
const collapseDraggedGroups = (event: DragStartEvent) => {
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
if (isGroup) {
handleCollapseGroup(true, event.active.id as EntryId);
}
};
/** // Keep selected cursor visible by expanding its parent group in any mode.
* When we drag over a group, we expand it if it is collapsed // Finder can select entries while layout is in Run mode, where we do not follow cursor scroll.
*/ useEffect(() => {
const expandOverGroup = (event: DragOverEvent) => { if (!cursor) {
// if we are dragging a group, the drop operation is invalid so we dont expand
if (event.active.data.current?.type === 'group') {
return; return;
} }
if (event.over?.data.current?.type !== 'group') {
const entry = entries[cursor];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
}, [cursor, entries, expandGroup]);
// Auto-follow behavior in Edit mode: follow the user's cursor
useEffect(() => {
if (dnd.isDraggingRef.current || editorMode !== AppMode.Edit || !cursor) {
return; return;
} }
const groupId = event.over?.id as EntryId;
const isCollapsed = getIsCollapsed(groupId);
if (isCollapsed) {
handleCollapseGroup(false, groupId);
}
};
if (sortableData.length < 1) { scrollToEntry(cursor);
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />; }, [editorMode, cursor, scrollToEntry, dnd.isDraggingRef]);
}
// Auto-follow behavior in Run mode: follow the currently playing event
useEffect(() => {
if (dnd.isDraggingRef.current || editorMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
}
// Open parent group if the target is inside a collapsed group
const entry = entries[featureData.selectedEventId];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
scrollToEntry(featureData.selectedEventId);
}, [editorMode, featureData?.selectedEventId, entries, expandGroup, scrollToEntry, dnd.isDraggingRef]);
// gather presentation options // gather presentation options
const isEditMode = editorMode === AppMode.Edit; const isEditMode = editorMode === AppMode.Edit;
@@ -454,157 +194,146 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
// gather rundown wide data // gather rundown wide data
const lastEntryId = order.at(-1); const lastEntryId = order.at(-1);
// Extract primitive values from featureData to avoid unnecessary callback recreations
const nextEventId = featureData?.nextEventId;
const playback = featureData?.playback;
// Virtuoso item renderer
const itemContent = useCallback(
(index: number, entryId: EntryId) => {
// Handle end-group pseudo entries
const isEndGroup = entryId.startsWith('end-');
if (isEndGroup) {
const parentId = entryId.split('end-')[1];
const parentMetadata = metadata[parentId];
return (
<Fragment key={entryId}>
{isEditMode && parentMetadata?.groupEntries === 0 && (
<QuickAddButtons
previousEventId={null}
parentGroup={parentId}
backgroundColor={parentMetadata?.groupColour}
/>
)}
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
</Fragment>
);
}
const entry = entries[entryId];
const entryMetadata = metadata[entryId];
if (!entry || !entryMetadata) {
return null;
}
const isNext = nextEventId === entry.id;
const hasCursor = entry.id === cursor;
const isGroup = isOntimeGroup(entry);
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
const isFirst = index === 0;
const isLast = entryId === lastEntryId;
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
const parentIdForAfter = entryMetadata.groupId;
const collapsed = isGroup ? getIsCollapsed(entry.id) : false;
return (
<Fragment key={entry.id}>
{/* QuickAddInline before the entry - edit mode only, if there is a cursor, if it is not the first entry */}
{isEditMode && hasCursor && !isFirst && (
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
)}
{isGroup ? (
<RundownGroup data={entry} hasCursor={hasCursor} collapsed={collapsed} onCollapse={handleCollapseGroup} />
) : (
<div
className={style.entryWrapper}
data-testid={`entry-${entryMetadata.eventIndex}`}
style={groupColour ? { '--user-bg': groupColour } : {}}
>
{isOntimeEvent(entry) && (
<div className={style.entryIndex}>
{entry.flag && <TbFlagFilled className={style.flag} />}
<div className={style.index}>{entryMetadata.eventIndex}</div>
</div>
)}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={entryMetadata.isPast}
eventIndex={entryMetadata.eventIndex}
data={entry}
loaded={entryMetadata.isLoaded}
hasCursor={hasCursor}
isNext={isNext}
isNextDay={entryMetadata.isNextDay}
playback={entryMetadata.isLoaded ? playback : undefined}
isRolling={playback === Playback.Roll}
totalGap={entryMetadata.totalGap}
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
/>
</div>
</div>
)}
{/* QuickAddInline after the entry - edit mode only, if there is a cursor, if it is not the last entry */}
{isEditMode && hasCursor && !isLast && (
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
)}
</Fragment>
);
},
[entries, metadata, getIsCollapsed, isEditMode, cursor, nextEventId, playback, lastEntryId, handleCollapseGroup],
);
if (sortableData.length < 1) {
return <RundownEmpty handleAddNew={(type: SupportedEntry) => entryActions.addEntry({ type })} />;
}
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext <DndContext
onDragEnd={handleOnDragEnd} onDragEnd={dnd.handleOnDragEnd}
onDragStart={collapseDraggedGroups} onDragStart={dnd.collapseDraggedGroups}
onDragOver={expandOverGroup} onDragOver={dnd.expandOverGroup}
sensors={sensors} sensors={dnd.sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
> >
<SortableContext items={sortableData} strategy={verticalListSortingStrategy}> <SortableContext items={sortableData} strategy={verticalListSortingStrategy}>
<div className={style.list}> <Virtuoso
{isEditMode && <QuickAddButtons previousEventId={null} parentGroup={null} />} ref={virtuosoRef}
{sortableData.map((entryId, index) => { data={visibleData}
// the entry might be a pseudo end-group which does not generate metadata and should not be processed computeItemKey={(_index, entryId) => entryId}
if (entryId.startsWith('end-')) { itemContent={itemContent}
const parentId = entryId.split('end-')[1]; increaseViewportBy={{ top: 200, bottom: 400 }}
const isGroupCollapsed = getIsCollapsed(parentId); style={{ height: '100%' }}
const parentMetadata = metadata[parentId]; components={{
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
if (isGroupCollapsed) { Footer: () => (
return null; <>
} {isEditMode && (
<QuickAddButtons
// if the previous element is selected, it will have its own QuickAddInline previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey]?.thisId}
// we use thisId instead of previousEntryId because the end-group does not process parentGroup={null}
// and it does not cause the reassignment of the iteration id to the previous entry
return (
<Fragment key={entryId}>
{isEditMode && parentMetadata?.groupEntries === 0 && (
<QuickAddButtons
previousEventId={null}
parentGroup={parentId}
backgroundColor={parentMetadata?.groupColour}
/>
)}
<RundownGroupEnd key={entryId} id={entryId} colour={parentMetadata?.groupColour} />
</Fragment>
);
}
// we iterate through a stateful copy of order to make the dnd operations smoother
// this means that this can be out of sync with order until the useEffect runs
// instead of writing all the logic guards, we simply short circuit rendering here
const entry = entries[entryId];
const entryMetadata = metadata[entryId];
if (!entry || !entryMetadata) return null;
// if the entry has a parent, and it is collapsed, render nothing
if (
entry.type !== SupportedEntry.Group &&
entryMetadata.groupId !== null &&
getIsCollapsed(entryMetadata.groupId)
) {
return null;
}
const isNext = featureData?.nextEventId === entry.id;
const hasCursor = entry.id === cursor;
/**
* Outside a group, the value will be undefined
* If the colour is empty string ''
* ie: we are inside a group, but there is no defined colour
* we default to $gray-500 #9d9d9d
*/
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
const isFirst = index === 0;
const isLast = entryId === lastEntryId;
/**
* We need to provide the parent ID for the QuickAdd components
* This should be different depending on whether we are adding before or after an element
* - when adding before, we need to avoid a group referencing itself as the parent
* - when adding after, we can use the group ID directly to insert at the top of the group
*/
const parentIdForBefore = entryMetadata.thisId !== entryMetadata.groupId ? entryMetadata.groupId : null;
const parentIdForAfter = entryMetadata.groupId;
return (
<Fragment key={entry.id}>
{/**
* Before the entry
* - edit mode only
* - if there is a cursor
* - if it is not the first entry (the buttons would be there)
*/}
{isEditMode && hasCursor && !isFirst && (
<QuickAddInline placement='before' referenceEntryId={entry.id} parentGroup={parentIdForBefore} />
)}
{isOntimeGroup(entry) ? (
<RundownGroup
data={entry}
hasCursor={hasCursor}
collapsed={getIsCollapsed(entry.id)}
onCollapse={handleCollapseGroup}
/> />
) : (
<div
className={style.entryWrapper}
data-testid={`entry-${entryMetadata.eventIndex}`}
style={groupColour ? { '--user-bg': groupColour } : {}}
>
{isOntimeEvent(entry) && (
<div className={style.entryIndex}>
{entry.flag && <TbFlagFilled className={style.flag} />}
<div className={style.index}>{entryMetadata.eventIndex}</div>
</div>
)}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={entryMetadata.isPast}
eventIndex={entryMetadata.eventIndex}
data={entry}
loaded={entryMetadata.isLoaded}
hasCursor={hasCursor}
isNext={isNext}
isNextDay={entryMetadata.isNextDay}
playback={entryMetadata.isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
totalGap={entryMetadata.totalGap}
isLinkedToLoaded={entryMetadata.isLinkedToLoaded}
/>
</div>
</div>
)} )}
{/** <div className={style.spacer} />
* After the entry </>
* - edit mode only ),
* - if there is a cursor List: VirtuosoListComponent,
* - if it is not the last entry (the buttons would be there) }}
* - if the entry is not the group header />
*/}
{isEditMode && hasCursor && !isLast && (
<QuickAddInline placement='after' referenceEntryId={entry.id} parentGroup={parentIdForAfter} />
)}
</Fragment>
);
})}
{isEditMode && (
<QuickAddButtons
previousEventId={metadata[lastMetadataKey]?.groupId ?? metadata[lastMetadataKey].thisId}
parentGroup={null}
/>
)}
<div className={style.spacer} />
</div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</div> </div>
); );
} }
// Virtuoso components - extracted to prevent recreation on every render
const VirtuosoListComponent = forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(({ children, ...props }, ref) => (
<div ref={ref} {...props} className={style.list}>
{children}
</div>
));
VirtuosoListComponent.displayName = 'VirtuosoListComponent';
@@ -32,6 +32,8 @@ export default function RundownEntry({
totalGap, totalGap,
isLinkedToLoaded, isLinkedToLoaded,
}: RundownEntryProps) { }: RundownEntryProps) {
'use memo';
if (isOntimeEvent(data)) { if (isOntimeEvent(data)) {
return ( return (
<RundownEvent <RundownEvent
@@ -1,6 +1,7 @@
.rundownExport { .rundownExport {
height: 100%; height: 100%;
flex: 1 1 auto; /* flex-grow: 1, flex-shrink: 1, flex-basis: auto */ flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
min-width: 0;
&.extracted { &.extracted {
.list { .list {
@@ -20,10 +21,19 @@
height: 100%; height: 100%;
} }
.rundownRoot {
height: calc(100% - 1rem);
width: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.list { .list {
display: flex; display: flex;
height: inherit; height: inherit;
padding-inline: 0; padding-inline: 0;
padding-bottom: 0;
box-shadow: $box-shadow-right; box-shadow: $box-shadow-right;
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */ flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 */
@@ -5,16 +5,25 @@ import * as Editor from '../../common/components/editor-utils/EditorUtils';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu'; import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute'; import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice'; import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
import { handleLinks } from '../../common/utils/linkUtils'; import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils'; import { cx } from '../../common/utils/styleUtils';
import { getIsNavigationLocked } from '../../externals'; import { getIsNavigationLocked } from '../../externals';
import { AppMode, sessionKeys } from '../../ontimeConfig'; import { AppMode } from '../../ontimeConfig';
import EntryEditModal from '../../views/cuesheet/cuesheet-edit-modal/EntryEditModal';
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
import RundownEntryEditor from './entry-editor/RundownEntryEditor'; import RundownEntryEditor from './entry-editor/RundownEntryEditor';
import FinderPlacement from './placements/FinderPlacement'; import FinderPlacement from './placements/FinderPlacement';
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu'; import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
import RundownWrapper from './RundownWrapper'; import RundownHeader from './rundown-header/RundownHeader';
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
import RundownTable from './rundown-table/RundownTable';
import { RundownViewMode } from './rundown.options';
import RundownList from './RundownList';
import { useEditorFollowMode } from './useEditorFollowMode';
import style from './RundownExport.module.scss'; import style from './RundownExport.module.scss';
@@ -22,59 +31,88 @@ export default memo(RundownExport);
function RundownExport() { function RundownExport() {
const isExtracted = window.location.pathname.includes('/rundown'); const isExtracted = window.location.pathname.includes('/rundown');
const [editorMode] = useSessionStorage({ const { editorMode } = useEditorFollowMode();
key: sessionKeys.editorMode, const { layoutMode } = useEditorLayout();
defaultValue: AppMode.Edit, const [viewMode, setViewMode] = useSessionStorage<RundownViewMode>({
key: 'rundown-view-mode',
defaultValue: RundownViewMode.List,
}); });
const isSmallDevice = useIsSmallDevice(); const isSmallDevice = useIsSmallDevice();
const entryActions = useEntryActions();
if (isSmallDevice && isExtracted) { if (isSmallDevice && isExtracted) {
return ( return (
<ProtectRoute permission='editor'> <EntryActionsProvider actions={entryActions}>
<div <ProtectRoute permission='editor'>
className={cx([style.rundownExport, style.extracted])} <div
data-target='small-device' className={cx([style.rundownExport, style.extracted])}
data-testid='panel-rundown' data-target='small-device'
> data-testid='panel-rundown'
<FinderPlacement /> >
<ViewNavigationMenu suppressSettings /> <FinderPlacement />
<div className={style.rundown}> <ViewNavigationMenu suppressSettings />
<ErrorBoundary> <div className={style.rundown}>
<RundownContextMenu> <ErrorBoundary>
<RundownWrapper isSmallDevice /> <RundownRoot isSmallDevice isExtracted viewMode={viewMode} setViewMode={setViewMode} />
</RundownContextMenu> <RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</div>
</div> </div>
</div> </ProtectRoute>
</ProtectRoute> </EntryActionsProvider>
); );
} }
const hideSideBar = isExtracted && editorMode === 'run'; const hideSideBar =
layoutMode === EditorLayoutMode.TRACKING ||
(isExtracted && editorMode === AppMode.Run) ||
viewMode === RundownViewMode.Table;
return ( return (
<ProtectRoute permission='editor'> <EntryActionsProvider actions={entryActions}>
<div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'> <ProtectRoute permission='editor'>
<FinderPlacement /> <div className={cx([style.rundownExport, isExtracted && style.extracted])} data-testid='panel-rundown'>
{isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />} <FinderPlacement />
<div className={style.rundown}> {isExtracted && <ViewNavigationMenu suppressSettings isNavigationLocked={getIsNavigationLocked()} />}
<Editor.Panel className={style.list}> <div className={style.rundown}>
<ErrorBoundary> <Editor.Panel className={style.list}>
{!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownContextMenu>
<RundownWrapper />
</RundownContextMenu>
</ErrorBoundary>
</Editor.Panel>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary> <ErrorBoundary>
<RundownEntryEditor /> {!isExtracted && <Editor.CornerExtract onClick={(event) => handleLinks('rundown', event)} />}
<RundownRoot isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
<RundownContextMenu />
</ErrorBoundary> </ErrorBoundary>
</div> </Editor.Panel>
)} {!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<RundownEntryEditor />
</ErrorBoundary>
</div>
)}
</div>
</div> </div>
</div> </ProtectRoute>
</ProtectRoute> </EntryActionsProvider>
);
}
interface RundownRootProps {
isSmallDevice?: boolean;
isExtracted?: boolean;
viewMode: RundownViewMode;
setViewMode: (mode: RundownViewMode) => void;
}
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
return (
<div className={style.rundownRoot}>
{isSmallDevice ? (
<RundownHeaderMobile viewMode={viewMode} setViewMode={setViewMode} />
) : (
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
)}
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
{viewMode === RundownViewMode.Table && <EntryEditModal />}
</div>
); );
} }
@@ -0,0 +1,30 @@
import { memo } from 'react';
import Empty from '../../common/components/state/Empty';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import Rundown from './Rundown';
export default memo(RundownList);
function RundownList() {
const { data, status, rundownMetadata } = useRundownWithMetadata();
const featureData = useRundownEditor();
const isLoading = status !== 'success' || !data || !rundownMetadata;
if (isLoading) {
return <Empty text='Connecting to server' />;
}
return (
<Rundown
order={data.order}
flatOrder={data.flatOrder}
entries={data.entries}
id={data.id}
rundownMetadata={rundownMetadata}
featureData={featureData}
/>
);
}
@@ -1,27 +0,0 @@
import Empty from '../../common/components/state/Empty';
import { useRundownWithMetadata } from '../../common/hooks-query/useRundown';
import RundownHeader from './rundown-header/RundownHeader';
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
import Rundown from './Rundown';
import styles from './Rundown.module.scss';
interface RundownWrapperProps {
isSmallDevice?: boolean;
}
export default function RundownWrapper({ isSmallDevice }: RundownWrapperProps) {
const { data, status, rundownMetadata } = useRundownWithMetadata();
return (
<div className={styles.rundownWrapper}>
{isSmallDevice ? <RundownHeaderMobile /> : <RundownHeader />}
{status === 'success' && data && rundownMetadata ? (
<Rundown data={data} rundownMetadata={rundownMetadata} />
) : (
<Empty text='Connecting to server' />
)}
</div>
);
}
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import style from './TitleEditor.module.scss'; import style from './TitleEditor.module.scss';
@@ -15,7 +15,7 @@ interface TitleEditorProps {
} }
export default function TitleEditor({ title, entryId, placeholder, className }: TitleEditorProps) { export default function TitleEditor({ title, entryId, placeholder, className }: TitleEditorProps) {
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const ref = useRef<HTMLInputElement | null>(null); const ref = useRef<HTMLInputElement | null>(null);
const submitCallback = useCallback( const submitCallback = useCallback(
(text: string) => { (text: string) => {
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useMemo } from 'react';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types'; import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
@@ -15,20 +15,14 @@ interface CuesheetEntryEditorProps {
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) { export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
const { data } = useRundown(); const { data } = useRundown();
const [entry, setEntry] = useState<OntimeEntry | null>(null);
useEffect(() => { const entry = useMemo<OntimeEntry | null>(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
setEntry(null); return null;
return;
} }
const event = data.entries[entryId]; const event = data.entries[entryId];
if (event) { return event ?? null;
setEntry(event);
} else {
setEntry(null);
}
}, [entryId, data.order, data.entries]); }, [entryId, data.order, data.entries]);
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
@@ -1,6 +1,6 @@
.cuesheetEditor, .cuesheetEditor,
.rundownEditor { .rundownEditor {
max-height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-x: auto; overflow-x: auto;
@@ -3,7 +3,7 @@ import { OntimeEvent } from 'ontime-types';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EntryEditorCustomFields from './composite/EventEditorCustomFields'; import EntryEditorCustomFields from './composite/EventEditorCustomFields';
@@ -22,7 +22,7 @@ interface EventEditorProps {
export default function EventEditor({ event }: EventEditorProps) { export default function EventEditor({ event }: EventEditorProps) {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const isEditor = window.location.pathname.includes('editor'); const isEditor = window.location.pathname.includes('editor');
@@ -12,8 +12,8 @@
.shortcutSection { .shortcutSection {
flex: 1; flex: 1;
display: grid; margin-top: 15vh;
place-content: center; margin-inline: auto;
gap: 1rem; gap: 1rem;
} }
@@ -23,12 +23,12 @@
border-spacing: 4rem 0; border-spacing: 4rem 0;
tr { tr {
white-space: nowrap;
td:nth-child(odd) { td:nth-child(odd) {
text-align: left; text-align: left;
} }
td:nth-child(even) { td:nth-child(even) {
text-align: right; text-align: right;
white-space: nowrap;
} }
} }
} }
@@ -53,6 +53,22 @@ function EventEditorEmpty() {
<Kbd></Kbd> <Kbd></Kbd>
</td> </td>
</tr> </tr>
<tr>
<td>Jump to top / bottom</td>
<td>
<Kbd>Home</Kbd>
<AuxKey>/</AuxKey>
<Kbd>End</Kbd>
</td>
</tr>
<tr>
<td>Page up / down</td>
<td>
<Kbd>PgUp</Kbd>
<AuxKey>/</AuxKey>
<Kbd>PgDn</Kbd>
</td>
</tr>
<tr> <tr>
<td>Deselect entry</td> <td>Deselect entry</td>
<td> <td>
@@ -80,6 +96,14 @@ function EventEditorEmpty() {
<Kbd>C</Kbd> <Kbd>C</Kbd>
</td> </td>
</tr> </tr>
<tr>
<td>Cut selected entry</td>
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>X</Kbd>
</td>
</tr>
<tr> <tr>
<td>Paste above</td> <td>Paste above</td>
<td> <td>
@@ -99,10 +123,18 @@ function EventEditorEmpty() {
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Delete selected entry</td> <td>Clone selected entry</td>
<td> <td>
<Kbd>{deviceMod}</Kbd> <Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
</tr>
<tr>
<td>Delete selected entry</td>
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd>Backspace</Kbd> <Kbd>Backspace</Kbd>
</td> </td>
</tr> </tr>
@@ -140,7 +172,7 @@ function EventEditorEmpty() {
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>Shift</Kbd> <Kbd>Shift</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>M</Kbd> <Kbd>G</Kbd>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -148,7 +180,7 @@ function EventEditorEmpty() {
<td> <td>
<Kbd>{deviceAlt}</Kbd> <Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey> <AuxKey>+</AuxKey>
<Kbd>G</Kbd> <Kbd>M</Kbd>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -5,7 +5,7 @@ import { millisToString } from 'ontime-utils';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, enDash, timerPlaceholder } from '../../../common/utils/styleUtils';
@@ -28,7 +28,7 @@ interface GroupEditorProps {
export default function GroupEditor({ group }: GroupEditorProps) { export default function GroupEditor({ group }: GroupEditorProps) {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const handleSubmit = useCallback( const handleSubmit = useCallback(
(field: GroupEditorUpdateTextFields | GroupEditorUpdateMaybeNumberFields, value: string | MaybeNumber) => { (field: GroupEditorUpdateTextFields | GroupEditorUpdateMaybeNumberFields, value: string | MaybeNumber) => {
@@ -5,7 +5,7 @@ import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import AppLink from '../../../common/components/link/app-link/AppLink'; import AppLink from '../../../common/components/link/app-link/AppLink';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../common/hooks-query/useCustomFields';
import EntryEditorCustomFields from './composite/EventEditorCustomFields'; import EntryEditorCustomFields from './composite/EventEditorCustomFields';
@@ -22,7 +22,7 @@ interface MilestoneEditorProps {
} }
export default function MilestoneEditor({ milestone }: MilestoneEditorProps) { export default function MilestoneEditor({ milestone }: MilestoneEditorProps) {
const { data: customFields } = useCustomFields(); const { data: customFields } = useCustomFields();
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const handleSubmit = useCallback( const handleSubmit = useCallback(
(field: MilestoneEditorUpdateTextFields, value: string) => { (field: MilestoneEditorUpdateTextFields, value: string) => {
@@ -1,13 +1,5 @@
import { useEffect, useState } from 'react'; import { useMemo } from 'react';
import { import { isOntimeEvent, isOntimeGroup, isOntimeMilestone, OntimeEntry } from 'ontime-types';
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
isOntimeMilestone,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
} from 'ontime-types';
import useRundown from '../../../common/hooks-query/useRundown'; import useRundown from '../../../common/hooks-query/useRundown';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -24,27 +16,19 @@ export default function RundownEntryEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(); const { data } = useRundown();
const [entry, setEntry] = useState<OntimeEvent | OntimeGroup | OntimeMilestone | null>(null); const entry = useMemo<OntimeEntry | null>(() => {
useEffect(() => {
if (data.order.length === 0) { if (data.order.length === 0) {
setEntry(null); return null;
return;
} }
const selectedEventId = Array.from(selectedEvents).at(0); const selectedEventId = Array.from(selectedEvents).at(0);
if (!selectedEventId) { if (!selectedEventId) {
setEntry(null); return null;
return;
} }
const event = data.entries[selectedEventId];
if (event && !isOntimeDelay(event)) { const event = data.entries[selectedEventId];
setEntry(event); return event ?? null;
} else { }, [data.order.length, data.entries, selectedEvents]);
setEntry(null);
}
}, [data.order, data.entries, selectedEvents]);
if (!entry) { if (!entry) {
return <EventEditorEmpty />; return <EventEditorEmpty />;
@@ -8,7 +8,7 @@ import TimeInput from '../../../../common/components/input/time-input/TimeInput'
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig'; import { millisToDelayString } from '../../../../common/utils/dateConfig';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
@@ -46,7 +46,7 @@ function EventEditorTimes({
timeWarning, timeWarning,
timeDanger, timeDanger,
}: EventEditorTimesProps) { }: EventEditorTimesProps) {
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const handleSubmit = (field: HandledActions, value: string | boolean) => { const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'countToEnd') { if (field === 'countToEnd') {
@@ -5,7 +5,7 @@ import * as Editor from '../../../../common/components/editor-utils/EditorUtils'
import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect'; import SwatchSelect from '../../../../common/components/input/colour-input/SwatchSelect';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import Switch from '../../../../common/components/switch/Switch'; import Switch from '../../../../common/components/switch/Switch';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import EventTextArea from './EventTextArea'; import EventTextArea from './EventTextArea';
import EntryEditorTextInput from './EventTextInput'; import EntryEditorTextInput from './EventTextInput';
@@ -23,7 +23,7 @@ interface EventEditorTitlesProps {
export default memo(EventEditorTitles); export default memo(EventEditorTitles);
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) { function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const cueSubmitHandler = (_field: string, newValue: string) => { const cueSubmitHandler = (_field: string, newValue: string) => {
updateEntry({ id: eventId, cue: sanitiseCue(newValue) }); updateEntry({ id: eventId, cue: sanitiseCue(newValue) });
@@ -8,7 +8,7 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants'; import { eventTriggerOptions } from './eventTrigger.constants';
@@ -38,7 +38,7 @@ interface EventTriggerFormProps {
function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) { function EventTriggerForm({ eventId, triggers }: EventTriggerFormProps) {
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const [automationId, setAutomationId] = useState<string | undefined>(undefined); const [automationId, setAutomationId] = useState<string | undefined>(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart); const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
@@ -123,7 +123,7 @@ interface ExistingEventTriggersProps {
} }
function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) { function ExistingEventTriggers({ eventId, triggers }: ExistingEventTriggersProps) {
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const { data: automationSettings } = useAutomationSettings(); const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback( const handleDelete = useCallback(
@@ -2,6 +2,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
padding-left: 0.5rem;
padding-block: 0.5rem; padding-block: 0.5rem;
background: color-mix(in srgb, transparent 90%, var(--user-bg, transparent) 10%); background: color-mix(in srgb, transparent 90%, var(--user-bg, transparent) 10%);
@@ -4,7 +4,7 @@ import { Toolbar } from '@base-ui/react/toolbar';
import { MaybeString, SupportedEntry } from 'ontime-types'; import { MaybeString, SupportedEntry } from 'ontime-types';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { cx } from '../../../../common/utils/styleUtils'; import { cx } from '../../../../common/utils/styleUtils';
import style from './QuickAddButtons.module.scss'; import style from './QuickAddButtons.module.scss';
@@ -17,7 +17,7 @@ interface QuickAddButtonsProps {
export default memo(QuickAddButtons); export default memo(QuickAddButtons);
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) { function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
const { addEntry } = useEntryActions(); const { addEntry } = useEntryActionsContext();
const addEvent = () => { const addEvent = () => {
addEntry( addEntry(
@@ -4,7 +4,7 @@ import { MaybeString, SupportedEntry } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import style from './QuickAddInline.module.scss'; import style from './QuickAddInline.module.scss';
@@ -16,7 +16,7 @@ interface QuickAddInlineProps {
export default memo(QuickAddInline); export default memo(QuickAddInline);
function QuickAddInline({ referenceEntryId, parentGroup, placement }: QuickAddInlineProps) { function QuickAddInline({ referenceEntryId, parentGroup, placement }: QuickAddInlineProps) {
const { addEntry } = useEntryActions(); const { addEntry } = useEntryActionsContext();
const handleAddEntry = (type: SupportedEntry) => { const handleAddEntry = (type: SupportedEntry) => {
if (placement === 'before') { if (placement === 'before') {
@@ -0,0 +1,228 @@
import { useCallback } from 'react';
import { type EntryId, type OntimeEntry, type Rundown, SupportedEntry } from 'ontime-types';
import { getNextGroupNormal, getNextNormal, getPreviousGroupNormal, getPreviousNormal } from 'ontime-utils';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { SelectionMode } from '../useEventSelection';
const PAGE_SIZE = 5;
interface UseRundownCommandsOptions {
entries: Rundown['entries'];
flatOrder: EntryId[];
entryActions: ReturnType<typeof useEntryActions>;
selectEntry: (selection: { id: EntryId; selectMode: SelectionMode; index: number }) => void;
handleCollapseGroup: (collapsed: boolean, groupId: EntryId) => void;
}
/**
* Common operations for the rundown lists
*/
export function useRundownCommands({
entries,
flatOrder,
entryActions,
selectEntry: applySelection,
handleCollapseGroup,
}: UseRundownCommandsOptions) {
const { addEntry, clone, deleteEntry, move, reorderEntry } = entryActions;
const deleteAtCursor = useCallback(
(cursor: string | null) => {
if (!cursor) return;
const { entry, index } = getPreviousNormal(entries, flatOrder, cursor);
deleteEntry([cursor]);
if (entry && index !== null) {
applySelection({ id: entry.id, selectMode: 'click', index });
}
},
[entries, flatOrder, deleteEntry, applySelection],
);
const insertCopyAtId = useCallback(
(atId: EntryId | null, above = false) => {
// lazily get the value from the store
const { entryCopyId, entryCopyMode, setEntryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
return;
}
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
const refElement = atId ? entries[atId] : undefined;
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
normalisedAtId = refElement.parent;
}
if (entryCopyMode === 'cut') {
if (!normalisedAtId) {
const firstId = flatOrder[0];
if (!firstId || firstId === entryCopyId) {
return;
}
reorderEntry(entryCopyId, firstId, 'before')
.then(() => setEntryCopyId(null))
.catch(() => {});
return;
}
if (normalisedAtId === entryCopyId) {
return;
}
const placement = above ? 'before' : 'after';
reorderEntry(entryCopyId, normalisedAtId, placement)
.then(() => setEntryCopyId(null))
.catch(() => {});
return;
}
clone(entryCopyId, {
after: above ? undefined : normalisedAtId ?? undefined,
// if we don't have a cursor add the new event on top
before: above ? normalisedAtId ?? undefined : undefined,
});
},
[entries, flatOrder, clone, reorderEntry],
);
/**
* Add a new item referring to an existing one
*/
const insertAtId = useCallback(
(patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: EntryId | null, above = false) => {
addEntry(patch, {
after: id && !above ? id : undefined,
before: id && above ? id : undefined,
lastEventId: !above && id ? id : undefined,
});
},
[addEntry],
);
const selectGroup = useCallback(
(cursor: EntryId | null, direction: 'up' | 'down') => {
if (flatOrder.length < 1) {
return null;
}
const selected =
direction === 'up'
? getPreviousGroupNormal(entries, flatOrder, cursor)
: getNextGroupNormal(entries, flatOrder, cursor);
if (selected.entry && selected.index !== null) {
applySelection({ id: selected.entry.id, selectMode: 'click', index: selected.index });
return selected.entry.id;
}
return null;
},
[flatOrder, entries, applySelection],
);
const selectEntry = useCallback(
(cursor: EntryId | null, direction: 'up' | 'down') => {
if (flatOrder.length < 1) {
return null;
}
const selected =
direction === 'up' ? getPreviousNormal(entries, flatOrder, cursor) : getNextNormal(entries, flatOrder, cursor);
if (selected.entry && selected.index !== null) {
applySelection({ id: selected.entry.id, selectMode: 'click', index: selected.index });
return selected.entry.id;
}
return null;
},
[flatOrder, entries, applySelection],
);
const moveEntry = useCallback(
async (cursor: EntryId | null, direction: 'up' | 'down') => {
if (cursor == null) {
return;
}
const movedIntoGroupId = await move(cursor, direction);
// if we are moving into a group, we need to make sure it is expanded
if (movedIntoGroupId) {
handleCollapseGroup(false, movedIntoGroupId);
}
},
[handleCollapseGroup, move],
);
const cloneEntry = useCallback(
(cursor: EntryId | null) => {
if (!cursor) {
return;
}
clone(cursor, { after: cursor });
},
[clone],
);
const selectEdge = useCallback(
(direction: 'top' | 'bottom') => {
if (flatOrder.length < 1) {
return null;
}
const index = direction === 'top' ? 0 : flatOrder.length - 1;
const selectedId = flatOrder[index];
if (!selectedId) return null;
applySelection({ id: selectedId, selectMode: 'click', index });
return selectedId;
},
[flatOrder, applySelection],
);
const selectPage = useCallback(
(cursor: EntryId | null, direction: 'up' | 'down') => {
if (flatOrder.length < 1) {
return null;
}
let currentIndex = cursor ? flatOrder.indexOf(cursor) : -1;
if (currentIndex === -1) {
currentIndex = direction === 'down' ? -1 : flatOrder.length;
}
let targetIndex = currentIndex;
let lastValidIndex: number | null = null;
for (let step = 0; step < PAGE_SIZE; step += 1) {
targetIndex = direction === 'down' ? targetIndex + 1 : targetIndex - 1;
if (targetIndex < 0 || targetIndex >= flatOrder.length) {
break;
}
lastValidIndex = targetIndex;
}
if (lastValidIndex === null) {
return null;
}
const selectedId = flatOrder[lastValidIndex];
if (!selectedId) return null;
applySelection({ id: selectedId, selectMode: 'click', index: lastValidIndex });
return selectedId;
},
[flatOrder, applySelection],
);
return {
cloneEntry,
deleteAtCursor,
insertCopyAtId,
insertAtId,
selectGroup,
selectEntry,
moveEntry,
selectEdge,
selectPage,
};
}
@@ -0,0 +1,152 @@
import { Dispatch, SetStateAction, useCallback, useMemo, useRef } from 'react';
import { DragEndEvent, DragOverEvent, DragStartEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { type EntryId, type Rundown, isOntimeGroup, SupportedEntry } from 'ontime-types';
import { reorderArray } from 'ontime-utils';
import type { useEntryActions } from '../../../common/hooks/useEntryAction';
import { canDrop } from '../rundown.utils';
interface UseRundownDndOptions {
entries: Rundown['entries'];
sortableData: EntryId[];
setSortableData: Dispatch<SetStateAction<EntryId[]>>;
getIsCollapsed: (groupId: EntryId) => boolean;
handleCollapseGroup: (collapsed: boolean, groupId: EntryId | undefined) => void;
entryActions: ReturnType<typeof useEntryActions>;
}
export function useRundownDnd({
entries,
sortableData,
setSortableData,
getIsCollapsed,
handleCollapseGroup,
entryActions,
}: UseRundownDndOptions) {
const { reorderEntry } = entryActions;
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
const isDraggingRef = useRef(false);
/**
* On drag end, we reorder the events
*/
const handleOnDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
isDraggingRef.current = false;
if (!over?.id || active.id === over.id) {
return;
}
if (!active.data.current || !over.data.current) {
return;
}
const fromIndex: number = active.data.current.sortable.index;
const toIndex: number = over.data.current.sortable.index;
let placement: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
let destinationId = over.id as EntryId;
const isDraggingGroup = active.data.current?.type === SupportedEntry.Group;
// prevent dropping a group inside another
if (
isDraggingGroup &&
!canDrop(over.data.current.type, over.data.current.parent, placement, getIsCollapsed(destinationId))
) {
return;
}
/**
* We need to specially handle the end-group
* Dragging before a end-group will add the entry to the end of the group
* Dragging after a end-group will add the event after the group itself
* Dragging to the top of a group either place before first entry or if no entries do insert
*/
if (destinationId.startsWith('end-')) {
destinationId = destinationId.replace('end-', '');
// if we are moving before the end, we use the insert operation
if (placement === 'before') {
placement = 'insert';
}
} else {
const group = entries[destinationId];
// if dragging into a group
if (isOntimeGroup(group) && placement === 'after') {
if (isDraggingGroup) {
// ... and the dragged entry is a group, we know that the group is collapsed, because of the safe check canDrop from before
// so we can safely push the dragged event after the group
destinationId = group.id;
} else if (group.entries.length === 0) {
// ... and the group is entry, we insert
destinationId = group.id;
placement = 'insert';
} else {
// otherwise we add it to before the first group child
destinationId = group.entries[0];
placement = 'before';
}
}
}
// Optimistic update pattern to keep DND responsive
// 1. Keep copy of current state in case we need to revert
const currentEntries = [...sortableData];
// 2. Immediately update local state for responsive UI
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
// 3. Trigger async mutation, revert on error
reorderEntry(active.id as EntryId, destinationId, placement).catch((_) => {
setSortableData(currentEntries);
});
},
[entries, sortableData, setSortableData, getIsCollapsed, reorderEntry],
);
/**
* When we drag a group, we force collapse it
* This avoids strange scenarios like dropping a group inside itself
*/
const collapseDraggedGroups = useCallback(
(event: DragStartEvent) => {
isDraggingRef.current = true;
const isGroup = event.active.data.current?.type === SupportedEntry.Group;
if (isGroup) {
handleCollapseGroup(true, event.active.id as EntryId);
}
},
[handleCollapseGroup],
);
/**
* When we drag over a group, we expand it if it is collapsed
*/
const expandOverGroup = useCallback(
(event: DragOverEvent) => {
// if we are dragging a group, the drop operation is invalid so we dont expand
if (event.active.data.current?.type === SupportedEntry.Group) {
return;
}
if (event.over?.data.current?.type !== SupportedEntry.Group) {
return;
}
const groupId = event.over?.id as EntryId | undefined;
handleCollapseGroup(false, groupId);
},
[handleCollapseGroup],
);
return useMemo(
() => ({
sensors,
isDraggingRef,
handleOnDragEnd,
collapseDraggedGroups,
expandOverGroup,
}),
[sensors, handleOnDragEnd, collapseDraggedGroups, expandOverGroup],
);
}
@@ -0,0 +1,238 @@
import { useHotkeys } from '@mantine/hooks';
import { type OntimeEntry, EntryId, SupportedEntry } from 'ontime-types';
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { useEventSelection } from '../useEventSelection';
interface UseRundownKeyboardOptions {
cursor: EntryId | null;
commands: {
selectEntry: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
selectGroup: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
selectEdge: (direction: 'top' | 'bottom') => EntryId | null;
selectPage: (cursor: EntryId | null, direction: 'up' | 'down') => EntryId | null;
cloneEntry: (cursor: EntryId | null) => void;
moveEntry: (cursor: EntryId | null, direction: 'up' | 'down') => void;
deleteAtCursor: (cursor: EntryId | null) => void;
insertAtId: (patch: Partial<OntimeEntry> & { type: SupportedEntry }, id: EntryId | null, above?: boolean) => void;
insertCopyAtId: (atId: EntryId | null, above?: boolean) => void;
};
clearSelectedEvents: () => void;
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
}
/**
* Returns true when a keyboard event target is a text input element.
* Use this to avoid intercepting browser-native copy/cut/paste shortcuts
* while users are typing in form fields.
*/
function isEditableElement(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
return false;
}
const tagName = target.tagName.toLowerCase();
return tagName === 'input' || tagName === 'textarea';
}
export function useRundownKeyboard({
cursor,
commands,
clearSelectedEvents,
setEntryCopyId,
}: UseRundownKeyboardOptions) {
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
useHotkeys([
[
'alt + ArrowDown',
() => {
const nextId = commands.selectEntry(cursor, 'down');
if (nextId) {
scrollToEntry(nextId);
}
},
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + ArrowUp',
() => {
const nextId = commands.selectEntry(cursor, 'up');
if (nextId) {
scrollToEntry(nextId);
}
},
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + ArrowDown',
() => {
const nextId = commands.selectGroup(cursor, 'down');
if (nextId) {
scrollToEntry(nextId);
}
},
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + ArrowUp',
() => {
const nextId = commands.selectGroup(cursor, 'up');
if (nextId) {
scrollToEntry(nextId);
}
},
{ preventDefault: true, usePhysicalKeys: true },
],
[
'Home',
() => {
const nextId = commands.selectEdge('top');
if (nextId) {
scrollToEntry(nextId);
}
},
{ preventDefault: true, usePhysicalKeys: true },
],
[
'End',
() => {
const nextId = commands.selectEdge('bottom');
if (nextId) {
scrollToEntry(nextId);
}
},
{ preventDefault: true, usePhysicalKeys: true },
],
[
'PageUp',
() => {
const nextId = commands.selectPage(cursor, 'up');
if (nextId) {
scrollToEntry(nextId);
}
},
{ preventDefault: true, usePhysicalKeys: true },
],
[
'PageDown',
() => {
const nextId = commands.selectPage(cursor, 'down');
if (nextId) {
scrollToEntry(nextId);
}
},
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + mod + ArrowDown',
() => commands.moveEntry(cursor, 'down'),
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + mod + ArrowUp', () => commands.moveEntry(cursor, 'up'), { preventDefault: true, usePhysicalKeys: true }],
[
'Escape',
() => {
clearSelectedEvents();
setEntryCopyId(null);
},
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + Backspace', () => commands.deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
[
'alt + E',
() => commands.insertAtId({ type: SupportedEntry.Event }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + E',
() => commands.insertAtId({ type: SupportedEntry.Event }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + G',
() => commands.insertAtId({ type: SupportedEntry.Group }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + G',
() => commands.insertAtId({ type: SupportedEntry.Group }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + D',
() => commands.insertAtId({ type: SupportedEntry.Delay }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + D',
() => commands.insertAtId({ type: SupportedEntry.Delay }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + M',
() => commands.insertAtId({ type: SupportedEntry.Milestone }, cursor),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'alt + shift + M',
() => commands.insertAtId({ type: SupportedEntry.Milestone }, cursor, true),
{ preventDefault: true, usePhysicalKeys: true },
],
[
'mod + C',
(event) => {
if (cursor === null || isEditableElement(event.target)) {
return;
}
event.preventDefault();
setEntryCopyId(cursor);
},
{ usePhysicalKeys: true },
],
[
'mod + X',
(event) => {
if (cursor === null || isEditableElement(event.target)) {
return;
}
event.preventDefault();
setEntryCopyId(cursor, 'cut');
},
{ usePhysicalKeys: true },
],
[
'mod + V',
(event) => {
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
return;
}
event.preventDefault();
commands.insertCopyAtId(cursor);
},
{ usePhysicalKeys: true },
],
['mod + D', () => commands.cloneEntry(cursor), { preventDefault: true, usePhysicalKeys: true }],
[
'mod + shift + V',
(event) => {
if (isEditableElement(event.target) || useEntryCopy.getState().entryCopyId === null) {
return;
}
event.preventDefault();
commands.insertCopyAtId(cursor, true);
},
{ usePhysicalKeys: true },
],
]);
}
@@ -9,8 +9,8 @@ function FinderPlacement() {
const [isOpen, handler] = useDisclosure(); const [isOpen, handler] = useDisclosure();
useHotkeys([ useHotkeys([
['mod + f', handler.toggle], ['mod + f', handler.toggle, { preventDefault: true }],
['Escape', handler.close], ['Escape', handler.close, { preventDefault: true }],
]); ]);
if (isOpen) { if (isOpen) {
@@ -1,4 +1,3 @@
import type { PropsWithChildren } from 'react';
import { create } from 'zustand'; import { create } from 'zustand';
import { DropdownMenuOption, PositionedDropdownMenu } from '../../../common/components/dropdown-menu/DropdownMenu'; import { DropdownMenuOption, PositionedDropdownMenu } from '../../../common/components/dropdown-menu/DropdownMenu';
@@ -24,7 +23,7 @@ export const useContextMenuStore = create<ContextMenuStore>((set) => ({
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })), setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
})); }));
export function RundownContextMenu({ children }: PropsWithChildren) { export function RundownContextMenu() {
const { position, options, isOpen, setIsOpen } = useContextMenuStore(); const { position, options, isOpen, setIsOpen } = useContextMenuStore();
const onClose = () => { const onClose = () => {
@@ -32,13 +31,8 @@ export function RundownContextMenu({ children }: PropsWithChildren) {
}; };
if (!isOpen) { if (!isOpen) {
return children; return null;
} }
return ( return <PositionedDropdownMenu isOpen position={position} onClose={onClose} items={options} />;
<>
{children}
<PositionedDropdownMenu isOpen position={position} onClose={onClose} items={options} />
</>
);
} }
@@ -1,8 +1,8 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react'; import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { millisToString, parseUserTime } from 'ontime-utils'; import { millisToString, parseUserTime } from 'ontime-utils';
import { useEntryActions } from '../../../hooks/useEntryAction'; import Input from '../../../common/components/input/input/Input';
import Input from '../input/Input'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import BlockRadio from './BlockRadio'; import BlockRadio from './BlockRadio';
@@ -13,9 +13,8 @@ interface DelayInputProps {
duration: number; duration: number;
} }
export default function DelayInput(props: DelayInputProps) { export default function DelayInput({ eventId, duration }: DelayInputProps) {
const { eventId, duration } = props; const { updateEntry } = useEntryActionsContext();
const { updateEntry } = useEntryActions();
const [value, setValue] = useState<string>(''); const [value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@@ -16,6 +16,11 @@
&.hasCursor { &.hasCursor {
outline: 1px solid $block-cursor-color; outline: 1px solid $block-cursor-color;
} }
&.copyTarget {
outline: 1px dashed $blue-500;
outline-offset: -2px;
}
} }
.drag { .drag {
@@ -5,10 +5,12 @@ import { CSS } from '@dnd-kit/utilities';
import { OntimeDelay } from 'ontime-types'; import { OntimeDelay } from 'ontime-types';
import Button from '../../../common/components/buttons/Button'; import Button from '../../../common/components/buttons/Button';
import DelayInput from '../../../common/components/input/delay-input/DelayInput'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import DelayInput from './DelayInput';
import style from './RundownDelay.module.scss'; import style from './RundownDelay.module.scss';
interface RundownDelayProps { interface RundownDelayProps {
@@ -17,8 +19,11 @@ interface RundownDelayProps {
} }
export default function RundownDelay({ data, hasCursor }: RundownDelayProps) { export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
const { applyDelay, deleteEntry } = useEntryActions(); 'use memo';
const { applyDelay, deleteEntry } = useEntryActionsContext();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const { const {
attributes: dragAttributes, attributes: dragAttributes,
@@ -57,7 +62,7 @@ export default function RundownDelay({ data, hasCursor }: RundownDelayProps) {
return ( return (
<div <div
className={cx([style.delay, hasCursor ? style.hasCursor : null])} className={cx([style.delay, hasCursor && style.hasCursor, entryCopyId === data.id && style.copyTarget])}
ref={setNodeRef} ref={setNodeRef}
style={dragStyle} style={dragStyle}
data-testid='rundown-delay' data-testid='rundown-delay'
@@ -58,6 +58,10 @@ $skip-opacity: 0.2;
outline: 1px solid $block-cursor-color; outline: 1px solid $block-cursor-color;
} }
&.copyTarget {
outline: 2px dashed $block-cursor-color;
}
&.past:not(.skip) { &.past:not(.skip) {
.timerNote, .timerNote,
.statusElements, .statusElements,
@@ -1,4 +1,4 @@
import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { MouseEvent, useEffect, useRef } from 'react';
import { import {
IoAdd, IoAdd,
IoDuplicateOutline, IoDuplicateOutline,
@@ -15,8 +15,10 @@ import { CSS } from '@dnd-kit/utilities';
import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { isPlaybackActive } from 'ontime-utils'; import { isPlaybackActive } from 'ontime-utils';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventIdSwapping } from '../useEventIdSwapping'; import { useEventIdSwapping } from '../useEventIdSwapping';
import { getSelectionMode, useEventSelection } from '../useEventSelection'; import { getSelectionMode, useEventSelection } from '../useEventSelection';
@@ -91,14 +93,25 @@ export default function RundownEvent({
isLinkedToLoaded, isLinkedToLoaded,
hasTriggers, hasTriggers,
}: RundownEventProps) { }: RundownEventProps) {
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); 'use memo';
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const selectedEventId = useEventIdSwapping((state) => state.selectedEventId);
const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId);
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
const unselect = useEventSelection((state) => state.unselect);
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection();
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
const [onContextMenu] = useContextMenu<HTMLDivElement>( const [onContextMenu] = useContextMenu<HTMLDivElement>(() =>
selectedEvents.size > 1 selectedEvents.size > 1
? [ ? [
{ {
@@ -133,6 +146,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceMod}+Del`,
onClick: () => { onClick: () => {
clearSelectedEvents(); clearSelectedEvents();
deleteEntry(Array.from(selectedEvents)); deleteEntry(Array.from(selectedEvents));
@@ -170,6 +184,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Clone', label: 'Clone',
icon: IoDuplicateOutline, icon: IoDuplicateOutline,
shortcut: `${deviceMod}+D`,
onClick: () => clone(eventId, { after: eventId }), onClick: () => clone(eventId, { after: eventId }),
}, },
{ type: 'divider' }, { type: 'divider' },
@@ -177,6 +192,7 @@ export default function RundownEvent({
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceMod}+Del`,
onClick: () => { onClick: () => {
deleteEntry([eventId]); deleteEntry([eventId]);
unselect(eventId); unselect(eventId);
@@ -225,40 +241,15 @@ export default function RundownEvent({
} }
}, [hasCursor]); }, [hasCursor]);
useLayoutEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
}
},
{
root: null,
threshold: 1,
},
);
const handleRefCurrent = handleRef.current;
if (handleRefCurrent) {
observer.observe(handleRefCurrent);
}
return () => {
if (handleRefCurrent) {
observer.unobserve(handleRefCurrent);
}
};
}, [handleRef]);
const isSelected = selectedEvents.has(eventId);
const blockClasses = cx([ const blockClasses = cx([
style.rundownEvent, style.rundownEvent,
skip ? style.skip : null, skip && style.skip,
isPast ? style.past : null, isPast && style.past,
loaded ? style.loaded : null, loaded && style.loaded,
playback ? style[playback] : null, playback && style[playback],
isSelected ? style.selected : null, isSelected && style.selected,
hasCursor ? style.hasCursor : null, hasCursor && style.hasCursor,
entryCopyId === eventId && style.copyTarget,
]); ]);
const handleFocusClick = (event: MouseEvent) => { const handleFocusClick = (event: MouseEvent) => {
@@ -274,7 +265,7 @@ export default function RundownEvent({
// UI indexes are 1 based // UI indexes are 1 based
const index = eventIndex - 1; const index = eventIndex - 1;
const editMode = getSelectionMode(event); const editMode = getSelectionMode(event);
setSelectedEvents({ id: eventId, index, selectMode: editMode }); selectEntry({ id: eventId, index, selectMode: editMode });
}; };
const isPlaying = playback ? isPlaybackActive(playback) : false; const isPlaying = playback ? isPlaybackActive(playback) : false;
@@ -298,33 +289,31 @@ export default function RundownEvent({
<span className={style.cue}>{cue}</span> <span className={style.cue}>{cue}</span>
</div> </div>
{isVisible && ( <RundownEventInner
<RundownEventInner timeStart={timeStart}
timeStart={timeStart} timeEnd={timeEnd}
timeEnd={timeEnd} duration={duration}
duration={duration} linkStart={linkStart}
linkStart={linkStart} countToEnd={countToEnd}
countToEnd={countToEnd} timeStrategy={timeStrategy}
timeStrategy={timeStrategy} eventId={eventId}
eventId={eventId} eventIndex={eventIndex}
eventIndex={eventIndex} endAction={endAction}
endAction={endAction} timerType={timerType}
timerType={timerType} title={title}
title={title} note={note}
note={note} delay={delay}
delay={delay} isNext={isNext}
isNext={isNext} skip={skip}
skip={skip} loaded={loaded}
loaded={loaded} playback={playback}
playback={playback} isRolling={isRolling}
isRolling={isRolling} dayOffset={dayOffset}
dayOffset={dayOffset} isPast={isPast}
isPast={isPast} totalGap={totalGap}
totalGap={totalGap} isLinkedToLoaded={isLinkedToLoaded}
isLinkedToLoaded={isLinkedToLoaded} hasTriggers={hasTriggers}
hasTriggers={hasTriggers} />
/>
)}
</div> </div>
); );
} }
@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react'; import { memo } from 'react';
import { import {
IoArrowDown, IoArrowDown,
IoArrowUp, IoArrowUp,
@@ -10,14 +10,14 @@ import {
IoTime, IoTime,
} from 'react-icons/io5'; } from 'react-icons/io5';
import { LuArrowDownToLine } from 'react-icons/lu'; import { LuArrowDownToLine } from 'react-icons/lu';
import { useSessionStorage } from '@mantine/hooks';
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import { AppMode, sessionKeys } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import TitleEditor from '../common/TitleEditor'; import TitleEditor from '../common/TitleEditor';
import TimeInputFlow from '../time-input-flow/TimeInputFlow'; import TimeInputFlow from '../time-input-flow/TimeInputFlow';
import { useEditorFollowMode } from '../useEditorFollowMode';
import RundownEventChip from './composite/RundownEventChip'; import RundownEventChip from './composite/RundownEventChip';
import EventBlockPlayback from './composite/RundownEventPlayback'; import EventBlockPlayback from './composite/RundownEventPlayback';
@@ -76,16 +76,7 @@ function RundownEventInner({
isLinkedToLoaded, isLinkedToLoaded,
hasTriggers, hasTriggers,
}: RundownEventInnerProps) { }: RundownEventInnerProps) {
const [renderInner, setRenderInner] = useState(false); const { editorMode } = useEditorFollowMode();
const [editorMode] = useSessionStorage({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
useEffect(() => {
setRenderInner(true);
}, []);
const eventIsPlaying = playback === Playback.Play; const eventIsPlaying = playback === Playback.Play;
const eventIsPaused = playback === Playback.Pause; const eventIsPaused = playback === Playback.Pause;
@@ -97,7 +88,7 @@ function RundownEventInner({
playBtnStyles._hover = {}; playBtnStyles._hover = {};
} }
return !renderInner ? null : ( return (
<> <>
<div className={cx([style.eventTimers, editorMode === AppMode.Edit && style.editMode])}> <div className={cx([style.eventTimers, editorMode === AppMode.Edit && style.editMode])}>
<TimeInputFlow <TimeInputFlow
@@ -161,8 +152,7 @@ function RundownEventInner({
); );
} }
function EndActionIcon(props: { action: EndAction; className: string }) { function EndActionIcon({ action, className }: { action: EndAction; className: string }) {
const { action, className } = props;
const maybeActiveClasses = cx([action !== EndAction.None && style.active, className]); const maybeActiveClasses = cx([action !== EndAction.None && style.active, className]);
if (action === EndAction.LoadNext) { if (action === EndAction.LoadNext) {
@@ -174,8 +164,7 @@ function EndActionIcon(props: { action: EndAction; className: string }) {
return <IoPlay className={className} />; return <IoPlay className={className} />;
} }
function TimerIcon(props: { type: TimerType; className: string }) { function TimerIcon({ type, className }: { type: TimerType; className: string }) {
const { type, className } = props;
if (type === TimerType.CountUp) { if (type === TimerType.CountUp) {
return <IoArrowUp className={className} />; return <IoArrowUp className={className} />;
} }
@@ -35,7 +35,7 @@ export default function RundownEventChip({
duration, duration,
isLinkedToLoaded, isLinkedToLoaded,
}: RundownEventChipProps) { }: RundownEventChipProps) {
const { playback } = usePlayback(); const playback = usePlayback();
if (isLoaded) { if (isLoaded) {
return null; return null;
@@ -3,7 +3,7 @@ import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Tooltip from '../../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { setEventPlayback } from '../../../../common/hooks/useSocket'; import { setEventPlayback } from '../../../../common/hooks/useSocket';
import style from '../RundownEvent.module.scss'; import style from '../RundownEvent.module.scss';
@@ -26,7 +26,7 @@ function RundownEventPlayback({
loaded, loaded,
disablePlayback, disablePlayback,
}: RundownEventPlaybackProps) { }: RundownEventPlaybackProps) {
const { updateEntry } = useEntryActions(); const { updateEntry } = useEntryActionsContext();
const toggleSkip = (event: MouseEvent) => { const toggleSkip = (event: MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
@@ -13,6 +13,10 @@
outline: 1px solid $block-cursor-color; outline: 1px solid $block-cursor-color;
} }
&.copyTarget {
outline: 2px dashed $block-cursor-color;
}
&.expanded { &.expanded {
margin-block: 0.5rem 0; margin-block: 0.5rem 0;
border-radius: $block-border-radius $block-border-radius 0 0; border-radius: $block-border-radius $block-border-radius 0 0;
@@ -53,29 +57,48 @@
.metaRow { .metaRow {
display: flex; display: flex;
gap: 3rem; gap: $block-clearance; // same as RundownEvent.eventTimers
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
padding-left: 0.25rem;
white-space: nowrap; white-space: nowrap;
} }
.metaEntry { .metaEntry {
width: 4.5em; width: 8rem; // timeInput + button
:first-child { &:first-of-type {
font-size: calc(1rem - 3px); width: 4.5rem; // binder + buttons
color: $label-gray;
} }
} }
.metaLabel {
color: $muted-gray;
font-size: calc(1rem - 3px);
}
} }
.strike { .strike {
text-decoration: line-through; text-decoration: wavy underline;
margin-right: 0.25rem;
color: $ui-white;
} }
.over { .over {
color: $playback-over; color: $playback-over;
.strike {
text-decoration-color: $playback-over;
}
.offsetLabel {
background-color: $playback-over;
}
} }
.under { .under {
color: $playback-under; color: $playback-under;
.strike {
text-decoration-color: $playback-under;
}
.offsetLabel {
background-color: $playback-under;
}
} }
.drag { .drag {
@@ -13,8 +13,11 @@ import { EntryId, OntimeGroup } from 'ontime-types';
import { MILLIS_PER_MINUTE, millisToString } from 'ontime-utils'; import { MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
import IconButton from '../../../common/components/buttons/IconButton'; import IconButton from '../../../common/components/buttons/IconButton';
import Tag from '../../../common/components/tag/Tag';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { getOffsetState } from '../../../common/utils/offset'; import { getOffsetState } from '../../../common/utils/offset';
import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour, timerPlaceholder } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time'; import { formatDuration } from '../../../common/utils/time';
@@ -33,15 +36,21 @@ interface RundownGroupProps {
//TODO: the group should maybe include a multiple day indicator //TODO: the group should maybe include a multiple day indicator
export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: RundownGroupProps) { export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }: RundownGroupProps) {
const handleRef = useRef<null | HTMLSpanElement>(null); 'use memo';
const { clone, ungroup, deleteEntry } = useEntryActions();
const { selectedEvents, setSingleEntrySelection } = useEventSelection();
const [onContextMenu] = useContextMenu<HTMLDivElement>([ const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActionsContext();
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
{ {
type: 'item', type: 'item',
label: 'Clone Group', label: 'Clone Group',
icon: IoDuplicateOutline, icon: IoDuplicateOutline,
shortcut: `${deviceMod}+D`,
onClick: () => clone(data.id), onClick: () => clone(data.id),
}, },
{ {
@@ -56,6 +65,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
type: 'item', type: 'item',
label: 'Delete Group', label: 'Delete Group',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceMod}+Del`,
onClick: () => deleteEntry([data.id]), onClick: () => deleteEntry([data.id]),
}, },
]); ]);
@@ -88,7 +98,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
} }
// UI indexes are 1 based // UI indexes are 1 based
setSingleEntrySelection({ id: data.id }); selectSingleEntry({ id: data.id });
}; };
const binderColours = data.colour && getAccessibleColour(data.colour); const binderColours = data.colour && getAccessibleColour(data.colour);
@@ -119,7 +129,12 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
return ( return (
<div <div
className={cx([style.group, hasCursor && style.hasCursor, !collapsed && style.expanded])} className={cx([
style.group,
hasCursor && style.hasCursor,
!collapsed && style.expanded,
entryCopyId === data.id && style.copyTarget,
])}
ref={setNodeRef} ref={setNodeRef}
onClick={handleFocusClick} onClick={handleFocusClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
@@ -148,30 +163,28 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
</div> </div>
<div className={style.metaRow}> <div className={style.metaRow}>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>Start</div> <div className={style.metaLabel}>Entries</div>
<div>{data.entries.length}</div>
</div>
<div className={style.metaEntry}>
<div className={style.metaLabel}>Start</div>
<div>{millisToString(data.timeStart, { fallback: timerPlaceholder })}</div> <div>{millisToString(data.timeStart, { fallback: timerPlaceholder })}</div>
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>End</div> <div className={style.metaLabel}>End</div>
<div>{millisToString(data.timeEnd, { fallback: timerPlaceholder })}</div> <div>{millisToString(data.timeEnd, { fallback: timerPlaceholder })}</div>
</div> </div>
<div className={style.metaEntry}> <div className={style.metaEntry}>
<div>Duration</div> <div className={style.metaLabel}>Duration</div>
{planOffset === null ? ( {planOffset === null ? (
<div className={cx([planOffsetLabel !== null && style[planOffsetLabel]])}> <div>{formatDuration(data.duration)}</div>
{formatDuration(data.duration)}
</div>
) : ( ) : (
<div> <div className={cx([planOffsetLabel && style[planOffsetLabel]])}>
<span className={style.strike}>{formatDuration(data.duration)}</span> <span className={style.strike}>{formatDuration(data.duration)}</span>
<span className={cx([planOffsetLabel !== null && style[planOffsetLabel]])}>{planOffset}</span> <Tag className={style.offsetLabel}>{planOffset}</Tag>
</div> </div>
)} )}
</div> </div>
<div className={style.metaEntry}>
<div>Entries</div>
<div>{data.entries.length}</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -2,6 +2,7 @@
padding-inline: 1rem 2rem; padding-inline: 1rem 2rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem;
:is([data-target='small-device']) & { :is([data-target='small-device']) & {
padding-inline: 0; padding-inline: 0;
@@ -57,10 +58,17 @@
} }
} }
.toggleTooltipTrigger {
display: flex;
}
.apart { .apart {
margin-left: auto; margin-left: auto;
} }
.separator { .column {
margin-inline: 1rem; display: flex;
flex-direction: column;
gap: 0.5rem;
align-self: start;
} }
@@ -2,22 +2,56 @@ import { memo } from 'react';
import { Toggle } from '@base-ui/react/toggle'; import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group'; import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar'; import { Toolbar } from '@base-ui/react/toolbar';
import { useSessionStorage } from '@mantine/hooks';
import { OffsetMode } from 'ontime-types'; import { OffsetMode } from 'ontime-types';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket'; import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket';
import { AppMode, sessionKeys } from '../../../ontimeConfig'; import { AppMode } from '../../../ontimeConfig';
import { EditorLayoutMode, useEditorLayout } from '../../../views/editor/useEditorLayout';
import { RundownViewMode } from '../rundown.options';
import { useEditorFollowMode } from '../useEditorFollowMode';
import RundownMenu from './RundownMenu'; import RundownMenu from './RundownMenu';
import style from './RundownHeader.module.scss'; import style from './RundownHeader.module.scss';
export default memo(RundownHeader); interface RundownHeaderProps {
function RundownHeader() { isExtracted?: boolean;
const [editorMode, setEditorMode] = useSessionStorage({ key: sessionKeys.editorMode, defaultValue: AppMode.Edit }); viewMode: RundownViewMode;
setViewMode: (mode: RundownViewMode) => void;
}
const { offsetMode } = useOffsetMode(); interface HeaderControlsConfig {
showRunEditToggle: boolean;
showOffsetToggle: boolean;
showOverflowMenu: boolean;
}
export const HEADER_CONTROLS_CONFIG: Record<EditorLayoutMode, HeaderControlsConfig> = {
[EditorLayoutMode.CONTROL]: {
showRunEditToggle: true,
showOffsetToggle: true,
showOverflowMenu: true,
},
[EditorLayoutMode.PLANNING]: {
showRunEditToggle: false,
showOffsetToggle: false,
showOverflowMenu: true,
},
[EditorLayoutMode.TRACKING]: {
showRunEditToggle: false,
showOffsetToggle: true,
showOverflowMenu: false,
},
} as const;
export default memo(RundownHeader);
function RundownHeader({ isExtracted, viewMode, setViewMode }: RundownHeaderProps) {
const { editorMode, setEditorMode } = useEditorFollowMode();
const offsetMode = useOffsetMode();
const { layoutMode } = useEditorLayout();
const { showRunEditToggle, showOffsetToggle, showOverflowMenu } = HEADER_CONTROLS_CONFIG[layoutMode];
const toggleAppMode = (mode: AppMode[]) => { const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode // we need to stop user from deselecting a mode
@@ -26,8 +60,13 @@ function RundownHeader() {
setEditorMode(newValue); setEditorMode(newValue);
}; };
const toggleViewMode = (mode: RundownViewMode[]) => {
const newValue = mode.at(0);
if (!newValue) return;
setViewMode(newValue);
};
const toggleOffsetMode = (mode: OffsetMode[]) => { const toggleOffsetMode = (mode: OffsetMode[]) => {
// we need to stop user from deselecting a mode
const newValue = mode.at(0); const newValue = mode.at(0);
if (!newValue) return; if (!newValue) return;
setOffsetMode(newValue); setOffsetMode(newValue);
@@ -35,27 +74,56 @@ function RundownHeader() {
return ( return (
<Toolbar.Root className={style.header}> <Toolbar.Root className={style.header}>
<ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}> {showRunEditToggle && (
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}> <ToggleGroup value={[editorMode]} onValueChange={toggleAppMode} className={style.group}>
Run <Tooltip
</Toolbar.Button> text='Live playback view with auto-follow'
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}> render={<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton} />}
Edit >
</Toolbar.Button> Run
</Tooltip>
<Tooltip
text='Manual editing without playback automation'
render={<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton} />}
>
Edit
</Tooltip>
</ToggleGroup>
)}
<ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
<Tooltip
text='View rundown in list mode'
render={<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton} />}
>
List
</Tooltip>
<Tooltip
text='View rundown in table mode'
render={<Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton} />}
>
Table
</Tooltip>
</ToggleGroup> </ToggleGroup>
<Editor.Separator className={style.separator} /> {showOffsetToggle && (
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}>
<Tooltip
text='Offsets use fixed clock time'
render={<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton} />}
>
Absolute
</Tooltip>
<Tooltip
text='Offsets follow the rundown relative start'
render={<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton} />}
>
Relative
</Tooltip>
</ToggleGroup>
)}
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}> {showOverflowMenu && <RundownMenu allowNavigation={!isExtracted} />}
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton}>
Absolute
</Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton}>
Relative
</Toolbar.Button>
</ToggleGroup>
<RundownMenu />
</Toolbar.Root> </Toolbar.Root>
); );
} }
@@ -2,23 +2,21 @@ import { memo } from 'react';
import { Toggle } from '@base-ui/react/toggle'; import { Toggle } from '@base-ui/react/toggle';
import { ToggleGroup } from '@base-ui/react/toggle-group'; import { ToggleGroup } from '@base-ui/react/toggle-group';
import { Toolbar } from '@base-ui/react/toolbar'; import { Toolbar } from '@base-ui/react/toolbar';
import { useSessionStorage } from '@mantine/hooks';
import { OffsetMode } from 'ontime-types';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import { AppMode } from '../../../ontimeConfig';
import { setOffsetMode, useOffsetMode } from '../../../common/hooks/useSocket'; import { RundownViewMode } from '../rundown.options';
import { AppMode, sessionKeys } from '../../../ontimeConfig'; import { useEditorFollowMode } from '../useEditorFollowMode';
import style from './RundownHeader.module.scss'; import style from './RundownHeader.module.scss';
export default memo(RundownHeader); interface RundownHeaderMobileProps {
function RundownHeader() { viewMode: RundownViewMode;
const [editorMode, setEditorMode] = useSessionStorage<AppMode>({ setViewMode: (mode: RundownViewMode) => void;
key: sessionKeys.editorMode, }
defaultValue: AppMode.Edit,
});
const { offsetMode } = useOffsetMode(); export default memo(RundownHeaderMobile);
function RundownHeaderMobile({ viewMode, setViewMode }: RundownHeaderMobileProps) {
const { editorMode, setEditorMode } = useEditorFollowMode();
const toggleAppMode = (mode: AppMode[]) => { const toggleAppMode = (mode: AppMode[]) => {
// we need to stop user from deselecting a mode // we need to stop user from deselecting a mode
@@ -27,11 +25,10 @@ function RundownHeader() {
setEditorMode(newValue); setEditorMode(newValue);
}; };
const toggleOffsetMode = (mode: OffsetMode[]) => { const toggleViewMode = (mode: RundownViewMode[]) => {
// we need to stop user from deselecting a mode
const newValue = mode.at(0); const newValue = mode.at(0);
if (!newValue) return; if (!newValue) return;
setOffsetMode(newValue); setViewMode(newValue);
}; };
return ( return (
@@ -40,19 +37,18 @@ function RundownHeader() {
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}> <Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
Run Run
</Toolbar.Button> </Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}> <Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
Edit Edit
</Toolbar.Button> </Toolbar.Button>
</ToggleGroup> </ToggleGroup>
<Editor.Separator className={style.separator} /> <ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton}>
<ToggleGroup value={[offsetMode]} onValueChange={toggleOffsetMode} className={style.group}> List
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton}>
Absolute
</Toolbar.Button> </Toolbar.Button>
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton}> <Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton}>
Relative Table
</Toolbar.Button> </Toolbar.Button>
</ToggleGroup> </ToggleGroup>
</Toolbar.Root> </Toolbar.Root>
@@ -1,26 +1,29 @@
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { IoTrash } from 'react-icons/io5'; import { IoEllipsisHorizontal, IoList, IoTrash } from 'react-icons/io5';
import { Toolbar } from '@base-ui/react/toolbar'; import { Toolbar } from '@base-ui/react/toolbar';
import { useDisclosure, useSessionStorage } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import Button from '../../../common/components/buttons/Button'; import Button from '../../../common/components/buttons/Button';
import IconButton from '../../../common/components/buttons/IconButton';
import Dialog from '../../../common/components/dialog/Dialog'; import Dialog from '../../../common/components/dialog/Dialog';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { DropdownMenu } from '../../../common/components/dropdown-menu/DropdownMenu';
import { AppMode, sessionKeys } from '../../../ontimeConfig'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import useAppSettingsNavigation from '../../app-settings/useAppSettingsNavigation';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
import style from './RundownHeader.module.scss'; import style from './RundownHeader.module.scss';
interface RundownMenuProps {
allowNavigation?: boolean;
}
export default memo(RundownMenu); export default memo(RundownMenu);
function RundownMenu() { function RundownMenu({ allowNavigation }: RundownMenuProps) {
const [isOpen, handlers] = useDisclosure(); const [isOpen, handlers] = useDisclosure();
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents); const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const [editorMode] = useSessionStorage({ const { deleteAllEntries } = useEntryActionsContext();
key: sessionKeys.editorMode, const { setLocation } = useAppSettingsNavigation();
defaultValue: AppMode.Edit,
});
const { deleteAllEntries } = useEntryActions();
const deleteAll = useCallback(() => { const deleteAll = useCallback(() => {
deleteAllEntries(); deleteAllEntries();
@@ -30,15 +33,30 @@ function RundownMenu() {
return ( return (
<> <>
<Toolbar.Button <div className={style.apart}>
render={<Button variant='subtle-destructive' />} <DropdownMenu
onClick={handlers.open} render={<Toolbar.Button render={<IconButton variant='subtle-white' aria-label='Rundown menu' />} />}
disabled={editorMode === AppMode.Run} items={[
className={style.apart} {
> type: 'item',
<IoTrash /> label: 'Manage Rundowns...',
Clear all icon: IoList,
</Toolbar.Button> onClick: () => setLocation('manage__rundowns'),
disabled: !allowNavigation,
},
{ type: 'divider' },
{
type: 'destructive',
label: 'Clear all',
icon: IoTrash,
onClick: handlers.open,
},
]}
>
<IoEllipsisHorizontal />
</DropdownMenu>
</div>
<Dialog <Dialog
isOpen={isOpen} isOpen={isOpen}
onClose={handlers.close} onClose={handlers.close}
@@ -17,6 +17,10 @@
&.hasCursor { &.hasCursor {
outline: 1px solid $block-cursor-color; outline: 1px solid $block-cursor-color;
} }
&.copyTarget {
outline: 2px dashed $block-cursor-color;
}
} }
.binder { .binder {
@@ -6,8 +6,10 @@ import { EntryId } from 'ontime-types';
import Input from '../../../common/components/input/input/Input'; import Input from '../../../common/components/input/input/Input';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryCopy } from '../../../common/stores/entryCopyStore';
import { deviceMod } from '../../../common/utils/deviceUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { useEventSelection } from '../useEventSelection'; import { useEventSelection } from '../useEventSelection';
@@ -22,15 +24,21 @@ interface RundownMilestoneProps {
} }
export default function RundownMilestone({ colour, cue, entryId, hasCursor, title }: RundownMilestoneProps) { export default function RundownMilestone({ colour, cue, entryId, hasCursor, title }: RundownMilestoneProps) {
const handleRef = useRef<null | HTMLSpanElement>(null); 'use memo';
const { updateEntry, deleteEntry } = useEntryActions();
const { selectedEvents, setSingleEntrySelection } = useEventSelection();
const [onContextMenu] = useContextMenu<HTMLDivElement>([ const handleRef = useRef<null | HTMLSpanElement>(null);
const { updateEntry, deleteEntry } = useEntryActionsContext();
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
{ {
type: 'item', type: 'item',
label: 'Delete', label: 'Delete',
icon: IoTrash, icon: IoTrash,
shortcut: `${deviceMod}+Del`,
onClick: () => deleteEntry([entryId]), onClick: () => deleteEntry([entryId]),
}, },
]); ]);
@@ -61,7 +69,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
} }
// UI indexes are 1 based // UI indexes are 1 based
setSingleEntrySelection({ id: entryId }); selectSingleEntry({ id: entryId });
}; };
const handleUpdate = (field: 'cue' | 'title', value: string) => { const handleUpdate = (field: 'cue' | 'title', value: string) => {
@@ -78,7 +86,11 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
return ( return (
<div <div
className={cx([style.milestone, hasCursor ? style.hasCursor : null])} className={cx([
style.milestone,
hasCursor ? style.hasCursor : null,
entryCopyId === entryId ? style.copyTarget : null,
])}
ref={setNodeRef} ref={setNodeRef}
onClick={handleFocusClick} onClick={handleFocusClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
@@ -0,0 +1,39 @@
import { Toolbar } from '@base-ui/react/toolbar';
import type { Column } from '@tanstack/react-table';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import {
ColumnSettings,
ViewSettings,
} from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings';
import { usePersistedRundownOptions } from '../rundown.options';
import style from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss';
interface EditorTableSettingsProps {
columns: Column<ExtendedEntry, unknown>[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
}
export default function EditorTableSettings({
columns,
handleResetResizing,
handleResetReordering,
handleClearToggles,
}: EditorTableSettingsProps) {
const options = usePersistedRundownOptions();
return (
<Toolbar.Root className={style.tableSettings}>
<ViewSettings optionsStore={options} />
<ColumnSettings
columns={columns}
handleResetResizing={handleResetResizing}
handleResetReordering={handleResetReordering}
handleClearToggles={handleClearToggles}
/>
</Toolbar.Root>
);
}
@@ -0,0 +1,42 @@
import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
import { useEditorFollowMode } from '../useEditorFollowMode';
import { makeRundownColumns } from './makeRundownColumns';
export default memo(RundownTable);
function RundownTable() {
const { data: customFields, status: customFieldStatus } = useCustomFields();
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
const { editorMode } = useEditorFollowMode();
// Editor always has full permissions
useEffect(() => {
setPermissions({
canChangeMode: true,
canCreateEntries: true,
canEditEntries: true,
canFlag: true,
canShare: true,
});
}, [setPermissions]);
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
)}
</CuesheetDnd>
);
}
@@ -0,0 +1,16 @@
import type { ColumnDef } from '@tanstack/react-table';
import type { CustomFields } from 'ontime-types';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { AppMode } from '../../../ontimeConfig';
import { makeCuesheetColumns } from '../../../views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
/**
* Creates column definitions for the rundown table
* Reuses cuesheetColsFactory with preset=undefined for full access
*/
export function makeRundownColumns(customFields: CustomFields): ColumnDef<ExtendedEntry>[] {
// When preset=undefined, factory defaults to fullRead=true, fullWrite=true
// canWrite is determined by editorMode (AppMode.Edit vs AppMode.Run)
return makeCuesheetColumns(customFields, AppMode.Edit, undefined);
}
@@ -0,0 +1,65 @@
import { CustomFields } from 'ontime-types';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type OptionValues = {
hideTableSeconds: boolean;
hideIndexColumn: boolean;
showDelayedTimes: boolean;
hideDelays: boolean;
};
const defaultOptions: OptionValues = {
hideTableSeconds: false,
hideIndexColumn: false,
showDelayedTimes: false,
hideDelays: false,
};
export type RundownOptionKeys = keyof OptionValues;
export interface RundownOptions extends OptionValues {
setOption: <K extends RundownOptionKeys>(key: K, value: OptionValues[K]) => void;
toggleOption: (key: RundownOptionKeys) => void;
resetOptions: () => void;
}
export const usePersistedRundownOptions = create<RundownOptions>()(
persist(
(set) => {
return {
...defaultOptions,
setOption: (key, value) => set((state) => ({ ...state, [key]: value })),
toggleOption: (key) => set((state) => ({ ...state, [key]: !state[key] })),
resetOptions: () => set(defaultOptions),
};
},
{
name: 'editor-options',
},
),
);
export const rundownDefaultColumns = [
{ value: 'flag', label: 'Flag' },
{ value: 'cue', label: 'Cue' },
{ value: 'title', label: 'Title' },
{ value: 'timeStart', label: 'Time start' },
{ value: 'timeEnd', label: 'Time end' },
{ value: 'duration', label: 'Duration' },
{ value: 'note', label: 'Note' },
];
export function makeRundownCustomColumns(customFields: CustomFields) {
return Object.entries(customFields).map(([key, field]) => {
return {
value: `custom-${key}`,
label: field.label,
};
});
}
export enum RundownViewMode {
List = 'list',
Table = 'table',
}
@@ -1,10 +1,13 @@
import { EntryId, isOntimeEvent, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types'; import { EntryId, isOntimeGroup, RundownEntries, SupportedEntry } from 'ontime-types';
/** /**
* Creates a sortable list of entries * Creates a sortable list of entries
* ------------------------------------ * ------------------------------------
* Due to limitations in dnd-kit we need to flatten the list of entries * Due to limitations in dnd-kit we need to flatten the list of entries
* This list should also be aware of any elements that are sortable (ie: group ends) * This list should also be aware of any elements that are sortable (ie: group ends)
*
* Note: This creates the FULL structure including all entries and pseudo end-group entries.
* For rendering, use filterVisibleEntries() to exclude collapsed items.
*/ */
export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] { export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] {
const flatIds: EntryId[] = []; const flatIds: EntryId[] = [];
@@ -31,6 +34,42 @@ export function makeSortableList(order: EntryId[], entries: RundownEntries): Ent
return flatIds; return flatIds;
} }
/**
* Filters sortable list to only include visible entries based on collapsed state
* ------------------------------------
* Excludes:
* - Children of collapsed groups
* - End-group markers of collapsed groups
*
* This is used by Virtuoso for rendering, while DND-kit uses the full sortableData.
*/
export function filterVisibleEntries(
sortableData: EntryId[],
entries: RundownEntries,
getIsCollapsed: (groupId: EntryId) => boolean,
): EntryId[] {
return sortableData.filter((entryId) => {
// group end pseudo entries are only shown if the group is expanded
if (entryId.startsWith('end-')) {
const parentId = entryId.split('end-')[1];
return !getIsCollapsed(parentId);
}
// retrieve the entry as usual
const entry = entries[entryId];
if (!entry) {
return false;
}
// if entry has a parent and parent is collapsed, filter it out
if (entry.type !== SupportedEntry.Group && 'parent' in entry && entry.parent) {
return !getIsCollapsed(entry.parent);
}
return true;
});
}
/** /**
* Checks whether a drop operation is valid * Checks whether a drop operation is valid
* Currently only used for validating dropping groups * Currently only used for validating dropping groups
@@ -113,7 +152,7 @@ export function moveUp(
} }
// 4. moving into the same group as previous entry // 4. moving into the same group as previous entry
if (isOntimeEvent(previousEntry) && previousEntry.parent !== null && currentEntryParent === null) { if ('parent' in previousEntry && previousEntry.parent !== null && currentEntryParent === null) {
return { destinationId: previousEntryId, order: 'after' }; return { destinationId: previousEntryId, order: 'after' };
} }
@@ -188,7 +227,7 @@ export function moveDown(
} }
// 5. handle moving between group and top level // 5. handle moving between group and top level
const nextEntryParent = isOntimeEvent(nextEntry) ? nextEntry.parent : null; const nextEntryParent = 'parent' in nextEntry ? nextEntry.parent : null;
if (nextEntryParent !== null && currentEntryParent === null) { if (nextEntryParent !== null && currentEntryParent === null) {
return { destinationId: nextEntryId, order: 'after' }; return { destinationId: nextEntryId, order: 'after' };
} }
@@ -7,7 +7,7 @@ import IconButton from '../../../common/components/buttons/IconButton';
import * as Editor from '../../../common/components/editor-utils/EditorUtils'; import * as Editor from '../../../common/components/editor-utils/EditorUtils';
import TimeInput from '../../../common/components/input/time-input/TimeInput'; import TimeInput from '../../../common/components/input/time-input/TimeInput';
import Tooltip from '../../../common/components/tooltip/Tooltip'; import Tooltip from '../../../common/components/tooltip/Tooltip';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import TimeInputGroup from './TimeInputGroup'; import TimeInputGroup from './TimeInputGroup';
@@ -37,7 +37,7 @@ function TimeInputFlow({
delay, delay,
showLabels, showLabels,
}: TimeInputFlowProps) { }: TimeInputFlowProps) {
const { updateEntry, updateTimer } = useEntryActions(); const { updateEntry, updateTimer } = useEntryActionsContext();
// In sync with EventEditorTimes // In sync with EventEditorTimes
const handleSubmit = (field: TimeField, value: string) => { const handleSubmit = (field: TimeField, value: string) => {
@@ -0,0 +1,47 @@
import { useCallback, useMemo } from 'react';
import { useSessionStorage } from '@mantine/hooks';
import { EntryId } from 'ontime-types';
/**
* Keeps track of which groups are collapsed
* This information is saved in session storage as an array (serializable)
* but provides Set-like operations for fast lookups
*/
export function useCollapsedGroups(rundownId: string) {
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
key: `rundown.${rundownId}-editor-collapsed-groups`,
defaultValue: [],
});
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const getIsCollapsed = useCallback(
(groupId: EntryId): boolean => {
return collapsedGroupSet.has(groupId);
},
[collapsedGroupSet],
);
const collapseGroup = useCallback(
(groupId: EntryId | null | undefined) => {
if (!groupId) return;
setCollapsedGroups((prev) => {
if (prev.includes(groupId)) {
return prev;
}
return [...prev, groupId];
});
},
[setCollapsedGroups],
);
const expandGroup = useCallback(
(groupId: EntryId | null | undefined) => {
if (!groupId) return;
setCollapsedGroups((prev) => prev.filter((id) => id !== groupId));
},
[setCollapsedGroups],
);
return { getIsCollapsed, collapseGroup, expandGroup };
}
@@ -0,0 +1,41 @@
import { useSessionStorage } from '@mantine/hooks';
import { AppMode, sessionKeys } from '../../ontimeConfig';
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
/**
* Manages the editor mode (Edit/Run) derived from layout mode
*
* Editor mode is automatically determined by the layout:
* - PLANNING: Always Edit mode (follows user selection)
* - TRACKING: Always Run mode (follows current event)
* - CONTROL: User preference (defaults to Run mode), persisted in session storage
*
* Edit: Manual editing, follows user selection
* Run: Live playback view, auto-follows current event
*/
export function useEditorFollowMode() {
const { layoutMode } = useEditorLayout();
// Only used for CONTROL layout - stores user preference
const [controlModePreference, setControlModePreference] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode,
defaultValue: AppMode.Run,
});
// Derive editor mode from layout mode
const editorMode = (() => {
if (layoutMode === EditorLayoutMode.CONTROL) return controlModePreference;
if (layoutMode === EditorLayoutMode.PLANNING) return AppMode.Edit;
return AppMode.Run;
})();
// setEditorMode only affects CONTROL layout
const setEditorMode = (mode: AppMode) => {
if (layoutMode === EditorLayoutMode.CONTROL) {
setControlModePreference(mode);
}
};
return { editorMode, setEditorMode };
}
@@ -1,30 +1,38 @@
import { MouseEvent } from 'react'; import { MouseEvent } from 'react';
import { EntryId, isOntimeEvent, MaybeNumber, MaybeString, Rundown } from 'ontime-types'; import { EntryId, isOntimeEvent, MaybeNumber, Rundown } from 'ontime-types';
import { create } from 'zustand'; import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants'; import { RUNDOWN } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient'; import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils'; import { isMacOS } from '../../common/utils/deviceUtils';
type SelectionMode = 'shift' | 'click' | 'ctrl'; export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore { interface EventSelectionStore {
selectedEvents: Set<EntryId>; selectedEvents: Set<EntryId>;
anchoredIndex: MaybeNumber; anchoredIndex: MaybeNumber;
cursor: MaybeString; cursor: EntryId | null;
entryMode: 'event' | 'single' | null; entryMode: 'event' | 'single' | null;
scrollHandler: ((id: EntryId) => void) | null;
setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void; setSingleEntrySelection: (selectionArgs: { id: EntryId }) => void;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void; setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void; clearSelectedEvents: () => void;
clearMultiSelect: () => void; clearMultiSelect: () => void;
unselect: (id: EntryId) => void; unselect: (id: EntryId) => void;
setScrollHandler: (handler: ((id: EntryId) => void) | null) => void;
scrollToEntry: (id: EntryId) => void;
} }
/**
* Keeps track of the selected entries and selection mode
* Provides methods to update the selection based on user interactions
*/
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(), selectedEvents: new Set(),
anchoredIndex: null, anchoredIndex: null,
cursor: null, cursor: null,
entryMode: null, entryMode: null,
scrollHandler: null,
setSingleEntrySelection: ({ id }) => { setSingleEntrySelection: ({ id }) => {
set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' }); set({ selectedEvents: new Set([id]), anchoredIndex: null, cursor: id, entryMode: 'single' });
}, },
@@ -117,6 +125,15 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
entryMode: selectedEvents.size === 0 ? null : entryMode, 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 { export function getSelectionMode(event: MouseEvent): SelectionMode {
@@ -0,0 +1,29 @@
import { useCallback } from 'react';
import { EntryId, MaybeString } from 'ontime-types';
import { useCollapsedGroups } from './useCollapsedGroups';
import { useEventSelection } from './useEventSelection';
type SelectAndRevealOptions = {
id: EntryId;
index: number;
parent?: MaybeString;
};
export function useSelectAndRevealEntry(rundownId: string) {
const { expandGroup } = useCollapsedGroups(rundownId);
const selectEntry = useEventSelection((state) => state.setSelectedEvents);
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
return useCallback(
({ id, index, parent }: SelectAndRevealOptions) => {
expandGroup(parent);
selectEntry({ id, index, selectMode: 'click' });
// Wait one frame so collapsed-group expansion is reflected in rendered/visible entries before scrolling.
requestAnimationFrame(() => {
scrollToEntry(id);
});
},
[expandGroup, scrollToEntry, selectEntry],
);
}
-2
View File
@@ -49,8 +49,6 @@ $playback-under: $green-500;
$bg-container-l1: $gray-1350; $bg-container-l1: $gray-1350;
$bg-container-l2: $gray-1300; $bg-container-l2: $gray-1300;
$bg-container-onlight: $gray-100;
$backdrop-color: rgba(0, 0, 0, 0.7); $backdrop-color: rgba(0, 0, 0, 0.7);
$box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0; $box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
$box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0; $box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0;
@@ -21,6 +21,7 @@ export const langDe: TranslationObject = {
'countdown.to_start': 'Zeit bis zum Start', 'countdown.to_start': 'Zeit bis zum Start',
'countdown.waiting': 'Warten auf den Veranstaltungsbeginn', 'countdown.waiting': 'Warten auf den Veranstaltungsbeginn',
'countdown.overtime': 'überfällig', 'countdown.overtime': 'überfällig',
'countdown.all_have_finished': 'Alle ausgewählten Veranstaltungen sind beendet',
'timeline.live': 'live', 'timeline.live': 'live',
'timeline.done': 'Beendet', 'timeline.done': 'Beendet',
'timeline.due': 'fällig', 'timeline.due': 'fällig',
@@ -21,6 +21,7 @@ export const langEs: TranslationObject = {
'countdown.to_start': 'Tiempo para comenzar', 'countdown.to_start': 'Tiempo para comenzar',
'countdown.waiting': 'Esperando el inicio del evento', 'countdown.waiting': 'Esperando el inicio del evento',
'countdown.overtime': 'en tiempo extra', 'countdown.overtime': 'en tiempo extra',
'countdown.all_have_finished': 'Todos los eventos seleccionados han terminado',
'timeline.live': 'live', 'timeline.live': 'live',
'timeline.done': 'Terminado', 'timeline.done': 'Terminado',
'timeline.due': 'pendiente', 'timeline.due': 'pendiente',
@@ -21,6 +21,7 @@ export const langFr: TranslationObject = {
'countdown.to_start': 'Évènement commence dans', 'countdown.to_start': 'Évènement commence dans',
'countdown.waiting': 'En attente du début de l’évènement', 'countdown.waiting': 'En attente du début de l’évènement',
'countdown.overtime': 'en dépassement', 'countdown.overtime': 'en dépassement',
'countdown.all_have_finished': 'Tous les évènements sélectionnés ont terminé',
'timeline.live': 'live', 'timeline.live': 'live',
'timeline.done': 'Terminé', 'timeline.done': 'Terminé',
'timeline.due': 'dû', 'timeline.due': 'dû',
@@ -21,6 +21,7 @@ export const langIt: TranslationObject = {
'countdown.to_start': 'Tempo alla partenza', 'countdown.to_start': 'Tempo alla partenza',
'countdown.waiting': "In attesa dell'inizio dell'evento", 'countdown.waiting': "In attesa dell'inizio dell'evento",
'countdown.overtime': 'in ritardo', 'countdown.overtime': 'in ritardo',
'countdown.all_have_finished': 'Tutti gli eventi selezionati sono finiti',
'timeline.live': 'live', 'timeline.live': 'live',
'timeline.done': 'Terminato', 'timeline.done': 'Terminato',
'timeline.due': 'previsto', 'timeline.due': 'previsto',

Some files were not shown because too many files have changed in this diff Show More