Skip to content

Commit 1284f9f

Browse files
author
Mint de Wit
committed
feat: retime piece user action
1 parent f7a4fe7 commit 1284f9f

File tree

10 files changed

+312
-19
lines changed

10 files changed

+312
-19
lines changed

packages/blueprints-integration/src/ingest.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export enum DefaultUserOperationsTypes {
129129
REVERT_PART = '__sofie-revert-part',
130130
REVERT_RUNDOWN = '__sofie-revert-rundown',
131131
UPDATE_PROPS = '__sofie-update-props',
132+
RETIME_PIECE = '__sofie-retime-piece',
132133
}
133134

134135
export interface DefaultUserOperationRevertRundown {
@@ -153,6 +154,17 @@ export interface DefaultUserOperationEditProperties {
153154
}
154155
}
155156

157+
export type DefaultUserOperationRetimePiece = {
158+
id: DefaultUserOperationsTypes.RETIME_PIECE
159+
payload: {
160+
segmentExternalId: string
161+
partExternalId: string
162+
163+
inPoint: number
164+
// note - at some point this could also include an updated duration
165+
}
166+
}
167+
156168
export type DefaultUserOperations =
157169
| DefaultUserOperationRevertRundown
158170
| DefaultUserOperationRevertSegment

packages/blueprints-integration/src/userEditing.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ import type { JSONBlob } from '@sofie-automation/shared-lib/dist/lib/JSONBlob'
22
import type { ITranslatableMessage } from './translations'
33
import { JSONSchema } from '@sofie-automation/shared-lib/dist/lib/JSONSchemaTypes'
44
import { SourceLayerType } from './content'
5+
import { DefaultUserOperationsTypes } from './ingest'
56

67
/**
78
* Description of a user performed editing operation allowed on an document
89
*/
9-
export type UserEditingDefinition = UserEditingDefinitionAction | UserEditingDefinitionForm
10+
export type UserEditingDefinition =
11+
| UserEditingDefinitionAction
12+
| UserEditingDefinitionForm
13+
| UserEditingDefinitionSofieDefault
1014

1115
/**
1216
* A simple 'action' that can be performed
@@ -40,11 +44,22 @@ export interface UserEditingDefinitionForm {
4044
currentValues: Record<string, any>
4145
}
4246

47+
/**
48+
* A built in Sofie User operation
49+
*/
50+
export interface UserEditingDefinitionSofieDefault {
51+
type: UserEditingType.SOFIE
52+
/** Id of this operation */
53+
id: DefaultUserOperationsTypes
54+
}
55+
4356
export enum UserEditingType {
4457
/** Action */
4558
ACTION = 'action',
4659
/** Form */
4760
FORM = 'form',
61+
/** Operation for the Built-in Sofie Rich Editing UI */
62+
SOFIE = 'sofie',
4863
}
4964

