diff --git a/meteor/server/publications/partInstancesUI/reactiveContentCache.ts b/meteor/server/publications/partInstancesUI/reactiveContentCache.ts index 7a35485814..66e1e0658e 100644 --- a/meteor/server/publications/partInstancesUI/reactiveContentCache.ts +++ b/meteor/server/publications/partInstancesUI/reactiveContentCache.ts @@ -5,7 +5,6 @@ import { MongoFieldSpecifierOnesStrict, MongoFieldSpecifierZeroes } from '@sofie import { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist' import { DBStudio, IStudioSettings } from '@sofie-automation/corelib/dist/dataModel/Studio' import { DBPartInstance } from '@sofie-automation/corelib/dist/dataModel/PartInstance' -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' import { StudioId } from '@sofie-automation/corelib/dist/dataModel/Ids' import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' diff --git a/packages/blueprints-integration/src/ingest.ts b/packages/blueprints-integration/src/ingest.ts index 5a96109936..dd97b3c361 100644 --- a/packages/blueprints-integration/src/ingest.ts +++ b/packages/blueprints-integration/src/ingest.ts @@ -130,6 +130,7 @@ export enum DefaultUserOperationsTypes { REVERT_RUNDOWN = '__sofie-revert-rundown', UPDATE_PROPS = '__sofie-update-props', IMPORT_MOS_ITEM = '__sofie-import-mos', + RETIME_PIECE = '__sofie-retime-piece', } export interface DefaultUserOperationRevertRundown { @@ -161,12 +162,24 @@ export type DefaultUserOperationImportMOSItem = { payload: any } +export type DefaultUserOperationRetimePiece = { + id: DefaultUserOperationsTypes.RETIME_PIECE + payload: { + segmentExternalId: string + partExternalId: string + + inPoint: number + // note - at some point this could also include an updated duration + } +} + export type DefaultUserOperations = | DefaultUserOperationRevertRundown | DefaultUserOperationRevertSegment | DefaultUserOperationRevertPart | DefaultUserOperationEditProperties | DefaultUserOperationImportMOSItem + | DefaultUserOperationRetimePiece export interface UserOperationChange { /** Indicate that this change is from user operations */ diff --git a/packages/blueprints-integration/src/triggers.ts b/packages/blueprints-integration/src/triggers.ts index 22691a309b..bcaf6279fb 100644 --- a/packages/blueprints-integration/src/triggers.ts +++ b/packages/blueprints-integration/src/triggers.ts @@ -272,6 +272,12 @@ export interface IShelfAction extends ITriggeredActionBase { filterChain: IGUIContextFilterLink[] } +export interface IEditModeAction extends ITriggeredActionBase { + action: ClientActions.editMode + state: true | false | 'toggle' + filterChain: IGUIContextFilterLink[] +} + export interface IGoToOnAirLineAction extends ITriggeredActionBase { action: ClientActions.goToOnAirLine filterChain: IGUIContextFilterLink[] @@ -325,6 +331,7 @@ export type SomeAction = | IRundownPlaylistResetAction | IRundownPlaylistResyncAction | IShelfAction + | IEditModeAction | IGoToOnAirLineAction | IRewindSegmentsAction | IShowEntireCurrentSegmentAction diff --git a/packages/corelib/src/dataModel/UserEditingDefinitions.ts b/packages/corelib/src/dataModel/UserEditingDefinitions.ts index c9ddfcf0dd..fd53660fb6 100644 --- a/packages/corelib/src/dataModel/UserEditingDefinitions.ts +++ b/packages/corelib/src/dataModel/UserEditingDefinitions.ts @@ -47,6 +47,12 @@ export interface CoreUserEditingDefinitionForm { translationNamespaces: string[] } +export interface CoreUserEditingDefinitionSofie { + type: UserEditingType.SOFIE + /** Id of this operation */ + id: DefaultUserOperationsTypes +} + export interface CoreUserEditingProperties { /** * These properties are dependent on the (primary) piece type, the user will get the option diff --git a/packages/job-worker/src/blueprints/context/lib.ts b/packages/job-worker/src/blueprints/context/lib.ts index b413c2c6d0..c56281b91e 100644 --- a/packages/job-worker/src/blueprints/context/lib.ts +++ b/packages/job-worker/src/blueprints/context/lib.ts @@ -551,27 +551,27 @@ function translateUserEditsToBlueprint( userEdits.map((userEdit) => { switch (userEdit.type) { case UserEditingType.ACTION: - return { + return literal({ type: UserEditingType.ACTION, id: userEdit.id, label: omit(userEdit.label, 'namespaces'), icon: userEdit.icon, iconInactive: userEdit.iconInactive, isActive: userEdit.isActive, - } satisfies Complete + }) case UserEditingType.FORM: - return { + return literal({ type: UserEditingType.FORM, id: userEdit.id, label: omit(userEdit.label, 'namespaces'), schema: clone(userEdit.schema), currentValues: clone(userEdit.currentValues), - } satisfies Complete + }) case UserEditingType.SOFIE: - return { + return literal({ type: UserEditingType.SOFIE, id: userEdit.id, - } satisfies Complete + }) default: assertNever(userEdit) return undefined @@ -613,28 +613,28 @@ export function translateUserEditsFromBlueprint( userEdits.map((userEdit) => { switch (userEdit.type) { case UserEditingType.ACTION: - return { + return literal({ type: UserEditingType.ACTION, id: userEdit.id, label: wrapTranslatableMessageFromBlueprints(userEdit.label, blueprintIds), icon: userEdit.icon, iconInactive: userEdit.iconInactive, isActive: userEdit.isActive, - } satisfies Complete + }) case UserEditingType.FORM: - return { + return literal({ type: UserEditingType.FORM, id: userEdit.id, label: wrapTranslatableMessageFromBlueprints(userEdit.label, blueprintIds), schema: clone(userEdit.schema), currentValues: clone(userEdit.currentValues), translationNamespaces: unprotectStringArray(blueprintIds), - } satisfies Complete + }) case UserEditingType.SOFIE: - return { + return literal({ type: UserEditingType.SOFIE, id: userEdit.id, - } satisfies Complete + }) default: assertNever(userEdit) return undefined diff --git a/packages/meteor-lib/src/triggers/RundownViewEventBus.ts b/packages/meteor-lib/src/triggers/RundownViewEventBus.ts index 60b575aeba..5cb11ce75c 100644 --- a/packages/meteor-lib/src/triggers/RundownViewEventBus.ts +++ b/packages/meteor-lib/src/triggers/RundownViewEventBus.ts @@ -29,6 +29,7 @@ export enum RundownViewEvents { REVEAL_IN_SHELF = 'revealInShelf', SWITCH_SHELF_TAB = 'switchShelfTab', SHELF_STATE = 'shelfState', + EDIT_MODE = 'editMode', MINI_SHELF_QUEUE_ADLIB = 'miniShelfQueueAdLib', GO_TO_PART = 'goToPart', GO_TO_PART_INSTANCE = 'goToPartInstance', @@ -74,6 +75,10 @@ export interface ShelfStateEvent extends IEventContext { state: boolean | 'toggle' } +export interface EditModeEvent extends IEventContext { + state: boolean | 'toggle' +} + export interface MiniShelfQueueAdLibEvent extends IEventContext { forward: boolean } @@ -139,6 +144,7 @@ export interface RundownViewEventBusEvents { [RundownViewEvents.SEGMENT_ZOOM_ON]: [] [RundownViewEvents.SEGMENT_ZOOM_OFF]: [] [RundownViewEvents.SHELF_STATE]: [e: ShelfStateEvent] + [RundownViewEvents.EDIT_MODE]: [e: EditModeEvent] [RundownViewEvents.REVEAL_IN_SHELF]: [e: RevealInShelfEvent] [RundownViewEvents.SWITCH_SHELF_TAB]: [e: SwitchToShelfTabEvent] [RundownViewEvents.MINI_SHELF_QUEUE_ADLIB]: [e: MiniShelfQueueAdLibEvent] diff --git a/packages/meteor-lib/src/triggers/actionFactory.ts b/packages/meteor-lib/src/triggers/actionFactory.ts index e7410ce173..2f5edfeefb 100644 --- a/packages/meteor-lib/src/triggers/actionFactory.ts +++ b/packages/meteor-lib/src/triggers/actionFactory.ts @@ -286,6 +286,17 @@ function createShelfAction(_filterChain: IGUIContextFilterLink[], state: boolean } } +function createEditModeAction(_filterChain: IGUIContextFilterLink[], state: boolean | 'toggle'): ExecutableAction { + return { + action: ClientActions.editMode, + execute: () => { + RundownViewEventBus.emit(RundownViewEvents.EDIT_MODE, { + state, + }) + }, + } +} + function createMiniShelfQueueAdLibAction(_filterChain: IGUIContextFilterLink[], forward: boolean): ExecutableAction { return { action: ClientActions.miniShelfQueueAdLib, @@ -442,6 +453,8 @@ export function createAction( switch (action.action) { case ClientActions.shelf: return createShelfAction(action.filterChain, action.state) + case ClientActions.editMode: + return createEditModeAction(action.filterChain, action.state) case ClientActions.goToOnAirLine: return createGoToOnAirLineAction(action.filterChain) case ClientActions.rewindSegments: diff --git a/packages/shared-lib/src/core/model/ShowStyle.ts b/packages/shared-lib/src/core/model/ShowStyle.ts index ee415a5be4..8c0d3d48be 100644 --- a/packages/shared-lib/src/core/model/ShowStyle.ts +++ b/packages/shared-lib/src/core/model/ShowStyle.ts @@ -107,6 +107,7 @@ export enum ClientActions { 'rewindSegments' = 'rewindSegments', 'showEntireCurrentSegment' = 'showEntireCurrentSegment', 'miniShelfQueueAdLib' = 'miniShelfQueueAdLib', + 'editMode' = 'editMode', } export enum DeviceActions { diff --git a/packages/webui/src/client/lib/ui/pieceUiClassNames.ts b/packages/webui/src/client/lib/ui/pieceUiClassNames.ts index 2b1149dbd0..0b5e350290 100644 --- a/packages/webui/src/client/lib/ui/pieceUiClassNames.ts +++ b/packages/webui/src/client/lib/ui/pieceUiClassNames.ts @@ -19,7 +19,8 @@ export function pieceUiClassNames( uiState?: { leftAnchoredWidth: number rightAnchoredWidth: number - } + }, + draggable?: boolean ): string { const typeClass = layerType ? RundownUtils.getSourceLayerClassName(layerType) : '' @@ -59,5 +60,7 @@ export function pieceUiClassNames( 'invert-flash': highlight, 'element-selected': selected, + + 'draggable-element': draggable, }) } diff --git a/packages/webui/src/client/styles/elementSelected.scss b/packages/webui/src/client/styles/elementSelected.scss index 2dcd17b97b..2cb9b2f73b 100644 --- a/packages/webui/src/client/styles/elementSelected.scss +++ b/packages/webui/src/client/styles/elementSelected.scss @@ -18,3 +18,7 @@ $glow-color: rgba(255, 255, 255, 0.58); } } } + +.draggable-element { + border: dotted white 1px; +} diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index 2fb51d01fc..8ec3154c7d 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -27,7 +27,7 @@ import { maintainFocusOnPartInstance, scrollToPartInstance, getHeaderHeight, -} from '../lib/viewPort' +} from '../lib/viewPort.js' import { AfterBroadcastForm } from './AfterBroadcastForm.js' import { RundownRightHandControls } from './RundownView/RundownRightHandControls.js' import { PeripheralDevicesAPI } from '../lib/clientAPI.js' @@ -38,7 +38,7 @@ import { } from './RundownView/RundownNotifier.js' import { NotificationCenterPanel } from '../lib/notifications/NotificationCenterPanel.js' import { NotificationCenter, NoticeLevel, Notification } from '../lib/notifications/notifications.js' -import { SupportPopUp } from './SupportPopUp' +import { SupportPopUp } from './SupportPopUp.js' import { KeyboardFocusIndicator } from '../lib/KeyboardFocusIndicator.js' import { PeripheralDeviceType } from '@sofie-automation/corelib/dist/dataModel/PeripheralDevice' import { doUserAction, UserAction } from '../lib/clientUserAction.js' @@ -111,6 +111,7 @@ import { useMiniShelfAdlibsData } from './RundownView/useQueueMiniShelfAdlib.js' import { RundownViewContextProviders } from './RundownView/RundownViewContextProviders.js' import { AnimatePresence } from 'motion/react' import { UserError } from '@sofie-automation/corelib/dist/error' +import { DragContextProvider } from './RundownView/DragContextProvider.js' const HIDE_NOTIFICATIONS_AFTER_MOUNT: number | undefined = 5000 @@ -1346,213 +1347,216 @@ const RundownViewContent = translateWithTracker - - {(selectionContext) => { - return ( -
0, - })} - style={this.getStyle()} - onWheelCapture={this.onWheel} - onContextMenu={this.onContextMenuTop} - > - {this.renderSegmentsList()} - - {this.props.matchedSegments && - this.props.matchedSegments.length > 0 && - this.props.userPermissions.studio && - studio.settings.enableEvaluationForm && } - - {!this.props.hideRundownHeader && ( + + + {(selectionContext) => { + return ( +
0, + })} + style={this.getStyle()} + onWheelCapture={this.onWheel} + onContextMenu={this.onContextMenuTop} + > + {this.renderSegmentsList()} + + {this.props.matchedSegments && + this.props.matchedSegments.length > 0 && + this.props.userPermissions.studio && + studio.settings.enableEvaluationForm && } + + {!this.props.hideRundownHeader && ( + + r._id)} + firstRundown={this.props.rundowns[0]} + onActivate={this.onActivate} + inActiveRundownView={this.props.inActiveRundownView} + currentRundown={currentRundown} + layout={this.props.selectedHeaderLayout} + showStyleBase={showStyleBase} + showStyleVariant={showStyleVariant} + /> + + )} - r._id)} - firstRundown={this.props.rundowns[0]} - onActivate={this.onActivate} - inActiveRundownView={this.props.inActiveRundownView} - currentRundown={currentRundown} - layout={this.props.selectedHeaderLayout} showStyleBase={showStyleBase} showStyleVariant={showStyleVariant} + onChangeBottomMargin={this.onChangeBottomMargin} + rundownLayout={this.props.selectedShelfLayout} + studio={studio} /> - )} - - - - - {this.props.userPermissions.studio && !Settings.disableBlurBorder && ( - -
-
- )} -
- - - - - {this.props.userPermissions.studio && currentRundown && ( - + {this.props.userPermissions.studio && !Settings.disableBlurBorder && ( + +
+
+ )} +
+ + - )} - - - - {this.state.isNotificationsCenterOpen && ( - + + {this.props.userPermissions.studio && currentRundown && ( + )} - {!this.state.isNotificationsCenterOpen && selectionContext.listSelectedElements().length > 0 && ( -
- -
- )} - - {this.state.isSupportPanelOpen && ( - -
- -
- - {t('Take a Snapshot')} - -
- {this.props.userPermissions.studio && ( - <> - -
- +
+ + + {this.state.isNotificationsCenterOpen && ( + + )} + {!this.state.isNotificationsCenterOpen && + selectionContext.listSelectedElements().length > 0 && ( +
+ +
)} - {this.props.userPermissions.studio && } - + + {this.state.isSupportPanelOpen && ( + +
+ +
+ + {t('Take a Snapshot')} + +
+ {this.props.userPermissions.studio && ( + <> + +
+ + )} + {this.props.userPermissions.studio && } +
+ )} +
+
+ + {this.props.userPermissions.studio && ( + )} -
-
- - {this.props.userPermissions.studio && ( - + + selectionContext.clearAndSetSelection(selection)} + studioMode={this.props.userPermissions.studio} + enablePlayFromAnywhere={!!studio.settings.enablePlayFromAnywhere} + enableQuickLoop={!!studio.settings.enableQuickLoop} + enableUserEdits={!!studio.settings.enableUserEdits} /> - )} - - - selectionContext.clearAndSetSelection(selection)} - studioMode={this.props.userPermissions.studio} - enablePlayFromAnywhere={!!studio.settings.enablePlayFromAnywhere} - enableQuickLoop={!!studio.settings.enableQuickLoop} - enableUserEdits={!!studio.settings.enableUserEdits} - /> - - - {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..dff67253e5 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/DragContext.ts @@ -0,0 +1,44 @@ +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 + + /** + * Whether dragging is enabled + */ + enabled: boolean + + /** + * 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..85491788ba --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/DragContextProvider.tsx @@ -0,0 +1,172 @@ +import { PartInstanceId, PieceInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react' +import { dragContext, IDragContext } from './DragContext.js' +import { PieceUi } from '../SegmentContainer/withResolvedSegment.js' +import { doUserAction, UserAction } from '../../lib/clientUserAction.js' +import { MeteorCall } from '../../lib/meteorApi.js' +import { TFunction } from 'i18next' +import { UIParts } from '../Collections.js' +import { Segments } from '../../collections/index.js' +import { literal } from '../../lib/tempLib.js' +import { DefaultUserOperationRetimePiece, DefaultUserOperationsTypes } from '@sofie-automation/blueprints-integration' +import RundownViewEventBus, { + RundownViewEvents, + EditModeEvent, +} from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' + +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 [enabled, setEnabled] = useState(false) + + 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 || localPiece.renderedInPoint === ogPiece.renderedInPoint) return cleanup() + + // find the parts so we can get their externalId + const startPartId = localPiece.instance.piece.startPartId // this could become a funny thing with infinites + const part = startPartId ? UIParts.findOne(startPartId) : undefined + const oldPart = + startPartId === ogPiece.instance.piece.startPartId + ? part + : ogPiece.instance.piece.startPartId + ? UIParts.findOne(ogPiece.instance.piece.startPartId) + : undefined + if (!part) return cleanup() // tough to continue without a parent for the piece + + // find the Segment's External ID + const segment = Segments.findOne(part?.segmentId) + const oldSegment = part?.segmentId === oldPart?.segmentId ? segment : Segments.findOne(oldPart?.segmentId) + if (!segment) return + + const operationTarget = { + segmentExternalId: oldSegment?.externalId, + partExternalId: oldPart?.externalId, + pieceExternalId: ogPiece.instance.piece.externalId, + } + doUserAction( + t, + e, + UserAction.EXECUTE_USER_OPERATION, + (e, ts) => + MeteorCall.userAction.executeUserChangeOperation( + e, + ts, + part.rundownId, + operationTarget, + literal({ + id: DefaultUserOperationsTypes.RETIME_PIECE, + payload: { + segmentExternalId: segment.externalId, + partExternalId: part.externalId, + + inPoint: localPiece.renderedInPoint ?? inPoint, + }, + }) + ), + () => { + 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 onSetEditMode = useCallback((e: EditModeEvent) => { + if (e.state === 'toggle') { + setEnabled((s) => !s) + } else { + setEnabled(e.state) + } + }, []) + + useEffect(() => { + RundownViewEventBus.on(RundownViewEvents.EDIT_MODE, onSetEditMode) + return () => { + RundownViewEventBus.off(RundownViewEvents.EDIT_MODE, onSetEditMode) + } + }) + + const ctx = literal({ + pieceId, + piece, + + enabled, + + 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 bbeb48211f..8394ac6b61 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, protectString, unprotectString } from '../../../lib/tempLib.js' @@ -11,6 +11,7 @@ import { SourceLayerItemContainer } from '../SourceLayerItemContainer.js' import { contextMenuHoldToDisplayTime } from '../../../lib/lib.js' import { UIStudio } from '@sofie-automation/meteor-lib/dist/api/studios' import { PieceInstanceId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { dragContext } from '../../RundownView/DragContext.js' export interface ISourceLayerPropsBase { key: string @@ -90,6 +91,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, @@ -106,9 +121,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 06e9ba6191..dc642c02a4 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.js' -import { SourceLayerType, PieceLifespan, IBlueprintPieceType } from '@sofie-automation/blueprints-integration' +import { + SourceLayerType, + PieceLifespan, + IBlueprintPieceType, + UserEditingType, + DefaultUserOperationsTypes, +} from '@sofie-automation/blueprints-integration' import { RundownUtils } from '../../lib/rundown.js' import { DefaultLayerItemRenderer } from './Renderers/DefaultLayerItemRenderer.js' import { MicSourceRenderer } from './Renderers/MicSourceRenderer.js' @@ -20,6 +26,7 @@ import { ReadonlyDeep } from 'type-fest' import { useSelectedElementsContext } from '../RundownView/SelectedElementsContext.js' import { PieceContentStatusObj } from '@sofie-automation/corelib/dist/dataModel/PieceContentStatus' import { useCallback, useRef, useState, useEffect, useContext } from 'react' +import { dragContext } from '../RundownView/DragContext.js' import { convertSourceLayerItemToPreview, IPreviewPopUpSession, @@ -114,6 +121,11 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele const [leftAnchoredWidth, setLeftAnchoredWidth] = useState(0) const [rightAnchoredWidth, setRightAnchoredWidth] = useState(0) + const dragCtx = useContext(dragContext) + const hasDraggableElement = !!piece.instance.piece.userEditOperations?.find( + (op) => op.type === UserEditingType.SOFIE && op.id === DefaultUserOperationsTypes.RETIME_PIECE + ) + const state = { highlight, showPreviewPopUp, @@ -164,6 +176,9 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele ) const itemDblClick = useCallback( (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + if (studio?.settings.enableUserEdits && !studio?.settings.allowPieceDirectPlay) { const pieceId = piece.instance.piece._id if (!selectElementContext.isSelected(pieceId)) { @@ -171,23 +186,34 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele } else { selectElementContext.clearSelections() } - // Until a proper data structure, the only reference is a part. - // const partId = this.props.part.instance.part._id - // if (!selectElementContext.isSelected(partId)) { - // selectElementContext.clearAndSetSelection({ type: 'part', elementId: partId }) - // } else { - // selectElementContext.clearSelections() - // } } else if (typeof onDoubleClick === 'function') { onDoubleClick(piece, e) } }, [piece] ) - const itemMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - }, []) + const itemMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + if (!hasDraggableElement) return + + const targetPos = (e.target as HTMLDivElement).getBoundingClientRect() + if (dragCtx && dragCtx.enabled) + dragCtx.startDrag( + piece, + timeScale, + { + x: e.clientX, + y: e.clientY, + }, + targetPos.x - e.clientX, + part.instance.segmentId + ) + }, + [piece, timeScale, dragCtx] + ) const itemMouseUp = useCallback((e: any) => { const eM = e as MouseEvent if (eM.ctrlKey === true) { @@ -531,29 +557,31 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele ...props, ...state, } + // Key cannot be part of a spread operator, therefore needs to be kept out of elProps + const elKey = unprotectString(piece.instance._id) switch (layer.type) { case SourceLayerType.SCRIPT: // case SourceLayerType.MIC: - return + return case SourceLayerType.VT: case SourceLayerType.LIVE_SPEAK: - return + return case SourceLayerType.GRAPHICS: case SourceLayerType.LOWER_THIRD: case SourceLayerType.STUDIO_SCREEN: - return + return case SourceLayerType.SPLITS: - return + return case SourceLayerType.TRANSITION: // TODOSYNC: TV2 uses other renderers, to be discussed. - return + return case SourceLayerType.LOCAL: - return + return default: - return + return } } @@ -575,8 +603,10 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele layer.type, part.partId, highlight, - elementWidth + elementWidth, // this.state + undefined, + hasDraggableElement && dragCtx?.enabled )} data-obj-id={piece.instance._id} ref={setRef} diff --git a/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx index 0802556b74..5a2da2fc86 100644 --- a/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx +++ b/packages/webui/src/client/ui/Settings/components/triggeredActions/actionEditors/actionSelector/ActionSelector.tsx @@ -93,6 +93,17 @@ function getArguments(t: TFunction, action: SomeAction): string[] { assertNever(action.state) } break + case ClientActions.editMode: + if (action.state === true) { + result.push(t('Enable')) + } else if (action.state === false) { + result.push(t('Disable')) + } else if (action.state === 'toggle') { + result.push(t('Toggle')) + } else { + assertNever(action.state) + } + break case ClientActions.goToOnAirLine: break case ClientActions.rewindSegments: @@ -147,6 +158,8 @@ function hasArguments(action: SomeAction): boolean { return false case ClientActions.shelf: return true + case ClientActions.editMode: + return true case ClientActions.goToOnAirLine: return false case ClientActions.rewindSegments: @@ -193,6 +206,8 @@ function actionToLabel(t: TFunction, action: SomeAction['action']): string { return t('Switch Route Set') case ClientActions.shelf: return t('Shelf') + case ClientActions.editMode: + return t('Edit Mode') case ClientActions.rewindSegments: return t('Rewind Segments to start') case ClientActions.goToOnAirLine: @@ -376,6 +391,40 @@ function getActionParametersEditor( /> ) + case ClientActions.editMode: + return ( +
+ + + classNames="input text-input input-m" + value={action.state} + // placholder={t('State')} + options={[ + { + name: t('Enable'), + value: true, + i: 0, + }, + { + name: t('Disable'), + value: false, + i: 1, + }, + { + name: t('Toggle'), + value: 'toggle', + i: 2, + }, + ]} + handleUpdate={(newVal) => { + onChange({ + ...action, + state: newVal, + }) + }} + /> +
+ ) case ClientActions.goToOnAirLine: return null case ClientActions.rewindSegments: