mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 00:43:54 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 197042e630 | |||
| d1780aa98c | |||
| 87b1721b9a | |||
| 9c08f0dcb1 | |||
| 0ac98f0f02 | |||
| 261cd25bec | |||
| 8cfbcd35cd | |||
| c4435259c5 | |||
| 84a7b1c1f7 | |||
| db9444aaa4 | |||
| fc007cf124 | |||
| e95685d366 | |||
| 5f634bb48a | |||
| 2af13b340b |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.3.1",
|
"version": "4.4.0-beta.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.3.1",
|
"version": "4.4.0-beta.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+146
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+184
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,26 @@
|
|||||||
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 { type EntryId, type Rundown, isOntimeEvent, isOntimeGroup, Playback, SupportedEntry } from 'ontime-types';
|
||||||
import {
|
|
||||||
type EntryId,
|
|
||||||
type MaybeString,
|
|
||||||
type Rundown,
|
|
||||||
isOntimeEvent,
|
|
||||||
isOntimeGroup,
|
|
||||||
OntimeEntry,
|
|
||||||
Playback,
|
|
||||||
SupportedEntry,
|
|
||||||
} 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 +28,150 @@ import style from './Rundown.module.scss';
|
|||||||
const RundownEntry = lazy(() => import('./RundownEntry'));
|
const RundownEntry = lazy(() => import('./RundownEntry'));
|
||||||
|
|
||||||
interface RundownProps {
|
interface RundownProps {
|
||||||
data: Rundown;
|
entries: Rundown['entries'];
|
||||||
|
id: Rundown['id'];
|
||||||
|
order: Rundown['order'];
|
||||||
|
flatOrder: Rundown['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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
// Auto-follow behavior in Edit mode: follow the user's cursor
|
||||||
* When we drag over a group, we expand it if it is collapsed
|
useEffect(() => {
|
||||||
*/
|
if (dnd.isDraggingRef.current || editorMode !== AppMode.Edit || !cursor) {
|
||||||
const expandOverGroup = (event: DragOverEvent) => {
|
|
||||||
// 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') {
|
|
||||||
|
// Open parent group if the target is inside a collapsed group
|
||||||
|
const entry = entries[cursor];
|
||||||
|
if (entry && 'parent' in entry) {
|
||||||
|
expandGroup(entry.parent);
|
||||||
|
}
|
||||||
|
scrollToEntry(cursor);
|
||||||
|
}, [editorMode, cursor, entries, expandGroup, 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;
|
return;
|
||||||
}
|
}
|
||||||
const groupId = event.over?.id as EntryId;
|
|
||||||
const isCollapsed = getIsCollapsed(groupId);
|
|
||||||
if (isCollapsed) {
|
|
||||||
handleCollapseGroup(false, groupId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (sortableData.length < 1) {
|
// Open parent group if the target is inside a collapsed group
|
||||||
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
|
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 +179,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} className={style.list} {...props}>
|
||||||
|
{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,6 +21,14 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rundownRoot {
|
||||||
|
height: calc(100% - 1.5rem);
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: inherit;
|
height: inherit;
|
||||||
|
|||||||
@@ -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,11 +123,20 @@ 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>
|
||||||
|
<AuxKey>/</AuxKey>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr className={style.spacer} />
|
<tr className={style.spacer} />
|
||||||
@@ -140,7 +173,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 +181,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(
|
||||||
|
|||||||
+1
@@ -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,183 @@
|
|||||||
|
import { useHotkeys } from '@mantine/hooks';
|
||||||
|
import { type OntimeEntry, EntryId, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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', () => setEntryCopyId(cursor), { preventDefault: true, usePhysicalKeys: true }],
|
||||||
|
['mod + X', () => setEntryCopyId(cursor, 'cut'), { preventDefault: true, usePhysicalKeys: true }],
|
||||||
|
['mod + V', () => commands.insertCopyAtId(cursor), { preventDefault: true, usePhysicalKeys: true }],
|
||||||
|
['mod + D', () => commands.cloneEntry(cursor), { preventDefault: true, usePhysicalKeys: true }],
|
||||||
|
['mod + shift + V', () => commands.insertCopyAtId(cursor, true), { preventDefault: 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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -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: 0.5rem;
|
||||||
|
|
||||||
:is([data-target='small-device']) & {
|
:is([data-target='small-device']) & {
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
@@ -57,10 +58,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
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,50 @@ 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 text='Live playback view with auto-follow' render={<span />}>
|
||||||
</Toolbar.Button>
|
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
Run
|
||||||
Edit
|
</Toolbar.Button>
|
||||||
</Toolbar.Button>
|
</Tooltip>
|
||||||
|
<Tooltip text='Manual editing without playback automation' render={<span />}>
|
||||||
|
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||||
|
Edit
|
||||||
|
</Toolbar.Button>
|
||||||
|
</Tooltip>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ToggleGroup value={[viewMode]} onValueChange={toggleViewMode} className={style.group}>
|
||||||
|
<Tooltip text='View rundown in list mode' render={<span />}>
|
||||||
|
<Toolbar.Button render={<Toggle />} value={RundownViewMode.List} className={style.radioButton}>
|
||||||
|
List
|
||||||
|
</Toolbar.Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text='View rundown in table mode' render={<span />}>
|
||||||
|
<Toolbar.Button render={<Toggle />} value={RundownViewMode.Table} className={style.radioButton}>
|
||||||
|
Table
|
||||||
|
</Toolbar.Button>
|
||||||
|
</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={<span />}>
|
||||||
|
<Toolbar.Button render={<Toggle />} value={OffsetMode.Absolute} className={style.radioButton}>
|
||||||
|
Absolute
|
||||||
|
</Toolbar.Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text='Offsets follow the rundown relative start' render={<span />}>
|
||||||
|
<Toolbar.Button render={<Toggle />} value={OffsetMode.Relative} className={style.radioButton}>
|
||||||
|
Relative
|
||||||
|
</Toolbar.Button>
|
||||||
|
</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'),
|
||||||
|
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,26 @@
|
|||||||
|
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' });
|
||||||
|
scrollToEntry(id);
|
||||||
|
},
|
||||||
|
[expandGroup, scrollToEntry, selectEntry],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ function ExtraInfo({ projectData, size, source }: ExtraInfoProps) {
|
|||||||
|
|
||||||
function BackstageClock() {
|
function BackstageClock() {
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const { clock } = useClock();
|
const clock = useClock();
|
||||||
|
|
||||||
// gather timer data
|
// gather timer data
|
||||||
const formattedClock = formatTime(clock);
|
const formattedClock = formatTime(clock);
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
|
|||||||
|
|
||||||
function CountdownClock() {
|
function CountdownClock() {
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const { clock } = useClock();
|
const clock = useClock();
|
||||||
|
|
||||||
// gather timer data
|
// gather timer data
|
||||||
const formattedClock = formatTime(clock);
|
const formattedClock = formatTime(clock);
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ interface CountdownSubscriptionsProps {
|
|||||||
|
|
||||||
export default function CountdownSubscriptions({ subscribedEvents, goToEditMode }: CountdownSubscriptionsProps) {
|
export default function CountdownSubscriptions({ subscribedEvents, goToEditMode }: CountdownSubscriptionsProps) {
|
||||||
const { secondarySource, showExpected } = useCountdownOptions();
|
const { secondarySource, showExpected } = useCountdownOptions();
|
||||||
const { playback } = usePlayback();
|
const playback = usePlayback();
|
||||||
const { selectedEventId } = useSelectedEventId();
|
const selectedEventId = useSelectedEventId();
|
||||||
const showFab = useFadeOutOnInactivity(true);
|
const showFab = useFadeOutOnInactivity(true);
|
||||||
|
|
||||||
const { data: reportData } = useReport();
|
const { data: reportData } = useReport();
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { useDisclosure } from '@mantine/hooks';
|
|||||||
|
|
||||||
import IconButton from '../../common/components/buttons/IconButton';
|
import IconButton from '../../common/components/buttons/IconButton';
|
||||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||||
|
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||||
|
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||||
import { getIsNavigationLocked } from '../../externals';
|
import { getIsNavigationLocked } from '../../externals';
|
||||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||||
|
|
||||||
import CuesheetEditModal from './cuesheet-edit-modal/CuesheetEditModal';
|
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
||||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||||
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
||||||
|
|
||||||
@@ -15,15 +17,16 @@ import styles from './CuesheetPage.module.scss';
|
|||||||
|
|
||||||
export default function CuesheetPage() {
|
export default function CuesheetPage() {
|
||||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||||
|
const entryActions = useEntryActions();
|
||||||
|
|
||||||
useWindowTitle('Cuesheet');
|
useWindowTitle('Cuesheet');
|
||||||
|
|
||||||
const isLocked = getIsNavigationLocked();
|
const isLocked = getIsNavigationLocked();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<EntryActionsProvider actions={entryActions}>
|
||||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||||
<CuesheetEditModal />
|
<EntryEditModal />
|
||||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||||
<CuesheetOverview>
|
<CuesheetOverview>
|
||||||
{!isLocked && (
|
{!isLocked && (
|
||||||
@@ -35,6 +38,6 @@ export default function CuesheetPage() {
|
|||||||
<CuesheetProgress />
|
<CuesheetProgress />
|
||||||
<CuesheetTableWrapper />
|
<CuesheetTableWrapper />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</EntryActionsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ import { useColumnOrder } from '../cuesheet-table/useColumnManager';
|
|||||||
|
|
||||||
interface CuesheetDndProps {
|
interface CuesheetDndProps {
|
||||||
columns: ColumnDef<ExtendedEntry>[];
|
columns: ColumnDef<ExtendedEntry>[];
|
||||||
|
tableRoot?: 'editor' | 'cuesheet';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetDnd({ columns, children }: PropsWithChildren<CuesheetDndProps>) {
|
export default function CuesheetDnd({
|
||||||
const { columnOrder, saveColumnOrder } = useColumnOrder(columns);
|
columns,
|
||||||
|
tableRoot = 'cuesheet',
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<CuesheetDndProps>) {
|
||||||
|
const { columnOrder, saveColumnOrder } = useColumnOrder(columns, tableRoot);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
|
|||||||
+5
-5
@@ -3,12 +3,12 @@ import { memo } from 'react';
|
|||||||
import Modal from '../../../common/components/modal/Modal';
|
import Modal from '../../../common/components/modal/Modal';
|
||||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||||
|
|
||||||
import { useCuesheetEditModal } from './useCuesheetEditModal';
|
import { useEditModal } from './useEditModal';
|
||||||
|
|
||||||
export default memo(CuesheetEditModal);
|
export default memo(EntryEditModal);
|
||||||
function CuesheetEditModal() {
|
function EntryEditModal() {
|
||||||
const entryId = useCuesheetEditModal((state) => state.selectedEntryId);
|
const entryId = useEditModal((state) => state.selectedEntryId);
|
||||||
const closeModal = useCuesheetEditModal((state) => state.clearSelection);
|
const closeModal = useEditModal((state) => state.clearSelection);
|
||||||
|
|
||||||
if (entryId === null) {
|
if (entryId === null) {
|
||||||
return null;
|
return null;
|
||||||
+1
-1
@@ -7,7 +7,7 @@ interface SelectedEntryState {
|
|||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCuesheetEditModal = create<SelectedEntryState>((set) => ({
|
export const useEditModal = create<SelectedEntryState>((set) => ({
|
||||||
selectedEntryId: null,
|
selectedEntryId: null,
|
||||||
setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }),
|
setEditableEntry: (entryId: EntryId) => set({ selectedEntryId: entryId }),
|
||||||
clearSelection: () => set({ selectedEntryId: null }),
|
clearSelection: () => set({ selectedEntryId: null }),
|
||||||
@@ -11,6 +11,11 @@ $table-header-font-size: calc(1rem - 2px);
|
|||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
padding-bottom: 70vh; // allow focus to reach last elements
|
padding-bottom: 70vh; // allow focus to reach last elements
|
||||||
|
|
||||||
|
:is([data-target='small-device']) & {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
thead {
|
thead {
|
||||||
tr {
|
tr {
|
||||||
&::before {
|
&::before {
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import { isOntimeDelay, isOntimeGroup, isOntimeMilestone, OntimeEntry, TimeField
|
|||||||
|
|
||||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||||
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||||
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
|
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
|
||||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||||
|
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
||||||
|
import EditorTableSettings from '../../../features/rundown/rundown-table/EditorTableSettings';
|
||||||
|
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||||
import { AppMode } from '../../../ontimeConfig';
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||||
|
|
||||||
@@ -18,7 +21,7 @@ import DelayRow from './cuesheet-table-elements/DelayRow';
|
|||||||
import EventRow from './cuesheet-table-elements/EventRow';
|
import EventRow from './cuesheet-table-elements/EventRow';
|
||||||
import GroupRow from './cuesheet-table-elements/GroupRow';
|
import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||||
import CuesheetTableMenu from './cuesheet-table-menu/CuesheetTableMenu';
|
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||||
|
|
||||||
@@ -27,16 +30,21 @@ import style from './CuesheetTable.module.scss';
|
|||||||
interface CuesheetTableProps {
|
interface CuesheetTableProps {
|
||||||
columns: ColumnDef<ExtendedEntry>[];
|
columns: ColumnDef<ExtendedEntry>[];
|
||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
|
tableRoot?: 'editor' | 'cuesheet';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTableProps) {
|
export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cuesheet' }: CuesheetTableProps) {
|
||||||
const { data, status } = useFlatRundownWithMetadata();
|
const { data, status } = useFlatRundownWithMetadata();
|
||||||
const { updateEntry, updateTimer } = useEntryActions();
|
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||||
const showDelayedTimes = usePersistedCuesheetOptions((state) => state.showDelayedTimes);
|
|
||||||
const hideTableSeconds = usePersistedCuesheetOptions((state) => state.hideTableSeconds);
|
|
||||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
|
||||||
|
|
||||||
const { selectedEventId } = useSelectedEventId();
|
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
||||||
|
const showDelayedTimes = useOptions((state) => state.showDelayedTimes);
|
||||||
|
const hideTableSeconds = useOptions((state) => state.hideTableSeconds);
|
||||||
|
const hideIndexColumn = useOptions((state) => state.hideIndexColumn);
|
||||||
|
|
||||||
|
const selectedEventId = useSelectedEventId();
|
||||||
|
const cursor = useEventSelection((state) => state.cursor);
|
||||||
|
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
||||||
|
|
||||||
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
|
||||||
const { listeners } = useTableNav();
|
const { listeners } = useTableNav();
|
||||||
@@ -79,9 +87,9 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { columnOrder, resetColumnOrder } = useColumnOrder(columns);
|
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
|
||||||
const { columnSizing, setColumnSizing } = useColumnSizes();
|
const { columnSizing, setColumnSizing } = useColumnSizes(tableRoot);
|
||||||
const { columnVisibility, setColumnVisibility } = useColumnVisibility();
|
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -106,16 +114,42 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
setColumnSizing({});
|
setColumnSizing({});
|
||||||
}, [setColumnSizing]);
|
}, [setColumnSizing]);
|
||||||
|
|
||||||
// in run mode, we follow the selected row
|
// in Run mode, follow the current event
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cuesheetMode === AppMode.Edit || virtuosoRef.current === null || !selectedEventId) {
|
if (virtuosoRef.current === null || cuesheetMode !== AppMode.Run || !selectedEventId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
|
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
|
||||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'smooth' });
|
if (eventIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||||
}, [cuesheetMode, data, selectedEventId]);
|
}, [cuesheetMode, data, selectedEventId]);
|
||||||
|
|
||||||
|
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (entryId: string) => {
|
||||||
|
if (virtuosoRef.current === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventIndex = data.findIndex((event) => event.id === entryId);
|
||||||
|
if (eventIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||||
|
};
|
||||||
|
|
||||||
|
setScrollHandler(handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setScrollHandler(null);
|
||||||
|
};
|
||||||
|
}, [data, setScrollHandler]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* To improve performance on resizing, we memoise the column sizes
|
* To improve performance on resizing, we memoise the column sizes
|
||||||
* and pass them as CSS variables to the table container.
|
* and pass them as CSS variables to the table container.
|
||||||
@@ -143,9 +177,12 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
return <EmptyPage text='Loading...' />;
|
return <EmptyPage text='Loading...' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// control components need different implementations for handling permissions
|
||||||
|
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CuesheetTableSettings
|
<TableRootSettings
|
||||||
columns={allLeafColumns}
|
columns={allLeafColumns}
|
||||||
handleResetResizing={resetColumnResizing}
|
handleResetResizing={resetColumnResizing}
|
||||||
handleResetReordering={resetColumnOrder}
|
handleResetReordering={resetColumnOrder}
|
||||||
@@ -174,6 +211,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
const row = rows[rowIndex];
|
const row = rows[rowIndex];
|
||||||
const key = row.original.id;
|
const key = row.original.id;
|
||||||
const entry = row.original;
|
const entry = row.original;
|
||||||
|
const hasCursor = entry.id === cursor;
|
||||||
|
|
||||||
if (isOntimeGroup(entry)) {
|
if (isOntimeGroup(entry)) {
|
||||||
return (
|
return (
|
||||||
@@ -185,6 +223,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
rowIndex={row.index}
|
rowIndex={row.index}
|
||||||
table={table}
|
table={table}
|
||||||
injectedStyles={injectedStyles}
|
injectedStyles={injectedStyles}
|
||||||
|
hasCursor={hasCursor}
|
||||||
{...virtuosoProps}
|
{...virtuosoProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -192,7 +231,13 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
|
|
||||||
if (isOntimeDelay(entry)) {
|
if (isOntimeDelay(entry)) {
|
||||||
return (
|
return (
|
||||||
<DelayRow key={key} duration={entry.duration} injectedStyles={injectedStyles} {...virtuosoProps} />
|
<DelayRow
|
||||||
|
key={key}
|
||||||
|
duration={entry.duration}
|
||||||
|
injectedStyles={injectedStyles}
|
||||||
|
hasCursor={hasCursor}
|
||||||
|
{...virtuosoProps}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +254,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
rowIndex={rowIndex}
|
rowIndex={rowIndex}
|
||||||
table={table}
|
table={table}
|
||||||
injectedStyles={injectedStyles}
|
injectedStyles={injectedStyles}
|
||||||
|
hasCursor={hasCursor}
|
||||||
{...virtuosoProps}
|
{...virtuosoProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -231,6 +277,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
rowIndex={rowIndex}
|
rowIndex={rowIndex}
|
||||||
table={table}
|
table={table}
|
||||||
injectedStyles={injectedStyles}
|
injectedStyles={injectedStyles}
|
||||||
|
hasCursor={hasCursor}
|
||||||
{...virtuosoProps}
|
{...virtuosoProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -249,7 +296,7 @@ export default function CuesheetTable({ columns, cuesheetMode }: CuesheetTablePr
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CuesheetTableMenu />
|
<TableMenu />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -5,6 +5,12 @@
|
|||||||
color: $ontime-delay-text;
|
color: $ontime-delay-text;
|
||||||
border-left: 4px solid transparent;
|
border-left: 4px solid transparent;
|
||||||
|
|
||||||
|
&[data-cursor='true'] {
|
||||||
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: -2px;
|
||||||
|
background: color-mix(in srgb, transparent 80%, var(--user-bg, $gray-500) 20%);
|
||||||
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
width: calc(100% - 4px);
|
width: calc(100% - 4px);
|
||||||
padding-block: 0.5rem;
|
padding-block: 0.5rem;
|
||||||
@@ -15,3 +21,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delayRowHidden {
|
||||||
|
visibility: hidden;
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0;
|
||||||
|
height: 1px;
|
||||||
|
line-height: 1px;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user