diff --git a/apps/app/src/electron/EverythingService.ts b/apps/app/src/electron/EverythingService.ts index 67966250..737b3840 100644 --- a/apps/app/src/electron/EverythingService.ts +++ b/apps/app/src/electron/EverythingService.ts @@ -30,7 +30,7 @@ import { } from '../lib/util' import { PartialDeep } from 'type-fest' import deepExtend from 'deep-extend' -import { Group, PlayingPart } from '../models/rundown/Group' +import { Group, GroupViewMode, PlayingPart } from '../models/rundown/Group' import { Part } from '../models/rundown/Part' import { TSRTimelineObj, @@ -2162,6 +2162,56 @@ export class EverythingService implements ConvertToServerSide ledgerKey: arg.rundownId, } } + async setGroupViewMode(arg: { + rundownId: string + groupId: string + viewMode: GroupViewMode + }): Promise> { + const { rundown, group } = this.getGroup(arg) + const originalValue = group.viewMode + + group.viewMode = arg.viewMode + + this._saveUpdates({ rundownId: arg.rundownId, rundown, group, noEffectOnPlayout: true }) + + return { + undo: () => { + const { rundown, group } = this.getGroup(arg) + + group.viewMode = originalValue + + this._saveUpdates({ rundownId: arg.rundownId, rundown, group, noEffectOnPlayout: true }) + }, + description: ActionDescription.ToggleGroupCollapse, + } + } + async setAllGroupsViewMode(arg: { rundownId: string; viewMode: GroupViewMode }): Promise> { + const { rundown } = this.getRundown(arg) + + const originalValues = new Map(rundown.groups.map((g) => [g.id, g.viewMode])) + + for (const group of rundown.groups) { + group.viewMode = arg.viewMode + } + + this._saveUpdates({ rundownId: arg.rundownId, rundown, noEffectOnPlayout: true }) + + return { + undo: () => { + const { rundown } = this.getRundown(arg) + + for (const group of rundown.groups) { + const originalValue = originalValues.get(group.id) + if (originalValue !== undefined) { + group.viewMode = originalValue + } + } + + this._saveUpdates({ rundownId: arg.rundownId, rundown, noEffectOnPlayout: true }) + }, + description: ActionDescription.ToggleAllGroupsCollapse, + } + } async refreshResources(): Promise { this.callbacks.refreshResources() } diff --git a/apps/app/src/electron/api/GroupService.ts b/apps/app/src/electron/api/GroupService.ts index b308b940..22e8be43 100644 --- a/apps/app/src/electron/api/GroupService.ts +++ b/apps/app/src/electron/api/GroupService.ts @@ -6,7 +6,7 @@ import { PartialDeep } from 'type-fest/source/partial-deep' import { ClientEventBus } from '../ClientEventBus' import { ResourceAny } from '@shared/models' import { MoveTarget } from '../../lib/util' -import { Group } from '../../models/rundown/Group' +import { Group, GroupViewMode } from '../../models/rundown/Group' import { ServiceTypes } from '../../ipc/IPCAPI' interface GroupData { @@ -96,4 +96,22 @@ export class GroupService extends EventEmitter { const result = await this.everythingService.duplicateGroup(data) if (!result) throw new GeneralError() } + + async setViewMode(data: { rundownId: string; groupId: string; viewMode: GroupViewMode }): Promise { + // TODO: access control + const result = await this.everythingService.setGroupViewMode({ + rundownId: data.rundownId, + groupId: data.groupId, + viewMode: data.viewMode, + }) + if (!result) throw new GeneralError() + } + async setAllViewMode(data: { rundownId: string; viewMode: GroupViewMode }): Promise { + // TODO: access control + const result = await this.everythingService.setAllGroupsViewMode({ + rundownId: data.rundownId, + viewMode: data.viewMode, + }) + if (!result) throw new GeneralError() + } } diff --git a/apps/app/src/electron/storageHandler.ts b/apps/app/src/electron/storageHandler.ts index 0dc0e858..c3ac8446 100644 --- a/apps/app/src/electron/storageHandler.ts +++ b/apps/app/src/electron/storageHandler.ts @@ -19,6 +19,7 @@ import { ensureValidId, ensureValidObject } from '../lib/TimelineObj' import { AnalogInput } from '../models/project/AnalogInput' import { ValidatorCache } from 'graphics-data-definition' import { Bridge } from '../models/project/Bridge' +import { GroupViewMode } from '../models/rundown/Group' const fsWriteFile = fs.promises.writeFile const fsAppendFile = fs.promises.appendFile @@ -1245,6 +1246,9 @@ export class StorageHandler extends EventEmitter { playingParts: {}, } } + if (group.viewMode === undefined) { + group.viewMode = GroupViewMode.TIMELINE + } if (!group.autoFill) { group.autoFill = getDefaultGroup().autoFill } diff --git a/apps/app/src/ipc/IPCAPI.ts b/apps/app/src/ipc/IPCAPI.ts index acd17c96..d1ca4f88 100644 --- a/apps/app/src/ipc/IPCAPI.ts +++ b/apps/app/src/ipc/IPCAPI.ts @@ -5,7 +5,7 @@ import { ResourceAny, ResourceId, MetadataAny, SerializedProtectedMap, TSRDevice import { Rundown } from '../models/rundown/Rundown' import { TimelineObj } from '../models/rundown/TimelineObj' import { Part } from '../models/rundown/Part' -import { Group } from '../models/rundown/Group' +import { Group, GroupViewMode } from '../models/rundown/Group' import { AppData } from '../models/App/AppData' import { PeripheralArea, PeripheralStatus } from '../models/project/Peripheral' import { ActiveTrigger, ActiveTriggers, ApplicationTrigger, RundownTrigger } from '../models/rundown/Trigger' @@ -64,6 +64,8 @@ export const ClientMethods: ServiceKeyArrays = { 'playPrev', 'remove', 'update', + 'setViewMode', + 'setAllViewMode', ], [ServiceName.LEGACY]: [], [ServiceName.PARTS]: [ @@ -303,6 +305,8 @@ export interface IPCServerMethods { toggleGroupLock: (arg: { rundownId: string; groupId: string; value: boolean }) => void toggleGroupCollapse: (arg: { rundownId: string; groupId: string; value: boolean }) => void toggleAllGroupsCollapse: (arg: { rundownId: string; value: boolean }) => void + setGroupViewMode: (arg: { rundownId: string; groupId: string; viewMode: GroupViewMode }) => void + setAllGroupsViewMode: (arg: { rundownId: string; viewMode: GroupViewMode }) => void refreshResources: () => void refreshResourcesSetAuto: (arg: { interval: number }) => void triggerHandleAutoFill: () => void diff --git a/apps/app/src/lib/defaults.ts b/apps/app/src/lib/defaults.ts index 35f70bb1..1eff94c9 100644 --- a/apps/app/src/lib/defaults.ts +++ b/apps/app/src/lib/defaults.ts @@ -1,6 +1,6 @@ import { Project } from '../models/project/Project' import { Rundown } from '../models/rundown/Rundown' -import { AutoFillMode, AutoFillSortMode, Group, PlayoutMode } from '../models/rundown/Group' +import { AutoFillMode, AutoFillSortMode, Group, GroupViewMode, PlayoutMode } from '../models/rundown/Group' import { INTERNAL_BRIDGE_ID } from '../models/project/Bridge' import { DeviceType, MappingCasparCG, TimelineContentTypeCasparCg } from 'timeline-state-resolver-types' import { literal } from '@shared/lib' @@ -241,6 +241,7 @@ export function getDefaultGroup(): Omit { type: RepeatingType.NO_REPEAT, }, }, + viewMode: GroupViewMode.TIMELINE, } } export function getDefaultPart(): Omit { diff --git a/apps/app/src/lib/partTimeline.ts b/apps/app/src/lib/partTimeline.ts index 6824ee29..00a1b7ea 100644 --- a/apps/app/src/lib/partTimeline.ts +++ b/apps/app/src/lib/partTimeline.ts @@ -26,14 +26,15 @@ interface SortedLayer { layerId: string objectIds: string[] } +export interface TimelineObjectsOnLayer { + layerId: string + objectsOnLayer: Array<{ resolved: ResolvedTimelineObject['resolved']; timelineObj: TimelineObj }> +} export function timelineObjsOntoLayers( sortedLayers: SortedLayer[], resolvedTimeline: ResolvedTimeline, timeline: TimelineObj[] -): Array<{ - layerId: string - objectsOnLayer: Array<{ resolved: ResolvedTimelineObject['resolved']; timelineObj: TimelineObj }> -}> { +): Array { return sortedLayers.map(({ layerId, objectIds }) => { const objectsOnLayer: { resolved: ResolvedTimelineObject['resolved'] diff --git a/apps/app/src/models/rundown/Group.ts b/apps/app/src/models/rundown/Group.ts index ff3ca4b9..7af56acc 100644 --- a/apps/app/src/models/rundown/Group.ts +++ b/apps/app/src/models/rundown/Group.ts @@ -37,6 +37,9 @@ export interface GroupBase { /** Whether or not this Group should be visually collapsed in the app view. Does not affect playout. */ collapsed?: boolean + /** How the group is displayed in the GUI. Defaults to Timeline. */ + viewMode: GroupViewMode + /** An additional, optional ID to be used by API clients to track the Groups they are responsible for */ externalId?: string } @@ -104,3 +107,8 @@ export interface ScheduleSettings { startTime?: DateTimeObject repeating: RepeatingSettingsAny } + +export enum GroupViewMode { + TIMELINE = 0, // default + BUTTONS = 1, +} diff --git a/apps/app/src/react/api/ApiClient.ts b/apps/app/src/react/api/ApiClient.ts index 380fe34f..9a122706 100644 --- a/apps/app/src/react/api/ApiClient.ts +++ b/apps/app/src/react/api/ApiClient.ts @@ -268,6 +268,12 @@ export class ApiClient { ): ServerReturn<'toggleAllGroupsCollapse'> { return this.invokeServerMethod('toggleAllGroupsCollapse', ...args) } + async setGroupViewMode(...args: ServerArgs<'setGroupViewMode'>): ServerReturn<'setGroupViewMode'> { + return this.groupService.setViewMode(...args) + } + async setAllGroupsViewMode(...args: ServerArgs<'setAllGroupsViewMode'>): ServerReturn<'setAllGroupsViewMode'> { + return this.groupService.setAllViewMode(...args) + } async refreshResources(...args: ServerArgs<'refreshResources'>): ServerReturn<'refreshResources'> { return this.invokeServerMethod('refreshResources', ...args) } diff --git a/apps/app/src/react/components/inputs/Btn/style.scss b/apps/app/src/react/components/inputs/Btn/style.scss index 39c48270..9380f2a8 100644 --- a/apps/app/src/react/components/inputs/Btn/style.scss +++ b/apps/app/src/react/components/inputs/Btn/style.scss @@ -1,5 +1,9 @@ +@use 'sass:color'; @import './../../../styles/foundation/variables'; +$btnTextColor: #fff; +$btnBgColor: #545a78; + .btn { cursor: pointer; display: inline-flex; @@ -19,18 +23,13 @@ text-transform: uppercase; border-radius: 4px; - color: #fff; - background: #545a78; + color: $btnTextColor; + background: $btnBgColor; padding: 6px 16px; min-width: 22px; min-height: 22px; border: none; - // background: none; - // opacity: 0.3; - // &.selected { - // opacity: 1; - // } transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; @@ -48,7 +47,8 @@ } &.disabled { - opacity: 0.5; + color: color.adjust($btnTextColor, $lightness: -40%); + background: color.adjust($btnBgColor, $lightness: -10%); pointer-events: none; cursor: inherit; } diff --git a/apps/app/src/react/components/rundown/GroupView/GroupView.tsx b/apps/app/src/react/components/rundown/GroupView/GroupView.tsx index ac2bd73f..adfd394f 100644 --- a/apps/app/src/react/components/rundown/GroupView/GroupView.tsx +++ b/apps/app/src/react/components/rundown/GroupView/GroupView.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState, useContext, useCallback } from 'react' import sorensen from '@sofie-automation/sorensen' import { TrashBtn } from '../../inputs/TrashBtn' -import { GroupBase, GroupGUI } from '../../../../models/rundown/Group' +import { GroupBase, GroupGUI, GroupViewMode } from '../../../../models/rundown/Group' import { PartView } from './PartView' import { GroupPreparedPlayData, SectionEndAction } from '../../../../models/GUI/PreparedPlayhead' import { IPCServerContext } from '../../../contexts/IPCServer' @@ -30,6 +30,8 @@ import { MdPlaylistPlay, MdRepeat, MdOutlineDragIndicator, + MdGridView, + MdOutlineViewAgenda, } from 'react-icons/md' import { IoMdEye } from 'react-icons/io' import { RiEyeCloseLine } from 'react-icons/ri' @@ -55,6 +57,7 @@ import { ErrorBoundary } from '../../util/ErrorBoundary' import { DISPLAY_DECIMAL_COUNT } from '../../../constants' import { useFrame } from '../../../lib/useFrame' import { AntiWiggle } from '../../util/AntiWiggle/AntiWiggle' +import { PartButtonView } from './PartButtonView' const DEFAULT_PART_HEIGHT = 80 @@ -137,6 +140,7 @@ export const GroupView: React.FC<{ if ( targetEl.closest('.part') || + targetEl.closest('.part-button') || targetEl.closest('.controls-left>*') || targetEl.closest('.controls-right>*') || targetEl.closest('button') || @@ -473,6 +477,31 @@ export const GroupView: React.FC<{ .catch(handleError) }, [group.id, group.locked, handleError, ipcServer, rundownId]) + const cycleViewMode = useCallback(() => { + let newValue: GroupViewMode = GroupViewMode.TIMELINE + if (group.viewMode === GroupViewMode.TIMELINE) { + newValue = GroupViewMode.BUTTONS + } + + const pressed = sorensen.getPressedKeys() + if (pressed.includes('AltLeft') || pressed.includes('AltRight')) { + ipcServer + .setAllGroupsViewMode({ + rundownId, + viewMode: newValue, + }) + .catch(handleError) + } else { + ipcServer + .setGroupViewMode({ + rundownId, + groupId: group.id, + viewMode: newValue, + }) + .catch(handleError) + } + }, [group.id, group.viewMode, handleError, ipcServer, rundownId]) + // One-at-a-time button: const toggleOneAtATime = useCallback(() => { ipcServer @@ -680,6 +709,24 @@ export const GroupView: React.FC<{ {group.locked ? : } + + {group.viewMode === GroupViewMode.TIMELINE ? ( + + ) : ( + + )} + +
{hidePartsHeight === null && - group.partIds.map((partId) => ( - - ))} + (group.viewMode === GroupViewMode.TIMELINE + ? group.partIds.map((partId) => ( + + )) + : group.partIds.map((partId) => ( + + )))}
- {!group.locked && } + {!group.locked && } )} @@ -856,7 +916,7 @@ export const GroupView: React.FC<{ } }) -const GroupOptions: React.FC<{ rundownId: string; group: GroupGUI }> = ({ rundownId, group }) => { +const GroupAddPartArea: React.FC<{ rundownId: string; group: GroupGUI }> = ({ rundownId, group }) => { const ipcServer = useContext(IPCServerContext) const { handleError } = useContext(ErrorHandlerContext) const [newPartOpen, setNewPartOpen] = React.useState(false) diff --git a/apps/app/src/react/components/rundown/GroupView/PartButtonView.tsx b/apps/app/src/react/components/rundown/GroupView/PartButtonView.tsx new file mode 100644 index 00000000..e3ec618f --- /dev/null +++ b/apps/app/src/react/components/rundown/GroupView/PartButtonView.tsx @@ -0,0 +1,815 @@ +import React, { useContext, useLayoutEffect, useMemo, useRef, useState, useEffect, useCallback } from 'react' +import _ from 'lodash' +import sorensen from '@sofie-automation/sorensen' +import { + ResolvedTimeline, + ResolvedTimelineObject, + Resolver, + ResolverCache, + TimelineObjectInstance, +} from 'superfly-timeline' +import { allowMovingPartIntoGroup, getResolvedTimelineTotalDuration, MoveTarget } from '../../../../lib/util' +import { Group, PlayoutMode } from '../../../../models/rundown/Group' +import classNames from 'classnames' +import { IPCServerContext } from '../../../contexts/IPCServer' +import { DropTargetMonitor, useDrag, useDrop, XYCoord } from 'react-dnd' +import { DragItemTypes, isPartDragItem, PartDragItem } from '../../../api/DragItemTypes' +import { MdOutlineDragIndicator, MdMoreHoriz } from 'react-icons/md' +import { TimelineObj, DEFAULT_DURATION } from '../../../../models/rundown/TimelineObj' +import { Mappings } from 'timeline-state-resolver-types' +import { SnapPoint } from '../../../../lib/moveTimelineObj' +import { HotkeyContext } from '../../../contexts/Hotkey' +import { ErrorHandlerContext } from '../../../contexts/ErrorHandler' +import { store } from '../../../mobx/store' +import { observer } from 'mobx-react-lite' +import { computed } from 'mobx' +import { LoggerContext } from '../../../contexts/Logger' +import { useMemoComputedObject } from '../../../mobx/lib' +import { TimelineObjectMove } from '../../../mobx/GuiStore' +import { ToggleBtn } from '../../inputs/ToggleBtn/ToggleBtn' +import VisibilitySensor from 'react-visibility-sensor' +import { sortSelected } from '../../../lib/clientUtil' +import { sortLayers, timelineObjsOntoLayers } from '../../../../lib/partTimeline' +import { + PartTimeline, + PartControlButtons, + PartEditControls, + usePartModifiedTimeline, + selectPart, + sortSnapPoints, +} from './PartView' +import { TrashBtn } from '../../inputs/TrashBtn' +import { DuplicateBtn } from '../../inputs/DuplicateBtn' +import { ConfirmationDialog } from '../../util/ConfirmationDialog' + +/** + * How close an edge of a timeline object needs to be to another edge before it will snap to that edge (in pixels). + */ +const SNAP_DISTANCE_IN_PIXELS = 10 + +/** + * A an array of unique identifiers indicating which timelineObj move actions have been handled. + */ +const HANDLED_MOVE_IDS: string[] = [] + +/** + * The maximum length of the HANDLED_MOVE_IDS array. + */ +const MAX_HANDLED_MOVE_IDS = 100 + +export const PartButtonView: React.FC<{ + rundownId: string + parentGroupId: string + partId: string + mappings: Mappings +}> = observer(function PartView({ rundownId, parentGroupId, partId, mappings }) { + const part = store.rundownsStore.getPart(partId) + const ipcServer = useContext(IPCServerContext) + + const hotkeyContext = useContext(HotkeyContext) + const { handleError } = useContext(ErrorHandlerContext) + const log = useContext(LoggerContext) + const layersDivRef = useRef(null) + const changedObjects = useRef<{ + [objectId: string]: TimelineObj + } | null>(null) + const duplicatedObjects = useRef<{ + [objectId: string]: TimelineObj + } | null>(null) + const objectsToMoveToNewLayer = useRef(null) + const [trackWidth, setTrackWidth] = useState(0) + const [bypassSnapping, setBypassSnapping] = useState(false) + const [waitingForBackendUpdate, setWaitingForBackendUpdate] = useState(false) + + const cache = useRef({}) + + const [extended, setExtended] = useState(false) + + const selectable = true + const isSelected = computed(() => + store.guiStore.isSelected({ + type: 'part', + groupId: parentGroupId, + partId: partId, + }) + ) + const updateSelection = (event: React.MouseEvent) => { + if (!selectable) return + const targetEl = event.target as HTMLElement + + if ( + targetEl.closest('.timeline-object') || + targetEl.closest('.layer-names-dropdown') || + targetEl.closest('button') || + targetEl.closest('input') || + targetEl.closest('.editable') || + targetEl.closest('.MuiModal-root') + ) + return + + selectPart(rundownId, parentGroupId, partId) + } + + const timelineObjMove = useMemoComputedObject( + () => { + const objMove = store.guiStore.timelineObjMove + + if (objMove.partId === part.id) { + return objMove + } else { + return { + moveType: null, + wasMoved: null, + partId: null, + moveId: null, + hoveredLayerId: null, + } + } + }, + [part.id], + true + ) + + const { orgMaxDuration, orgResolvedTimeline, resolverErrorMessage } = useMemoComputedObject(() => { + let errorMessage = '' + + const partTimeline = store.rundownsStore.getPartTimeline(partId) + let orgResolvedTimeline: ResolvedTimeline + try { + orgResolvedTimeline = Resolver.resolveTimeline( + partTimeline.map((o) => o.obj), + { time: 0, cache: cache.current } + ) + /** Max duration for display. Infinite objects are counted to this */ + } catch (e) { + orgResolvedTimeline = { + options: { + time: Date.now(), + }, + objects: {}, + classes: {}, + layers: {}, + statistics: { + unresolvedCount: 0, + resolvedCount: 0, + resolvedInstanceCount: 0, + resolvedObjectCount: 0, + resolvedGroupCount: 0, + resolvedKeyframeCount: 0, + resolvingCount: 0, + }, + } + errorMessage = `Fatal error in timeline: ${e}` + } + + return { + orgResolvedTimeline, + orgMaxDuration: orgResolvedTimeline + ? part.duration ?? getResolvedTimelineTotalDuration(orgResolvedTimeline, true) + : 0, + resolverErrorMessage: errorMessage, + } + // }, [part.timeline, trackWidth]) + }, [partId, part.duration]) + + const maxDurationAdjusted = orgMaxDuration || DEFAULT_DURATION + + const msPerPixel = maxDurationAdjusted / trackWidth + const snapDistanceInMilliseconds = msPerPixel * SNAP_DISTANCE_IN_PIXELS + + const snapPoints = useMemo(() => { + const snapPoints: Array = [] + + for (const timelineObj of Object.values(orgResolvedTimeline.objects)) { + if (Array.isArray(timelineObj.enable)) { + return + } + const instance = timelineObj.resolved.instances[0] as TimelineObjectInstance | undefined + if (!instance) continue + + const referring: string = [...instance.references, ...timelineObj.resolved.directReferences].join(',') + + snapPoints.push({ + timelineObjId: timelineObj.id, + time: instance.start, + expression: `#${timelineObj.id}.start`, + referring, + }) + if (instance.end) { + snapPoints.push({ + timelineObjId: timelineObj.id, + time: instance.end, + expression: `#${timelineObj.id}.end`, + referring, + }) + } + } + snapPoints.sort(sortSnapPoints) + + return snapPoints + }, [orgResolvedTimeline]) + + /** + * This useEffect hook is part of a solution to the problem of + * timelineObjs briefly flashing back to their original start position + * after the user releases their mouse button after performing a drag move. + * + * In other words: this solves a purely aesthetic problem. + */ + useEffect(() => { + if (waitingForBackendUpdate) { + // A move has completed and we're waiting for the backend to give us the updated timelineObjs. + + return () => { + // The backend has updated us (we know because `part` now points to a new object) + // and we can clear the partId of the `move` context so that we stop displaying + // timelineObjs with a drag delta applied. + // + // This is where a move operation has truly completed, including the backend response. + setWaitingForBackendUpdate(false) + store.guiStore.updateTimelineObjMove({ + partId: null, + moveId: undefined, + }) + } + } + }, [waitingForBackendUpdate, part]) + + // Initialize trackWidth. + useLayoutEffect(() => { + if (layersDivRef.current) { + const size = layersDivRef.current.getBoundingClientRect() + setTrackWidth(size.width) + } + }, []) + + // Update trackWidth at the end of a move. + // @TODO: Update trackWidth _during_ a move? + useLayoutEffect(() => { + if (timelineObjMove.moveType && timelineObjMove.partId === part.id && layersDivRef.current) { + const size = layersDivRef.current.getBoundingClientRect() + setTrackWidth(size.width) + } + }, [timelineObjMove.moveType, timelineObjMove.partId, part.id]) + + const { modifiedTimeline, resolvedTimeline, newChangedObjects, newDuplicatedObjects, newObjectsToMoveToNewLayer } = + usePartModifiedTimeline( + timelineObjMove, + part.id, + mappings, + handleError, + orgResolvedTimeline, + bypassSnapping, + snapPoints, + snapDistanceInMilliseconds, + log + ) + + useEffect(() => { + if (newObjectsToMoveToNewLayer && !_.isEmpty(newObjectsToMoveToNewLayer)) { + changedObjects.current = null + } else if (newChangedObjects && !_.isEmpty(newChangedObjects)) { + changedObjects.current = newChangedObjects + + if (newDuplicatedObjects && !_.isEmpty(newDuplicatedObjects)) { + duplicatedObjects.current = newDuplicatedObjects + } else { + duplicatedObjects.current = null + } + } + }, [newChangedObjects, newObjectsToMoveToNewLayer, newDuplicatedObjects]) + + useEffect(() => { + // Handle when we stop moving: + + if ( + timelineObjMove.partId === part.id && + timelineObjMove.moveType === null && + timelineObjMove.wasMoved !== null && + timelineObjMove.moveId !== null && + !waitingForBackendUpdate && + !HANDLED_MOVE_IDS.includes(timelineObjMove.moveId) + ) { + HANDLED_MOVE_IDS.unshift(timelineObjMove.moveId) + setWaitingForBackendUpdate(true) + store.guiStore.updateTimelineObjMove({ + saving: true, + }) + + // Prevent the list of handled move IDs from growing infinitely: + if (HANDLED_MOVE_IDS.length > MAX_HANDLED_MOVE_IDS) { + HANDLED_MOVE_IDS.length = MAX_HANDLED_MOVE_IDS + } + + const promises: Promise[] = [] + + if (changedObjects.current) { + for (const obj of Object.values(changedObjects.current)) { + const promise = ipcServer.updateTimelineObj({ + rundownId: rundownId, + partId: part.id, + groupId: parentGroupId, + timelineObjId: obj.obj.id, + timelineObj: obj, + }) + promises.push(promise) + } + changedObjects.current = null + } + if (duplicatedObjects.current) { + promises.push( + ipcServer.insertTimelineObjs({ + rundownId: rundownId, + partId: part.id, + groupId: parentGroupId, + timelineObjs: Object.values(duplicatedObjects.current), + target: null, + }) + ) + duplicatedObjects.current = null + } + + if (objectsToMoveToNewLayer.current) { + for (const objId of objectsToMoveToNewLayer.current) { + const promise = ipcServer.moveTimelineObjToNewLayer({ + rundownId: rundownId, + partId: part.id, + groupId: parentGroupId, + timelineObjId: objId, + }) + promises.push(promise) + } + objectsToMoveToNewLayer.current = null + } + Promise.allSettled(promises) + .then((_results) => { + setWaitingForBackendUpdate(false) + }) + .catch((error) => { + handleError(error) + setWaitingForBackendUpdate(false) + }) + } + }, [ + // + part.id, + snapDistanceInMilliseconds, + ipcServer, + rundownId, + parentGroupId, + waitingForBackendUpdate, + handleError, + timelineObjMove.partId, + timelineObjMove.moveType, + timelineObjMove.wasMoved, + timelineObjMove.moveId, + ]) + useEffect(() => { + objectsToMoveToNewLayer.current = newObjectsToMoveToNewLayer + }, [newObjectsToMoveToNewLayer]) + + useEffect(() => { + const onKey = () => { + const pressed = sorensen.getPressedKeys() + setBypassSnapping(pressed.includes('ShiftLeft') || pressed.includes('ShiftRight')) + } + onKey() + + sorensen.bind('Shift', onKey, { + up: false, + global: true, + }) + sorensen.bind('Shift', onKey, { + up: true, + global: true, + }) + + sorensen.addEventListener('keycancel', onKey) + + return () => { + sorensen.unbind('Shift', onKey) + sorensen.removeEventListener('keycancel', onKey) + } + }, [hotkeyContext]) + + // Drag n' Drop re-ordering: + // Adapted from https://react-dnd.github.io/react-dnd/examples/sortable/simple + const dragRef = useRef(null) + const previewRef = useRef(null) + const [{ handlerId }, drop] = useDrop( + // Use case: Drag Parts over this Part, to insert the Parts above or below this Part + { + accept: DragItemTypes.PART_ITEM, + collect(monitor) { + return { + handlerId: monitor.getHandlerId(), + } + }, + canDrop: (movedItem) => { + if (!isPartDragItem(movedItem)) { + return false + } + + const parentGroup = store.rundownsStore.getGroupInCurrentRundown(parentGroupId) || null + + if (!parentGroup) { + return false + } + + for (const movePart of movedItem.parts) { + if (!allowMovingPartIntoGroup(movePart.partId, movePart.fromGroup, parentGroup)) { + return false + } + } + + return true + }, + hover(movedItem, monitor: DropTargetMonitor) { + if (!isPartDragItem(movedItem)) { + return + } + + if (!previewRef.current) { + return + } + + const parentGroup = store.rundownsStore.getGroupInCurrentRundown(parentGroupId) || null + let hoverGroup: Group | null = parentGroup + const hoverPartId = part.id + + if (parentGroup === null || hoverGroup === null) { + return + } + + // Don't replace items with themselves + if (movedItem.parts.find((p) => p.partId === hoverPartId)) { + return + } + + for (const movePart of movedItem.parts) { + if (!allowMovingPartIntoGroup(movePart.partId, movePart.fromGroup, parentGroup)) { + return false + } + } + + // Determine rectangle on screen + const hoverBoundingRect = previewRef.current?.getBoundingClientRect() + + // Get vertical middle + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2 + + // Determine mouse position + const clientOffset = monitor.getClientOffset() + + // Get pixels to the top + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top + + let target: MoveTarget + if (hoverGroup.transparent) { + // Handle transparent group moves: + if (hoverClientY < hoverMiddleY) { + target = { + type: 'before', + id: hoverGroup.id, + } + } else { + target = { + type: 'after', + id: hoverGroup.id, + } + } + hoverGroup = null + } else { + if (hoverClientY < hoverMiddleY) { + target = { + type: 'before', + id: part.id, + } + } else { + target = { + type: 'after', + id: part.id, + } + } + } + + // Time to actually perform the action + store.rundownsStore.movePartsInCurrentRundown( + movedItem.parts.map((p) => p.partId), + hoverGroup?.id ?? null, + target + ) + + // Note: we're mutating the monitor item here! + // Generally it's better to avoid mutations, + // but it's good here for the sake of performance + // to avoid expensive index searches. + movedItem.toGroupId = hoverGroup?.id ?? null + movedItem.target = target + }, + }, + [parentGroupId, part.id] + ) + const [{ isDragging }, drag, preview] = useDrag( + { + type: DragItemTypes.PART_ITEM, + item: (): PartDragItem | null => { + const parentGroup = store.rundownsStore.getGroupInCurrentRundown(parentGroupId) + + if (!parentGroup) { + return null + } + + // If this Part isn't included in the current selection, select it: + if ( + !store.guiStore.isSelected({ + type: 'part', + groupId: parentGroup.id, + partId: part.id, + }) + ) { + store.guiStore.setSelected({ + type: 'part', + groupId: parentGroup.id, + partId: part.id, + }) + } + + const selectedParts = sortSelected( + rundownId, + store.rundownsStore, + store.guiStore.getSelectedOfType('part') + ) + return { + type: DragItemTypes.PART_ITEM, + parts: selectedParts.map((selectedPart) => { + return { + partId: selectedPart.partId, + fromGroup: store.rundownsStore.getGroup(selectedPart.groupId), + } + }), + + toGroupId: parentGroupId, + target: null, + } + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + isDragging: (monitor) => { + return !!monitor.getItem().parts.find((p) => p.partId === part.id) + }, + end: () => { + store.rundownsStore.commitMovePartInCurrentRundown()?.catch(handleError) + }, + }, + [part.id, parentGroupId] + ) + + useEffect(() => { + console.log('dragRef', dragRef.current) + drag(dragRef) + }, [drag]) + + useEffect(() => { + drop(preview(previewRef)) + }, [drop, preview]) + + const groupDisabled = + computed(() => store.rundownsStore.getGroupInCurrentRundown(parentGroupId)?.disabled).get() || false + const groupLocked = + computed(() => store.rundownsStore.getGroupInCurrentRundown(parentGroupId)?.locked).get() || false + const groupOrPartDisabled = groupDisabled || part.disabled + + const groupPlayoutMode = + computed(() => store.rundownsStore.getGroupInCurrentRundown(parentGroupId)?.playoutMode).get() || + PlayoutMode.NORMAL + + const groupOrPartLocked = groupLocked || part.locked || false + const sortedLayers = useMemo(() => { + return sortLayers(resolvedTimeline.layers, mappings) + }, [mappings, resolvedTimeline.layers]) + + const { partLabel, tabAdditionalClassNames } = useMemoComputedObject(() => { + const partLabel = part.name || '' + const tabAdditionalClassNames: { [key: string]: boolean } = {} + + const firstTimelineObj = modifiedTimeline.find((obj) => obj.obj.id === sortedLayers[0]?.objectIds[0]) + if (firstTimelineObj) { + const firstTimelineObjType = (firstTimelineObj.obj.content as any).type as string + if (typeof firstTimelineObjType === 'string') { + tabAdditionalClassNames[firstTimelineObjType] = true + } + + // const objMapping = mappings[firstTimelineObj.obj.layer] + // const deviceId: TSRDeviceId = protectString(objMapping?.deviceId) + // if (objMapping) { + // const deviceMetadata = store.resourcesAndMetadataStore.getMetadata(deviceId) + // const description = describeTimelineObject(firstTimelineObj.obj, deviceMetadata) + + // partLabel = part.name || description.label || '' + // } + } + + return { + partLabel, + tabAdditionalClassNames, + } + }, [modifiedTimeline]) + + const allActionsForPart = useMemoComputedObject( + () => { + return store.rundownsStore.getActionsForPart(partId) + }, + [partId], + true + ) + + const timelineLayerObjects = timelineObjsOntoLayers(sortedLayers, resolvedTimeline, modifiedTimeline) + + const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false) + const handleDelete = useCallback(() => { + ipcServer.deletePart({ rundownId, groupId: parentGroupId, partId }).catch(handleError) + }, [parentGroupId, handleError, ipcServer, partId, rundownId]) + + const handleDuplicateBtn = useCallback(() => { + ipcServer + .duplicatePart({ + rundownId, + groupId: parentGroupId, + partId: partId, + }) + .catch(handleError) + }, [parentGroupId, handleError, ipcServer, partId, rundownId]) + + // This is used to defer initial rendering of some components, in order to improve initial rendering times: + const [renderEverything, setRenderEverything] = useState(false) + const onChange = useCallback((isVisible: boolean) => { + if (isVisible) { + setRenderEverything(true) + } else { + setRenderEverything(false) + } + }, []) + + return ( + +
+
+
+ {!groupLocked && ( +
+ +
+ )} + + {renderEverything && ( + <> + {!groupLocked ? ( + <> + + {!groupLocked && ( +
+ setExtended(!extended)} + > + + +
+ )} + {extended && ( + <> + +
+ { + const pressedKeys = sorensen.getPressedKeys() + if ( + pressedKeys.includes('ControlLeft') || + pressedKeys.includes('ControlRight') + ) { + // Delete immediately with no confirmation dialog. + handleDelete() + } else { + setDeleteConfirmationOpen(true) + } + }} + /> + +
+ + )} + + ) : ( + <> +
{partLabel}
+ + )} + + )} +
+
+ {!extended && ( +
+
+ +
+
+ {partLabel} +
+
+ )} +
+ +
+
+ { + handleDelete() + setDeleteConfirmationOpen(false) + }} + onDiscarded={() => { + setDeleteConfirmationOpen(false) + }} + > +
+ Are you sure you want to delete the part {part.name}? +
+
+ (Tip: Hold CTRL when clicking the button to skip this dialog)` +
+
+
+ + ) +}) diff --git a/apps/app/src/react/components/rundown/GroupView/PartView.tsx b/apps/app/src/react/components/rundown/GroupView/PartView.tsx index 4748fdab..6503ab9b 100644 --- a/apps/app/src/react/components/rundown/GroupView/PartView.tsx +++ b/apps/app/src/react/components/rundown/GroupView/PartView.tsx @@ -1,8 +1,6 @@ import React, { useContext, useLayoutEffect, useMemo, useRef, useState, useEffect, useCallback } from 'react' import _ from 'lodash' import sorensen from '@sofie-automation/sorensen' -import { PlayHead } from './PlayHead' -import { Layer, LayerEmpty } from './Layer' import { ResolvedTimeline, ResolvedTimelineObject, @@ -25,7 +23,6 @@ import { MdOutlineDragIndicator, MdMoreHoriz, MdLockOpen, MdLock, MdRepeatOne } import { TimelineObj, DEFAULT_DURATION } from '../../../../models/rundown/TimelineObj' import { compact, stringifyError } from '@shared/lib' import { Mappings } from 'timeline-state-resolver-types' -import { EmptyLayer } from './EmptyLayer' import { applyMovementToTimeline, SnapPoint } from '../../../../lib/moveTimelineObj' import { HotkeyContext } from '../../../contexts/Hotkey' import { ErrorHandlerContext } from '../../../contexts/ErrorHandler' @@ -40,7 +37,6 @@ import { observer } from 'mobx-react-lite' import { computed } from 'mobx' import { CurrentTime } from './part/CurrentTime/CurrentTime' import { RemainingTime } from './part/RemainingTime/RemainingTime' -import { CountdownHeads } from './part/CountdownHeads/CountdownHeads' import { PlayBtn } from '../../inputs/PlayBtn/PlayBtn' import { PauseBtn } from '../../inputs/PauseBtn/PauseBtn' import { PlayButtonData, StopBtn } from '../../inputs/StopBtn/StopBtn' @@ -56,8 +52,15 @@ import { ConfirmationDialog } from '../../util/ConfirmationDialog' import { TrashBtn } from '../../inputs/TrashBtn' import { DuplicateBtn } from '../../inputs/DuplicateBtn' import { sortSelected } from '../../../lib/clientUtil' -import { sortLayers, timelineObjsOntoLayers } from '../../../../lib/partTimeline' +import { TimelineObjectsOnLayer, sortLayers, timelineObjsOntoLayers } from '../../../../lib/partTimeline' import { convertSorensenToElectron } from '../../../../lib/triggers/identifiers' +import { PartGUI } from '../../../../models/rundown/Part' +import { RundownActionLight } from '../../../../lib/triggers/action' +import { EmptyLayer } from './EmptyLayer' +import { Layer, LayerEmpty } from './Layer' +import { PlayHead } from './PlayHead' +import { CountdownHeads } from './part/CountdownHeads/CountdownHeads' +import { LoggerLike } from '@shared/api' /** * How close an edge of a timeline object needs to be to another edge before it will snap to that edge (in pixels). @@ -125,55 +128,7 @@ export const PartView: React.FC<{ ) return - const pressed = sorensen.getPressedKeys() - if (pressed.includes('ControlLeft') || pressed.includes('ControlRight')) { - // Add this part to the selection: - store.guiStore.toggleAddSelected({ - type: 'part', - groupId: parentGroupId, - partId: partId, - }) - } else if (pressed.includes('ShiftLeft') || pressed.includes('ShiftRight')) { - // Add all parts between the last selected and this one: - const mainSelected = store.guiStore.mainSelected - if (mainSelected && mainSelected.type === 'part') { - const allPartIds: { partId: string; groupId: string }[] = [] - for (const group of store.rundownsStore.getRundownGroups(rundownId)) { - for (const part of store.rundownsStore.getGroupParts(group.id)) { - allPartIds.push({ - groupId: group.id, - partId: part.id, - }) - } - } - const mainIndex = allPartIds.findIndex((p) => p.partId === mainSelected.partId) - const thisIndex = allPartIds.findIndex((p) => p.partId === partId) - if (mainIndex === -1 || thisIndex === -1) return - if (mainIndex < thisIndex) { - for (let i = mainIndex + 1; i <= thisIndex; i++) { - store.guiStore.addSelected({ - type: 'part', - groupId: allPartIds[i].groupId, - partId: allPartIds[i].partId, - }) - } - } else if (mainIndex > thisIndex) { - for (let i = mainIndex - 1; i >= thisIndex; i--) { - store.guiStore.addSelected({ - type: 'part', - groupId: allPartIds[i].groupId, - partId: allPartIds[i].partId, - }) - } - } - } - } else { - store.guiStore.toggleSelected({ - type: 'part', - groupId: parentGroupId, - partId: partId, - }) - } + selectPart(rundownId, parentGroupId, partId) } const timelineObjMove = useMemoComputedObject( @@ -336,114 +291,7 @@ export const PartView: React.FC<{ }, [timelineObjMove.moveType, timelineObjMove.partId, part.id]) const { modifiedTimeline, resolvedTimeline, newChangedObjects, newDuplicatedObjects, newObjectsToMoveToNewLayer } = - useMemoComputedObject(() => { - let modifiedTimeline: TimelineObj[] - let resolvedTimeline: ResolvedTimeline - let newChangedObjects: { [objectId: string]: TimelineObj } | null = null - let newDuplicatedObjects: { [objectId: string]: TimelineObj } | null = null - let newObjectsToMoveToNewLayer: string[] | null = null - - const partTimeline = store.rundownsStore.getPartTimeline(partId) - - const dragDelta = timelineObjMove.dragDelta || 0 - const leaderObj = partTimeline.find((obj) => obj.obj.id === timelineObjMove.leaderTimelineObjId) - const leaderObjOriginalLayerId = leaderObj?.obj.layer - const leaderObjLayerChanged = leaderObjOriginalLayerId !== timelineObjMove.hoveredLayerId - if ( - leaderObj && - timelineObjMove.moveType === 'whole' && - timelineObjMove.hoveredLayerId && - timelineObjMove.hoveredLayerId.startsWith(EMPTY_LAYER_ID_PREFIX) && - store.guiStore.selected.length === 1 - ) { - // Handle moving a timelineObj to the "new layer" area - // This type of move is only allowed when a single timelineObj is selected. - - modifiedTimeline = store.rundownsStore.getPartTimeline(partId) - resolvedTimeline = orgResolvedTimeline - newObjectsToMoveToNewLayer = [leaderObj.obj.id] - } else if ( - (dragDelta || leaderObjLayerChanged) && - timelineObjMove.partId === part.id && - leaderObj && - timelineObjMove.leaderTimelineObjId && - timelineObjMove.moveId !== null && - !HANDLED_MOVE_IDS.includes(timelineObjMove.moveId) - ) { - // Handle movement, snapping - - // Check the the layer movement is legal: - let moveToLayerId = timelineObjMove.hoveredLayerId - if (moveToLayerId && !moveToLayerId.startsWith(EMPTY_LAYER_ID_PREFIX)) { - const newLayerMapping = mappings[moveToLayerId] - // @TODO: Figure out how newLayerMapping can be undefined here. - if (!newLayerMapping || !filterMapping(newLayerMapping, leaderObj?.obj)) { - moveToLayerId = null - } - } - - const selectedTimelineObjIds = compact( - store.guiStore.selected.map((s) => (s.type === 'timelineObj' ? s.timelineObjId : undefined)) - ) - try { - const o = applyMovementToTimeline({ - orgTimeline: partTimeline, - orgResolvedTimeline: orgResolvedTimeline, - snapPoints: bypassSnapping ? [] : snapPoints || [], - snapDistanceInMilliseconds: snapDistanceInMilliseconds, - dragDelta: dragDelta, - - // The use of wasMoved here helps prevent a brief flash at the - // end of a move where the moved timelineObjs briefly appear at their pre-move position. - moveType: timelineObjMove.moveType ?? timelineObjMove.wasMoved, - leaderTimelineObjId: timelineObjMove.leaderTimelineObjId, - selectedTimelineObjIds: selectedTimelineObjIds, - cache: cache.current, - leaderTimelineObjNewLayer: moveToLayerId, - duplicate: Boolean(timelineObjMove.duplicate), - }) - modifiedTimeline = o.modifiedTimeline - resolvedTimeline = o.resolvedTimeline - newChangedObjects = o.changedObjects - newDuplicatedObjects = o.duplicatedObjects - - if ( - typeof leaderObjOriginalLayerId === 'string' && - !resolvedTimeline.layers[leaderObjOriginalLayerId] - ) { - // If the leaderObj's original layer is now empty, it won't be rendered, - // making it impossible for the user to move the leaderObj back to whence it came. - // So, we add an empty layer object here to force it to remain visible. - resolvedTimeline.layers[leaderObjOriginalLayerId] = [] - } - } catch (e) { - // If there was an error applying the movement (for example a circular dependency), - // reset the movement to the original state: - - handleError('There was an error when trying to move: ' + stringifyError(e)) - - modifiedTimeline = partTimeline - resolvedTimeline = orgResolvedTimeline - newChangedObjects = null - newDuplicatedObjects = null - newObjectsToMoveToNewLayer = null - } - } else { - modifiedTimeline = partTimeline - resolvedTimeline = orgResolvedTimeline - } - - const maxDuration = getResolvedTimelineTotalDuration(resolvedTimeline, false) - - return { - maxDuration, - modifiedTimeline, - resolvedTimeline, - newChangedObjects, - newDuplicatedObjects, - newObjectsToMoveToNewLayer, - } - }, [ + usePartModifiedTimeline( timelineObjMove, part.id, mappings, @@ -452,8 +300,8 @@ export const PartView: React.FC<{ bypassSnapping, snapPoints, snapDistanceInMilliseconds, - log, - ]) + log + ) useEffect(() => { if (newObjectsToMoveToNewLayer && !_.isEmpty(newObjectsToMoveToNewLayer)) { @@ -597,53 +445,6 @@ export const PartView: React.FC<{ } }, [hotkeyContext]) - // Disable button: - const toggleDisable = useCallback(() => { - ipcServer - .updatePart({ - rundownId, - groupId: parentGroupId, - partId: part.id, - part: { - disabled: !part.disabled, - }, - }) - .catch(handleError) - }, [handleError, ipcServer, parentGroupId, part.disabled, part.id, rundownId]) - - // Lock button: - const toggleLock = useCallback(() => { - ipcServer - .updatePart({ - rundownId, - groupId: parentGroupId, - partId: part.id, - part: { - locked: !part.locked, - }, - }) - .catch(handleError) - }, [handleError, ipcServer, parentGroupId, part.id, part.locked, rundownId]) - - // Loop button: - const toggleLoop = useCallback(() => { - ipcServer - .updatePart({ - rundownId, - groupId: parentGroupId, - partId: part.id, - part: { - loop: !part.loop, - }, - }) - .catch(handleError) - }, [handleError, ipcServer, parentGroupId, part.id, part.loop, rundownId]) - - // Trigger button: - const handleTriggerBtn = useCallback((event: React.MouseEvent) => { - setTriggersSubmenuPopoverAnchorEl(event.currentTarget) - }, []) - // Drag n' Drop re-ordering: // Adapted from https://react-dnd.github.io/react-dnd/examples/sortable/simple const dragRef = useRef(null) @@ -833,14 +634,6 @@ export const PartView: React.FC<{ }, []) const partSubmenuOpen = Boolean(partSubmenuPopoverAnchorEl) - // Triggers Submenu - const [triggersSubmenuPopoverAnchorEl, setTriggersSubmenuPopoverAnchorEl] = - React.useState(null) - const closeTriggersSubmenu = useCallback(() => { - setTriggersSubmenuPopoverAnchorEl(null) - }, []) - const triggersSubmenuOpen = Boolean(triggersSubmenuPopoverAnchorEl) - const groupDisabled = computed(() => store.rundownsStore.getGroupInCurrentRundown(parentGroupId)?.disabled).get() || false const groupLocked = @@ -872,17 +665,6 @@ export const PartView: React.FC<{ const timelineLayerObjects = timelineObjsOntoLayers(sortedLayers, resolvedTimeline, modifiedTimeline) - const failedGlobalShortcuts = useMemoComputedObject(() => { - return store.triggersStore.failedGlobalTriggers - }, [store.triggersStore.failedGlobalTriggers]) - const anyGlobalTriggerFailed = useMemo(() => { - return allActionsForPart.some((action) => - failedGlobalShortcuts.has( - action.trigger.fullIdentifiers.map(convertSorensenToElectron).filter(Boolean).join('+') - ) - ) - }, [allActionsForPart, failedGlobalShortcuts]) - // This is used to defer initial rendering of some components, in order to improve initial rendering times: const [renderEverything, setRenderEverything] = useState(false) const onChange = useCallback((isVisible: boolean) => { @@ -969,58 +751,21 @@ export const PartView: React.FC<{ }} /> )} - {!groupLocked && ( + {
{renderEverything && ( - <> - - {part.disabled ? : } - - - {part.locked ? : } - - - - - - + )}
- )} + }
@@ -1074,52 +819,25 @@ export const PartView: React.FC<{ )}
-
-
- -
-
- {renderEverything && ( - <> - {resolverErrorMessage && ( -
{resolverErrorMessage}
- )} - - - )} -
- {timelineLayerObjects.map(({ layerId, objectsOnLayer }) => { - if (renderEverything) { - return ( - - ) - } else { - return - } - })} - - {!groupOrPartLocked && ( - - )} -
-
-
+ - - - - )} @@ -1174,7 +874,7 @@ export const PartView: React.FC<{ ) }) -const PartControlButtons: React.FC<{ +export const PartControlButtons: React.FC<{ rundownId: string groupId: string partId: string @@ -1316,7 +1016,7 @@ const EndCap: React.FC<{ ) }) -const sortSnapPoints = (a: SnapPoint, b: SnapPoint): number => { +export function sortSnapPoints(a: SnapPoint, b: SnapPoint): number { if (a.time < b.time) { return -1 } @@ -1394,3 +1094,421 @@ const EndCapHover: React.FC<{ ) } +export const PartEditControls: React.FC<{ + rundownId: string + parentGroupId: string + part: PartGUI + allActionsForPart: RundownActionLight[] + renderEverything: boolean + groupLocked: boolean + groupOrPartLocked: boolean +}> = observer( + ({ rundownId, parentGroupId, part, allActionsForPart, renderEverything, groupLocked, groupOrPartLocked }) => { + const ipcServer = useContext(IPCServerContext) + const { handleError } = useContext(ErrorHandlerContext) + + // Triggers Submenu + const [triggersSubmenuPopoverAnchorEl, setTriggersSubmenuPopoverAnchorEl] = + React.useState(null) + const closeTriggersSubmenu = useCallback(() => { + setTriggersSubmenuPopoverAnchorEl(null) + }, []) + const triggersSubmenuOpen = Boolean(triggersSubmenuPopoverAnchorEl) + + const failedGlobalShortcuts = useMemoComputedObject(() => { + return store.triggersStore.failedGlobalTriggers + }, [store.triggersStore.failedGlobalTriggers]) + + const anyGlobalTriggerFailed = useMemo(() => { + return allActionsForPart.some((action) => + failedGlobalShortcuts.has( + action.trigger.fullIdentifiers.map(convertSorensenToElectron).filter(Boolean).join('+') + ) + ) + }, [allActionsForPart, failedGlobalShortcuts]) + + // Disable button: + const toggleDisable = useCallback(() => { + if (groupOrPartLocked) return + ipcServer + .updatePart({ + rundownId, + groupId: parentGroupId, + partId: part.id, + part: { + disabled: !part.disabled, + }, + }) + .catch(handleError) + }, [handleError, ipcServer, parentGroupId, part.disabled, part.id, rundownId, groupOrPartLocked]) + + // Lock button: + const toggleLock = useCallback(() => { + ipcServer + .updatePart({ + rundownId, + groupId: parentGroupId, + partId: part.id, + part: { + locked: !part.locked, + }, + }) + .catch(handleError) + }, [handleError, ipcServer, parentGroupId, part.id, part.locked, rundownId]) + + // Loop button: + const toggleLoop = useCallback(() => { + if (groupOrPartLocked) return + ipcServer + .updatePart({ + rundownId, + groupId: parentGroupId, + partId: part.id, + part: { + loop: !part.loop, + }, + }) + .catch(handleError) + }, [handleError, ipcServer, parentGroupId, part.id, part.loop, rundownId, groupOrPartLocked]) + + // Trigger button: + const handleTriggerBtn = useCallback((event: React.MouseEvent) => { + setTriggersSubmenuPopoverAnchorEl(event.currentTarget) + }, []) + + if (groupLocked) return null + + return ( + <> + + {part.disabled ? : } + + + {part.locked ? : } + + + + + + {renderEverything && ( + <> + + + + + )} + + ) + } +) +export const PartTimeline: React.FC<{ + renderEverything: boolean + rundownId: string + parentGroupId: string + mappings: Mappings + part: PartGUI + resolverErrorMessage: string + orgMaxDuration: number + timelineObjMove: TimelineObjectMove + timelineLayerObjects: TimelineObjectsOnLayer[] + layersDivRef: React.RefObject + maxDurationAdjusted: number + msPerPixel: number + groupOrPartLocked: boolean + + emptyLayer: boolean +}> = ({ + renderEverything, + rundownId, + parentGroupId, + mappings, + part, + resolverErrorMessage, + orgMaxDuration, + + timelineObjMove, + timelineLayerObjects, + layersDivRef, + maxDurationAdjusted, + msPerPixel, + groupOrPartLocked, + emptyLayer: displayEmptyLayer, +}) => { + return ( +
+
+ +
+
+ {renderEverything && ( + <> + {resolverErrorMessage &&
{resolverErrorMessage}
} + + + )} +
+ {timelineLayerObjects.map(({ layerId, objectsOnLayer }) => { + if (renderEverything) { + return ( + + ) + } else { + return + } + })} + + {displayEmptyLayer && !groupOrPartLocked && ( + + )} +
+
+
+ ) +} + +export function usePartModifiedTimeline( + timelineObjMove: TimelineObjectMove, + partId: string, + mappings: Mappings, + handleError: (error: unknown) => void, + orgResolvedTimeline: ResolvedTimeline, + bypassSnapping: boolean, + snapPoints: SnapPoint[] | undefined, + snapDistanceInMilliseconds: number, + log: LoggerLike +): { + maxDuration: number | null + modifiedTimeline: TimelineObj[] + resolvedTimeline: ResolvedTimeline + newChangedObjects: { + [objectId: string]: TimelineObj + } | null + newDuplicatedObjects: { + [objectId: string]: TimelineObj + } | null + newObjectsToMoveToNewLayer: string[] | null +} { + const cache = useRef({}) + + return useMemoComputedObject(() => { + let modifiedTimeline: TimelineObj[] + let resolvedTimeline: ResolvedTimeline + let newChangedObjects: { [objectId: string]: TimelineObj } | null = null + let newDuplicatedObjects: { [objectId: string]: TimelineObj } | null = null + let newObjectsToMoveToNewLayer: string[] | null = null + + const partTimeline = store.rundownsStore.getPartTimeline(partId) + + const dragDelta = timelineObjMove.dragDelta || 0 + const leaderObj = partTimeline.find((obj) => obj.obj.id === timelineObjMove.leaderTimelineObjId) + const leaderObjOriginalLayerId = leaderObj?.obj.layer + const leaderObjLayerChanged = leaderObjOriginalLayerId !== timelineObjMove.hoveredLayerId + if ( + leaderObj && + timelineObjMove.moveType === 'whole' && + timelineObjMove.hoveredLayerId && + timelineObjMove.hoveredLayerId.startsWith(EMPTY_LAYER_ID_PREFIX) && + store.guiStore.selected.length === 1 + ) { + // Handle moving a timelineObj to the "new layer" area + // This type of move is only allowed when a single timelineObj is selected. + + modifiedTimeline = store.rundownsStore.getPartTimeline(partId) + resolvedTimeline = orgResolvedTimeline + newObjectsToMoveToNewLayer = [leaderObj.obj.id] + } else if ( + (dragDelta || leaderObjLayerChanged) && + timelineObjMove.partId === partId && + leaderObj && + timelineObjMove.leaderTimelineObjId && + timelineObjMove.moveId !== null && + !HANDLED_MOVE_IDS.includes(timelineObjMove.moveId) + ) { + // Handle movement, snapping + + // Check the the layer movement is legal: + let moveToLayerId = timelineObjMove.hoveredLayerId + if (moveToLayerId && !moveToLayerId.startsWith(EMPTY_LAYER_ID_PREFIX)) { + const newLayerMapping = mappings[moveToLayerId] + // @TODO: Figure out how newLayerMapping can be undefined here. + if (!newLayerMapping || !filterMapping(newLayerMapping, leaderObj?.obj)) { + moveToLayerId = null + } + } + + const selectedTimelineObjIds = compact( + store.guiStore.selected.map((s) => (s.type === 'timelineObj' ? s.timelineObjId : undefined)) + ) + try { + const o = applyMovementToTimeline({ + orgTimeline: partTimeline, + orgResolvedTimeline: orgResolvedTimeline, + snapPoints: bypassSnapping ? [] : snapPoints || [], + snapDistanceInMilliseconds: snapDistanceInMilliseconds, + dragDelta: dragDelta, + + // The use of wasMoved here helps prevent a brief flash at the + // end of a move where the moved timelineObjs briefly appear at their pre-move position. + moveType: timelineObjMove.moveType ?? timelineObjMove.wasMoved, + leaderTimelineObjId: timelineObjMove.leaderTimelineObjId, + selectedTimelineObjIds: selectedTimelineObjIds, + cache: cache.current, + leaderTimelineObjNewLayer: moveToLayerId, + duplicate: Boolean(timelineObjMove.duplicate), + }) + modifiedTimeline = o.modifiedTimeline + resolvedTimeline = o.resolvedTimeline + newChangedObjects = o.changedObjects + newDuplicatedObjects = o.duplicatedObjects + + if ( + typeof leaderObjOriginalLayerId === 'string' && + !resolvedTimeline.layers[leaderObjOriginalLayerId] + ) { + // If the leaderObj's original layer is now empty, it won't be rendered, + // making it impossible for the user to move the leaderObj back to whence it came. + // So, we add an empty layer object here to force it to remain visible. + resolvedTimeline.layers[leaderObjOriginalLayerId] = [] + } + } catch (e) { + // If there was an error applying the movement (for example a circular dependency), + // reset the movement to the original state: + + handleError('There was an error when trying to move: ' + stringifyError(e)) + + modifiedTimeline = partTimeline + resolvedTimeline = orgResolvedTimeline + newChangedObjects = null + newDuplicatedObjects = null + newObjectsToMoveToNewLayer = null + } + } else { + modifiedTimeline = partTimeline + resolvedTimeline = orgResolvedTimeline + } + + const maxDuration = getResolvedTimelineTotalDuration(resolvedTimeline, false) + + return { + maxDuration, + modifiedTimeline, + resolvedTimeline, + newChangedObjects, + newDuplicatedObjects, + newObjectsToMoveToNewLayer, + } + }, [ + timelineObjMove, + partId, + mappings, + handleError, + orgResolvedTimeline, + bypassSnapping, + snapPoints, + snapDistanceInMilliseconds, + log, + ]) +} +export function selectPart(rundownId: string, parentGroupId: string, partId: string): void { + const pressed = sorensen.getPressedKeys() + if (pressed.includes('ControlLeft') || pressed.includes('ControlRight')) { + // Add this part to the selection: + store.guiStore.toggleAddSelected({ + type: 'part', + groupId: parentGroupId, + partId: partId, + }) + } else if (pressed.includes('ShiftLeft') || pressed.includes('ShiftRight')) { + // Add all parts between the last selected and this one: + const mainSelected = store.guiStore.mainSelected + if (mainSelected && mainSelected.type === 'part') { + const allPartIds: { partId: string; groupId: string }[] = [] + for (const group of store.rundownsStore.getRundownGroups(rundownId)) { + for (const part of store.rundownsStore.getGroupParts(group.id)) { + allPartIds.push({ + groupId: group.id, + partId: part.id, + }) + } + } + const mainIndex = allPartIds.findIndex((p) => p.partId === mainSelected.partId) + const thisIndex = allPartIds.findIndex((p) => p.partId === partId) + if (mainIndex === -1 || thisIndex === -1) return + if (mainIndex < thisIndex) { + for (let i = mainIndex + 1; i <= thisIndex; i++) { + store.guiStore.addSelected({ + type: 'part', + groupId: allPartIds[i].groupId, + partId: allPartIds[i].partId, + }) + } + } else if (mainIndex > thisIndex) { + for (let i = mainIndex - 1; i >= thisIndex; i--) { + store.guiStore.addSelected({ + type: 'part', + groupId: allPartIds[i].groupId, + partId: allPartIds[i].partId, + }) + } + } + } + } else { + store.guiStore.toggleSelected({ + type: 'part', + groupId: parentGroupId, + partId: partId, + }) + } +} diff --git a/apps/app/src/react/components/rundown/GroupView/part/LayerName/style.scss b/apps/app/src/react/components/rundown/GroupView/part/LayerName/style.scss index fb64e4a7..314b2f53 100644 --- a/apps/app/src/react/components/rundown/GroupView/part/LayerName/style.scss +++ b/apps/app/src/react/components/rundown/GroupView/part/LayerName/style.scss @@ -1,8 +1,10 @@ @import '../../../../../styles/foundation/all'; $background-color: #464646; -$background-color-selected: lighten($color: $background-color, $amount: 20); - +$background-color-selected: lighten( + $color: $background-color, + $amount: 20, +); .layer-name { height: $layerHeight; @@ -59,8 +61,6 @@ $background-color-selected: lighten($color: $background-color, $amount: 20); } .mappings-dropdown-selector { - - border: 0.1rem solid #545a78; border-top: none; display: none; @@ -68,8 +68,8 @@ $background-color-selected: lighten($color: $background-color, $amount: 20); width: 100%; top: 100%; left: 0; - z-index: 10; - background: $background-color + z-index: $dropdown-selector-zIndex; + background: $background-color; } .mapping { diff --git a/apps/app/src/react/styles/app.scss b/apps/app/src/react/styles/app.scss index 7d4df5d1..6233bc26 100644 --- a/apps/app/src/react/styles/app.scss +++ b/apps/app/src/react/styles/app.scss @@ -142,6 +142,7 @@ input[type='search']::-webkit-search-cancel-button { @import './group.scss'; @import './part.scss'; +@import './part-button.scss'; @import './layer.scss'; @import './countDownHead.scss'; @import './playHead.scss'; diff --git a/apps/app/src/react/styles/foundation/_colors.scss b/apps/app/src/react/styles/foundation/_colors.scss index 59650686..fdccde05 100644 --- a/apps/app/src/react/styles/foundation/_colors.scss +++ b/apps/app/src/react/styles/foundation/_colors.scss @@ -53,6 +53,8 @@ $lightRedColor: #d44e4e; $lightGreenColor: #5afc3e; +$groupOutlineColor: #5a5a5a; + $partOutlineColor: #5a5a5a; $partDragHandleColor: #2d3340; $partMetaColor: #2d3340; diff --git a/apps/app/src/react/styles/foundation/_variables.scss b/apps/app/src/react/styles/foundation/_variables.scss index cb586202..c5c05950 100644 --- a/apps/app/src/react/styles/foundation/_variables.scss +++ b/apps/app/src/react/styles/foundation/_variables.scss @@ -16,3 +16,10 @@ $default-transition: all 150ms ease; $default-transition-time: 150ms ease; $timelineObjHandleWidth: 8px; $top-header-height: 5rem; + +// z-indexes: + +$timeline-cover-zIndex: 998; +$playhead-zIndex: 999; + +$dropdown-selector-zIndex: 10000; diff --git a/apps/app/src/react/styles/group.scss b/apps/app/src/react/styles/group.scss index 39d066c4..0e2ba535 100644 --- a/apps/app/src/react/styles/group.scss +++ b/apps/app/src/react/styles/group.scss @@ -1,7 +1,7 @@ $group-background: #2c2c2c; $margin-v: 2rem; .group { - border: 0.1rem solid $partOutlineColor; + border: 0.1rem solid $groupOutlineColor; position: relative; margin: $margin-v 0; padding-top: 0.4rem; @@ -28,6 +28,9 @@ $margin-v: 2rem; cursor: initial; // pointer-events: none; } + .group__content__parts.buttons { + cursor: pointer; + } } &.selected { box-shadow: inset 0px 0px 0px 3px #ffffff85; @@ -130,9 +133,9 @@ $margin-v: 2rem; border-top-right-radius: 5px; } - border-top: 0.1rem solid $partOutlineColor; - border-left: 0.1rem solid $partOutlineColor; - border-right: 0.1rem solid $partOutlineColor; + border-top: 0.1rem solid $groupOutlineColor; + border-left: 0.1rem solid $groupOutlineColor; + border-right: 0.1rem solid $groupOutlineColor; border-top-left-radius: 5px; border-top-right-radius: 5px; @@ -143,6 +146,15 @@ $margin-v: 2rem; padding: 0.5rem 1rem 0; } + &__content__parts.buttons { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: flex-start; + align-items: stretch; + align-content: flex-start; + } + &.disabled { color: #999; .group__header { diff --git a/apps/app/src/react/styles/part-button.scss b/apps/app/src/react/styles/part-button.scss new file mode 100644 index 00000000..163e5d3d --- /dev/null +++ b/apps/app/src/react/styles/part-button.scss @@ -0,0 +1,199 @@ +@use 'sass:color'; +@import './objectTypeStyling.scss'; + +$partPadding: 2px; + +$partWidth: 8em; +$partMargin: 0.2em; +$partMinHeight: 5em; + +$partHeaderHeight: 1.75em; + +.part-button { + position: relative; + margin: $partMargin; + + border-radius: calc($partTabWidth / 2); + + background: $emptyLayerColor; + border-bottom: 1px solid $partOutlineColor; + border-left: 1px solid $partOutlineColor; + border-right: 1px solid $partOutlineColor; + + &.selectable { + cursor: pointer; + + &:hover { + // background-color: color.adjust($partMetaColor, $lightness: -3%); + .part-button__cover { + background-color: rgba(0, 0, 0, 0.3); + } + } + } + + &.extended { + // height: 5em; + + .part-button__content { + width: calc($partWidth * 2 + $partMargin * 2 + 3px); + } + } + + &__header { + position: absolute; + top: 0; + left: 0; + min-width: 100%; + height: $partHeaderHeight; + + padding: $partPadding $partPadding 0 $partPadding; + background: $partDragHandleColor; + border-top-right-radius: calc($partTabWidth / 2); + border-top-left-radius: calc($partTabWidth / 2); + + @include useObjectTypeStyles(); + + &__controls { + // display: inline-block; + display: flex; + margin-top: 0.3rem; + height: 2.2rem; + } + &__label { + position: absolute; + top: $partPadding; + left: $partPadding; + right: $partPadding; + bottom: 0; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + &__content { + position: relative; + + padding: $partPadding; + + border-bottom-right-radius: calc($partTabWidth / 2); + border-bottom-left-radius: calc($partTabWidth / 2); + + overflow: hidden; + + margin-top: $partHeaderHeight; + + min-height: $partMinHeight; + width: $partWidth; + + transition: width 0.25s ease-in-out, height 0.25s ease-in-out; + + > .part__timeline > .layers-wrapper { + min-height: $partMinHeight; + } + } + + &__cover { + position: absolute; + top: $partHeaderHeight; + left: $partPadding; + right: $partPadding; + bottom: $partPadding; + z-index: $timeline-cover-zIndex; + border-bottom-right-radius: calc($partTabWidth / 2); + border-bottom-left-radius: calc($partTabWidth / 2); + + overflow: hidden; + + background-color: rgba(0, 0, 0, 0.5); + + &__label { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1; + + /** Center the text vertically and horizontally */ + display: flex; + justify-content: space-around; + align-items: center; + + > span { + text-align: center; + font-weight: bold; + line-height: 1; + } + } + &__control-buttons { + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + z-index: 2; + + /** Center the buttons vertically and horizontally */ + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: flex-end; + + > .btn { + height: 3em; + width: 3em; + flex-grow: 1; + } + } + } + + &.locked { + .part-button__drag-handle { + pointer-events: none; + visibility: hidden; + } + } + + &__drag-handle { + cursor: grab; + display: inline-block; + } + + > .part__selected { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 2; + transition: $default-transition; + + border-radius: calc($partTabWidth / 2); + } + + &.selected { + > .part__selected { + box-shadow: inset 0px 0px 0px 2px #ffffff65; + } + } + + // Display the control-button on hover, when selected or locked + .part-button__cover__control-buttons { + display: none; + } + .part-button__cover__label { + display: flex; + } + &.selected, + &:hover, + &.locked { + .part-button__cover__control-buttons { + display: flex; + } + .part-button__cover__label { + display: none; + } + } +} diff --git a/apps/app/src/react/styles/playHead.scss b/apps/app/src/react/styles/playHead.scss index 09828b44..6a1a811b 100644 --- a/apps/app/src/react/styles/playHead.scss +++ b/apps/app/src/react/styles/playHead.scss @@ -4,7 +4,7 @@ $line-width: 0.2rem; position: absolute; top: 0; left: 0; - z-index: 999; + z-index: $playhead-zIndex; height: 100%; right: $line-width; pointer-events: none;