5065
export interface UserEditingSourceLayer {

packages/corelib/src/dataModel/UserEditingDefinitions.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ import type {
33
JSONBlob,
44
JSONSchema,
55
UserEditingSourceLayer,
6+
DefaultUserOperationsTypes,
67
} from '@sofie-automation/blueprints-integration'
78
import type { ITranslatableMessage } from '../TranslatableMessage'
89

9-
export type CoreUserEditingDefinition = CoreUserEditingDefinitionAction | CoreUserEditingDefinitionForm
10+
export type CoreUserEditingDefinition =
11+
| CoreUserEditingDefinitionAction
12+
| CoreUserEditingDefinitionForm
13+
| CoreUserEditingDefinitionSofie
1014

1115
export interface CoreUserEditingDefinitionAction {
1216
type: UserEditingType.ACTION
@@ -83,3 +87,9 @@ export interface CoreUserEditingProperties {
8387
/** Translation namespaces to use when rendering this form */
8488
translationNamespaces: string[]
8589
}
90+
91+
export interface CoreUserEditingDefinitionSofie {
92+
type: UserEditingType.SOFIE
93+
/** Id of this operation */
94+
id: DefaultUserOperationsTypes
95+
}

packages/job-worker/src/blueprints/context/lib.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
UserEditingDefinition,
5858
UserEditingDefinitionAction,
5959
UserEditingDefinitionForm,
60+
UserEditingDefinitionSofieDefault,
6061
UserEditingProperties,
6162
UserEditingType,
6263
} from '@sofie-automation/blueprints-integration/dist/userEditing'
@@ -516,22 +517,27 @@ function translateUserEditsToBlueprint(
516517
userEdits.map((userEdit) => {
517518
switch (userEdit.type) {
518519
case UserEditingType.ACTION:
519-
return {
520+
return literal<UserEditingDefinitionAction>({
520521
type: UserEditingType.ACTION,
521522
id: userEdit.id,
522523
label: omit(userEdit.label, 'namespaces'),
523524
svgIcon: userEdit.svgIcon,
524525
svgIconInactive: userEdit.svgIconInactive,
525526
isActive: userEdit.isActive,
526-
} satisfies Complete<UserEditingDefinitionAction>
527+
})
527528
case UserEditingType.FORM:
528-
return {
529+
return literal<UserEditingDefinitionForm>({
529530
type: UserEditingType.FORM,
530531
id: userEdit.id,
531532
label: omit(userEdit.label, 'namespaces'),
532533
schema: clone(userEdit.schema),
533534
currentValues: clone(userEdit.currentValues),
534-
} satisfies Complete<UserEditingDefinitionForm>
535+
})
536+
case UserEditingType.SOFIE:
537+
return literal<UserEditingDefinitionSofieDefault>({
538+
type: UserEditingType.SOFIE,
539+
id: userEdit.id,
540+
})
535541
default:
536542
assertNever(userEdit)
537543
return undefined
@@ -573,23 +579,28 @@ export function translateUserEditsFromBlueprint(
573579
userEdits.map((userEdit) => {
574580
switch (userEdit.type) {
575581
case UserEditingType.ACTION:
576-
return {
582+
return literal<CoreUserEditingDefinitionAction>({
577583
type: UserEditingType.ACTION,
578584
id: userEdit.id,
579585
label: wrapTranslatableMessageFromBlueprints(userEdit.label, blueprintIds),
580586
svgIcon: userEdit.svgIcon,
581587
svgIconInactive: userEdit.svgIconInactive,
582588
isActive: userEdit.isActive,
583-
} satisfies Complete<CoreUserEditingDefinitionAction>
589+
})
584590
case UserEditingType.FORM:
585-
return {
591+
return literal<CoreUserEditingDefinitionForm>({
586592
type: UserEditingType.FORM,
587593
id: userEdit.id,
588594
label: wrapTranslatableMessageFromBlueprints(userEdit.label, blueprintIds),
589595
schema: clone(userEdit.schema),
590596
currentValues: clone(userEdit.currentValues),
591597
translationNamespaces: unprotectStringArray(blueprintIds),
592-
} satisfies Complete<CoreUserEditingDefinitionForm>
598+
})
599+
case UserEditingType.SOFIE:
600+
return literal<UserEditingDefinitionSofieDefault>({
601+
type: UserEditingType.SOFIE,
602+
id: userEdit.id,
603+
})
593604
default:
594605
assertNever(userEdit)
595606
return undefined

packages/webui/src/client/ui/RundownView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ import * as RundownResolver from '../lib/RundownResolver'
172172
import { MAGIC_TIME_SCALE_FACTOR } from './SegmentTimeline/Constants'
173173
import { SelectedElementProvider, SelectedElementsContext } from './RundownView/SelectedElementsContext'
174174
import { PropertiesPanel } from './UserEditOperations/PropertiesPanel'
175+
import { DragContextProvider } from './RundownView/DragContextProvider'
175176

176177
const REHEARSAL_MARGIN = 1 * 60 * 1000
177178
const HIDE_NOTIFICATIONS_AFTER_MOUNT: number | undefined = 5000
@@ -3018,6 +3019,7 @@ const RundownViewContent = translateWithTracker<IPropsWithReady, IState, ITracke
30183019
return (
30193020
<RundownTimingProvider playlist={playlist} defaultDuration={Settings.defaultDisplayDuration}>
30203021
<StudioContext.Provider value={studio}>
3022+
<DragContextProvider t={t}>
30213023
<SelectedElementProvider>
30223024
<SelectedElementsContext.Consumer>
30233025
{(selectionContext) => {
@@ -3266,6 +3268,7 @@ const RundownViewContent = translateWithTracker<IPropsWithReady, IState, ITracke
32663268
}
32673269
</SelectedElementsContext.Consumer>
32683270
</SelectedElementProvider>
3271+
</DragContextProvider>
32693272
</StudioContext.Provider>
32703273
</RundownTimingProvider>
32713274
)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { PartInstanceId, PieceInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids'
2+
import { createContext } from 'react'
3+
import { PieceUi } from '../SegmentContainer/withResolvedSegment'
4+
5+
export interface IDragContext {
6+
/**
7+
* Indicate a drag operation on a piece has started
8+
* @param piece The piece that is being dragged
9+
* @param timeScale The current TimeScale of the segment
10+
* @param position The position of the mouse
11+
* @param elementOffset The x-coordinate of the element relative to the mouse position
12+
* @param limitToSegment Whether the piece can be dragged to other segments (note: if the other segment does not have the right source layer the piece will look to have disappeared... consider omitting this is a todo)
13+
*/
14+
startDrag: (
15+
piece: PieceUi,
16+
timeScale: number,
17+
position: { x: number; y: number },
18+
elementOffset?: number,
19+
limitToSegment?: SegmentId
20+
) => void
21+
/**
22+
* Indicate the part the mouse is on has changed
23+
* @param partId The part id that the mouse is currently hovering on
24+
* @param segmentId The segment the part currenly hover is in
25+
* @param position The position of the part in absolute coords to the screen
26+
*/
27+
setHoveredPart: (partId: PartInstanceId, segmentId: SegmentId, position: { x: number; y: number }) => void
28+
29+
/**
30+
* PieceId of the piece that is being dragged
31+
*/
32+
pieceId: undefined | PieceInstanceId
33+
/**
34+
* The piece with any local overrides coming from dragging it around (i.e. changed renderedInPoint)
35+
*/
36+
piece: undefined | PieceUi
37+
}
38+
39+
export const dragContext = createContext<IDragContext | undefined>(undefined) // slay.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { PartInstanceId, PieceInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids'
2+
import { PropsWithChildren, useRef, useState } from 'react'
3+
import { dragContext, IDragContext } from './DragContext'
4+
import { PieceUi } from '../SegmentContainer/withResolvedSegment'
5+
import { doUserAction, UserAction } from '../../lib/clientUserAction'
6+
import { MeteorCall } from '../../lib/meteorApi'
7+
import { TFunction } from 'i18next'
8+
import { UIParts } from '../Collections'
9+
import { Segments } from '../../collections'
10+
import { literal } from '../../lib/tempLib'
11+
import { DefaultUserOperationRetimePiece, DefaultUserOperationsTypes } from '@sofie-automation/blueprints-integration'
12+
13+
const DRAG_TIMEOUT = 10000
14+
15+
interface Props {
16+
t: TFunction
17+
}
18+
19+
// notes: this doesn't limit dragging between rundowns right now but I'm not sure if the ingest stage will be happy with that - mint
20+
export function DragContextProvider({ t, children }: PropsWithChildren<Props>): JSX.Element {
21+
const [pieceId, setPieceId] = useState<undefined | PieceInstanceId>(undefined)
22+
const [piece, setPiece] = useState<undefined | PieceUi>(undefined)
23+
24+
const partIdRef = useRef<undefined | PartInstanceId>(undefined)
25+
const positionRef = useRef({ x: 0, y: 0 })
26+
const segmentIdRef = useRef<undefined | SegmentId>(undefined)
27+
28+
const startDrag = (
29+
ogPiece: PieceUi,
30+
timeScale: number,
31+
pos: { x: number; y: number },
32+
elementOffset?: number,
33+
limitToSegment?: SegmentId
34+
) => {
35+
if (pieceId) return // a drag is currently in progress....
36+
37+
const inPoint = ogPiece.renderedInPoint ?? 0
38+
segmentIdRef.current = limitToSegment
39+
positionRef.current = pos
40+
setPieceId(ogPiece.instance._id)
41+
42+
let localPiece = ogPiece // keep a copy of the overriden piece because react does not let us access the state of the context easily
43+
44+
const onMove = (e: MouseEvent) => {
45+
const newInPoint =
46+
(!partIdRef.current ? inPoint : (elementOffset ?? 0) / timeScale) +
47+
(e.clientX - positionRef.current.x) / timeScale
48+
49+
localPiece = {
50+
...ogPiece,
51+
instance: { ...ogPiece.instance, partInstanceId: partIdRef.current ?? ogPiece.instance.partInstanceId },
52+
renderedInPoint: newInPoint,
53+
}
54+
setPiece(localPiece)
55+
}
56+
57+
const cleanup = () => {
58+
// unset state - note: for ux reasons this runs after the backend operation has returned a result
59+
setPieceId(undefined)
60+
setPiece(undefined)
61+
partIdRef.current = undefined
62+
segmentIdRef.current = undefined
63+
}
64+
65+
const onMouseUp = (e: MouseEvent) => {
66+
// detach from the mouse
67+
document.removeEventListener('mousemove', onMove)
68+
document.removeEventListener('mouseup', onMouseUp)
69+
70+
// process the drag
71+
if (!localPiece || localPiece.renderedInPoint === ogPiece.renderedInPoint) return cleanup()
72+
73+
// find the parts so we can get their externalId
74+
const startPartId = localPiece.instance.piece.startPartId // this could become a funny thing with infinites
75+
const part = UIParts.findOne(startPartId)
76+
const oldPart =
77+
startPartId === ogPiece.instance.piece.startPartId ? part : UIParts.findOne(ogPiece.instance.piece.startPartId)
78+
if (!part) return cleanup() // tough to continue without a parent for the piece
79+
80+
// find the Segment's External ID
81+
const segment = Segments.findOne(part?.segmentId)
82+
const oldSegment = part?.segmentId === oldPart?.segmentId ? segment : Segments.findOne(oldPart?.segmentId)
83+
if (!segment) return
84+
85+
const operationTarget = {
86+
segmentExternalId: oldSegment?.externalId,
87+
partExternalId: oldPart?.externalId,
88+
pieceExternalId: ogPiece.instance.piece.externalId,
89+
}
90+
doUserAction(
91+
t,
92+
e,
93+
UserAction.EXECUTE_USER_OPERATION,
94+
(e, ts) =>
95+
MeteorCall.userAction.executeUserChangeOperation(
96+
e,
97+
ts,
98+
part.rundownId,
99+
operationTarget,
100+
literal<DefaultUserOperationRetimePiece>({
101+
id: DefaultUserOperationsTypes.RETIME_PIECE,
102+
payload: {
103+
segmentExternalId: segment.externalId,
104+
partExternalId: part.externalId,
105+
106+
inPoint: localPiece.renderedInPoint ?? inPoint,
107+
},
108+
})
109+
),
110+
() => {
111+
cleanup()
112+
}
113+
)
114+
}
115+
116+
document.addEventListener('mousemove', onMove)
117+
document.addEventListener('mouseup', onMouseUp)
118+
119+
setTimeout(() => {
120+
// after the timeout we want to bail out in case something went wrong
121+
document.removeEventListener('mousemove', onMove)
122+
document.removeEventListener('mouseup', onMouseUp)
123+
124+
cleanup()
125+
}, DRAG_TIMEOUT)
126+
}
127+
const setHoveredPart = (updatedPartId: PartInstanceId, segmentId: SegmentId, pos: { x: number; y: number }) => {
128+
if (!pieceId) return
129+
if (updatedPartId === piece?.instance.partInstanceId) return
130+
if (segmentIdRef.current && segmentIdRef.current !== segmentId) return
131+
132+
partIdRef.current = updatedPartId
133+
positionRef.current = pos
134+
}
135+
136+
const ctx = literal<IDragContext>({
137+
pieceId,
138+
piece,
139+
140+
startDrag,
141+
setHoveredPart,
142+
})
143+
144+
return <dragContext.Provider value={ctx}>{children}</dragContext.Provider>
145+
}

0 commit comments

Comments
 (0)