diff --git a/apps/client/src/common/hooks/useFollowComponent.ts b/apps/client/src/common/hooks/useFollowComponent.ts index a757b2e9c..7b03d70b1 100644 --- a/apps/client/src/common/hooks/useFollowComponent.ts +++ b/apps/client/src/common/hooks/useFollowComponent.ts @@ -22,6 +22,11 @@ interface UseFollowComponentProps { scrollRef: RefObject; doFollow: boolean; topOffset?: number; + /** + * Resolved at scroll time, for offsets which can only be measured from the DOM (eg. a sticky header). + * Takes precedence over topOffset. Keep the reference stable, it is a dependency of the follow effect. + */ + getTopOffset?: () => number; setScrollFlag?: (newValue: boolean) => void; followTrigger?: MaybeString; // this would be an entry id or null } @@ -31,6 +36,7 @@ export default function useFollowComponent({ scrollRef, doFollow, topOffset = 100, + getTopOffset, setScrollFlag, followTrigger, }: UseFollowComponentProps) { @@ -44,21 +50,23 @@ export default function useFollowComponent({ setScrollFlag?.(true); // Use requestAnimationFrame to ensure the component is fully loaded window.requestAnimationFrame(() => { - scrollToComponent(followRef as RefObject, scrollRef as RefObject, topOffset); + // resolve the offset after layout, so that measured values are up to date + const offset = getTopOffset?.() ?? topOffset; + scrollToComponent(followRef as RefObject, scrollRef as RefObject, offset); setScrollFlag?.(false); }); } - }, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]); + }, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset, getTopOffset]); const scrollToRefComponent = useCallback( - (componentRef = followRef, containerRef = scrollRef, offset = topOffset) => { + (componentRef = followRef, containerRef = scrollRef, offset?: number) => { if (componentRef && containerRef) { // @ts-expect-error -- we know this are not null // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - scrollToComponent(componentRef!, containerRef!, offset); + scrollToComponent(componentRef!, containerRef!, offset ?? getTopOffset?.() ?? topOffset); } }, - [followRef, scrollRef, topOffset], + [followRef, scrollRef, topOffset, getTopOffset], ); return scrollToRefComponent; diff --git a/apps/client/src/features/operator/Operator.module.scss b/apps/client/src/features/operator/Operator.module.scss index a8bcfa5a5..6b410b5b2 100644 --- a/apps/client/src/features/operator/Operator.module.scss +++ b/apps/client/src/features/operator/Operator.module.scss @@ -18,6 +18,14 @@ padding-bottom: 95vh; } +/* a group and its events, bounds the sticky group header to its own section */ +.groupSection { + display: flex; + flex-direction: column; + /* the rows used to be direct children of the list, keep the gap they had */ + gap: 2px; +} + .editPrompt { position: fixed; z-index: $zindex-dialog; diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index f1b6ffc41..ea5511ebb 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -1,5 +1,5 @@ import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types'; -import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import EmptyFill from '../../common/components/state/EmptyFill'; import EmptyPage from '../../common/components/state/EmptyPage'; @@ -54,11 +54,21 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator const [lockAutoScroll, setLockAutoScroll] = useState(false); const selectedRef = useRef(null); const scrollRef = useRef(null); + const stickyHeaderRef = useRef(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, - topOffset: selectedOffset, + getTopOffset, followTrigger: selectedEventId, }); @@ -86,7 +96,8 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator const selectedRect = selectedRef.current.getBoundingClientRect(); const scrollerRect = scrollRef.current.getBoundingClientRect(); if (selectedRect && scrollerRect) { - const distanceFromTop = selectedRect.top - scrollerRect.top; + // 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); } @@ -186,13 +197,14 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator } return ( - +
{entry.entries.map((nestedEntryId) => { const nestedEntry = rundown.entries[nestedEntryId]; @@ -239,7 +251,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator /> ); })} - +
); } return null; diff --git a/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss b/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss index b545380a2..5873bad92 100644 --- a/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss +++ b/apps/client/src/features/operator/operator-group/OperatorGroup.module.scss @@ -3,17 +3,32 @@ min-height: 2.5rem; padding: 0.4rem 0.75rem; border-left: 0.35rem solid var(--group-colour, $gray-500); - background-color: $gray-1350; - background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%); + /* the background comes inline from getAccessibleColour, which resolves to an opaque colour + and is what keeps the rows from showing through while the header is pinned */ font-size: 1.25rem; font-weight: 600; + /* stays visible as a header for the events of the group, bound by the group section */ + position: sticky; + /* covers the list padding, otherwise the rows scroll through it */ + top: -0.25rem; + z-index: 1; + display: flex; align-items: center; gap: 1rem; letter-spacing: 0; } +/* a group holding the running event is only outlined, the green fill belongs to the event row itself. + the card is already filled with the group colour, so we use the lighter active indicator over a dark + frame, which stays legible against any of them, green groups included */ +.live { + box-shadow: + inset 0 0 0 2px $ui-black, + inset 0 0 0 4px $active-indicator; +} + .title { flex: 1 1 auto; min-width: 0; diff --git a/apps/client/src/features/operator/operator-group/OperatorGroup.tsx b/apps/client/src/features/operator/operator-group/OperatorGroup.tsx index eb8036c2f..b421ab37d 100644 --- a/apps/client/src/features/operator/operator-group/OperatorGroup.tsx +++ b/apps/client/src/features/operator/operator-group/OperatorGroup.tsx @@ -1,6 +1,6 @@ -import { CSSProperties, memo } from 'react'; +import { CSSProperties, Ref, memo } from 'react'; -import { getAccessibleColour } from '../../../common/utils/styleUtils'; +import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { formatDuration } from '../../../common/utils/time'; import style from './OperatorGroup.module.scss'; @@ -10,15 +10,22 @@ interface OperatorGroup { colour: string; count: number; duration: number; + /** whether the group contains the currently running event */ + isLive?: boolean; + ref?: Ref; } export default memo(OperatorGroup); -function OperatorGroup({ title, colour, count, duration }: OperatorGroup) { +function OperatorGroup({ title, colour, count, duration, isLive, ref }: OperatorGroup) { const groupColour = colour || '#929292'; const groupColours = getAccessibleColour(groupColour); return ( -
+
{title} {`${count} ${count === 1 ? 'event' : 'events'}`} diff --git a/apps/client/src/views/countdown/Countdown.scss b/apps/client/src/views/countdown/Countdown.scss index e1bd75235..f72aaab84 100644 --- a/apps/client/src/views/countdown/Countdown.scss +++ b/apps/client/src/views/countdown/Countdown.scss @@ -103,6 +103,22 @@ $item-height: 3.5rem; padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom))); } + /* a subscribed group and its subscribed events, bounds the sticky header to its own section */ + .sub-section { + display: flex; + flex-direction: column; + /* flex-grow is set inline from the row count, so that the rows keep the share they had in the list */ + flex: 1 1 0; + } + + /* scoped to the section, the select view shows the same cards in a flat list */ + .sub-section .sub--group { + position: sticky; + /* sticky insets constrain the margin box, negate the row margin so the header sits flush */ + top: -2px; + z-index: 1; + } + /* ====================== LIST-ITEM ======================*/ .sub { margin: 2px; @@ -196,9 +212,15 @@ $item-height: 3.5rem; .sub--group { box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325); - background: + /* the card colour is translucent, an opaque base keeps the rows from showing through the sticky header. + split into longhands since the shorthand only takes a colour in its last layer */ + background-color: var(--background-color-override, $viewer-background-color); + background-image: linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%), - var(--card-background-color-override, $viewer-card-bg-color); + linear-gradient( + var(--card-background-color-override, $viewer-card-bg-color), + var(--card-background-color-override, $viewer-card-bg-color) + ); .sub__binder { background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color)); @@ -230,6 +252,16 @@ $item-height: 3.5rem; } } + /* a group holding the running event is only outlined, the green fill belongs to the event row itself */ + .sub--group.sub--live { + box-shadow: inset 0 0 0 2px $active-green; + } + + /* loaded but not yet running, subtle enough to not compete with the live ring */ + .sub--group.sub--armed { + box-shadow: inset 0 0 0 2px $gray-1000; + } + .sub__title { grid-area: title; padding-bottom: 0.5rem; diff --git a/apps/client/src/views/countdown/CountdownSubscriptions.tsx b/apps/client/src/views/countdown/CountdownSubscriptions.tsx index ced544457..eb5ef00ed 100644 --- a/apps/client/src/views/countdown/CountdownSubscriptions.tsx +++ b/apps/client/src/views/countdown/CountdownSubscriptions.tsx @@ -1,6 +1,6 @@ import { MaybeNumber, OntimeEvent } from 'ontime-types'; import { dayInMs } from 'ontime-utils'; -import { useEffect, useRef, useState } from 'react'; +import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { IoPencil } from 'react-icons/io5'; import Button from '../../common/components/buttons/Button'; @@ -22,6 +22,7 @@ import { CountdownTarget, extendEventData, getIsLive, + groupSubscriptionTargets, isOutsideRange, preferredFormat12, preferredFormat24, @@ -48,11 +49,22 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode const [lockAutoScroll, setLockAutoScroll] = useState(false); const selectedRef = useRef(null); const scrollRef = useRef(null); + const stickyHeaderRef = useRef(null); + + const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]); + + // 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 row margin, so that the rows do not touch + return header ? header.offsetHeight + 2 : 0; + }, []); + const scrollToComponent = useFollowComponent({ followRef: selectedRef, scrollRef, doFollow: !lockAutoScroll, - topOffset: 0, + getTopOffset: getStickyOffset, followTrigger: selectedEventId, }); @@ -79,7 +91,8 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode const selectedRect = selectedRef.current.getBoundingClientRect(); const scrollerRect = scrollRef.current.getBoundingClientRect(); if (selectedRect && scrollerRect) { - const distanceFromTop = selectedRect.top - scrollerRect.top; + // 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 > 50; setLockAutoScroll(hasScrolledOutOfThreshold); } @@ -96,43 +109,68 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode throttledHandleScroll(); }; + const renderRow = (event: CountdownTarget, rowRef: RefObject | undefined, isLive: boolean) => { + // while a group is live, surface the running event's title as the secondary line + const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined; + const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource); + const isGroupedEvent = !event.isGroup && Boolean(event.parent); + const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id; + const isArmed = !isLive && activeEntryId === selectedEventId; + const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData); + const displayTitle = getPropertyValue(event, mainSource ?? 'title'); + + return ( +
+
+ + +
+ {event.isGroup && Group} + {displayTitle} +
+ {secondaryData &&
{secondaryData}
} +
+ ); + }; + return (
- {subscribedEvents.map((event) => { - // while a group is live, surface the running event's title as the secondary line - const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined; - const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource); - const isGroupedEvent = !event.isGroup && Boolean(event.parent); - const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id; - // a subscribed group is live when any of its children is the selected/running event - const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false; - const isArmed = !isLive && activeEntryId === selectedEventId; - const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData); - const displayTitle = getPropertyValue(event, mainSource ?? 'title'); + {sections.map((section) => { + const rows = section.group ? [section.group, ...section.events] : section.events; + // the running event anchors the scroll, the group header stays pinned above it + const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null; + return (
-
- - -
- {event.isGroup && Group} - {displayTitle} -
- {secondaryData &&
{secondaryData}
} + {rows.map((event) => { + const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id; + // a subscribed group is live when any of its children is the selected/running event + const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false; + // only ever hand the ref to a single row, sharing it would null it out on the next commit + const isAnchor = isLive && (anchorId === null || event.id === anchorId); + const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined; + + return renderRow(event, rowRef, isLive); + })}
); })} diff --git a/apps/client/src/views/countdown/countdown.utils.test.ts b/apps/client/src/views/countdown/countdown.utils.test.ts index 13ba3a75c..524d71997 100644 --- a/apps/client/src/views/countdown/countdown.utils.test.ts +++ b/apps/client/src/views/countdown/countdown.utils.test.ts @@ -1,7 +1,7 @@ import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types'; import { ExtendedEntry } from '../../common/utils/rundownMetadata'; -import { resolveSubscriptionTarget } from './countdown.utils'; +import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils'; /** * Minimal builders for the extended (metadata enriched) entries the countdown view consumes. @@ -126,3 +126,89 @@ describe('resolveSubscriptionTarget()', () => { expect(result?.liveEntry).toBeNull(); }); }); + +describe('groupSubscriptionTargets()', () => { + /** + * Resolves a group the same way the view does, so that the tests exercise the real target shape + * (a resolved group carries type Event, so the helper cannot rely on the entry type) + */ + function resolveGroup(group: ExtendedEntry, flat: ExtendedEntry[]): CountdownTarget { + const resolved = resolveSubscriptionTarget(group, flat); + if (resolved === null) { + throw new Error('test setup: group has no playable children'); + } + return resolved; + } + + it('returns no sections for an empty subscription list', () => { + expect(groupSubscriptionTargets([])).toEqual([]); + }); + + it('gives each ungrouped event its own section', () => { + const e1 = makeEvent({ id: 'e1' }); + const e2 = makeEvent({ id: 'e2' }); + + expect(groupSubscriptionTargets([e1, e2])).toEqual([ + { group: null, events: [e1] }, + { group: null, events: [e2] }, + ]); + }); + + it('absorbs the children of a subscribed group into its section', () => { + const group = makeGroup({ id: 'g1' }); + const c1 = makeEvent({ id: 'c1', parent: 'g1' }); + const c2 = makeEvent({ id: 'c2', parent: 'g1' }); + const resolved = resolveGroup(group, [group, c1, c2]); + + expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]); + }); + + it('keeps a subscribed group with no subscribed children as an empty section', () => { + const group = makeGroup({ id: 'g1' }); + const c1 = makeEvent({ id: 'c1', parent: 'g1' }); + const resolved = resolveGroup(group, [group, c1]); + + expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]); + }); + + it('does not absorb an event which belongs to a different group', () => { + const group1 = makeGroup({ id: 'g1' }); + const c1 = makeEvent({ id: 'c1', parent: 'g1' }); + const group2 = makeGroup({ id: 'g2' }); + const c2 = makeEvent({ id: 'c2', parent: 'g2' }); + const flat = [group1, c1, group2, c2]; + const resolved1 = resolveGroup(group1, flat); + const resolved2 = resolveGroup(group2, flat); + + expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([ + { group: resolved1, events: [c1] }, + { group: resolved2, events: [c2] }, + ]); + }); + + it('does not absorb an event whose parent group is not subscribed', () => { + const group1 = makeGroup({ id: 'g1' }); + const c1 = makeEvent({ id: 'c1', parent: 'g1' }); + const group2 = makeGroup({ id: 'g2' }); + const c2 = makeEvent({ id: 'c2', parent: 'g2' }); + const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]); + + // only the first group is subscribed, so the second group's child stands alone + expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([ + { group: resolved1, events: [c1] }, + { group: null, events: [c2] }, + ]); + }); + + it('closes a section when an ungrouped event follows a group', () => { + const group = makeGroup({ id: 'g1' }); + const c1 = makeEvent({ id: 'c1', parent: 'g1' }); + const e1 = makeEvent({ id: 'e1' }); + const resolved = resolveGroup(group, [group, c1]); + + expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([ + { group: resolved, events: [c1] }, + { group: null, events: [e1] }, + ]); + }); +}); diff --git a/apps/client/src/views/countdown/countdown.utils.ts b/apps/client/src/views/countdown/countdown.utils.ts index 746b38bbe..4c04b34d3 100644 --- a/apps/client/src/views/countdown/countdown.utils.ts +++ b/apps/client/src/views/countdown/countdown.utils.ts @@ -252,6 +252,44 @@ export function resolveSubscriptionTarget( }; } +/** + * A subscribed group along with the subscribed events which belong to it. + * Events without a subscribed parent group form their own section with no group. + */ +export type CountdownSection = { + group: CountdownTarget | null; + events: CountdownTarget[]; +}; + +/** + * Folds the flat, rundown ordered subscription targets into sections. + * A group opens a section which absorbs the following targets that declare it as parent, + * which allows the group to be rendered as a sticky header for its own events. + */ +export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] { + const sections: CountdownSection[] = []; + let openSection: CountdownSection | null = null; + + for (const target of targets) { + // resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here + if (target.isGroup) { + openSection = { group: target, events: [] }; + sections.push(openSection); + continue; + } + + if (openSection?.group && target.parent === openSection.group.id) { + openSection.events.push(target); + continue; + } + + openSection = null; + sections.push({ group: null, events: [target] }); + } + + return sections; +} + export function extendEventData( event: CountdownTarget, currentDay: number,