1- import React , { PropsWithChildren } from 'react'
1+ import React , { createContext , PropsWithChildren , ReactNode , useRef } from 'react'
22import _ from 'underscore'
33import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist'
44import ClassNames from 'classnames'
@@ -39,6 +39,9 @@ import { MeteorCall } from '../../lib/meteorApi.js'
3939
4040const DEFAULT_UPDATE_THROTTLE = 250 //ms
4141const PIECE_MISSING_UPDATE_THROTTLE = 2000 //ms
42+ const FROZEN_UPDATE_THROTTLE = 50 //ms
43+
44+ const PIECE_CONTINUATION_CLASS = 'continuation'
4245
4346interface PrompterConfig {
4447 mirror ?: boolean
@@ -120,7 +123,24 @@ function asArray<T>(value: T | T[] | null): T[] {
120123 }
121124}
122125
126+ interface PrompterStore {
127+ isFrozen : boolean
128+ }
129+
130+ type PrompterStoreRef = React . MutableRefObject < PrompterStore >
131+
132+ const PrompterStoreContext = createContext < PrompterStoreRef | null > ( null )
133+
134+ export function PrompterStoreProvider ( { children } : { children : ReactNode } ) : JSX . Element {
135+ const storeRef = useRef < PrompterStore > ( { isFrozen : false } )
136+
137+ return < PrompterStoreContext . Provider value = { storeRef } > { children } </ PrompterStoreContext . Provider >
138+ }
139+
123140export class PrompterViewContent extends React . Component < Translated < IProps & ITrackedProps > , IState > {
141+ static contextType = PrompterStoreContext
142+ declare context : PrompterStoreRef
143+
124144 autoScrollPreviousPartInstanceId : PartInstanceId | null = null
125145
126146 configOptions : PrompterConfig
@@ -263,6 +283,9 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
263283 componentWillUnmount ( ) : void {
264284 documentTitle . set ( null )
265285
286+ this . _lastAnimation ?. stop ( )
287+ this . context . current . isFrozen = false
288+
266289 const themeColor = document . head . querySelector ( 'meta[name="theme-color"]' )
267290 if ( themeColor ) {
268291 themeColor . setAttribute ( 'content' , themeColor . getAttribute ( 'data-content' ) || '#ffffff' )
@@ -300,7 +323,9 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
300323 this . autoScrollPreviousPartInstanceId = playlist . currentPartInfo ?. partInstanceId ?? null
301324 if ( playlist . currentPartInfo === null ) return
302325
303- this . scrollToPartInstance ( playlist . currentPartInfo . partInstanceId )
326+ // scrolls to a part instance, but only if it isn't showing a continuation of an infinite script piece
327+ // those should not be scrolled to, because they are handled by restoreScrollAnchor
328+ this . scrollToPartInstanceIfNotContinuation ( playlist . currentPartInfo . partInstanceId )
304329 }
305330 private calculateScrollPosition ( ) {
306331 let pixelMargin = this . calculateMarginPosition ( )
@@ -335,9 +360,11 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
335360 } ) )
336361 }
337362
338- scrollToPartInstance ( partInstanceId : PartInstanceId ) : void {
363+ scrollToPartInstanceIfNotContinuation ( partInstanceId : PartInstanceId ) : void {
339364 const scrollMargin = this . calculateScrollPosition ( )
340- const target = document . querySelector < HTMLElement > ( `[data-part-instance-id="${ partInstanceId } "]` )
365+ const target = document . querySelector < HTMLElement > (
366+ `[data-part-instance-id="${ partInstanceId } "]:not(:has(+ div.${ PIECE_CONTINUATION_CLASS } ))`
367+ )
341368
342369 if ( ! target ) return
343370
@@ -385,14 +412,14 @@ export class PrompterViewContent extends React.Component<Translated<IProps & ITr
385412 }
386413 private animateScrollTo ( scrollToPosition : number ) {
387414 this . _lastAnimation ?. stop ( )
415+ this . context . current . isFrozen = true
388416 this . _lastAnimation = animate ( window . scrollY , scrollToPosition , {
389417 duration : 0.4 ,
390418 ease : 'easeOut' ,
391- onUpdate : ( latest : number ) =>
392- window . scrollTo ( {
393- top : latest ,
394- behavior : 'instant' ,
395- } ) ,
419+ onUpdate : ( latest ) => window . scrollTo ( { top : latest , behavior : 'instant' } ) ,
420+ onComplete : ( ) => {
421+ this . context . current . isFrozen = false
422+ } ,
396423 } )
397424 }
398425 listAnchorPositions ( startY : number , endY : number , sortDirection = 1 ) : [ number , Element ] [ ] {
@@ -657,12 +684,14 @@ export function PrompterView(props: Readonly<IProps>): JSX.Element {
657684 )
658685
659686 return (
660- < PrompterViewContentWithTranslation
661- { ...props }
662- studio = { studio }
663- rundownPlaylist = { rundownPlaylist }
664- subsReady = { allSubsReady }
665- />
687+ < PrompterStoreProvider >
688+ < PrompterViewContentWithTranslation
689+ { ...props }
690+ studio = { studio }
691+ rundownPlaylist = { rundownPlaylist }
692+ subsReady = { allSubsReady }
693+ />
694+ </ PrompterStoreProvider >
666695 )
667696}
668697
@@ -679,6 +708,7 @@ interface ScrollAnchor {
679708 /** offset to use to scroll the anchor. null means "just scroll the anchor into view, best effort" */
680709 offset : number | null
681710 anchorId : string
711+ continuationOfId ?: string
682712}
683713type PrompterSnapshot = ScrollAnchor [ ] | null
684714
@@ -737,6 +767,9 @@ const PrompterContent = withTranslation()(
737767 Translated < PropsWithChildren < IPrompterProps > & IPrompterTrackedProps > ,
738768 { }
739769 > {
770+ static contextType = PrompterStoreContext
771+ declare context : PrompterStoreRef
772+
740773 private _debounceUpdate : NodeJS . Timeout | undefined
741774
742775 constructor ( props : Translated < PropsWithChildren < IPrompterProps > & IPrompterTrackedProps > ) {
@@ -765,10 +798,9 @@ const PrompterContent = withTranslation()(
765798 getScrollAnchors = ( ) : ScrollAnchor [ ] => {
766799 const readPosition = this . getReadPosition ( )
767800
768- const useableTextAnchors : {
801+ const useableTextAnchors : ( ScrollAnchor & {
769802 offset : number
770- anchorId : string
771- } [ ] = [ ]
803+ } ) [ ] = [ ]
772804 /** Maps anchorId -> offset */
773805 const foundScrollAnchors : ( ScrollAnchor & {
774806 /** Positive number. How "good" the anchor is. The anchor with the lowest number will preferred later. */
@@ -790,29 +822,43 @@ const PrompterContent = withTranslation()(
790822
791823 // Gather anchors from any text blocks in view:
792824
793- for ( const textAnchor of document . querySelectorAll ( '.prompter .prompter-line:not(.empty)' ) ) {
825+ for ( const textAnchor of document . querySelectorAll < HTMLElement > ( '.prompter .prompter-line:not(.empty)' ) ) {
794826 const { top, bottom } = textAnchor . getBoundingClientRect ( )
795827
796828 // Is the text block in view?
797829 if ( top <= readPosition && bottom > readPosition ) {
798- useableTextAnchors . push ( { anchorId : textAnchor . id , offset : top } )
830+ useableTextAnchors . push ( {
831+ anchorId : textAnchor . id ,
832+ offset : top ,
833+ continuationOfId : textAnchor . dataset . liveContinuationOf ,
834+ } )
799835 }
800836 }
801837
802838 // Also use scroll-anchors (Segment and Part names)
803839
804- for ( const scrollAnchor of document . querySelectorAll ( '.prompter .scroll-anchor' ) ) {
840+ for ( const scrollAnchor of document . querySelectorAll < HTMLElement > ( '.prompter .scroll-anchor' ) ) {
805841 const { top, bottom } = scrollAnchor . getBoundingClientRect ( )
806842
807843 const distanceToReadPosition = Math . abs ( top - readPosition )
808844
809845 if ( top <= windowInnerHeight && bottom > 0 ) {
810846 // If the anchor is in view, use the offset to keep it's position unchanged, relative to the viewport
811- foundScrollAnchors . push ( { anchorId : scrollAnchor . id , distanceToReadPosition, offset : top } )
847+ foundScrollAnchors . push ( {
848+ anchorId : scrollAnchor . id ,
849+ distanceToReadPosition,
850+ offset : top ,
851+ continuationOfId : scrollAnchor . dataset . liveContinuationOf ,
852+ } )
812853 } else {
813854 // If the anchor is not in view, set the offset to null, this will cause the view to
814855 // jump so that the anchor will be in view.
815- foundScrollAnchors . push ( { anchorId : scrollAnchor . id , distanceToReadPosition, offset : null } )
856+ foundScrollAnchors . push ( {
857+ anchorId : scrollAnchor . id ,
858+ distanceToReadPosition,
859+ offset : null ,
860+ continuationOfId : scrollAnchor . dataset . liveContinuationOf ,
861+ } )
816862 }
817863 }
818864
@@ -838,7 +884,19 @@ const PrompterContent = withTranslation()(
838884
839885 // Go through the anchors and use the first one that we find:
840886 for ( const scrollAnchor of scrollAnchors ) {
841- const anchor = document . getElementById ( scrollAnchor . anchorId )
887+ // if there is a live continuation of this anchor (or anchor that this anchor continues), it should be prioritized over the actual anchor, which now likely is empty
888+ let anchor = document . querySelector (
889+ `[data-live-continuation-of="${ scrollAnchor . continuationOfId || scrollAnchor . anchorId } "]`
890+ )
891+ // in case the anchor is already a continuation, but the script returned to its original part:
892+ if ( ! anchor && scrollAnchor . continuationOfId ) {
893+ anchor = document . getElementById ( scrollAnchor . continuationOfId )
894+ }
895+ // in case of a regular anchor:
896+ if ( ! anchor && ! scrollAnchor . continuationOfId ) {
897+ anchor = document . getElementById ( scrollAnchor . anchorId )
898+ }
899+
842900 if ( ! anchor ) continue
843901
844902 const { top } = anchor . getBoundingClientRect ( )
@@ -874,7 +932,7 @@ const PrompterContent = withTranslation()(
874932 logger . error (
875933 `Read anchor could not be found after update: ${ scrollAnchors
876934 . slice ( 0 , 10 )
877- . map ( ( sa ) => `"${ sa . anchorId } " (${ sa . offset } )` )
935+ . map ( ( sa ) => `"${ sa . anchorId } " (offset: ${ sa . offset } , continuationOfId: ${ sa . continuationOfId } )` )
878936 . join ( ', ' ) } `
879937 )
880938
@@ -959,6 +1017,15 @@ const PrompterContent = withTranslation()(
9591017 return false
9601018 }
9611019
1020+ forceUpdate ( callback ?: ( ) => void ) : void {
1021+ if ( this . context . current . isFrozen ) {
1022+ clearTimeout ( this . _debounceUpdate )
1023+ this . _debounceUpdate = setTimeout ( ( ) => this . forceUpdate ( ) , FROZEN_UPDATE_THROTTLE )
1024+ return
1025+ }
1026+ super . forceUpdate ( callback )
1027+ }
1028+
9621029 getSnapshotBeforeUpdate ( ) : PrompterSnapshot {
9631030 return this . getScrollAnchors ( )
9641031 }
@@ -1006,48 +1073,83 @@ const PrompterContent = withTranslation()(
10061073 return
10071074 }
10081075
1009- const firstPart = segment . parts [ 0 ]
1010- const firstPartStatus = this . getPartStatus ( prompterData , firstPart )
1076+ let pieceIdToHideScript : PieceId | undefined
1077+ const partStatuses = segment . parts . map ( ( part ) => this . getPartStatus ( prompterData , part ) )
10111078
10121079 lines . push (
10131080 < div
10141081 id = { `segment_${ segment . id } ` }
10151082 data-obj-id = { segment . id }
10161083 key = { 'segment_' + segment . id }
1017- className = { ClassNames ( 'prompter-segment' , 'scroll-anchor' , firstPartStatus ) }
1084+ className = { ClassNames ( 'prompter-segment' , 'scroll-anchor' , partStatuses [ 0 ] ) }
10181085 >
10191086 { segment . title || 'N/A' }
10201087 </ div >
10211088 )
10221089
10231090 hasInsertedScript = true
10241091
1092+ for ( let i = 0 ; i < segment . parts . length ; i ++ ) {
1093+ const part = segment . parts [ i ]
1094+
1095+ const firstPiece = part . pieces [ 0 ]
1096+ if (
1097+ firstPiece &&
1098+ firstPiece . continuationOf &&
1099+ partStatuses [ i ] === 'live' &&
1100+ firstPiece . startPartId &&
1101+ segment . parts . find ( ( part ) => part . id === firstPiece . startPartId )
1102+ ) {
1103+ // the i-th part is live and has taken over the infinite script from the start part,
1104+ // therefore we need to hide the script from the start part
1105+ pieceIdToHideScript = firstPiece . continuationOf
1106+ break
1107+ }
1108+ }
1109+
10251110 for ( const part of segment . parts ) {
1111+ const partStatus = this . getPartStatus ( prompterData , part )
1112+ const firstPiece = part . pieces [ 0 ]
1113+ const continuesFromPart = firstPiece ?. continuationOf && firstPiece . startPartId
10261114 lines . push (
10271115 < div
10281116 id = { `part_${ part . id } ` }
10291117 data-obj-id = { segment . id + '_' + part . id }
10301118 data-part-instance-id = { part . partInstanceId }
1119+ data-live-continuation-of = {
1120+ partStatus === 'live' && continuesFromPart ? `part_${ continuesFromPart } ` : undefined
1121+ }
10311122 key = { 'part_' + part . id }
1032- className = { ClassNames ( 'prompter-part' , 'scroll-anchor' , this . getPartStatus ( prompterData , part ) ) }
1123+ className = { ClassNames ( 'prompter-part' , 'scroll-anchor' , partStatus ) }
10331124 >
10341125 { part . title || 'N/A' }
10351126 </ div >
10361127 )
10371128
10381129 for ( const line of part . pieces ) {
1130+ let text = line . text || ''
1131+ if ( line . id === pieceIdToHideScript ) {
1132+ text = ''
1133+ }
1134+ if ( line . continuationOf && partStatus !== 'live' ) {
1135+ // if a continuation is not in a live part, it should not display its text
1136+ text = ''
1137+ }
10391138 lines . push (
10401139 < div
10411140 id = { `line_${ line . id } ` }
10421141 data-obj-id = { segment . id + '_' + part . id + '_' + line . id }
10431142 key = { 'line_' + part . id + '_' + segment . id + '_' + line . id }
1044- className = { ClassNames (
1045- 'prompter-line' ,
1046- this . props . config . addBlankLine ? 'add-blank' : undefined ,
1047- ! line . text ? 'empty' : undefined
1048- ) }
1143+ data-live-continuation-of = {
1144+ partStatus === 'live' && line . continuationOf ? `line_${ line . continuationOf } ` : undefined
1145+ }
1146+ className = { ClassNames ( 'prompter-line' , {
1147+ 'add-blank' : this . props . config . addBlankLine ,
1148+ empty : ! text ,
1149+ [ PIECE_CONTINUATION_CLASS ] : line . continuationOf ,
1150+ } ) }
10491151 >
1050- { line . text || '' }
1152+ { text }
10511153 </ div >
10521154 )
10531155 }
0 commit comments