feat: operator view

This commit is contained in:
cv
2023-08-29 12:37:03 +02:00
parent 5202f8669d
commit e667755fd6
10 changed files with 150 additions and 67 deletions
@@ -8,11 +8,12 @@ import { tooltipDelayFast } from '../../../ontimeConfig';
interface PlaybackIconProps { interface PlaybackIconProps {
state: Playback; state: Playback;
skipTooltip?: boolean;
className?: string; className?: string;
} }
export default function PlaybackIcon(props: PlaybackIconProps) { export default function PlaybackIcon(props: PlaybackIconProps) {
const { state, className } = props; const { state, skipTooltip, className } = props;
// if timer is Pause or Armed // if timer is Pause or Armed
let label = 'Timer Paused'; let label = 'Timer Paused';
@@ -29,6 +30,10 @@ export default function PlaybackIcon(props: PlaybackIconProps) {
Icon = IoStop; Icon = IoStop;
} }
if (skipTooltip) {
return <Icon className={className} />;
}
return ( return (
<Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren> <Tooltip openDelay={tooltipDelayFast} label={label} shouldWrapChildren>
<Icon className={className} /> <Icon className={className} />
@@ -0,0 +1,61 @@
import { MutableRefObject, useCallback, useEffect } from 'react';
function scrollToComponent<ComponentRef extends HTMLElement, ScrollRef extends HTMLElement>(
componentRef: MutableRefObject<ComponentRef>,
scrollRef: MutableRefObject<ScrollRef>,
topOffset: number,
) {
if (!componentRef.current || !scrollRef.current) {
return;
}
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - topOffset;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
interface UseFollowComponentProps {
followRef: MutableRefObject<HTMLElement | null>;
scrollRef: MutableRefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
}
export default function useFollowComponent(props: UseFollowComponentProps) {
const { followRef, scrollRef, doFollow, topOffset = 100 } = props;
// when cursor moves, view should follow
useEffect(() => {
if (!doFollow) {
return;
}
if (followRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(
followRef as MutableRefObject<HTMLElement>,
scrollRef as MutableRefObject<HTMLElement>,
topOffset,
);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [followRef?.current, scrollRef?.current]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
if (followRef.current && containerRef.current) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, scrollRef!, offset);
}
},
[followRef, scrollRef, topOffset],
);
return scrollToRefComponent;
}
@@ -15,7 +15,6 @@ export const useOperator = () => {
const featureSelector = (state: RuntimeStore) => ({ const featureSelector = (state: RuntimeStore) => ({
playback: state.playback, playback: state.playback,
selectedEventId: state.loaded.selectedEventId, selectedEventId: state.loaded.selectedEventId,
nextEventId: state.loaded.nextEventId,
}); });
return useRuntimeStore(featureSelector, deepCompare); return useRuntimeStore(featureSelector, deepCompare);
+4 -30
View File
@@ -1,4 +1,4 @@
import { MutableRefObject, useEffect, useRef } from 'react'; import { useRef } from 'react';
import { Tooltip } from '@chakra-ui/react'; import { Tooltip } from '@chakra-ui/react';
import { import {
closestCenter, closestCenter,
@@ -14,6 +14,7 @@ import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordin
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage'; import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { millisToDelayString } from '../../common/utils/dateConfig'; import { millisToDelayString } from '../../common/utils/dateConfig';
import { getAccessibleColour } from '../../common/utils/styleUtils'; import { getAccessibleColour } from '../../common/utils/styleUtils';
@@ -46,6 +47,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {}); const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
const selectedRef = useRef<HTMLTableRowElement | null>(null); const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
const table = useReactTable({ const table = useReactTable({
data, data,
@@ -63,7 +66,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
onColumnSizingChange: setColumnSizing, onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const tableContainerRef = useRef<HTMLDivElement>(null);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@@ -83,34 +85,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
}), }),
); );
// when selection moves, view should follow
useEffect(() => {
function scrollToComponent(
componentRef: MutableRefObject<HTMLTableRowElement>,
scrollRef: MutableRefObject<HTMLDivElement>,
) {
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
if (!followSelected) {
return;
}
if (selectedRef.current && tableContainerRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(
selectedRef as MutableRefObject<HTMLTableRowElement>,
tableContainerRef as MutableRefObject<HTMLDivElement>,
);
});
}
// eslint-disable-next-line -- the prompt seems incorrect, we need the refs
}, [selectedRef.current, tableContainerRef.current, followSelected]);
const handleOnDragEnd = (event: DragEndEvent) => { const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event; const { delta, active, over } = event;
@@ -18,6 +18,10 @@
gap: 2px; gap: 2px;
} }
.spacer {
min-height: 80vh;
}
@mixin block() { @mixin block() {
width: 100%; width: 100%;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
+55 -9
View File
@@ -1,4 +1,4 @@
import { UIEvent, useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { SupportedEvent, UserFields } from 'ontime-types'; import { SupportedEvent, UserFields } from 'ontime-types';
import { getLastEvent } from 'ontime-utils'; import { getLastEvent } from 'ontime-utils';
@@ -7,6 +7,7 @@ import NavigationMenu from '../../common/components/navigation-menu/NavigationMe
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants'; import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket'; import { useOperator } from '../../common/hooks/useSocket';
import useRundown from '../../common/hooks-query/useRundown'; import useRundown from '../../common/hooks-query/useRundown';
import useUserFields from '../../common/hooks-query/useUserFields'; import useUserFields from '../../common/hooks-query/useUserFields';
@@ -19,23 +20,53 @@ import StatusBar from './status-bar/StatusBar';
import style from './Operator.module.scss'; import style from './Operator.module.scss';
const selectedOffset = 50;
export default function Operator() { export default function Operator() {
const { data, status } = useRundown(); const { data, status } = useRundown();
const { data: userFields, status: userFieldsStatus } = useUserFields(); const { data: userFields, status: userFieldsStatus } = useUserFields();
const featureData = useOperator(); const featureData = useOperator();
const [showChild, setShowChild] = useState(false);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef: scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
});
// Set window title // Set window title
useEffect(() => { useEffect(() => {
document.title = 'ontime - Operator'; document.title = 'ontime - Operator';
}, []); }, []);
const handleScroll = (event: UIEvent<HTMLElement>) => { // reset scroll if nothing is selected
const scrollThreshold = 50; useEffect(() => {
const scrollPosition = (event.target as HTMLElement).scrollTop; if (!featureData?.selectedEventId) {
if (!lockAutoScroll) {
scrollRef.current?.scrollTo(0, 0);
}
}
}, [featureData?.selectedEventId, lockAutoScroll, scrollRef]);
setShowChild(scrollPosition > scrollThreshold); const handleOffset = () => {
if (featureData.selectedEventId) {
scrollToComponent();
}
setLockAutoScroll(false);
};
const handleScroll = () => {
if (selectedRef && scrollRef) {
const selectedRect = selectedRef.current?.getBoundingClientRect();
if (selectedRect) {
const hasScrolledOutOfThreshold = selectedRect.top < 0 || selectedRect.top > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
}
}; };
if (!data || status === 'loading' || !userFields || userFieldsStatus === 'loading') { if (!data || status === 'loading' || !userFields || userFieldsStatus === 'loading') {
@@ -49,24 +80,38 @@ export default function Operator() {
const lastEvent = getLastEvent(data); const lastEvent = getLastEvent(data);
const operatorOptions = getOperatorOptions(userFields); const operatorOptions = getOperatorOptions(userFields);
let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast'));
return ( return (
<div className={style.operatorContainer}> <div className={style.operatorContainer}>
<NavigationMenu /> <NavigationMenu />
<ViewParamsEditor paramFields={operatorOptions} /> <ViewParamsEditor paramFields={operatorOptions} />
<div className={style.operatorEvents} onScroll={handleScroll}> <div className={style.operatorEvents} onScroll={handleScroll} ref={scrollRef}>
{data.map((entry) => { {data.map((entry) => {
if (entry.type === SupportedEvent.Event) { if (entry.type === SupportedEvent.Event) {
const isSelected = featureData.selectedEventId === entry.id;
if (isSelected) {
isPast = false;
}
// hide past events (if setting) and skipped events
if ((hidePast && isPast) || entry.skip) {
return null;
}
return ( return (
<OperatorEvent <OperatorEvent
key={entry.id} key={entry.id}
cue={entry.cue} cue={entry.cue}
data={entry} data={entry}
isSelected={featureData.selectedEventId === entry.id} isSelected={isSelected}
subscribed={subscribe} subscribed={subscribe}
subscribedAlias={subscribedAlias} subscribedAlias={subscribedAlias}
showSeconds={showSeconds} showSeconds={showSeconds}
isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined}
/> />
); );
} }
@@ -76,8 +121,9 @@ export default function Operator() {
} }
return null; return null;
})} })}
<div className={style.spacer} />
</div> </div>
<FollowButton isVisible={showChild} onClickHandler={() => undefined} /> <FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
<StatusBar playback={featureData.playback} lastEvent={lastEvent} selectedEventId={featureData.selectedEventId} /> <StatusBar playback={featureData.playback} lastEvent={lastEvent} selectedEventId={featureData.selectedEventId} />
</div> </div>
); );
@@ -5,6 +5,7 @@
.event { .event {
@include block(); @include block();
background-color: $gray-1300; background-color: $gray-1300;
opacity: 1;
&.subscribed { &.subscribed {
background-color: $gray-1250; background-color: $gray-1250;
@@ -13,6 +14,10 @@
&.running { &.running {
background-color: $red-700; background-color: $red-700;
} }
&.past {
opacity: 0.2;
}
} }
.titles { .titles {
@@ -1,3 +1,4 @@
import { RefObject } from 'react';
import { OntimeEvent, UserFields } from 'ontime-types'; import { OntimeEvent, UserFields } from 'ontime-types';
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
@@ -14,6 +15,8 @@ interface OperatorEventProps {
subscribed: keyof UserFields | null; subscribed: keyof UserFields | null;
subscribedAlias: string; subscribedAlias: string;
showSeconds: boolean; showSeconds: boolean;
isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>;
} }
// extract this to contain re-renders // extract this to contain re-renders
@@ -23,7 +26,7 @@ function RollingTime() {
} }
export default function OperatorEvent(props: OperatorEventProps) { export default function OperatorEvent(props: OperatorEventProps) {
const { data, cue, isSelected, subscribed, subscribedAlias, showSeconds } = props; const { data, cue, isSelected, subscribed, subscribedAlias, showSeconds, isPast, selectedRef } = props;
const start = formatTime(data.timeStart, { showSeconds }); const start = formatTime(data.timeStart, { showSeconds });
const end = formatTime(data.timeEnd, { showSeconds }); const end = formatTime(data.timeEnd, { showSeconds });
@@ -35,10 +38,11 @@ export default function OperatorEvent(props: OperatorEventProps) {
style.event, style.event,
isSelected ? style.running : null, isSelected ? style.running : null,
subscribedData ? style.subscribed : null, subscribedData ? style.subscribed : null,
isPast ? style.past : null,
]); ]);
return ( return (
<div className={operatorClasses}> <div className={operatorClasses} ref={selectedRef}>
<div className={style.titles}> <div className={style.titles}>
<span className={style.title}> <span className={style.title}>
{data.title} - {data.subtitle} {data.title} - {data.subtitle}
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { OntimeEvent, Playback } from 'ontime-types'; import { OntimeEvent, Playback } from 'ontime-types';
import { millisToString } from 'ontime-utils'; import { millisToString } from 'ontime-utils';
@@ -37,9 +38,13 @@ export default function StatusBar({
const runningTime = millisToString(timer.current); const runningTime = millisToString(timer.current);
const elapsedTime = millisToString(timer.elapsed); const elapsedTime = millisToString(timer.elapsed);
const PlaybackIconComponent = useMemo(() => {
return <PlaybackIcon state={playback} skipTooltip className={styles.playbackIcon} />;
}, [playback]);
return ( return (
<div className={styles.statusBar}> <div className={styles.statusBar}>
<PlaybackIcon state={playback} className={styles.playbackIcon} /> {PlaybackIconComponent}
<div className={styles.clock}> <div className={styles.clock}>
<div className={styles.column}> <div className={styles.column}>
<span className={styles.label}>Time now</span> <span className={styles.label}>Time now</span>
+3 -23
View File
@@ -1,10 +1,11 @@
import { Fragment, lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'; import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types'; import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { getFirst, getNext, getPrevious } from 'ontime-utils'; import { getFirst, getNext, getPrevious } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction'; import { useEventAction } from '../../common/hooks/useEventAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket'; import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEditorSettings } from '../../common/stores/editorSettings'; import { useEditorSettings } from '../../common/stores/editorSettings';
@@ -41,6 +42,7 @@ export default function Rundown(props: RundownProps) {
const moveCursorTo = useAppMode((state) => state.setCursor); const moveCursorTo = useAppMode((state) => state.setCursor);
const cursorRef = useRef<HTMLDivElement | null>(null); const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true });
// DND KIT // DND KIT
const sensors = useSensors(useSensor(PointerSensor)); const sensors = useSensors(useSensor(PointerSensor));
@@ -153,28 +155,6 @@ export default function Rundown(props: RundownProps) {
}; };
}, [handleKeyPress]); }, [handleKeyPress]);
// when cursor moves, view should follow
useEffect(() => {
function scrollToComponent(
componentRef: MutableRefObject<HTMLDivElement>,
scrollRef: MutableRefObject<HTMLDivElement>,
) {
const componentRect = componentRef.current.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
const top = componentRect.top - scrollRect.top + scrollRef.current.scrollTop - 100;
scrollRef.current.scrollTo({ top, behavior: 'smooth' });
}
if (cursorRef.current && scrollRef.current) {
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(cursorRef as MutableRefObject<HTMLDivElement>, scrollRef as MutableRefObject<HTMLDivElement>);
});
}
// eslint-disable-next-line -- the prompt seems incorrect
}, [cursorRef?.current, scrollRef]);
useEffect(() => { useEffect(() => {
// in run mode, we follow selection // in run mode, we follow selection
if (!viewFollowsCursor || !featureData?.selectedEventId) { if (!viewFollowsCursor || !featureData?.selectedEventId) {