From e667755fd6d8cab5c18045d179e364725ea1114b Mon Sep 17 00:00:00 2001 From: cv <34649812+cpvalente@users.noreply.github.com> Date: Tue, 29 Aug 2023 12:37:03 +0200 Subject: [PATCH] feat: operator view --- .../components/playback-icon/PlaybackIcon.tsx | 7 +- .../src/common/hooks/useFollowComponent.ts | 61 ++++++++++++++++++ apps/client/src/common/hooks/useSocket.ts | 1 - .../client/src/features/cuesheet/Cuesheet.tsx | 34 ++-------- .../features/operator/Operator.module.scss | 4 ++ .../client/src/features/operator/Operator.tsx | 64 ++++++++++++++++--- .../operator-event/OperatorEvent.module.scss | 5 ++ .../operator/operator-event/OperatorEvent.tsx | 8 ++- .../operator/status-bar/StatusBar.tsx | 7 +- apps/client/src/features/rundown/Rundown.tsx | 26 +------- 10 files changed, 150 insertions(+), 67 deletions(-) create mode 100644 apps/client/src/common/hooks/useFollowComponent.ts diff --git a/apps/client/src/common/components/playback-icon/PlaybackIcon.tsx b/apps/client/src/common/components/playback-icon/PlaybackIcon.tsx index 7a2886c3c..067dbdbcb 100644 --- a/apps/client/src/common/components/playback-icon/PlaybackIcon.tsx +++ b/apps/client/src/common/components/playback-icon/PlaybackIcon.tsx @@ -8,11 +8,12 @@ import { tooltipDelayFast } from '../../../ontimeConfig'; interface PlaybackIconProps { state: Playback; + skipTooltip?: boolean; className?: string; } export default function PlaybackIcon(props: PlaybackIconProps) { - const { state, className } = props; + const { state, skipTooltip, className } = props; // if timer is Pause or Armed let label = 'Timer Paused'; @@ -29,6 +30,10 @@ export default function PlaybackIcon(props: PlaybackIconProps) { Icon = IoStop; } + if (skipTooltip) { + return ; + } + return ( diff --git a/apps/client/src/common/hooks/useFollowComponent.ts b/apps/client/src/common/hooks/useFollowComponent.ts new file mode 100644 index 000000000..a949abf22 --- /dev/null +++ b/apps/client/src/common/hooks/useFollowComponent.ts @@ -0,0 +1,61 @@ +import { MutableRefObject, useCallback, useEffect } from 'react'; + +function scrollToComponent( + componentRef: MutableRefObject, + scrollRef: MutableRefObject, + 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; + scrollRef: MutableRefObject; + 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, + scrollRef as MutableRefObject, + 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; +} diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 17d0dc57d..6f0f3e520 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -15,7 +15,6 @@ export const useOperator = () => { const featureSelector = (state: RuntimeStore) => ({ playback: state.playback, selectedEventId: state.loaded.selectedEventId, - nextEventId: state.loaded.nextEventId, }); return useRuntimeStore(featureSelector, deepCompare); diff --git a/apps/client/src/features/cuesheet/Cuesheet.tsx b/apps/client/src/features/cuesheet/Cuesheet.tsx index f8806e65f..f7662545f 100644 --- a/apps/client/src/features/cuesheet/Cuesheet.tsx +++ b/apps/client/src/features/cuesheet/Cuesheet.tsx @@ -1,4 +1,4 @@ -import { MutableRefObject, useEffect, useRef } from 'react'; +import { useRef } from 'react'; import { Tooltip } from '@chakra-ui/react'; import { closestCenter, @@ -14,6 +14,7 @@ import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordin import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; +import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useLocalStorage } from '../../common/hooks/useLocalStorage'; import { millisToDelayString } from '../../common/utils/dateConfig'; 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 selectedRef = useRef(null); + const tableContainerRef = useRef(null); + useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected }); const table = useReactTable({ data, @@ -63,7 +66,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu onColumnSizingChange: setColumnSizing, getCoreRowModel: getCoreRowModel(), }); - const tableContainerRef = useRef(null); const sensors = useSensors( 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, - scrollRef: MutableRefObject, - ) { - 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, - tableContainerRef as MutableRefObject, - ); - }); - } - // eslint-disable-next-line -- the prompt seems incorrect, we need the refs - }, [selectedRef.current, tableContainerRef.current, followSelected]); - const handleOnDragEnd = (event: DragEndEvent) => { const { delta, active, over } = event; diff --git a/apps/client/src/features/operator/Operator.module.scss b/apps/client/src/features/operator/Operator.module.scss index 62ba30e1f..2974e5f02 100644 --- a/apps/client/src/features/operator/Operator.module.scss +++ b/apps/client/src/features/operator/Operator.module.scss @@ -18,6 +18,10 @@ gap: 2px; } +.spacer { + min-height: 80vh; +} + @mixin block() { width: 100%; padding: 0.25rem 0.5rem; diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index e03ced9dd..93a707d53 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -1,4 +1,4 @@ -import { UIEvent, useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { SupportedEvent, UserFields } from 'ontime-types'; 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 { getOperatorOptions } from '../../common/components/view-params-editor/constants'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; +import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useOperator } from '../../common/hooks/useSocket'; import useRundown from '../../common/hooks-query/useRundown'; import useUserFields from '../../common/hooks-query/useUserFields'; @@ -19,23 +20,53 @@ import StatusBar from './status-bar/StatusBar'; import style from './Operator.module.scss'; +const selectedOffset = 50; + export default function Operator() { const { data, status } = useRundown(); const { data: userFields, status: userFieldsStatus } = useUserFields(); const featureData = useOperator(); - const [showChild, setShowChild] = useState(false); const [searchParams] = useSearchParams(); + const [lockAutoScroll, setLockAutoScroll] = useState(false); + const selectedRef = useRef(null); + const scrollRef = useRef(null); + const scrollToComponent = useFollowComponent({ + followRef: selectedRef, + scrollRef: scrollRef, + doFollow: !lockAutoScroll, + topOffset: selectedOffset, + }); + // Set window title useEffect(() => { document.title = 'ontime - Operator'; }, []); - const handleScroll = (event: UIEvent) => { - const scrollThreshold = 50; - const scrollPosition = (event.target as HTMLElement).scrollTop; + // reset scroll if nothing is selected + useEffect(() => { + 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') { @@ -49,24 +80,38 @@ export default function Operator() { const lastEvent = getLastEvent(data); const operatorOptions = getOperatorOptions(userFields); + let isPast = Boolean(featureData.selectedEventId); + const hidePast = isStringBoolean(searchParams.get('hidepast')); return (
-
+
{data.map((entry) => { 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 ( ); } @@ -76,8 +121,9 @@ export default function Operator() { } return null; })} +
- undefined} /> +
); diff --git a/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss index da879f742..0876a0e67 100644 --- a/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss +++ b/apps/client/src/features/operator/operator-event/OperatorEvent.module.scss @@ -5,6 +5,7 @@ .event { @include block(); background-color: $gray-1300; + opacity: 1; &.subscribed { background-color: $gray-1250; @@ -13,6 +14,10 @@ &.running { background-color: $red-700; } + + &.past { + opacity: 0.2; + } } .titles { diff --git a/apps/client/src/features/operator/operator-event/OperatorEvent.tsx b/apps/client/src/features/operator/operator-event/OperatorEvent.tsx index 29d7d3bf4..7259e2c1d 100644 --- a/apps/client/src/features/operator/operator-event/OperatorEvent.tsx +++ b/apps/client/src/features/operator/operator-event/OperatorEvent.tsx @@ -1,3 +1,4 @@ +import { RefObject } from 'react'; import { OntimeEvent, UserFields } from 'ontime-types'; import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator'; @@ -14,6 +15,8 @@ interface OperatorEventProps { subscribed: keyof UserFields | null; subscribedAlias: string; showSeconds: boolean; + isPast: boolean; + selectedRef?: RefObject; } // extract this to contain re-renders @@ -23,7 +26,7 @@ function RollingTime() { } 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 end = formatTime(data.timeEnd, { showSeconds }); @@ -35,10 +38,11 @@ export default function OperatorEvent(props: OperatorEventProps) { style.event, isSelected ? style.running : null, subscribedData ? style.subscribed : null, + isPast ? style.past : null, ]); return ( -
+
{data.title} - {data.subtitle} diff --git a/apps/client/src/features/operator/status-bar/StatusBar.tsx b/apps/client/src/features/operator/status-bar/StatusBar.tsx index 8c7975026..0b6919a97 100644 --- a/apps/client/src/features/operator/status-bar/StatusBar.tsx +++ b/apps/client/src/features/operator/status-bar/StatusBar.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { OntimeEvent, Playback } from 'ontime-types'; import { millisToString } from 'ontime-utils'; @@ -37,9 +38,13 @@ export default function StatusBar({ const runningTime = millisToString(timer.current); const elapsedTime = millisToString(timer.elapsed); + const PlaybackIconComponent = useMemo(() => { + return ; + }, [playback]); + return (
- + {PlaybackIconComponent}
Time now diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index c18c30425..e4601d981 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -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 { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types'; import { getFirst, getNext, getPrevious } from 'ontime-utils'; import { useEventAction } from '../../common/hooks/useEventAction'; +import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useRundownEditor } from '../../common/hooks/useSocket'; import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { useEditorSettings } from '../../common/stores/editorSettings'; @@ -41,6 +42,7 @@ export default function Rundown(props: RundownProps) { const moveCursorTo = useAppMode((state) => state.setCursor); const cursorRef = useRef(null); const scrollRef = useRef(null); + useFollowComponent({ followRef: cursorRef, scrollRef: scrollRef, doFollow: true }); // DND KIT const sensors = useSensors(useSensor(PointerSensor)); @@ -153,28 +155,6 @@ export default function Rundown(props: RundownProps) { }; }, [handleKeyPress]); - // when cursor moves, view should follow - useEffect(() => { - function scrollToComponent( - componentRef: MutableRefObject, - scrollRef: MutableRefObject, - ) { - 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, scrollRef as MutableRefObject); - }); - } - - // eslint-disable-next-line -- the prompt seems incorrect - }, [cursorRef?.current, scrollRef]); - useEffect(() => { // in run mode, we follow selection if (!viewFollowsCursor || !featureData?.selectedEventId) {