Skip to content

Commit 261899c

Browse files
authored
fix(PrompterView): prevent jumping/scrolling to undesired positions (Sofie-Automation#1615)
* fix(EAV-693): prevent jumping when script is continued as an infinite into another part * fix(EAV-693): freeze prompter content during scrollTo this prevents from restoring scroll anchors during pending animations, which led to the content stopping at an undesired position * refactor(EAV-693): improve context typing
1 parent 7818483 commit 261899c

2 files changed

Lines changed: 149 additions & 36 deletions

File tree

packages/webui/src/client/ui/Prompter/PrompterView.tsx

Lines changed: 137 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { PropsWithChildren } from 'react'
1+
import React, { createContext, PropsWithChildren, ReactNode, useRef } from 'react'
22
import _ from 'underscore'
33
import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist'
44
import ClassNames from 'classnames'
@@ -39,6 +39,9 @@ import { MeteorCall } from '../../lib/meteorApi.js'
3939

4040
const DEFAULT_UPDATE_THROTTLE = 250 //ms
4141
const PIECE_MISSING_UPDATE_THROTTLE = 2000 //ms
42+
const FROZEN_UPDATE_THROTTLE = 50 //ms
43+
44+
const PIECE_CONTINUATION_CLASS = 'continuation'
4245

4346
interface 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+
123140
export 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
}
683713
type 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
}

packages/webui/src/client/ui/Prompter/prompter.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export interface PrompterDataPart {
5656
export interface PrompterDataPiece {
5757
id: PieceId
5858
text: string
59+
continuationOf?: PieceId
60+
startPartId?: PartId | null
5961
}
6062
export interface PrompterData {
6163
title: string
@@ -264,7 +266,16 @@ export namespace PrompterAPI {
264266

265267
const content = piece.content as ScriptContent
266268
if (!content.fullScript) continue
267-
if (piecesIncluded.indexOf(piece._id) >= 0) continue // piece already included in prompter script
269+
if (piecesIncluded.indexOf(piece._id) >= 0) {
270+
// piece already included in prompter script - mark it as a continuation
271+
partData.pieces.push({
272+
id: protectString(`${partData.id}_${piece._id}_continuation`),
273+
text: content.fullScript,
274+
continuationOf: piece._id,
275+
startPartId: piece.startPartId,
276+
})
277+
continue
278+
}
268279

269280
piecesIncluded.push(piece._id)
270281
partData.pieces.push({

0 commit comments

Comments
 (0)