From f7a4fe7376f0257878b0345a09e5ce6bf7b32e35 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 13 Nov 2024 07:57:11 +0000 Subject: [PATCH 1/3] chore: refactor SourceLayerItem component --- .../ui/SegmentTimeline/SourceLayerItem.tsx | 1051 +++++++---------- 1 file changed, 437 insertions(+), 614 deletions(-) diff --git a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx index baf426a0b4..951443746d 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx @@ -10,7 +10,6 @@ import { SplitsSourceRenderer } from './Renderers/SplitsSourceRenderer' import { LocalLayerItemRenderer } from './Renderers/LocalLayerItemRenderer' import { DEBUG_MODE } from './SegmentTimelineDebugMode' -import { withTranslation, WithTranslation } from 'react-i18next' import { getElementDocumentOffset, OffsetPosition } from '../../utils/positions' import { unprotectString } from '../../lib/tempLib' import RundownViewEventBus, { @@ -18,12 +17,12 @@ import RundownViewEventBus, { HighlightEvent, } from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' import { pieceUiClassNames } from '../../lib/ui/pieceUiClassNames' -import { SourceDurationLabelAlignment } from './Renderers/CustomLayerItemRenderer' 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 { SelectedElementsContext } from '../RundownView/SelectedElementsContext' +import { useSelectedElementsContext } from '../RundownView/SelectedElementsContext' +import { useCallback, useRef, useState, useEffect } from 'react' const LEFT_RIGHT_ANCHOR_SPACER = 15 const MARGINAL_ANCHORED_WIDTH = 5 @@ -83,667 +82,491 @@ export interface ISourceLayerItemProps { /** If source duration of piece's content should be displayed next to any labels */ showDuration?: boolean } -interface ISourceLayerItemState { - /** Whether hover-scrub / inspector is shown */ - showMiniInspector: boolean - /** Element position relative to document top-left */ - elementPosition: OffsetPosition - /** Cursor position relative to element */ - cursorPosition: OffsetPosition - /** Cursor position relative to entire viewport */ - cursorRawPosition: { clientX: number; clientY: number } - /** Timecode under cursor */ - cursorTimePosition: number - /** A reference to this element (&self) */ - itemElement: HTMLDivElement | null - /** Width of the child element anchored to the left side of this element */ - leftAnchoredWidth: number - /** Width of the child element anchored to the right side of this element */ - rightAnchoredWidth: number - /** Set to `true` when the segment is "highlighted" (in focus, generally from a scroll event) */ - highlight: boolean -} - -export const SourceLayerItem = withTranslation()( - class SourceLayerItem extends React.Component< - ISourceLayerItemProps & WithTranslation & ISourceLayerItemProps, - ISourceLayerItemState - > { - animFrameHandle: number | undefined - - constructor(props: ISourceLayerItemProps & WithTranslation & ISourceLayerItemProps) { - super(props) - this.state = { - showMiniInspector: false, - elementPosition: { - top: 0, - left: 0, - }, - cursorPosition: { - top: 0, - left: 0, - }, - cursorRawPosition: { - clientX: 0, - clientY: 0, - }, - cursorTimePosition: 0, - itemElement: null, - leftAnchoredWidth: 0, - rightAnchoredWidth: 0, - highlight: false, - } - } - - setRef = (e: HTMLDivElement) => { - this.setState({ - itemElement: e, - }) - } - convertTimeToPixels = (time: number) => { - return Math.round(this.props.timeScale * time) - } +export const SourceLayerItem = (props: Readonly): JSX.Element => { + const { + layer, + part, + partStartsAt, + partDuration, + piece, + contentStatus, + timeScale, + isLiveLine, + isTooSmallForText, + onClick, + onDoubleClick, + followLiveLine, + liveLineHistorySize, + scrollLeft, + scrollWidth, + studio, + } = props + + const [highlight, setHighlight] = useState(false) + const [showMiniInspector, setShowMiniInspector] = useState(false) + const [elementPosition, setElementPosition] = useState({ top: 0, left: 0 }) + const [cursorPosition, setCursorPosition] = useState({ top: 0, left: 0 }) + const [cursorTimePosition, setCursorTimePosition] = useState(0) + const [leftAnchoredWidth, setLeftAnchoredWidth] = useState(0) + const [rightAnchoredWidth, setRightAnchoredWidth] = useState(0) + + const state = { + highlight, + showMiniInspector, + elementPosition, + cursorPosition, + cursorTimePosition, + leftAnchoredWidth, + rightAnchoredWidth, + } - private getSourceDurationLabelAlignment = (): SourceDurationLabelAlignment => { - if (this.props.part && this.props.partStartsAt !== undefined && !this.props.isLiveLine) { - const elementWidth = this.getElementAbsoluteWidth() - return this.state.leftAnchoredWidth + this.state.rightAnchoredWidth > elementWidth - 10 ? 'left' : 'right' + const itemElementRef = useRef(null) + const animFrameHandle = useRef(undefined) + const cursorRawPosition = useRef({ clientX: 0, clientY: 0 }) + const setRef = useCallback((e: HTMLDivElement) => { + itemElementRef.current = e + }, []) + + const highlightTimeout = useRef(undefined) + const onHighlight = useCallback( + (e: HighlightEvent) => { + if (e.partId === part.partId && (e.pieceId === piece.instance.piece._id || e.pieceId === piece.instance._id)) { + setHighlight(true) + clearTimeout(highlightTimeout.current) + highlightTimeout.current = setTimeout(() => { + setHighlight(false) + }, 5000) } - return 'right' + }, + [part, piece] + ) + useEffect(() => { + RundownViewEventBus.on(RundownViewEvents.HIGHLIGHT, onHighlight) + return () => { + RundownViewEventBus.off(RundownViewEvents.HIGHLIGHT, onHighlight) + clearTimeout(highlightTimeout.current) } + }, []) - getItemLabelOffsetLeft = (): React.CSSProperties => { - const maxLabelWidth = this.props.piece.maxLabelWidth - - if (this.props.part && this.props.partStartsAt !== undefined) { - // && this.props.piece.renderedInPoint !== undefined && this.props.piece.renderedDuration !== undefined - const piece = this.props.piece - - const inPoint = piece.renderedInPoint || 0 - const duration = Number.isFinite(piece.renderedDuration || 0) - ? piece.renderedDuration || this.props.partDuration || this.props.part.renderedDuration || 0 - : this.props.partDuration || this.props.part.renderedDuration || 0 - - const elementWidth = this.getElementAbsoluteWidth() - - const widthConstrictedMode = - this.props.isTooSmallForText || - (this.state.leftAnchoredWidth > 0 && - this.state.rightAnchoredWidth > 0 && - this.state.leftAnchoredWidth + this.state.rightAnchoredWidth > elementWidth) - - const nextIsTouching = !!piece.cropped - - if (this.props.followLiveLine && this.props.isLiveLine) { - const liveLineHistoryWithMargin = this.props.liveLineHistorySize - 10 - if ( - this.props.scrollLeft + liveLineHistoryWithMargin / this.props.timeScale > - inPoint + this.props.partStartsAt + this.state.leftAnchoredWidth / this.props.timeScale && - this.props.scrollLeft + liveLineHistoryWithMargin / this.props.timeScale < - inPoint + duration + this.props.partStartsAt - ) { - const targetPos = this.convertTimeToPixels(this.props.scrollLeft - inPoint - this.props.partStartsAt) - - return { - maxWidth: - this.state.rightAnchoredWidth > 0 - ? (elementWidth - this.state.rightAnchoredWidth).toString() + 'px' - : maxLabelWidth !== undefined - ? this.convertTimeToPixels(maxLabelWidth).toString() + 'px' - : nextIsTouching - ? '100%' - : 'none', - transform: - 'translate(' + - (widthConstrictedMode - ? targetPos - : Math.min(targetPos, elementWidth - this.state.rightAnchoredWidth - liveLineHistoryWithMargin - 10) - ).toString() + - 'px, 0) ' + - 'translate(' + - liveLineHistoryWithMargin.toString() + - 'px, 0) ' + - 'translate(-100%, 0)', - willChange: 'transform', - } - } else if ( - this.state.rightAnchoredWidth < elementWidth && - this.state.leftAnchoredWidth < elementWidth && - this.props.scrollLeft + liveLineHistoryWithMargin / this.props.timeScale >= - inPoint + duration + this.props.partStartsAt - ) { - const targetPos = this.convertTimeToPixels(this.props.scrollLeft - inPoint - this.props.partStartsAt) - - return { - maxWidth: - this.state.rightAnchoredWidth > 0 - ? (elementWidth - this.state.rightAnchoredWidth).toString() + 'px' - : maxLabelWidth !== undefined - ? this.convertTimeToPixels(maxLabelWidth).toString() + 'px' - : nextIsTouching - ? '100%' - : 'none', - transform: - 'translate(' + - Math.min( - targetPos, - elementWidth - this.state.rightAnchoredWidth - liveLineHistoryWithMargin - 10 - ).toString() + - 'px, 0) ' + - 'translate(' + - liveLineHistoryWithMargin.toString() + - 'px, 0) ' + - 'translate3d(-100%, 0)', - willChange: 'transform', - } - } else { - return { - maxWidth: - this.state.rightAnchoredWidth > 0 - ? (elementWidth - this.state.rightAnchoredWidth - 10).toString() + 'px' - : maxLabelWidth !== undefined - ? this.convertTimeToPixels(maxLabelWidth).toString() + 'px' - : nextIsTouching - ? '100%' - : 'none', - } - } + const itemClick = useCallback( + (e: React.MouseEvent) => { + // this.props.onFollowLiveLine && this.props.onFollowLiveLine(false, e) + e.preventDefault() + e.stopPropagation() + onClick && onClick(piece, e) + }, + [piece] + ) + const itemDblClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + if (studio?.settings.enableUserEdits) { + const pieceId = piece.instance.piece._id + if (!selectElementContext.isSelected(pieceId)) { + selectElementContext.clearAndSetSelection({ type: 'piece', elementId: pieceId }) } else { - if ( - this.props.scrollLeft > inPoint + this.props.partStartsAt && - this.props.scrollLeft < inPoint + duration + this.props.partStartsAt - ) { - const targetPos = this.convertTimeToPixels(this.props.scrollLeft - inPoint - this.props.partStartsAt) - - return { - maxWidth: - this.state.rightAnchoredWidth > 0 - ? (elementWidth - this.state.rightAnchoredWidth - 10).toString() + 'px' - : maxLabelWidth !== undefined - ? this.convertTimeToPixels(maxLabelWidth).toString() + 'px' - : nextIsTouching - ? '100%' - : 'none', - transform: - 'translate(' + - (widthConstrictedMode || this.state.leftAnchoredWidth === 0 || this.state.rightAnchoredWidth === 0 - ? targetPos - : Math.min(targetPos, elementWidth - this.state.leftAnchoredWidth - this.state.rightAnchoredWidth) - ).toString() + - 'px, 0)', - } - } else { - return { - maxWidth: - this.state.rightAnchoredWidth > 0 - ? (elementWidth - this.state.rightAnchoredWidth - 10).toString() + 'px' - : maxLabelWidth !== undefined - ? this.convertTimeToPixels(maxLabelWidth).toString() + 'px' - : nextIsTouching - ? '100%' - : 'none', - } - } + selectElementContext.clearSelections() } + } else if (typeof onDoubleClick === 'function') { + onDoubleClick(piece, e) } - return {} + }, + [piece] + ) + const itemMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + }, []) + const itemMouseUp = useCallback((e: any) => { + const eM = e as MouseEvent + if (eM.ctrlKey === true) { + eM.preventDefault() + eM.stopPropagation() + } + return + }, []) + const toggleMiniInspectorOn = useCallback((e: React.MouseEvent) => toggleMiniInspector(e, true), []) + const toggleMiniInspectorOff = useCallback((e: React.MouseEvent) => toggleMiniInspector(e, false), []) + const updatePos = useCallback(() => { + const elementPos = getElementDocumentOffset(itemElementRef.current) || { + top: 0, + left: 0, + } + const cursorPosition = { + left: cursorRawPosition.current.clientX - elementPos.left, + top: cursorRawPosition.current.clientY - elementPos.top, } - getItemLabelOffsetRight = (): React.CSSProperties => { - if (!this.props.part || this.props.partStartsAt === undefined) return {} + const cursorTimePosition = Math.max(cursorPosition.left, 0) / timeScale - const piece = this.props.piece - const innerPiece = piece.instance.piece + setElementPosition(elementPos) + setCursorPosition(cursorPosition) + setCursorTimePosition(cursorTimePosition) - const inPoint = piece.renderedInPoint || 0 - const duration = - innerPiece.lifespan !== PieceLifespan.WithinPart || piece.renderedDuration === 0 - ? this.props.partDuration - inPoint - : Math.min(piece.renderedDuration || 0, this.props.partDuration - inPoint) - const outPoint = inPoint + duration - - const elementWidth = this.getElementAbsoluteWidth() - - // const widthConstrictedMode = this.state.leftAnchoredWidth > 0 && this.state.rightAnchoredWidth > 0 && ((this.state.leftAnchoredWidth + this.state.rightAnchoredWidth) > this.state.elementWidth) - - if ( - this.props.scrollLeft + this.props.scrollWidth < outPoint + this.props.partStartsAt && - this.props.scrollLeft + this.props.scrollWidth > inPoint + this.props.partStartsAt - ) { - const targetPos = Math.max( - (this.props.scrollLeft + this.props.scrollWidth - outPoint - this.props.partStartsAt) * this.props.timeScale, - (elementWidth - this.state.leftAnchoredWidth - this.state.rightAnchoredWidth - LEFT_RIGHT_ANCHOR_SPACER) * -1 - ) - - return { - transform: 'translate(' + targetPos.toString() + 'px, 0)', - } - } - return {} + animFrameHandle.current = requestAnimationFrame(updatePos) + }, []) + const toggleMiniInspector = useCallback((e: MouseEvent | any, v: boolean) => { + setShowMiniInspector(v) + cursorRawPosition.current = { + clientX: e.clientX, + clientY: e.clientY, } - getItemDuration = (returnInfinite?: boolean): number => { - const piece = this.props.piece - const innerPiece = piece.instance.piece - - const expectedDurationNumber = - typeof innerPiece.enable.duration === 'number' ? innerPiece.enable.duration || 0 : 0 + if (v) { + animFrameHandle.current = requestAnimationFrame(updatePos) + } else if (animFrameHandle.current !== undefined) { + cancelAnimationFrame(animFrameHandle.current) + } + }, []) + const moveMiniInspector = useCallback((e: MouseEvent | any) => { + cursorRawPosition.current = { + clientX: e.clientX, + clientY: e.clientY, + } + }, []) - let itemDuration: number - if (!returnInfinite) { - itemDuration = Math.min( - piece.renderedDuration || expectedDurationNumber || 0, - this.props.partDuration - (piece.renderedInPoint || 0) - ) - } else { - itemDuration = - this.props.partDuration - (piece.renderedInPoint || 0) < - (piece.renderedDuration || expectedDurationNumber || 0) - ? Number.POSITIVE_INFINITY - : piece.renderedDuration || expectedDurationNumber || 0 - } + const selectElementContext = useSelectedElementsContext() - if ( - (innerPiece.lifespan !== PieceLifespan.WithinPart || - (innerPiece.enable.start !== undefined && - innerPiece.enable.duration === undefined && - piece.instance.userDuration === undefined)) && - !piece.cropped && - piece.renderedDuration === null && - piece.instance.userDuration === undefined - ) { - if (!returnInfinite) { - itemDuration = this.props.partDuration - (piece.renderedInPoint || 0) - } else { - itemDuration = Number.POSITIVE_INFINITY - } - } + const convertTimeToPixels = (time: number) => { + return Math.round(timeScale * time) + } + const getItemDuration = (returnInfinite?: boolean): number => { + const innerPiece = piece.instance.piece - return itemDuration - } + const expectedDurationNumber = typeof innerPiece.enable.duration === 'number' ? innerPiece.enable.duration || 0 : 0 - getElementAbsoluteWidth(): number { - const itemDuration = this.getItemDuration() - return this.convertTimeToPixels(itemDuration) + let itemDuration: number + if (!returnInfinite) { + itemDuration = Math.min( + piece.renderedDuration || expectedDurationNumber || 0, + partDuration - (piece.renderedInPoint || 0) + ) + } else { + itemDuration = + partDuration - (piece.renderedInPoint || 0) < (piece.renderedDuration || expectedDurationNumber || 0) + ? Number.POSITIVE_INFINITY + : piece.renderedDuration || expectedDurationNumber || 0 } - getElementAbsoluteStyleWidth(): string { - const renderedInPoint = this.props.piece.renderedInPoint - if (renderedInPoint === 0) { - const itemPossiblyInfiniteDuration = this.getItemDuration(true) - if (!Number.isFinite(itemPossiblyInfiniteDuration)) { - return '100%' - } + if ( + (innerPiece.lifespan !== PieceLifespan.WithinPart || + (innerPiece.enable.start !== undefined && + innerPiece.enable.duration === undefined && + piece.instance.userDuration === undefined)) && + !piece.cropped && + piece.renderedDuration === null && + piece.instance.userDuration === undefined + ) { + if (!returnInfinite) { + itemDuration = partDuration - (piece.renderedInPoint || 0) + } else { + itemDuration = Number.POSITIVE_INFINITY } - const itemDuration = this.getItemDuration(false) - return this.convertTimeToPixels(itemDuration).toString() + 'px' } - getItemStyle(): { [key: string]: string } { - const piece = this.props.piece - const innerPiece = piece.instance.piece + return itemDuration + } + const getElementAbsoluteWidth = (): number => { + const itemDuration = getItemDuration() + return convertTimeToPixels(itemDuration) + } - // If this is a live line, take duration verbatim from SegmentLayerItemContainer with a fallback on expectedDuration. - // If not, as-run part "duration" limits renderdDuration which takes priority over MOS-import - // expectedDuration (editorial duration) + const isInsideViewport = RundownUtils.isInsideViewport( + scrollLeft, + scrollWidth, + part, + partStartsAt, + partDuration, + piece + ) + const getItemStyle = (): { [key: string]: string } => { + const innerPiece = piece.instance.piece - // let liveLinePadding = this.props.autoNextPart ? 0 : (this.props.isLiveLine ? this.props.liveLinePadding : 0) + // If this is a live line, take duration verbatim from SegmentLayerItemContainer with a fallback on expectedDuration. + // If not, as-run part "duration" limits renderdDuration which takes priority over MOS-import + // expectedDuration (editorial duration) - if (innerPiece.pieceType === IBlueprintPieceType.OutTransition) { - return { - left: 'auto', - right: '0', - width: this.getElementAbsoluteWidth().toString() + 'px', - } - } + // let liveLinePadding = this.props.autoNextPart ? 0 : (this.props.isLiveLine ? this.props.liveLinePadding : 0) + + if (innerPiece.pieceType === IBlueprintPieceType.OutTransition) { return { - left: this.convertTimeToPixels(piece.renderedInPoint || 0).toString() + 'px', - width: this.getElementAbsoluteStyleWidth(), + left: 'auto', + right: '0', + width: getElementAbsoluteWidth().toString() + 'px', } } - - // TODO(Performance): use ResizeObserver to avoid style recalculations - // checkElementWidth = () => { - // if (this.state.itemElement && this._forceSizingRecheck) { - // this._forceSizingRecheck = false - // const width = getElementWidth(this.state.itemElement) || 0 - // if (this.state.elementWidth !== width) { - // this.setState({ - // elementWidth: width - // }) - // } - // } - // } - - private highlightTimeout: NodeJS.Timeout | undefined - - private onHighlight = (e: HighlightEvent) => { - if ( - e.partId === this.props.part.partId && - (e.pieceId === this.props.piece.instance.piece._id || e.pieceId === this.props.piece.instance._id) - ) { - this.setState({ - highlight: true, - }) - clearTimeout(this.highlightTimeout) - this.highlightTimeout = setTimeout(() => { - this.setState({ - highlight: false, - }) - }, 5000) + return { + left: convertTimeToPixels(piece.renderedInPoint || 0).toString() + 'px', + width: getElementAbsoluteStyleWidth(), + } + } + const getElementAbsoluteStyleWidth = (): string => { + const renderedInPoint = piece.renderedInPoint + if (renderedInPoint === 0) { + const itemPossiblyInfiniteDuration = getItemDuration(true) + if (!Number.isFinite(itemPossiblyInfiniteDuration)) { + return '100%' } } + const itemDuration = getItemDuration(false) + return convertTimeToPixels(itemDuration).toString() + 'px' + } - componentDidMount(): void { - RundownViewEventBus.on(RundownViewEvents.HIGHLIGHT, this.onHighlight) - } + const getItemLabelOffsetLeft = (): React.CSSProperties => { + const maxLabelWidth = piece.maxLabelWidth - componentWillUnmount(): void { - super.componentWillUnmount && super.componentWillUnmount() - RundownViewEventBus.off(RundownViewEvents.HIGHLIGHT, this.onHighlight) - clearTimeout(this.highlightTimeout) - } + if (part && partStartsAt !== undefined) { + // && this.props.piece.renderedInPoint !== undefined && this.props.piece.renderedDuration !== undefined - componentDidUpdate(prevProps: ISourceLayerItemProps, _prevState: ISourceLayerItemState) { - if (this.state.showMiniInspector) { - if (prevProps.scrollLeft !== this.props.scrollLeft) { - const cursorPosition = { - left: this.state.cursorRawPosition.clientX - this.state.elementPosition.left, - top: this.state.cursorRawPosition.clientY - this.state.elementPosition.top, + const inPoint = piece.renderedInPoint || 0 + const duration = Number.isFinite(piece.renderedDuration || 0) + ? piece.renderedDuration || partDuration || part.renderedDuration || 0 + : partDuration || part.renderedDuration || 0 + + const elementWidth = getElementAbsoluteWidth() + + const widthConstrictedMode = + isTooSmallForText || + (leftAnchoredWidth > 0 && rightAnchoredWidth > 0 && leftAnchoredWidth + rightAnchoredWidth > elementWidth) + + const nextIsTouching = !!piece.cropped + + if (followLiveLine && isLiveLine) { + const liveLineHistoryWithMargin = liveLineHistorySize - 10 + if ( + scrollLeft + liveLineHistoryWithMargin / timeScale > inPoint + partStartsAt + leftAnchoredWidth / timeScale && + scrollLeft + liveLineHistoryWithMargin / timeScale < inPoint + duration + partStartsAt + ) { + const targetPos = convertTimeToPixels(scrollLeft - inPoint - partStartsAt) + + return { + maxWidth: + rightAnchoredWidth > 0 + ? (elementWidth - rightAnchoredWidth).toString() + 'px' + : maxLabelWidth !== undefined + ? convertTimeToPixels(maxLabelWidth).toString() + 'px' + : nextIsTouching + ? '100%' + : 'none', + transform: + 'translate(' + + (widthConstrictedMode + ? targetPos + : Math.min(targetPos, elementWidth - rightAnchoredWidth - liveLineHistoryWithMargin - 10) + ).toString() + + 'px, 0) ' + + 'translate(' + + liveLineHistoryWithMargin.toString() + + 'px, 0) ' + + 'translate(-100%, 0)', + willChange: 'transform', + } + } else if ( + rightAnchoredWidth < elementWidth && + leftAnchoredWidth < elementWidth && + scrollLeft + liveLineHistoryWithMargin / timeScale >= inPoint + duration + partStartsAt + ) { + const targetPos = convertTimeToPixels(scrollLeft - inPoint - partStartsAt) + + return { + maxWidth: + rightAnchoredWidth > 0 + ? (elementWidth - rightAnchoredWidth).toString() + 'px' + : maxLabelWidth !== undefined + ? convertTimeToPixels(maxLabelWidth).toString() + 'px' + : nextIsTouching + ? '100%' + : 'none', + transform: + 'translate(' + + Math.min(targetPos, elementWidth - rightAnchoredWidth - liveLineHistoryWithMargin - 10).toString() + + 'px, 0) ' + + 'translate(' + + liveLineHistoryWithMargin.toString() + + 'px, 0) ' + + 'translate3d(-100%, 0)', + willChange: 'transform', + } + } else { + return { + maxWidth: + rightAnchoredWidth > 0 + ? (elementWidth - rightAnchoredWidth - 10).toString() + 'px' + : maxLabelWidth !== undefined + ? convertTimeToPixels(maxLabelWidth).toString() + 'px' + : nextIsTouching + ? '100%' + : 'none', + } + } + } else { + if (scrollLeft > inPoint + partStartsAt && scrollLeft < inPoint + duration + partStartsAt) { + const targetPos = convertTimeToPixels(scrollLeft - inPoint - partStartsAt) + + return { + maxWidth: + rightAnchoredWidth > 0 + ? (elementWidth - rightAnchoredWidth - 10).toString() + 'px' + : maxLabelWidth !== undefined + ? convertTimeToPixels(maxLabelWidth).toString() + 'px' + : nextIsTouching + ? '100%' + : 'none', + transform: + 'translate(' + + (widthConstrictedMode || leftAnchoredWidth === 0 || rightAnchoredWidth === 0 + ? targetPos + : Math.min(targetPos, elementWidth - leftAnchoredWidth - rightAnchoredWidth) + ).toString() + + 'px, 0)', } - const cursorTimePosition = Math.max(cursorPosition.left, 0) / this.props.timeScale - if (this.state.cursorTimePosition !== cursorTimePosition) { - this.setState({ - cursorTimePosition, - }) + } else { + return { + maxWidth: + rightAnchoredWidth > 0 + ? (elementWidth - rightAnchoredWidth - 10).toString() + 'px' + : maxLabelWidth !== undefined + ? convertTimeToPixels(maxLabelWidth).toString() + 'px' + : nextIsTouching + ? '100%' + : 'none', } } } } + return {} + } + const getItemLabelOffsetRight = (): React.CSSProperties => { + if (!part || partStartsAt === undefined) return {} - itemClick = (e: React.MouseEvent) => { - // this.props.onFollowLiveLine && this.props.onFollowLiveLine(false, e) - e.preventDefault() - e.stopPropagation() - this.props.onClick && this.props.onClick(this.props.piece, e) - } + const innerPiece = piece.instance.piece - itemDblClick = (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() + const inPoint = piece.renderedInPoint || 0 + const duration = + innerPiece.lifespan !== PieceLifespan.WithinPart || piece.renderedDuration === 0 + ? partDuration - inPoint + : Math.min(piece.renderedDuration || 0, partDuration - inPoint) + const outPoint = inPoint + duration - if (typeof this.props.onDoubleClick === 'function') { - this.props.onDoubleClick(this.props.piece, e) - } - } + const elementWidth = getElementAbsoluteWidth() - itemMouseUp = (e: any) => { - const eM = e as MouseEvent - if (eM.ctrlKey === true) { - eM.preventDefault() - eM.stopPropagation() - } - return - } - - toggleMiniInspectorOn = (e: React.MouseEvent) => this.toggleMiniInspector(e, true) - toggleMiniInspectorOff = (e: React.MouseEvent) => this.toggleMiniInspector(e, false) - - toggleMiniInspector = (e: MouseEvent | any, v: boolean) => { - this.setState({ - showMiniInspector: v, - cursorRawPosition: { - clientX: e.clientX, - clientY: e.clientY, - }, - }) - - if (v) { - const updatePos = () => { - const elementPos = getElementDocumentOffset(this.state.itemElement) || { - top: 0, - left: 0, - } - const cursorPosition = { - left: this.state.cursorRawPosition.clientX - elementPos.left, - top: this.state.cursorRawPosition.clientY - elementPos.top, - } + // const widthConstrictedMode = this.state.leftAnchoredWidth > 0 && this.state.rightAnchoredWidth > 0 && ((this.state.leftAnchoredWidth + this.state.rightAnchoredWidth) > this.state.elementWidth) - const cursorTimePosition = Math.max(cursorPosition.left, 0) / this.props.timeScale + if (scrollLeft + scrollWidth < outPoint + partStartsAt && scrollLeft + scrollWidth > inPoint + partStartsAt) { + const targetPos = Math.max( + (scrollLeft + scrollWidth - outPoint - partStartsAt) * timeScale, + (elementWidth - leftAnchoredWidth - rightAnchoredWidth - LEFT_RIGHT_ANCHOR_SPACER) * -1 + ) - this.setState({ - elementPosition: elementPos, - cursorPosition, - cursorTimePosition, - }) - this.animFrameHandle = requestAnimationFrame(updatePos) - } - this.animFrameHandle = requestAnimationFrame(updatePos) - } else if (this.animFrameHandle !== undefined) { - cancelAnimationFrame(this.animFrameHandle) + return { + transform: 'translate(' + targetPos.toString() + 'px, 0)', } } + return {} + } + const setAnchoredElsWidths = (leftAnchoredWidth: number, rightAnchoredWidth: number) => { + // anchored labels will sometimes errorneously report some width. Discard if it's marginal. + setLeftAnchoredWidth(leftAnchoredWidth > MARGINAL_ANCHORED_WIDTH ? leftAnchoredWidth : 0) + setRightAnchoredWidth(rightAnchoredWidth > MARGINAL_ANCHORED_WIDTH ? rightAnchoredWidth : 0) + } - moveMiniInspector = (e: MouseEvent | any) => { - this.setState({ - cursorRawPosition: { - clientX: e.clientX, - clientY: e.clientY, - }, - }) - } - - setAnchoredElsWidths = (leftAnchoredWidth: number, rightAnchoredWidth: number) => { - // anchored labels will sometimes errorneously report some width. Discard if it's marginal. - this.setState({ - leftAnchoredWidth: leftAnchoredWidth > MARGINAL_ANCHORED_WIDTH ? leftAnchoredWidth : 0, - rightAnchoredWidth: rightAnchoredWidth > MARGINAL_ANCHORED_WIDTH ? rightAnchoredWidth : 0, - }) - } - - renderInsideItem(typeClass: string) { - switch (this.props.layer.type) { - case SourceLayerType.SCRIPT: - // case SourceLayerType.MIC: - return ( - - ) - case SourceLayerType.VT: - case SourceLayerType.LIVE_SPEAK: - return ( - - ) - case SourceLayerType.GRAPHICS: - case SourceLayerType.LOWER_THIRD: - case SourceLayerType.STUDIO_SCREEN: - return ( - - ) - case SourceLayerType.SPLITS: - return ( - - ) - - case SourceLayerType.TRANSITION: - // TODOSYNC: TV2 uses other renderers, to be discussed. - - return ( - - ) - case SourceLayerType.LOCAL: - return ( - - ) - default: - return ( - - ) - } + const renderInsideItem = (typeClass: string) => { + const elProps = { + key: unprotectString(piece.instance._id), + typeClass: typeClass, + getItemDuration: getItemDuration, + getItemLabelOffsetLeft: getItemLabelOffsetLeft, + getItemLabelOffsetRight: getItemLabelOffsetRight, + setAnchoredElsWidths: setAnchoredElsWidths, + itemElement: itemElementRef.current, + ...props, + ...state, } - isInsideViewport() { - return RundownUtils.isInsideViewport( - this.props.scrollLeft, - this.props.scrollWidth, - this.props.part, - this.props.partStartsAt, - this.props.partDuration, - this.props.piece - ) + switch (layer.type) { + case SourceLayerType.SCRIPT: + // case SourceLayerType.MIC: + return + case SourceLayerType.VT: + case SourceLayerType.LIVE_SPEAK: + return + case SourceLayerType.GRAPHICS: + case SourceLayerType.LOWER_THIRD: + case SourceLayerType.STUDIO_SCREEN: + return + case SourceLayerType.SPLITS: + return + + case SourceLayerType.TRANSITION: + // TODOSYNC: TV2 uses other renderers, to be discussed. + + return + case SourceLayerType.LOCAL: + return + default: + return } + } - render(): JSX.Element { - if (this.isInsideViewport()) { - const typeClass = RundownUtils.getSourceLayerClassName(this.props.layer.type) - - const piece = this.props.piece - const innerPiece = piece.instance.piece - - const elementWidth = this.getElementAbsoluteWidth() - - return ( - - {(selectElementContext) => ( -
{ - if (this.props.studio?.settings.enableUserEdits) { - const pieceId = this.props.piece.instance.piece._id - if (!selectElementContext.isSelected(pieceId)) { - selectElementContext.clearAndSetSelection({ type: 'piece', elementId: pieceId }) - } 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 { - this.itemDblClick(e) - } - }} - onMouseUp={this.itemMouseUp} - onMouseMove={this.moveMiniInspector} - onMouseEnter={this.toggleMiniInspectorOn} - onMouseLeave={this.toggleMiniInspectorOff} - style={this.getItemStyle()} - > - {this.renderInsideItem(typeClass)} - {DEBUG_MODE && this.props.studio && ( -
- {innerPiece.enable.start} /{' '} - {RundownUtils.formatTimeToTimecode(this.props.studio.settings, this.props.partDuration).substr(-5)}{' '} - /{' '} - {piece.renderedDuration - ? RundownUtils.formatTimeToTimecode(this.props.studio.settings, piece.renderedDuration).substr(-5) - : 'X'}{' '} - /{' '} - {typeof innerPiece.enable.duration === 'number' - ? RundownUtils.formatTimeToTimecode( - this.props.studio.settings, - innerPiece.enable.duration - ).substr(-5) - : ''} -
- )} -
- )} -
- ) - } else { - // render a placeholder - return ( -
- ) - } - } + if (isInsideViewport) { + const typeClass = RundownUtils.getSourceLayerClassName(layer.type) + + const innerPiece = piece.instance.piece + + const elementWidth = getElementAbsoluteWidth() + + return ( +
+ {renderInsideItem(typeClass)} + {DEBUG_MODE && studio && ( +
+ {innerPiece.enable.start} / {RundownUtils.formatTimeToTimecode(studio.settings, partDuration).substr(-5)} /{' '} + {piece.renderedDuration + ? RundownUtils.formatTimeToTimecode(studio.settings, piece.renderedDuration).substr(-5) + : 'X'}{' '} + /{' '} + {typeof innerPiece.enable.duration === 'number' + ? RundownUtils.formatTimeToTimecode(studio.settings, innerPiece.enable.duration).substr(-5) + : ''} +
+ )} +
+ ) + } else { + // render a placeholder + return ( +
+ ) } -) +} From 1284f9fe094ad127819eb90ea21dda18edb79ab8 Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Fri, 15 Nov 2024 13:27:56 +0000 Subject: [PATCH 2/3] feat: retime piece user action --- packages/blueprints-integration/src/ingest.ts | 12 ++ .../blueprints-integration/src/userEditing.ts | 17 +- .../src/dataModel/UserEditingDefinitions.ts | 12 +- .../job-worker/src/blueprints/context/lib.ts | 27 +++- packages/webui/src/client/ui/RundownView.tsx | 3 + .../src/client/ui/RundownView/DragContext.ts | 39 +++++ .../ui/RundownView/DragContextProvider.tsx | 145 ++++++++++++++++++ .../ui/SegmentTimeline/Parts/SourceLayer.tsx | 21 ++- .../ui/SegmentTimeline/SourceLayerItem.tsx | 53 ++++++- .../RenderUserEditOperations.tsx | 2 + 10 files changed, 312 insertions(+), 19 deletions(-) create mode 100644 packages/webui/src/client/ui/RundownView/DragContext.ts create mode 100644 packages/webui/src/client/ui/RundownView/DragContextProvider.tsx diff --git a/packages/blueprints-integration/src/ingest.ts b/packages/blueprints-integration/src/ingest.ts index 7ca21420af..eff6ebff59 100644 --- a/packages/blueprints-integration/src/ingest.ts +++ b/packages/blueprints-integration/src/ingest.ts @@ -129,6 +129,7 @@ export enum DefaultUserOperationsTypes { REVERT_PART = '__sofie-revert-part', REVERT_RUNDOWN = '__sofie-revert-rundown', UPDATE_PROPS = '__sofie-update-props', + RETIME_PIECE = '__sofie-retime-piece', } export interface DefaultUserOperationRevertRundown { @@ -153,6 +154,17 @@ export interface DefaultUserOperationEditProperties { } } +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 diff --git a/packages/blueprints-integration/src/userEditing.ts b/packages/blueprints-integration/src/userEditing.ts index 607998cef7..e8799d0c97 100644 --- a/packages/blueprints-integration/src/userEditing.ts +++ b/packages/blueprints-integration/src/userEditing.ts @@ -2,11 +2,15 @@ import type { 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 { SourceLayerType } from './content' +import { DefaultUserOperationsTypes } from './ingest' /** * Description of a user performed editing operation allowed on an document */ -export type UserEditingDefinition = UserEditingDefinitionAction | UserEditingDefinitionForm +export type UserEditingDefinition = + | UserEditingDefinitionAction + | UserEditingDefinitionForm + | UserEditingDefinitionSofieDefault /** * A simple 'action' that can be performed @@ -40,11 +44,22 @@ export interface UserEditingDefinitionForm { currentValues: Record } +/** + * A built in Sofie User operation + */ +export interface UserEditingDefinitionSofieDefault { + type: UserEditingType.SOFIE + /** Id of this operation */ + id: DefaultUserOperationsTypes +} + export enum UserEditingType { /** Action */ ACTION = 'action', /** Form */ FORM = 'form', + /** Operation for the Built-in Sofie Rich Editing UI */ + SOFIE = 'sofie', } export interface UserEditingSourceLayer { diff --git a/packages/corelib/src/dataModel/UserEditingDefinitions.ts b/packages/corelib/src/dataModel/UserEditingDefinitions.ts index 194b604054..b54b4f7ed8 100644 --- a/packages/corelib/src/dataModel/UserEditingDefinitions.ts +++ b/packages/corelib/src/dataModel/UserEditingDefinitions.ts @@ -3,10 +3,14 @@ import type { JSONBlob, JSONSchema, UserEditingSourceLayer, + DefaultUserOperationsTypes, } 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 @@ -83,3 +87,9 @@ export interface CoreUserEditingProperties { /** Translation namespaces to use when rendering this form */ translationNamespaces: string[] } + +export interface CoreUserEditingDefinitionSofie { + type: UserEditingType.SOFIE + /** Id of this operation */ + id: DefaultUserOperationsTypes +} diff --git a/packages/job-worker/src/blueprints/context/lib.ts b/packages/job-worker/src/blueprints/context/lib.ts index 60539616ec..bd0585ea58 100644 --- a/packages/job-worker/src/blueprints/context/lib.ts +++ b/packages/job-worker/src/blueprints/context/lib.ts @@ -57,6 +57,7 @@ import { UserEditingDefinition, UserEditingDefinitionAction, UserEditingDefinitionForm, + UserEditingDefinitionSofieDefault, UserEditingProperties, UserEditingType, } from '@sofie-automation/blueprints-integration/dist/userEditing' @@ -516,22 +517,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'), svgIcon: userEdit.svgIcon, svgIconInactive: userEdit.svgIconInactive, 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 literal({ + type: UserEditingType.SOFIE, + id: userEdit.id, + }) default: assertNever(userEdit) return undefined @@ -573,23 +579,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), svgIcon: userEdit.svgIcon, svgIconInactive: userEdit.svgIconInactive, 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 literal({ + type: UserEditingType.SOFIE, + id: userEdit.id, + }) default: assertNever(userEdit) return undefined diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index 502d3d19f7..d07729e87d 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -172,6 +172,7 @@ import * as RundownResolver from '../lib/RundownResolver' import { MAGIC_TIME_SCALE_FACTOR } from './SegmentTimeline/Constants' import { SelectedElementProvider, SelectedElementsContext } from './RundownView/SelectedElementsContext' import { PropertiesPanel } from './UserEditOperations/PropertiesPanel' +import { DragContextProvider } from './RundownView/DragContextProvider' const REHEARSAL_MARGIN = 1 * 60 * 1000 const HIDE_NOTIFICATIONS_AFTER_MOUNT: number | undefined = 5000 @@ -3018,6 +3019,7 @@ const RundownViewContent = translateWithTracker + {(selectionContext) => { @@ -3266,6 +3268,7 @@ const RundownViewContent = translateWithTracker + ) 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..917be0b210 --- /dev/null +++ b/packages/webui/src/client/ui/RundownView/DragContextProvider.tsx @@ -0,0 +1,145 @@ +import { PartInstanceId, PieceInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' +import { PropsWithChildren, useRef, useState } from 'react' +import { dragContext, IDragContext } 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' +import { literal } from '../../lib/tempLib' +import { DefaultUserOperationRetimePiece, DefaultUserOperationsTypes } from '@sofie-automation/blueprints-integration' + +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 || 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 = UIParts.findOne(startPartId) + const oldPart = + startPartId === ogPiece.instance.piece.startPartId ? part : UIParts.findOne(ogPiece.instance.piece.startPartId) + 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 ctx = literal({ + 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 951443746d..588eb215f5 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, + DefaultUserOperationsTypes, +} from '@sofie-automation/blueprints-integration' import { RundownUtils } from '../../lib/rundown' import { DefaultLayerItemRenderer } from './Renderers/DefaultLayerItemRenderer' import { MicSourceRenderer } from './Renderers/MicSourceRenderer' @@ -22,7 +28,8 @@ 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 { useSelectedElementsContext } from '../RundownView/SelectedElementsContext' -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 @@ -111,6 +118,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, @@ -176,10 +185,42 @@ 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() + console.log( + 'mousedown', + UserEditingType.SOFIE, + DefaultUserOperationsTypes, + piece.instance.piece.userEditOperations?.map( + (op) => op.type === UserEditingType.SOFIE && op.id === '__sofie-retime-piece' + ) + ) + + if ( + !piece.instance.piece.userEditOperations?.find( + // (op) => op.type === UserEditingType.SOFIE && op.id === DefaultUserOperationsTypes.RETIME_PIECE + (op) => op.type === UserEditingType.SOFIE && op.id === '__sofie-retime-piece' + ) + ) + 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) { diff --git a/packages/webui/src/client/ui/UserEditOperations/RenderUserEditOperations.tsx b/packages/webui/src/client/ui/UserEditOperations/RenderUserEditOperations.tsx index f33ab76b4e..57dcbe8aab 100644 --- a/packages/webui/src/client/ui/UserEditOperations/RenderUserEditOperations.tsx +++ b/packages/webui/src/client/ui/UserEditOperations/RenderUserEditOperations.tsx @@ -86,6 +86,8 @@ export function UserEditOperationMenuItems({ {translateMessage(userEditOperation.label, t)} ) + case UserEditingType.SOFIE: + return null default: assertNever(userEditOperation) return null From b0759678a22ba881aa3ddd9ccb4acd8bb29e44ae Mon Sep 17 00:00:00 2001 From: Mint de Wit Date: Wed, 18 Dec 2024 13:37:54 +0000 Subject: [PATCH 3/3] feat: edit mode for drag operations --- .../blueprints-integration/src/triggers.ts | 7 + .../src/triggers/RundownViewEventBus.ts | 7 + .../meteor-lib/src/triggers/actionFactory.ts | 13 + .../shared-lib/src/core/model/ShowStyle.ts | 1 + .../src/client/lib/ui/pieceUiClassNames.ts | 5 +- .../src/client/styles/elementSelected.scss | 28 +- packages/webui/src/client/ui/RundownView.tsx | 471 +++++++++--------- .../src/client/ui/RundownView/DragContext.ts | 5 + .../ui/RundownView/DragContextProvider.tsx | 25 +- .../ui/SegmentTimeline/SourceLayerItem.tsx | 27 +- .../actionSelector/ActionSelector.tsx | 49 ++ 11 files changed, 372 insertions(+), 266 deletions(-) diff --git a/packages/blueprints-integration/src/triggers.ts b/packages/blueprints-integration/src/triggers.ts index c360fa6567..49b662add5 100644 --- a/packages/blueprints-integration/src/triggers.ts +++ b/packages/blueprints-integration/src/triggers.ts @@ -265,6 +265,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[] @@ -318,6 +324,7 @@ export type SomeAction = | IRundownPlaylistResetAction | IRundownPlaylistResyncAction | IShelfAction + | IEditModeAction | IGoToOnAirLineAction | IRewindSegmentsAction | IShowEntireCurrentSegmentAction diff --git a/packages/meteor-lib/src/triggers/RundownViewEventBus.ts b/packages/meteor-lib/src/triggers/RundownViewEventBus.ts index 8460e67108..3419b98036 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 @@ class RundownViewEventBus0 extends EventEmitter { emit(event: RundownViewEvents.SEGMENT_ZOOM_ON): boolean emit(event: RundownViewEvents.SEGMENT_ZOOM_OFF): boolean emit(event: RundownViewEvents.SHELF_STATE, e: ShelfStateEvent): boolean + emit(event: RundownViewEvents.EDIT_MODE, e: EditModeEvent): boolean emit(event: RundownViewEvents.REVEAL_IN_SHELF, e: RevealInShelfEvent): boolean emit(event: RundownViewEvents.SWITCH_SHELF_TAB, e: SwitchToShelfTabEvent): boolean emit(event: RundownViewEvents.MINI_SHELF_QUEUE_ADLIB, e: MiniShelfQueueAdLibEvent): boolean @@ -175,6 +181,7 @@ class RundownViewEventBus0 extends EventEmitter { on(event: RundownViewEvents.SEGMENT_ZOOM_OFF, listener: () => void): this on(event: RundownViewEvents.REVEAL_IN_SHELF, listener: (e: RevealInShelfEvent) => void): this on(event: RundownViewEvents.SHELF_STATE, listener: (e: ShelfStateEvent) => void): this + on(event: RundownViewEvents.EDIT_MODE, listener: (e: EditModeEvent) => void): this on(event: RundownViewEvents.SWITCH_SHELF_TAB, listener: (e: SwitchToShelfTabEvent) => void): this on(event: RundownViewEvents.MINI_SHELF_QUEUE_ADLIB, listener: (e: MiniShelfQueueAdLibEvent) => void): this on(event: RundownViewEvents.GO_TO_PART, listener: (e: GoToPartEvent) => void): this diff --git a/packages/meteor-lib/src/triggers/actionFactory.ts b/packages/meteor-lib/src/triggers/actionFactory.ts index 7796716ffc..23fad78dec 100644 --- a/packages/meteor-lib/src/triggers/actionFactory.ts +++ b/packages/meteor-lib/src/triggers/actionFactory.ts @@ -287,6 +287,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, @@ -443,6 +454,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 9c1825ff28..283bb2f87d 100644 --- a/packages/shared-lib/src/core/model/ShowStyle.ts +++ b/packages/shared-lib/src/core/model/ShowStyle.ts @@ -104,6 +104,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 a66a68e8fc..38c4afd70d 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) : '' @@ -57,5 +58,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 ed37ae296c..65a4c92e5c 100644 --- a/packages/webui/src/client/styles/elementSelected.scss +++ b/packages/webui/src/client/styles/elementSelected.scss @@ -3,16 +3,20 @@ $glow-color: rgba(255, 255, 255, 0.58); .element-selected { - box-shadow: inset 0 0 15px $glow-color; - animation: subtle-glow 1s ease-in-out infinite; + box-shadow: inset 0 0 15px $glow-color; + animation: subtle-glow 1s ease-in-out infinite; - @keyframes subtle-glow { - 0%, 100% { - box-shadow: inset 0 0 15px $glow-color; - } - 50% { - box-shadow: inset 0 0 25px $glow-color, - inset 0 0 35px $glow-color; - } - } -} \ No newline at end of file + @keyframes subtle-glow { + 0%, + 100% { + box-shadow: inset 0 0 15px $glow-color; + } + 50% { + box-shadow: inset 0 0 25px $glow-color, inset 0 0 35px $glow-color; + } + } +} + +.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 d07729e87d..2c1e519513 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -3020,242 +3020,245 @@ 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 && } - - - 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.state.isNotificationsCenterOpen && - selectionContext.listSelectedElements().length > 0 && ( -
- -
+ + + {(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 && } + + + 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 && ( - + + + + + + selectionContext.clearAndSetSelection({ type: 'segment', elementId: id }) + } + onEditPartProps={(id) => + selectionContext.clearAndSetSelection({ type: 'part', elementId: id }) + } + studioMode={this.props.userPermissions.studio} + enablePlayFromAnywhere={!!studio.settings.enablePlayFromAnywhere} + enableQuickLoop={!!studio.settings.enableQuickLoop} + enableUserEdits={!!studio.settings.enableUserEdits} /> - )} - - - - - - - selectionContext.clearAndSetSelection({ type: 'segment', elementId: id }) - } - onEditPartProps={(id) => - selectionContext.clearAndSetSelection({ type: 'part', elementId: id }) - } - 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 index c47a03bb9a..dff67253e5 100644 --- a/packages/webui/src/client/ui/RundownView/DragContext.ts +++ b/packages/webui/src/client/ui/RundownView/DragContext.ts @@ -26,6 +26,11 @@ export interface IDragContext { */ 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 */ diff --git a/packages/webui/src/client/ui/RundownView/DragContextProvider.tsx b/packages/webui/src/client/ui/RundownView/DragContextProvider.tsx index 917be0b210..348c96af68 100644 --- a/packages/webui/src/client/ui/RundownView/DragContextProvider.tsx +++ b/packages/webui/src/client/ui/RundownView/DragContextProvider.tsx @@ -1,5 +1,5 @@ import { PartInstanceId, PieceInstanceId, SegmentId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { PropsWithChildren, useRef, useState } from 'react' +import { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react' import { dragContext, IDragContext } from './DragContext' import { PieceUi } from '../SegmentContainer/withResolvedSegment' import { doUserAction, UserAction } from '../../lib/clientUserAction' @@ -9,6 +9,10 @@ import { UIParts } from '../Collections' import { Segments } from '../../collections' import { literal } from '../../lib/tempLib' import { DefaultUserOperationRetimePiece, DefaultUserOperationsTypes } from '@sofie-automation/blueprints-integration' +import RundownViewEventBus, { + RundownViewEvents, + EditModeEvent, +} from '@sofie-automation/meteor-lib/dist/triggers/RundownViewEventBus' const DRAG_TIMEOUT = 10000 @@ -21,6 +25,8 @@ export function DragContextProvider({ t, children }: PropsWithChildren): 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) @@ -133,10 +139,27 @@ export function DragContextProvider({ t, children }: PropsWithChildren): 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, }) diff --git a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx index 588eb215f5..8c20b6baaa 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx @@ -119,6 +119,9 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele 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, @@ -189,25 +192,11 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - console.log( - 'mousedown', - UserEditingType.SOFIE, - DefaultUserOperationsTypes, - piece.instance.piece.userEditOperations?.map( - (op) => op.type === UserEditingType.SOFIE && op.id === '__sofie-retime-piece' - ) - ) - if ( - !piece.instance.piece.userEditOperations?.find( - // (op) => op.type === UserEditingType.SOFIE && op.id === DefaultUserOperationsTypes.RETIME_PIECE - (op) => op.type === UserEditingType.SOFIE && op.id === '__sofie-retime-piece' - ) - ) - return + if (!hasDraggableElement) return const targetPos = (e.target as HTMLDivElement).getBoundingClientRect() - if (dragCtx) + if (dragCtx && dragCtx.enabled) dragCtx.startDrag( piece, timeScale, @@ -219,7 +208,7 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele part.instance.segmentId ) }, - [piece, timeScale] + [piece, timeScale, dragCtx] ) const itemMouseUp = useCallback((e: any) => { const eM = e as MouseEvent @@ -570,8 +559,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 d9f150dcf3..a0af63d26e 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 @@ -89,6 +89,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: @@ -143,6 +154,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: @@ -189,6 +202,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: @@ -362,6 +377,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: