diff --git a/packages/blueprints-integration/src/ingest.ts b/packages/blueprints-integration/src/ingest.ts index 057a20a9da..d5a885d23e 100644 --- a/packages/blueprints-integration/src/ingest.ts +++ b/packages/blueprints-integration/src/ingest.ts @@ -124,11 +124,29 @@ export interface UserOperationTarget { pieceExternalId: string | undefined } -export type DefaultUserOperations = { - id: '__sofie-move-segment' // Future: define properly +export enum SofieUserOperations { + MoveSegment = '__sofie-move-segment', + RetimePiece = '__sofie-retime-piece', +} + +export type DefaultUserOperations = SofieUserOperationMoveSegment | SofieUserOperationRetimePiece + +export type SofieUserOperationMoveSegment = { + id: SofieUserOperations.MoveSegment payload: Record } +export type SofieUserOperationRetimePiece = { + id: SofieUserOperations.RetimePiece + payload: { + segmentExternalId: string + partExternalId: string + + inPoint: number + // note - at some point this could also include an updated duration + } +} + export interface UserOperationChange { /** Indicate that this change is from user operations */ source: IngestChangeType.User diff --git a/packages/blueprints-integration/src/userEditing.ts b/packages/blueprints-integration/src/userEditing.ts index b199341768..ac82aefe2b 100644 --- a/packages/blueprints-integration/src/userEditing.ts +++ b/packages/blueprints-integration/src/userEditing.ts @@ -1,6 +1,7 @@ import { JSONBlob } from '@sofie-automation/shared-lib/dist/lib/JSONBlob' import type { ITranslatableMessage } from './translations' import { JSONSchema } from '@sofie-automation/shared-lib/dist/lib/JSONSchemaTypes' +import { SofieUserOperations } from './ingest' /** * Description of a user performed editing operation allowed on an document @@ -37,9 +38,20 @@ export interface UserEditingDefinitionForm { currentValues: Record } +/** + * A Sofie based Rich UX user operation + */ +export interface UserEditingDefinitionSofieDefault { + type: UserEditingType.SOFIE + /** Id of this operation */ + id: SofieUserOperations +} + export enum UserEditingType { /** Action */ ACTION = 'action', /** Form of selections */ FORM = 'form', + /** Operation for the Built-in Sofie Rich Editing UI */ + SOFIE = 'sofie', } diff --git a/packages/corelib/src/dataModel/UserEditingDefinitions.ts b/packages/corelib/src/dataModel/UserEditingDefinitions.ts index 4930fbfbda..5e863a3ae4 100644 --- a/packages/corelib/src/dataModel/UserEditingDefinitions.ts +++ b/packages/corelib/src/dataModel/UserEditingDefinitions.ts @@ -1,7 +1,15 @@ -import type { UserEditingType, JSONBlob, JSONSchema } from '@sofie-automation/blueprints-integration' +import type { + UserEditingType, + JSONBlob, + JSONSchema, + SofieUserOperations, +} from '@sofie-automation/blueprints-integration' import type { ITranslatableMessage } from '../TranslatableMessage' -export type CoreUserEditingDefinition = CoreUserEditingDefinitionAction | CoreUserEditingDefinitionForm +export type CoreUserEditingDefinition = + | CoreUserEditingDefinitionAction + | CoreUserEditingDefinitionForm + | CoreUserEditingDefinitionSofie export interface CoreUserEditingDefinitionAction { type: UserEditingType.ACTION @@ -28,3 +36,9 @@ export interface CoreUserEditingDefinitionForm { /** Translation namespaces to use when rendering this form */ translationNamespaces: string[] } + +export interface CoreUserEditingDefinitionSofie { + type: UserEditingType.SOFIE + /** Id of this operation */ + id: SofieUserOperations +} diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index 021c09e9f6..263e1f71f6 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -169,6 +169,7 @@ import { UserPermissionsContext, UserPermissions } from './UserPermissions' import * as RundownResolver from '../lib/RundownResolver' import { MAGIC_TIME_SCALE_FACTOR } from './SegmentTimeline/Constants' +import { DragContextProvider } from './RundownView/DragContextProvider' const REHEARSAL_MARGIN = 1 * 60 * 1000 const HIDE_NOTIFICATIONS_AFTER_MOUNT: number | undefined = 5000 @@ -3007,221 +3008,222 @@ const RundownViewContent = translateWithTracker -
- {this.renderSegmentsList()} - - {this.props.matchedSegments && - this.props.matchedSegments.length > 0 && - this.props.userPermissions.studio && } - - - r._id)} - firstRundown={this.props.rundowns[0]} - onActivate={this.onActivate} - userPermissions={this.props.userPermissions} - inActiveRundownView={this.props.inActiveRundownView} - currentRundown={this.state.currentRundown || this.props.rundowns[0]} - layout={this.state.rundownHeaderLayout} - showStyleBase={showStyleBase} - showStyleVariant={showStyleVariant} - /> - - - {this.props.userPermissions.studio && !Settings.disableBlurBorder && ( - -
-
- )} -
- - - - {this.renderSorensenContext()} - - - {this.state.isNotificationsCenterOpen && ( - + +
+ {this.renderSegmentsList()} + + {this.props.matchedSegments && + this.props.matchedSegments.length > 0 && + this.props.userPermissions.studio && } + + + r._id)} + firstRundown={this.props.rundowns[0]} + onActivate={this.onActivate} + userPermissions={this.props.userPermissions} + inActiveRundownView={this.props.inActiveRundownView} + currentRundown={this.state.currentRundown || this.props.rundowns[0]} + layout={this.state.rundownHeaderLayout} + showStyleBase={showStyleBase} + showStyleVariant={showStyleVariant} + /> + + + {this.props.userPermissions.studio && !Settings.disableBlurBorder && ( + +
+
)} - - - {this.state.isSupportPanelOpen && ( - -
- -
- - {t('Take a Snapshot')} - -
- {this.props.userPermissions.studio && ( - <> - -
- - )} - {this.props.userPermissions.studio && - this.props.casparCGPlayoutDevices && - this.props.casparCGPlayoutDevices.map((i) => ( - - +
+ + {t('Take a Snapshot')} + +
+ {this.props.userPermissions.studio && ( + <> +
-
- ))} -
+ + )} + {this.props.userPermissions.studio && + this.props.casparCGPlayoutDevices && + this.props.casparCGPlayoutDevices.map((i) => ( + + +
+
+ ))} + + )} +
+
+ + {this.props.userPermissions.studio && ( + )} - - - - {this.props.userPermissions.studio && ( - + + + + + - )} - - - - - - - - - {this.state.isClipTrimmerOpen && - this.state.selectedPiece && - RundownUtils.isPieceInstance(this.state.selectedPiece) && - (selectedPieceRundown === undefined ? ( - this.setState({ selectedPiece: undefined })} - title={t('Rundown not found')} - acceptText={t('Close')} - > - {t('Rundown for piece "{{pieceLabel}}" could not be found.', { - pieceLabel: this.state.selectedPiece.instance.piece.name, - })} - - ) : ( - this.setState({ isClipTrimmerOpen: false })} - /> - ))} - - - - - - - - - {this.props.playlist && this.props.studio && this.props.showStyleBase && ( - - )} - -
- { - // USE IN CASE OF DEBUGGING EMERGENCY - /* getDeveloperMode() &&
+ + {this.state.isClipTrimmerOpen && + this.state.selectedPiece && + RundownUtils.isPieceInstance(this.state.selectedPiece) && + (selectedPieceRundown === undefined ? ( + this.setState({ selectedPiece: undefined })} + title={t('Rundown not found')} + acceptText={t('Close')} + > + {t('Rundown for piece "{{pieceLabel}}" could not be found.', { + pieceLabel: this.state.selectedPiece.instance.piece.name, + })} + + ) : ( + this.setState({ isClipTrimmerOpen: false })} + /> + ))} + + + + + + + + + {this.props.playlist && this.props.studio && this.props.showStyleBase && ( + + )} + +
+ { + // USE IN CASE OF DEBUGGING EMERGENCY + /* getDeveloperMode() &&
*/ - } + } +
) diff --git a/packages/webui/src/client/ui/RundownView/DragContext.ts b/packages/webui/src/client/ui/RundownView/DragContext.ts new file mode 100644 index 0000000000..c47a03bb9a --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/DragContext.ts @@ -0,0 +1,39 @@ +import { PartInstanceId, PieceInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { createContext } from 'react' +import { PieceUi } from '../SegmentContainer/withResolvedSegment' + +export interface IDragContext { + /** + * Indicate a drag operation on a piece has started + * @param piece The piece that is being dragged + * @param timeScale The current TimeScale of the segment + * @param position The position of the mouse + * @param elementOffset The x-coordinate of the element relative to the mouse position + * @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) + */ + startDrag: ( + piece: PieceUi, + timeScale: number, + position: { x: number; y: number }, + elementOffset?: number, + limitToSegment?: SegmentId + ) => void + /** + * Indicate the part the mouse is on has changed + * @param partId The part id that the mouse is currently hovering on + * @param segmentId The segment the part currenly hover is in + * @param position The position of the part in absolute coords to the screen + */ + setHoveredPart: (partId: PartInstanceId, segmentId: SegmentId, position: { x: number; y: number }) => void + + /** + * PieceId of the piece that is being dragged + */ + pieceId: undefined | PieceInstanceId + /** + * The piece with any local overrides coming from dragging it around (i.e. changed renderedInPoint) + */ + piece: undefined | PieceUi +} + +export const dragContext = createContext(undefined) // slay. diff --git a/packages/webui/src/client/ui/RundownView/DragContextProvider.tsx b/packages/webui/src/client/ui/RundownView/DragContextProvider.tsx new file mode 100644 index 0000000000..27b4adccc8 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/DragContextProvider.tsx @@ -0,0 +1,136 @@ +import { PartInstanceId, PieceInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PropsWithChildren, useRef, useState } from 'react' +import { dragContext } from './DragContext' +import { PieceUi } from '../SegmentContainer/withResolvedSegment' +import { doUserAction, UserAction } from '../../lib/clientUserAction' +import { MeteorCall } from '../../lib/meteorApi' +import { TFunction } from 'i18next' +import { UIParts } from '../Collections' +import { Segments } from '../../collections' + +const DRAG_TIMEOUT = 10000 + +interface Props { + t: TFunction +} + +// 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 +export function DragContextProvider({ t, children }: PropsWithChildren): JSX.Element { + const [pieceId, setPieceId] = useState(undefined) + const [piece, setPiece] = useState(undefined) + + const partIdRef = useRef(undefined) + const positionRef = useRef({ x: 0, y: 0 }) + const segmentIdRef = useRef(undefined) + + const startDrag = ( + ogPiece: PieceUi, + timeScale: number, + pos: { x: number; y: number }, + elementOffset?: number, + limitToSegment?: SegmentId + ) => { + if (pieceId) return // a drag is currently in progress.... + + const inPoint = ogPiece.renderedInPoint ?? 0 + segmentIdRef.current = limitToSegment + positionRef.current = pos + setPieceId(ogPiece.instance._id) + + let localPiece = ogPiece // keep a copy of the overriden piece because react does not let us access the state of the context easily + + const onMove = (e: MouseEvent) => { + const newInPoint = + (!partIdRef.current ? inPoint : (elementOffset ?? 0) / timeScale) + + (e.clientX - positionRef.current.x) / timeScale + + localPiece = { + ...ogPiece, + instance: { ...ogPiece.instance, partInstanceId: partIdRef.current ?? ogPiece.instance.partInstanceId }, + renderedInPoint: newInPoint, + } + setPiece(localPiece) + } + + const cleanup = () => { + // unset state - note: for ux reasons this runs after the backend operation has returned a result + setPieceId(undefined) + setPiece(undefined) + partIdRef.current = undefined + segmentIdRef.current = undefined + } + + const onMouseUp = (e: MouseEvent) => { + // detach from the mouse + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onMouseUp) + + // process the drag + if (!localPiece) return cleanup() + + // find the new part so we can get it's externalId + const startPartId = localPiece.instance.piece.startPartId // this could become a funny thing with infinites + const part = UIParts.findOne(startPartId) + if (!part) return cleanup() // tough to continue without a parent for the piece + + // find the new Segment's External ID + const segment = Segments.findOne(part?.segmentId) + + // note - should we be nice and look up the segment and part as well? + // if we do, should we put the old part/segment here or the new one? + // probably old - we target the piece (which is in the old part) and move it to the new part + const operationTarget = { + segmentExternalId: undefined, + partExternalId: undefined, + pieceExternalId: ogPiece.instance.piece.externalId, + } + doUserAction( + t, + e, + UserAction.EXECUTE_USER_OPERATION, + (e, ts) => + MeteorCall.userAction.executeUserChangeOperation(e, ts, part.rundownId, operationTarget, { + id: '__sofie-retime-piece', + payload: { + segmentExternalId: segment?.externalId, + partExternalId: part.externalId, + + inPoint: localPiece.renderedInPoint, + }, + }), + () => { + cleanup() + } + ) + } + + document.addEventListener('mousemove', onMove) + document.addEventListener('mouseup', onMouseUp) + + setTimeout(() => { + // after the timeout we want to bail out in case something went wrong + document.removeEventListener('mousemove', onMove) + document.removeEventListener('mouseup', onMouseUp) + + cleanup() + }, DRAG_TIMEOUT) + } + const setHoveredPart = (updatedPartId: PartInstanceId, segmentId: SegmentId, pos: { x: number; y: number }) => { + if (!pieceId) return + if (updatedPartId === piece?.instance.partInstanceId) return + if (segmentIdRef.current && segmentIdRef.current !== segmentId) return + + partIdRef.current = updatedPartId + positionRef.current = pos + } + + const ctx = { + pieceId, + piece, + + startDrag, + setHoveredPart, + } + + return {children} +} diff --git a/packages/webui/src/client/ui/SegmentTimeline/Parts/SourceLayer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/SourceLayer.tsx index 4f3b7504ea..0d1ca22aa4 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Parts/SourceLayer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Parts/SourceLayer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import React, { MouseEventHandler, useCallback, useContext, useState } from 'react' import _ from 'underscore' import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { literal, unprotectString } from '../../../lib/tempLib' @@ -10,6 +10,7 @@ import { ContextMenuTrigger } from '@jstarpl/react-contextmenu' import { SourceLayerItemContainer } from '../SourceLayerItemContainer' import { contextMenuHoldToDisplayTime } from '../../../lib/lib' import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' +import { dragContext } from '../../RundownView/DragContext' export interface ISourceLayerPropsBase { key: string @@ -79,6 +80,19 @@ export function useMouseContext(props: ISourceLayerPropsBase): { export function SourceLayer(props: Readonly): JSX.Element { const { getPartContext, onMouseDown } = useMouseContext(props) + const dragCtx = useContext(dragContext) + + const pieces = + dragCtx?.piece && dragCtx.piece.sourceLayer?._id === props.layer._id + ? (props.layer.pieces ?? []).filter((p) => p.instance._id !== dragCtx.piece?.instance._id).concat(dragCtx.piece) + : props.layer.pieces + + const onMouseEnter: MouseEventHandler = (e) => { + if (!dragCtx) return + + const pos = (e.target as HTMLDivElement).getBoundingClientRect() // ugly cast here because the event handler doesn't cast for us + dragCtx.setHoveredPart(props.part.instance._id, props.segment._id, { x: pos.x, y: pos.y }) + } return ( ): JSX.Element { //@ts-expect-error A Data attribue is perfectly fine 'data-layer-id': props.layer._id, onMouseDownCapture: (e) => onMouseDown(e), + onMouseEnter, role: 'log', 'aria-live': 'assertive', 'aria-label': props.layer.name, @@ -95,9 +110,9 @@ export function SourceLayer(props: Readonly): JSX.Element { holdToDisplay={contextMenuHoldToDisplayTime()} collect={getPartContext} > - {props.layer.pieces !== undefined + {pieces !== undefined ? _.chain( - props.layer.pieces.filter((piece) => { + pieces.filter((piece) => { // filter only pieces belonging to this part return piece.instance.partInstanceId === props.part.instance._id ? // filter only pieces, that have not been hidden from the UI diff --git a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx index 3324581799..2f251c0dfa 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx @@ -1,6 +1,12 @@ import * as React from 'react' import { ISourceLayerUi, IOutputLayerUi, PartUi, PieceUi } from './SegmentTimelineContainer' -import { SourceLayerType, PieceLifespan, IBlueprintPieceType } from '@sofie-automation/blueprints-integration' +import { + SourceLayerType, + PieceLifespan, + IBlueprintPieceType, + UserEditingType, + SofieUserOperations, +} from '@sofie-automation/blueprints-integration' import { RundownUtils } from '../../lib/rundown' import { DefaultLayerItemRenderer } from './Renderers/DefaultLayerItemRenderer' import { MicSourceRenderer } from './Renderers/MicSourceRenderer' @@ -21,7 +27,8 @@ import { TransitionSourceRenderer } from './Renderers/TransitionSourceRenderer' import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { ReadonlyDeep } from 'type-fest' import { PieceContentStatusObj } from '@sofie-automation/meteor-lib/dist/api/pieceContentStatus' -import { useCallback, useRef, useState, useEffect } from 'react' +import { useCallback, useRef, useState, useEffect, useContext } from 'react' +import { dragContext } from '../RundownView/DragContext' const LEFT_RIGHT_ANCHOR_SPACER = 15 const MARGINAL_ANCHORED_WIDTH = 5 @@ -110,6 +117,8 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele const [leftAnchoredWidth, setLeftAnchoredWidth] = useState(0) const [rightAnchoredWidth, setRightAnchoredWidth] = useState(0) + const dragCtx = useContext(dragContext) + const state = { highlight, showMiniInspector, @@ -168,10 +177,33 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele }, [piece] ) - const itemMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - }, []) + const itemMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + if ( + !piece.instance.piece.userEditOperations?.find( + (op) => op.type === UserEditingType.SOFIE && op.id === SofieUserOperations.RetimePiece + ) + ) + return + + const targetPos = (e.target as HTMLDivElement).getBoundingClientRect() + if (dragCtx) + dragCtx.startDrag( + piece, + timeScale, + { + x: e.clientX, + y: e.clientY, + }, + targetPos.x - e.clientX, + part.instance.segmentId + ) + }, + [piece, timeScale] + ) const itemMouseUp = useCallback((e: any) => { const eM = e as MouseEvent if (eM.ctrlKey === true) {