mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 10:53:51 +00:00
e4cfa1d17d
In the countdown view a user can subscribe to a group and to events inside it. Once an event within the group started, the auto scroll pushed the group card above the viewport, losing the context of which group the running event belongs to. The operator view had the same problem. Group and its events are now wrapped in a section, and the group card is pinned with position sticky, so it stays above the running event however far down the group that event sits. The follow scroll offsets the sticky header, and the follow button threshold discounts it so it keeps its meaning. A group holding the running event is now marked as active. In the countdown the card keeps its background and gains a green outline, the green fill stays reserved for the running event itself. In the operator the card is already filled with the group colour, so the ring uses the lighter active indicator over a dark frame to stay legible against any group colour. Neither state was visible before: .sub--group overrode both .sub--live and .sub--armed at equal specificity. Also fixes the countdown handing the same selectedRef to both a group row and its running child. React detaches refs before attaching them, so the child unsetting the ref left it null while the group never re-attached, silently disabling auto scroll. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GjX7D56AW8cL3FXjvtnjtL
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import EmptyFill from '../../common/components/state/EmptyFill';
|
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
|
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
|
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
|
import { useSelectedEventId } from '../../common/hooks/useSocket';
|
|
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
|
import { cx } from '../../common/utils/styleUtils';
|
|
import { throttle } from '../../common/utils/throttle';
|
|
import { getDefaultFormat } from '../../common/utils/time';
|
|
import { isTouchDevice } from '../../externals';
|
|
import { useTranslation } from '../../translation/TranslationProvider';
|
|
import Loader from '../../views/common/loader/Loader';
|
|
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
|
|
import FollowButton from './follow-button/FollowButton';
|
|
import OperatorEvent from './operator-event/OperatorEvent';
|
|
import OperatorGroup from './operator-group/OperatorGroup';
|
|
import { getOperatorOptions, useOperatorOptions } from './operator.options';
|
|
import type { EditEvent } from './operator.types';
|
|
import { getEventData } from './operator.utils';
|
|
import StatusBar from './status-bar/StatusBar';
|
|
import { OperatorData, useOperatorData } from './useOperatorData';
|
|
|
|
import style from './Operator.module.scss';
|
|
|
|
const selectedOffset = 50;
|
|
|
|
export default function OperatorLoader() {
|
|
const { data, status } = useOperatorData();
|
|
|
|
useWindowTitle('Operator');
|
|
|
|
if (status === 'pending') {
|
|
return <Loader />;
|
|
}
|
|
|
|
if (status === 'error') {
|
|
return <EmptyPage variant='error' text='There was an error fetching data, please refresh the page.' />;
|
|
}
|
|
|
|
return <Operator {...data} />;
|
|
}
|
|
|
|
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
|
|
const selectedEventId = useSelectedEventId();
|
|
const { getLocalizedString } = useTranslation();
|
|
const { subscribe, mainSource, secondarySource, shouldEdit, hidePast, showStart } = useOperatorOptions();
|
|
|
|
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
|
const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
|
|
|
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// the group of the running event is pinned to the top of the list, the running event needs to clear it
|
|
const getStickyOffset = useCallback(() => {
|
|
const header = stickyHeaderRef.current;
|
|
// account for the list gap, so that the rows do not touch
|
|
return header ? header.offsetHeight + 2 : 0;
|
|
}, []);
|
|
const getTopOffset = useCallback(() => selectedOffset + getStickyOffset(), [getStickyOffset]);
|
|
|
|
const scrollToComponent = useFollowComponent({
|
|
followRef: selectedRef,
|
|
scrollRef,
|
|
doFollow: !lockAutoScroll,
|
|
getTopOffset,
|
|
followTrigger: selectedEventId,
|
|
});
|
|
|
|
const timeoutId = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// reset scroll if nothing is selected
|
|
useEffect(() => {
|
|
if (!selectedEventId) {
|
|
if (!lockAutoScroll) {
|
|
scrollRef.current?.scrollTo(0, 0);
|
|
}
|
|
}
|
|
}, [selectedEventId, lockAutoScroll, scrollRef]);
|
|
|
|
const handleOffset = () => {
|
|
if (selectedEventId) {
|
|
scrollToComponent();
|
|
}
|
|
setLockAutoScroll(false);
|
|
};
|
|
|
|
// prevent considering automated scrolls as user scrolls
|
|
const handleUserScroll = () => {
|
|
if (selectedRef?.current && scrollRef?.current) {
|
|
const selectedRect = selectedRef.current.getBoundingClientRect();
|
|
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
|
if (selectedRect && scrollerRect) {
|
|
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
|
|
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
|
|
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
|
setLockAutoScroll(hasScrolledOutOfThreshold);
|
|
}
|
|
}
|
|
};
|
|
const throttledHandleScroll = throttle(handleUserScroll, 1000);
|
|
|
|
const handleScroll = () => {
|
|
if (timeoutId.current) {
|
|
clearTimeout(timeoutId.current);
|
|
}
|
|
timeoutId.current = setTimeout(() => {
|
|
setShowEditPrompt(false);
|
|
}, 700);
|
|
|
|
setShowEditPrompt(true);
|
|
|
|
throttledHandleScroll();
|
|
};
|
|
|
|
const handleEdit = useCallback((event: EditEvent) => {
|
|
setEditEvent({ ...event });
|
|
}, []);
|
|
|
|
// gather option data
|
|
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
|
const operatorOptions = useMemo(() => getOperatorOptions(customFields, defaultFormat), [customFields, defaultFormat]);
|
|
|
|
const canEdit = shouldEdit && subscribe.length;
|
|
const hasEvents = rundown.order.length > 0;
|
|
|
|
return (
|
|
<div className={style.operatorContainer} data-testid='operator-view'>
|
|
<ViewParamsEditor target={OntimeView.Operator} viewOptions={operatorOptions} />
|
|
{editEvent && <CustomFieldEditModal event={editEvent} onClose={() => setEditEvent(null)} />}
|
|
|
|
<StatusBar />
|
|
|
|
{canEdit && (
|
|
<div className={cx([style.editPrompt, showEditPrompt && style.show])}>
|
|
{isTouchDevice ? 'Press and hold to edit user field' : 'Right click to edit user field'}
|
|
</div>
|
|
)}
|
|
|
|
{!hasEvents ? (
|
|
<EmptyFill text={getLocalizedString('common.no_data')} />
|
|
) : (
|
|
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
|
{rundown.order.map((entryId) => {
|
|
const entry = rundown.entries[entryId];
|
|
if (isOntimeEvent(entry)) {
|
|
const { isPast, isLinkedToLoaded, isLoaded, totalGap } = rundownMetadata[entryId];
|
|
// hide past events (if setting) and skipped events
|
|
if ((hidePast && isPast) || entry.skip) {
|
|
return null;
|
|
}
|
|
|
|
const { mainField, secondaryField, subscribedData } = getEventData(
|
|
entry,
|
|
mainSource,
|
|
secondarySource,
|
|
subscribe,
|
|
customFields,
|
|
);
|
|
|
|
return (
|
|
<OperatorEvent
|
|
key={entry.id}
|
|
id={entry.id}
|
|
colour={entry.colour}
|
|
cue={entry.cue}
|
|
main={mainField}
|
|
secondary={secondaryField}
|
|
timeStart={entry.timeStart}
|
|
duration={entry.duration}
|
|
delay={entry.delay}
|
|
dayOffset={entry.dayOffset}
|
|
isLinkedToLoaded={isLinkedToLoaded}
|
|
isSelected={isLoaded}
|
|
isPast={isPast}
|
|
selectedRef={isLoaded ? selectedRef : undefined}
|
|
showStart={showStart}
|
|
subscribed={subscribedData}
|
|
totalGap={totalGap}
|
|
onLongPress={canEdit ? handleEdit : () => undefined}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (isOntimeGroup(entry)) {
|
|
const { isPast } = rundownMetadata[entry.id];
|
|
|
|
const isCurrentParent = selectedEventId ? rundownMetadata[selectedEventId]?.groupId === entry.id : false;
|
|
|
|
if (hidePast && isPast && !isCurrentParent) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={style.groupSection} key={entry.id}>
|
|
<OperatorGroup
|
|
ref={isCurrentParent ? stickyHeaderRef : undefined}
|
|
title={entry.title}
|
|
colour={entry.colour}
|
|
count={entry.entries.length}
|
|
duration={entry.duration}
|
|
isLive={isCurrentParent}
|
|
/>
|
|
{entry.entries.map((nestedEntryId) => {
|
|
const nestedEntry = rundown.entries[nestedEntryId];
|
|
if (!isOntimeEvent(nestedEntry)) {
|
|
return null;
|
|
}
|
|
|
|
const { isPast, isLoaded, isLinkedToLoaded, totalGap } = rundownMetadata[nestedEntryId];
|
|
|
|
// hide past events (if setting) and skipped events
|
|
if ((hidePast && isPast) || nestedEntry.skip) {
|
|
return null;
|
|
}
|
|
|
|
const { mainField, secondaryField, subscribedData } = getEventData(
|
|
nestedEntry,
|
|
mainSource,
|
|
secondarySource,
|
|
subscribe,
|
|
customFields,
|
|
);
|
|
|
|
return (
|
|
<OperatorEvent
|
|
key={nestedEntry.id}
|
|
id={nestedEntry.id}
|
|
colour={nestedEntry.colour}
|
|
cue={nestedEntry.cue}
|
|
main={mainField}
|
|
secondary={secondaryField}
|
|
timeStart={nestedEntry.timeStart}
|
|
duration={nestedEntry.duration}
|
|
delay={nestedEntry.delay}
|
|
dayOffset={nestedEntry.dayOffset}
|
|
isLinkedToLoaded={isLinkedToLoaded}
|
|
isSelected={isLoaded}
|
|
isPast={isPast}
|
|
groupColour={entry.colour}
|
|
selectedRef={isLoaded ? selectedRef : undefined}
|
|
showStart={showStart}
|
|
subscribed={subscribedData}
|
|
totalGap={totalGap}
|
|
onLongPress={canEdit ? handleEdit : () => undefined}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
</div>
|
|
)}
|
|
<FollowButton isVisible={lockAutoScroll} onClickHandler={handleOffset} />
|
|
</div>
|
|
);
|
|
}
|