diff --git a/packages/blueprints-integration/src/documents/part.ts b/packages/blueprints-integration/src/documents/part.ts index ea4f05cfdc..31b030869b 100644 --- a/packages/blueprints-integration/src/documents/part.ts +++ b/packages/blueprints-integration/src/documents/part.ts @@ -1,4 +1,4 @@ -import { UserEditingDefinition } from '../userEditing' +import { UserEditingDefinition, UserEditingProperties } from '../userEditing' import type { NoteSeverity } from '../lib' import type { ITranslatableMessage } from '../translations' @@ -88,6 +88,12 @@ export interface IBlueprintMutatablePart diff --git a/packages/blueprints-integration/src/ingest.ts b/packages/blueprints-integration/src/ingest.ts index e4594620e6..4c8c3dc20d 100644 --- a/packages/blueprints-integration/src/ingest.ts +++ b/packages/blueprints-integration/src/ingest.ts @@ -125,9 +125,10 @@ export interface UserOperationTarget { } export enum DefaultUserOperationsTypes { - REVERT_SEGMENT = 'revert-segment', - REVERT_PART = 'revert-part', - REVERT_RUNDOWN = 'revert-rundown', + REVERT_SEGMENT = '__sofie-revert-segment', + REVERT_PART = '__sofie-revert-part', + REVERT_RUNDOWN = '__sofie-revert-rundown', + UPDATE_PROPS = '__sofie-update-props', } export interface DefaultUserOperationRevertRundown { @@ -144,14 +145,19 @@ export interface DefaultUserOperationRevertPart { id: DefaultUserOperationsTypes.REVERT_PART } +export interface DefaultUserOperationEditProperties { + id: DefaultUserOperationsTypes.UPDATE_PROPS + payload: { + pieceTypeProperties: { type: string; value: Record } + globalProperties: Record + } +} + export type DefaultUserOperations = - | { - id: '__sofie-move-segment' // Future: define properly - payload: Record - } | DefaultUserOperationRevertRundown | DefaultUserOperationRevertSegment | DefaultUserOperationRevertPart + | DefaultUserOperationEditProperties export interface UserOperationChange { /** Indicate that this change is from user operations */ diff --git a/packages/blueprints-integration/src/userEditing.ts b/packages/blueprints-integration/src/userEditing.ts index a42e821c70..2dbfc2c818 100644 --- a/packages/blueprints-integration/src/userEditing.ts +++ b/packages/blueprints-integration/src/userEditing.ts @@ -77,6 +77,7 @@ export interface UserEditingSourceLayer { sourceLayerLabel: string sourceLayerType: SourceLayerType schema: JSONBlob + defaultValue?: Record } export enum UserEditingButtonType { @@ -87,3 +88,43 @@ export enum UserEditingButtonType { /** Hidden */ HIDDEN = 'hidden', } + +export interface UserEditingProperties { + /** + * These properties are dependent on the (primary) piece type, the user will get the option + * to select the type of piece (from the SourceLayerTypes i.e. Camera or Split etc.) and then + * be presented the corresponding form + * + * example: + * { + * schema: { + * camera: '{ "type": "object", "properties": { "input": { "type": "number" } } }', + * split: '{ "type": "object", ... }', + * }, + * currentValue: { + * type: 'camera', + * value: { + * input: 3 + * }, + * } + * } + */ + pieceTypeProperties?: { + schema: Record + currentValue: { type: string; value: Record } + } + + /** + * These are properties that are available to edit regardless of the piece type, examples + * could be whether it an element is locked from NRCS updates + * + * if you do not want the piece type to be changed, then use only this field. + */ + globalProperties?: { schema: JSONBlob; currentValue: Record } + + /** + * A list of id's of operations to be exposed on the properties panel as buttons. These operations + * must be available on the element + */ + operations?: UserEditingDefinitionAction[] +} diff --git a/packages/corelib/src/dataModel/Part.ts b/packages/corelib/src/dataModel/Part.ts index 5194cb98b8..9712dbe43d 100644 --- a/packages/corelib/src/dataModel/Part.ts +++ b/packages/corelib/src/dataModel/Part.ts @@ -3,7 +3,7 @@ import { ITranslatableMessage } from '../TranslatableMessage' import { PartId, RundownId, SegmentId } from './Ids' import { PartNote } from './Notes' import { ReadonlyDeep } from 'type-fest' -import { CoreUserEditingDefinition } from './UserEditingDefinitions' +import { CoreUserEditingDefinition, CoreUserEditingProperties } from './UserEditingDefinitions' export interface PartInvalidReason { message: ITranslatableMessage @@ -41,6 +41,12 @@ export interface DBPart extends Omit { * User editing definitions for this part */ userEditOperations?: CoreUserEditingDefinition[] + + /** + * Properties that are user editable from the properties panel in the Sofie UI, if the user saves changes to these + * it will trigger a user edit operation of type DefaultUserOperationEditProperties + */ + userEditProperties?: CoreUserEditingProperties } export function isPartPlayable(part: Pick, 'invalid' | 'floated'>): boolean { diff --git a/packages/corelib/src/dataModel/Segment.ts b/packages/corelib/src/dataModel/Segment.ts index 89b03d102f..7d756380c5 100644 --- a/packages/corelib/src/dataModel/Segment.ts +++ b/packages/corelib/src/dataModel/Segment.ts @@ -1,7 +1,7 @@ import { SegmentDisplayMode, SegmentTimingInfo } from '@sofie-automation/blueprints-integration' import { SegmentId, RundownId } from './Ids' import { SegmentNote } from './Notes' -import { CoreUserEditingDefinition } from './UserEditingDefinitions' +import { CoreUserEditingDefinition, CoreUserEditingProperties } from './UserEditingDefinitions' export enum SegmentOrphanedReason { /** Segment is deleted from the NRCS but we still need it */ @@ -51,4 +51,10 @@ export interface DBSegment { * User editing definitions for this segment */ userEditOperations?: CoreUserEditingDefinition[] + + /** + * Properties that are user editable from the properties panel in the Sofie UI, if the user saves changes to these + * it will trigger a user edit operation of type DefaultUserOperationEditProperties + */ + userEditProperties?: CoreUserEditingProperties } diff --git a/packages/corelib/src/dataModel/UserEditingDefinitions.ts b/packages/corelib/src/dataModel/UserEditingDefinitions.ts index b7db352204..52509e994b 100644 --- a/packages/corelib/src/dataModel/UserEditingDefinitions.ts +++ b/packages/corelib/src/dataModel/UserEditingDefinitions.ts @@ -66,3 +66,48 @@ export interface CoreUserEditingDefinitionSourceLayerForm { value: Record } } + +export interface CoreUserEditingProperties { + /** + * These properties are dependent on the (primary) piece type, the user will get the option + * to select the type of piece (from the SourceLayerTypes i.e. Camera or Split etc.) and then + * be presented the corresponding form + * + * example: + * { + * schema: { + * camera: '{ "type": "object", "properties": { "input": { "type": "number" } } }', + * split: '{ "type": "object", ... }', + * }, + * currentValue: { + * type: 'camera', + * value: { + * input: 3 + * }, + * } + * } + */ + pieceTypeProperties?: { + schema: Record + currentValue: { type: string; value: Record } + } + + /** + * These are properties that are available to edit regardless of the piece type, examples + * could be whether it an element is locked from NRCS updates + * + * if you do not want the piece type to be changed, then use only this field. + */ + globalProperties?: { schema: JSONBlob; currentValue: Record } + + /** + * A list of id's of operations to be exposed on the properties panel as buttons. These operations + * must be available on the element + * + * note - perhaps these should have their own full definitions? + */ + operations?: CoreUserEditingDefinitionAction[] + + /** Translation namespaces to use when rendering this form */ + translationNamespaces: string[] +} diff --git a/packages/documentation/docs/for-developers/json-config-schema.md b/packages/documentation/docs/for-developers/json-config-schema.md index b56e6e6ee7..862a5dd31f 100644 --- a/packages/documentation/docs/for-developers/json-config-schema.md +++ b/packages/documentation/docs/for-developers/json-config-schema.md @@ -43,7 +43,12 @@ If an integer property, whether to treat it as zero-based ### `ui:displayType` Override the presentation with a special mode. -Currently only valid for string properties. Valid values are 'json'. + +Currently only valid for: + +- object properties. Valid values are 'json'. +- string properties. Valid values are 'base64-image'. +- boolean properties. Valid values are 'switch'. ### `tsEnumNames` diff --git a/packages/job-worker/src/blueprints/context/lib.ts b/packages/job-worker/src/blueprints/context/lib.ts index 3335b57e24..fa22ee83e2 100644 --- a/packages/job-worker/src/blueprints/context/lib.ts +++ b/packages/job-worker/src/blueprints/context/lib.ts @@ -14,6 +14,7 @@ import { CoreUserEditingDefinitionAction, CoreUserEditingDefinitionForm, CoreUserEditingDefinitionSourceLayerForm, + CoreUserEditingProperties, } from '@sofie-automation/corelib/dist/dataModel/UserEditingDefinitions' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' import { assertNever, clone, Complete, literal, omit } from '@sofie-automation/corelib/dist/lib' @@ -58,6 +59,7 @@ import { UserEditingDefinitionAction, UserEditingDefinitionForm, UserEditingDefinitionSourceLayerForm, + UserEditingProperties, UserEditingType, } from '@sofie-automation/blueprints-integration/dist/userEditing' import type { PlayoutMutatablePart } from '../../playout/model/PlayoutPartInstanceModel' @@ -121,6 +123,7 @@ export const IBlueprintMutatablePartSampleKeys = allKeysOfObject): IBlueprintP part.hackListenToMediaObjectUpdates ), userEditOperations: translateUserEditsToBlueprint(part.userEditOperations), + userEditProperties: translateUserEditPropertiesToBlueprint(part.userEditProperties), } return obj @@ -349,6 +353,7 @@ export function convertSegmentToBlueprints(segment: ReadonlyDeep): IB showShelf: segment.showShelf, segmentTiming: segment.segmentTiming, userEditOperations: translateUserEditsToBlueprint(segment.userEditOperations), + userEditProperties: translateUserEditPropertiesToBlueprint(segment.userEditProperties), } return obj @@ -540,6 +545,30 @@ function translateUserEditsToBlueprint( ) } +function translateUserEditPropertiesToBlueprint( + props: ReadonlyDeep | undefined +): UserEditingProperties | undefined { + if (!props) return undefined + + return { + globalProperties: props.globalProperties, + pieceTypeProperties: props.pieceTypeProperties, + + operations: props.operations?.map( + (userEdit) => + ({ + type: UserEditingType.ACTION, + id: userEdit.id, + label: omit(userEdit.label, 'namespaces'), + svgIcon: userEdit.svgIcon, + svgIconInactive: userEdit.svgIconInactive, + isActive: userEdit.isActive, + buttonType: userEdit.buttonType, + } satisfies Complete) + ), + } +} + export function translateUserEditsFromBlueprint( userEdits: UserEditingDefinition[] | undefined, blueprintIds: BlueprintId[] @@ -585,6 +614,33 @@ export function translateUserEditsFromBlueprint( ) } +export function translateUserEditPropertiesFromBlueprint( + props: UserEditingProperties | undefined, + blueprintIds: BlueprintId[] +): CoreUserEditingProperties | undefined { + if (!props) return undefined + + return { + globalProperties: clone(props.globalProperties), + pieceTypeProperties: clone(props.pieceTypeProperties), + + operations: props.operations?.map( + (userEdit) => + ({ + type: UserEditingType.ACTION, + id: userEdit.id, + label: wrapTranslatableMessageFromBlueprints(userEdit.label, blueprintIds), + svgIcon: userEdit.svgIcon, + svgIconInactive: userEdit.svgIconInactive, + isActive: userEdit.isActive, + buttonType: userEdit.buttonType, + } satisfies Complete) + ), + + translationNamespaces: blueprintIds.map((id) => `blueprint_${id}`), + } +} + export function convertPartialBlueprintMutablePartToCore( updatePart: Partial, blueprintId: BlueprintId @@ -602,5 +658,13 @@ export function convertPartialBlueprintMutablePartToCore( delete playoutUpdatePart.userEditOperations } + if ('userEditProperties' in updatePart) { + playoutUpdatePart.userEditProperties = translateUserEditPropertiesFromBlueprint(updatePart.userEditProperties, [ + blueprintId, + ]) + } else { + delete playoutUpdatePart.userEditOperations + } + return playoutUpdatePart } diff --git a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts index 45d96f383c..1814cda588 100644 --- a/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts +++ b/packages/job-worker/src/blueprints/context/services/PartAndPieceInstanceActionService.ts @@ -388,6 +388,7 @@ export class PartAndPieceInstanceActionService { floated: false, expectedDurationWithTransition: undefined, // Filled in later userEditOperations: [], // Adlibbed parts can't be edited by ingest + userEditProperties: undefined, } const pieces = postProcessPieces( diff --git a/packages/job-worker/src/ingest/generationSegment.ts b/packages/job-worker/src/ingest/generationSegment.ts index 2898cef866..5842047f3c 100644 --- a/packages/job-worker/src/ingest/generationSegment.ts +++ b/packages/job-worker/src/ingest/generationSegment.ts @@ -24,7 +24,7 @@ import { IngestReplacePartType, IngestSegmentModel } from './model/IngestSegment import { ReadonlyDeep } from 'type-fest' import { Rundown } from '@sofie-automation/corelib/dist/dataModel/Rundown' import { WrappedShowStyleBlueprint } from '../blueprints/cache' -import { translateUserEditsFromBlueprint } from '../blueprints/context/lib' +import { translateUserEditPropertiesFromBlueprint, translateUserEditsFromBlueprint } from '../blueprints/context/lib' async function getWatchedPackagesHelper( context: JobContext, @@ -293,6 +293,9 @@ function updateModelWithGeneratedSegment( userEditOperations: translateUserEditsFromBlueprint(blueprintSegment.segment.userEditOperations, [ blueprintId, ]), + userEditProperties: translateUserEditPropertiesFromBlueprint(blueprintSegment.segment.userEditProperties, [ + blueprintId, + ]), }) ) @@ -376,6 +379,9 @@ function updateModelWithGeneratedPart( } : undefined, userEditOperations: translateUserEditsFromBlueprint(blueprintPart.part.userEditOperations, [blueprintId]), + userEditProperties: translateUserEditPropertiesFromBlueprint(blueprintPart.part.userEditProperties, [ + blueprintId, + ]), }) // Update pieces diff --git a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts index fdeccee75b..7c58213ad8 100644 --- a/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts +++ b/packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts @@ -540,6 +540,7 @@ export class PlayoutPartInstanceModelImpl implements PlayoutPartInstanceModel { ...this.partInstanceImpl.part, ...trimmedProps, userEditOperations: this.partInstanceImpl.part.userEditOperations, // Replaced below if changed + userEditProperties: this.partInstanceImpl.part.userEditProperties, } // Only replace `userEditOperations` if new values were provided diff --git a/packages/shared-lib/src/lib/JSONSchemaUtil.ts b/packages/shared-lib/src/lib/JSONSchemaUtil.ts index 02feb95420..49e6380de0 100644 --- a/packages/shared-lib/src/lib/JSONSchemaUtil.ts +++ b/packages/shared-lib/src/lib/JSONSchemaUtil.ts @@ -29,6 +29,7 @@ export enum SchemaFormUIField { * Currently only valid for: * - object properties. Valid values are 'json'. * - string properties. Valid values are 'base64-image'. + * - boolean properties. Valid values are 'switch'. */ DisplayType = 'ui:displayType', /** diff --git a/packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx b/packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx index 31c02b0214..bf1a03a614 100644 --- a/packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx +++ b/packages/webui/src/client/lib/forms/SchemaFormWithOverrides.tsx @@ -24,6 +24,7 @@ import { SchemaFormObjectTable } from './SchemaFormTable/ObjectTable' import { getSchemaUIField, SchemaFormUIField } from '@sofie-automation/blueprints-integration' import { SchemaFormSectionHeader } from './SchemaFormSectionHeader' import { Base64ImageInputControl } from '../Components/Base64ImageInput' +import { ToggleSwitchControl } from '../Components/ToggleSwitch' interface SchemaFormWithOverridesProps extends SchemaFormCommonProps { /** Base path of the schema within the document */ @@ -116,7 +117,11 @@ export function SchemaFormWithOverrides(props: Readonly case TypeName.Boolean: - return + if (getSchemaUIField(props.schema, SchemaFormUIField.DisplayType) === 'switch') { + return + } else { + return + } case TypeName.String: if (getSchemaUIField(props.schema, SchemaFormUIField.DisplayType) === 'base64-image') { return @@ -352,6 +357,17 @@ const BooleanFormWithOverrides = ({ commonAttrs }: Readonly) ) } +const SwitchFormWithOverrides = ({ commonAttrs }: Readonly) => { + return ( + + {(value, handleUpdate) => ( + // + + )} + + ) +} + const StringFormWithOverrides = ({ schema, commonAttrs }: Readonly) => { return ( diff --git a/packages/webui/src/client/lib/forms/SchemaFormWithState.tsx b/packages/webui/src/client/lib/forms/SchemaFormWithState.tsx new file mode 100644 index 0000000000..77e84b75ee --- /dev/null +++ b/packages/webui/src/client/lib/forms/SchemaFormWithState.tsx @@ -0,0 +1,72 @@ +import { useCallback, useMemo } from 'react' +import { + OverrideOpHelperForItemContentsBatcher, + WrappedOverridableItemNormal, +} from '../../ui/Settings/util/OverrideOpHelper' +import { SchemaFormCommonProps } from './schemaFormUtil' +import { SchemaFormWithOverrides } from './SchemaFormWithOverrides' +import { literal, objectPathSet } from '@sofie-automation/corelib/dist/lib' +import { AnyARecord } from 'dns' + +interface SchemaFormWithStateProps extends Omit { + object: any + + onUpdate: (object: any) => void +} + +export function SchemaFormWithState({ + object, + onUpdate, + ...commonProps +}: Readonly): JSX.Element { + const helper = useCallback( + () => + new OverrideOpHelperWithState(object, (object) => { + onUpdate(object) + }), + [object, onUpdate] + ) + + const wrappedItem = useMemo( + () => + literal>({ + type: 'normal', + id: 'not-used', + computed: object, + defaults: undefined, + overrideOps: [], + }), + [object] + ) + + return +} + +/** + * An alternate OverrideOpHelper designed to directly mutate an object, instead of using the `ObjectWithOverrides` system. + * This allows us to have one SchemaForm implementation that can handle working with `ObjectWithOverrides`, and simpler options + */ +class OverrideOpHelperWithState implements OverrideOpHelperForItemContentsBatcher { + readonly #object: any + readonly #onUpdate: (object: any) => void + + constructor(object: AnyARecord, onUpdate: (object: any) => void) { + this.#object = object + this.#onUpdate = onUpdate + } + + clearItemOverrides(_itemId: string, _subPath: string): this { + // Not supported as this is faking an item with overrides + + return this + } + setItemValue(_itemId: string, subPath: string, value: any): this { + objectPathSet(this.#object, subPath, value) + + return this + } + + commit(): void { + this.#onUpdate(this.#object) + } +} diff --git a/packages/webui/src/client/styles/_variables.scss b/packages/webui/src/client/styles/_variables.scss index b7b6df1117..a325879f78 100644 --- a/packages/webui/src/client/styles/_variables.scss +++ b/packages/webui/src/client/styles/_variables.scss @@ -4,6 +4,7 @@ $fullscreen-controls__button--radius: 3.125rem; $statusbar-width: 3.6rem; $notification-center-width: 25rem; +$properties-panel-width: 550px; $browser-context: 16px; // Default browser font size in pixels diff --git a/packages/webui/src/client/styles/propertiesPanel.scss b/packages/webui/src/client/styles/propertiesPanel.scss index 3c096419cb..10eb99c1eb 100644 --- a/packages/webui/src/client/styles/propertiesPanel.scss +++ b/packages/webui/src/client/styles/propertiesPanel.scss @@ -2,25 +2,23 @@ @import '_colorScheme'; @import '_variables'; - - .properties-panel { position: fixed; background: #7b7b7b; color: #000; - top: 50px; + top: 4rem; right: 0; - bottom: 10px; - width: calc(#{$notification-center-width} + 4.6875rem); + bottom: 0; + width: $properties-panel-width; z-index: 292; - transform: translateX(100%); - transition: transform 0.2s ease-out; - - // Add a class that will be applied when the component mounts - &.is-mounted { - transform: translateX(0%); - } + transform: translateX(100%); + transition: transform 0.2s ease-out; + + // Add a class that will be applied when the component mounts + &.is-mounted { + transform: translateX(0%); + } &::before { content: ' '; @@ -34,70 +32,108 @@ } .propertiespanel-pop-up { - background: #252525; + background: #2e2e2e; border-radius: 1px; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.7); - margin: 1px; - height: 98.2%; - width: 87%; + height: 100%; + width: calc(100% - 3.75rem); + position: relative; - &:first-child { - margin-top: 0.9375rem; - } - - &:last-child { - margin-bottom: 0.9375rem; - } - - > .propertiespanel-pop-up_close { - position: absolute; - top: 25px; - right: 70px; - color: white; - } + display: flex; + flex-direction: column; > .propertiespanel-pop-up__header { - background: #3d3d3d; + background: #0a20ed; color: #ddd; - min-width: 2.5rem; - height: 2.5rem; - font-size: 1.2em; + // min-width: 2.5rem; + // height: 2.5rem; + max-width: 100%; - text-align: left; - padding-left: 1rem; display: flex; + padding: 1em; align-items: center; - gap: 0.5rem; + gap: 0.2em; + align-self: stretch; + + // text-align: left; + // padding-left: 1rem; + // display: flex; + // align-items: center; + // gap: 0.5rem; + + text-shadow: 0.5px 0.5px 8px rgba(0, 0, 0, 0.8); + font-family: Roboto; + font-size: 1em; + font-style: normal; + font-weight: 500; + line-height: normal; + letter-spacing: 0.5px; > .svg { - filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.15)); - width: 1.1em; - height: 1.1em; + // filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.15)); + width: 1em; + height: 1.2em; + flex-shrink: 0; + } + > .title { + flex-grow: 1; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + min-width: 0; + } + > .properties { + color: #fff; + font-family: Roboto; + font-size: 16px; + font-style: normal; + font-weight: 300; + line-height: normal; + letter-spacing: -0.1px; + flex-shrink: 0; + } + > .propertiespanel-pop-up_close { + height: 1em; + margin-left: 1em; + background-color: unset; + border: none; } } > .propertiespanel-pop-up__footer { flex: 1; - position: absolute; - left: 0; - right: 0; - bottom: 10px; + // position: absolute; + // left: 0; + // right: 0; + // bottom: 0; + flex: 0 0 0; + padding: 10px; display: flex; - justify-content: center; - align-items: center; - margin-right: 50px; + justify-content: space-between; + // align-items: center; + gap: 12px; - > .propertiespanel-pop-up__button { - background: #636363; - padding: 5px 5px 5px 5px; - gap: 10px; - border-radius: 5px; - border: 1px; - border: 1px solid #7F7F7F; - color: #ddd; + // margin-right: 50px; + + > .propertiespanel-pop-up__button-group { + display: inherit; + gap: inherit; + } + + > .propertiespanel-pop-up__button, + .propertiespanel-pop-up__button-group .propertiespanel-pop-up__button { display: block; - margin: 10px auto; + + border-radius: 5px; + border: 1px solid #7f7f7f; + background: #636363; + padding: 10px; + + color: #dfdfdf; + font-size: 0.875em; + font-weight: 500; &:active { transform: scale(0.95); @@ -107,7 +143,7 @@ &:disabled { cursor: not-allowed; opacity: 0.3; - + &:active { transform: none; top: 0; @@ -122,27 +158,40 @@ height: 1em; } + svg { + width: 1.3em; + height: 0.875em; + } + .label { margin-left: 10px; margin-right: 10px; margin-top: 2px; line-height: inherit; } + + &.start { + justify-self: start; + } + &.end { + justify-self: end; + } } } > .propertiespanel-pop-up__contents { flex: 1; - padding: 0.625rem 0.9375rem; padding: 0.525rem 0.6375rem; cursor: default; - + overflow: hidden auto; overflow-wrap: break-word; + flex: 1 0; + > hr { margin-left: 0px; width: 100%; - border-color: #7F7F7F; + border-color: #7f7f7f; } > .propertiespanel-pop-up__groupselector { @@ -150,7 +199,7 @@ flex-wrap: wrap; margin-top: 0.5em; margin-bottom: 0.5em; - + > .propertiespanel-pop-up__groupselector__button { @include item-type-colors(); @@ -161,20 +210,20 @@ gap: 10px; color: #ddd; opacity: 0.2; - } - > .propertiespanel-pop-up__groupselector__button-active { - @include item-type-colors(); + &.splits { + background: linear-gradient( + to right, + $segment-layer-background-camera 50%, + $segment-layer-background-remote 50.0001% + ); + } - width: 50px; - height: 30px; - border: 0px; - margin: 3px; - gap: 10px; - color: #fff; - opacity: 1; + &.active { + color: #fff; + opacity: 1; + } } - } > .propertiespanel-pop-up__action { @@ -184,68 +233,29 @@ } > .properties-panel-pop-up__form { - margin-top: 15px; color: #ddd; - padding: 4px 4px; - position: relative; - display: flex; - align-items: flex-start; - gap: 8px; - - // Add positioning for the pencil icon - > svg { - margin-top: 4px; - flex-shrink: 0; - } - - > .properties-panel-pop-up__form__schema { - border-color: pink; - border-width: 0px; - flex-grow: 1; - } - } - - > .properties-panel-pop-up__has-been-edited { - background-color: #ffffff16; - border-radius: 8px; - padding: 4px 4px; } - - > .propertiespanel-pop-up__label { - color: #ddd; - } - - > .propertiespanel-pop-up__select{ - margin-top: 10px; - width: 100%; - height: 3em; - background: #232323; - margin-bottom: 0.5em; - color: #ddd; - } - - > .propertiespanel-pop-up__button { - margin-top: 10px; + .propertiespanel-pop-up__button { + // margin-top: 10px; background: #636363; - padding: 5px 5px 5px 5px; + padding: 10px; gap: 10px; border-radius: 5px; - border: 1px; - border: 1px solid #7F7F7F; - color: #ddd; + border: 1px solid #7f7f7f; + color: #dfdfdf; + + font-size: 0.875em; + font-weight: 500; &:active { transform: scale(0.95); top: 2px; } - > svg { - margin-top: -0.1em; - vertical-align: middle; - margin-right: -0.4em; + svg { width: 1em; - height: 1em; + height: 0.875em; } .label { diff --git a/packages/webui/src/client/styles/rundownView.scss b/packages/webui/src/client/styles/rundownView.scss index 2db0a2c0b7..e94d593d33 100644 --- a/packages/webui/src/client/styles/rundownView.scss +++ b/packages/webui/src/client/styles/rundownView.scss @@ -158,6 +158,16 @@ $break-width: 35rem; transition: 0s padding-right 1s; } } + + &.properties-panel-open { + padding-right: $properties-panel-width; + transition: 0s padding-right 1s; + + > .header .rundown-overview { + padding-right: calc(#{$properties-panel-width} + 1.5em); + transition: 0s padding-right 1s; + } + } } body.vertical-overflow-only { @@ -1399,12 +1409,13 @@ svg.icon { &.quickloop-start { left: -2px; - &::before, &::after { + &::before, + &::after { z-index: 1; margin-left: 5px; border-left-color: white; } - + .segment-timeline__part__nextline__label { z-index: 5; left: 5px; @@ -1586,7 +1597,6 @@ svg.icon { } } - &:not(.live) { .segment-timeline__part__nextline.auto-next:not(.segment-timeline__part__nextline--endline), .segment-timeline__part__nextline.invalid:not(.segment-timeline__part__nextline--endline) { @@ -1832,7 +1842,8 @@ svg.icon { right: 0; } - .segment-timeline__part__quickloop-start, .segment-timeline__part__quickloop-end { + .segment-timeline__part__quickloop-start, + .segment-timeline__part__quickloop-end { background: $segment-background-color; padding: 0 0.3em; margin-bottom: -2px; @@ -1849,7 +1860,7 @@ svg.icon { .segment-timeline__part__quickloop-end { padding-right: 0.6em; - margin-left: auto + margin-left: auto; } } diff --git a/packages/webui/src/client/ui/RundownView.tsx b/packages/webui/src/client/ui/RundownView.tsx index 985402f2cd..e07dc11219 100644 --- a/packages/webui/src/client/ui/RundownView.tsx +++ b/packages/webui/src/client/ui/RundownView.tsx @@ -2969,10 +2969,9 @@ const RundownViewContent = translateWithTracker 0, + 'notification-center-open': this.state.isNotificationsCenterOpen !== undefined, 'rundown-view--studio-mode': this.state.studioMode, + 'properties-panel-open': selectionContext.listSelectedElements().length > 0, })} style={this.getStyle()} onWheelCapture={this.onWheel} diff --git a/packages/webui/src/client/ui/Settings/Forms.scss b/packages/webui/src/client/ui/Settings/Forms.scss index 9d5849c0aa..a31b5f5453 100644 --- a/packages/webui/src/client/ui/Settings/Forms.scss +++ b/packages/webui/src/client/ui/Settings/Forms.scss @@ -7,6 +7,14 @@ &:not(:first-child) { margin-top: 20px; } + + &.form-dark { + color: white; + + > .text-input.bghl { + color: black !important; + } + } } .properties-grid > :not(.field) { diff --git a/packages/webui/src/client/ui/UserEditOperations/PropertiesPanel.tsx b/packages/webui/src/client/ui/UserEditOperations/PropertiesPanel.tsx index 35197eccb7..1c784a6a41 100644 --- a/packages/webui/src/client/ui/UserEditOperations/PropertiesPanel.tsx +++ b/packages/webui/src/client/ui/UserEditOperations/PropertiesPanel.tsx @@ -1,52 +1,40 @@ import * as React from 'react' -import { translateMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' import { doUserAction, UserAction } from '../../lib/clientUserAction' import { MeteorCall } from '../../lib/meteorApi' import { + DefaultUserOperationEditProperties, DefaultUserOperationsTypes, + JSONBlob, JSONBlobParse, - SourceLayerType, - UserEditingButtonType, + JSONSchema, + UserEditingDefinitionAction, + UserEditingProperties, UserEditingSourceLayer, UserEditingType, } from '@sofie-automation/blueprints-integration' -import { assertNever, clone } from '@sofie-automation/corelib/dist/lib' +import { literal } from '@sofie-automation/corelib/dist/lib' import classNames from 'classnames' -import { - CoreUserEditingDefinitionAction, - CoreUserEditingDefinitionForm, - CoreUserEditingDefinitionSourceLayerForm, -} from '@sofie-automation/corelib/dist/dataModel/UserEditingDefinitions' import { useTranslation } from 'react-i18next' import { useTracker } from '../../lib/ReactMeteorData/ReactMeteorData' import { Segments } from '../../collections' import { UIParts } from '../Collections' import { useSelection } from '../RundownView/SelectedElementsContext' import { DBSegment } from '@sofie-automation/corelib/dist/dataModel/Segment' -import { DBPart } from '@sofie-automation/corelib/dist/dataModel/Part' -import { RundownId } from '@sofie-automation/corelib/dist/dataModel/Ids' -import { StyledSchemaFormInPlace } from '../../lib/forms/SchemaFormInPlace' import { RundownUtils } from '../../lib/rundown' import * as CoreIcon from '@nrk/core-icons/jsx' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faPencilAlt } from '@fortawesome/free-solid-svg-icons' +import { useCallback, useMemo } from 'react' +import { SchemaFormWithState } from '../../lib/forms/SchemaFormWithState' +import { translateMessage } from '@sofie-automation/corelib/dist/TranslatableMessage' -interface PendingChange { - operationId: string - userEditingType: UserEditingType - sourceLayerType?: SourceLayerType - value?: Record - switchState?: boolean - cssTypeClass?: string -} +type PendingChange = DefaultUserOperationEditProperties['payload'] export function PropertiesPanel(): JSX.Element { const { listSelectedElements, clearSelections } = useSelection() const selectedElement = listSelectedElements()?.[0] const { t } = useTranslation() - const [pendingChanges, setPendingChanges] = React.useState([]) - const hasPendingChanges = pendingChanges.length > 0 + const [pendingChange, setPendingChange] = React.useState(undefined) + const hasPendingChanges = !!pendingChange const [isAnimatedIn, setIsAnimatedIn] = React.useState(false) React.useEffect(() => { @@ -67,7 +55,7 @@ export function PropertiesPanel(): JSX.Element { }, []) const part = useTracker(() => { - setPendingChanges([]) + setPendingChange(undefined) return UIParts.findOne({ _id: selectedElement?.elementId }) }, [selectedElement?.elementId]) @@ -78,9 +66,13 @@ export function PropertiesPanel(): JSX.Element { const rundownId = part ? part.rundownId : segment?.rundownId const handleCommitChanges = async (e: React.MouseEvent) => { - if (!rundownId || !selectedElement) return - for (const change of pendingChanges) { - doUserAction(t, e, UserAction.EXECUTE_USER_OPERATION, (e, ts) => + if (!rundownId || !selectedElement || !pendingChange) return + + doUserAction( + t, + e, + UserAction.EXECUTE_USER_OPERATION, + (e, ts) => MeteorCall.userAction.executeUserChangeOperation( e, ts, @@ -90,20 +82,18 @@ export function PropertiesPanel(): JSX.Element { partExternalId: part?.externalId, pieceExternalId: undefined, }, - { - id: change.operationId, - values: change.value, - } - ) - ) - } - // Delay the Clear pending changes after executing to avoid async flickering: - setTimeout(() => setPendingChanges([]), 100) + literal({ + id: DefaultUserOperationsTypes.UPDATE_PROPS, + payload: pendingChange, + }) + ), + () => setPendingChange(undefined) + ) } const handleRevertChanges = (e: React.MouseEvent) => { if (!rundownId || !selectedElement) return - setPendingChanges([]) + setPendingChange(undefined) doUserAction(t, e, UserAction.EXECUTE_USER_OPERATION, (e, ts) => MeteorCall.userAction.executeUserChangeOperation( e, @@ -124,424 +114,294 @@ export function PropertiesPanel(): JSX.Element { ) } + const handleCancel = () => { + setPendingChange(undefined) + clearSelections() + } + + const executeAction = (e: React.MouseEvent, id: string) => { + if (!rundownId || !selectedElement) return + doUserAction(t, e, UserAction.EXECUTE_USER_OPERATION, (e, ts) => + MeteorCall.userAction.executeUserChangeOperation( + e, + ts, + rundownId, + { + segmentExternalId: segment?.externalId, + partExternalId: part?.externalId, + pieceExternalId: undefined, + }, + { + id, + } + ) + ) + } + + const userEditOperations = + selectedElement?.type === 'part' + ? part?.userEditOperations + : selectedElement?.type === 'segment' + ? segment?.userEditOperations + : undefined + const userEditProperties = + selectedElement?.type === 'part' + ? part?.userEditProperties + : selectedElement?.type === 'segment' + ? segment?.userEditProperties + : undefined + const change = pendingChange ?? { + pieceTypeProperties: userEditProperties?.pieceTypeProperties?.currentValue ?? { type: '', value: {} }, + globalProperties: userEditProperties?.globalProperties?.currentValue ?? {}, + } + + const title = + selectedElement?.type === 'part' ? part?.title : selectedElement?.type === 'segment' ? segment?.name : undefined + return (
-
- +
+ {userEditOperations && + userEditOperations.map((operation) => { + if (operation.type !== UserEditingType.ACTION || !operation.svgIcon || !operation.isActive) return null + return ( +
+ ) + })} +
{title}
+ {t('Properties')} +
- {rundownId && selectedElement?.type === 'part' && ( - <> -
- {part?.userEditOperations && - part.userEditOperations.map((operation) => { - if (operation.type !== UserEditingType.ACTION || !operation.svgIcon || !operation.isActive) - return null - return ( -
- ) - })} - {part?.title.slice(0, 30)} -
-
- {segment && - part?._id && - part.userEditOperations?.map((userEditOperation, i) => { - switch (userEditOperation.type) { - case UserEditingType.ACTION: - return ( - - ) - case UserEditingType.FORM: - return ( - - ) - case UserEditingType.SOURCE_LAYER_FORM: - return ( - - ) - default: - assertNever(userEditOperation) - return null - } - })} -
-
- - )} - {rundownId && selectedElement?.type === 'segment' && ( - <> -
- {segment?.userEditOperations && - segment.userEditOperations.map((operation) => { - if (operation.type !== UserEditingType.ACTION || !operation.svgIcon || !operation.isActive) - return null +
+ {userEditProperties?.pieceTypeProperties && ( + + )} + {userEditProperties?.globalProperties && ( + + )} + {userEditProperties?.operations && ( + + )} +
- return ( -
- ) - })} - {segment?.name.slice(0, 30)} -
-
- {segment && - segment?.userEditOperations?.map((userEditOperation, i) => { - switch (userEditOperation.type) { - case UserEditingType.ACTION: - return ( - - ) - case UserEditingType.FORM: - return ( - - ) - case UserEditingType.SOURCE_LAYER_FORM: - return ( - - ) - default: - assertNever(userEditOperation) - return null - } - })} -
- - )}
- +
+ + +
) } -function EditingTypeAction(props: { - userEditOperation: CoreUserEditingDefinitionAction - segment: DBSegment | undefined - part: DBPart | undefined - rundownId: RundownId - pendingChanges: PendingChange[] - setPendingChanges: React.Dispatch> -}) { - const { t } = useTranslation() - - const getPendingState = () => { - const pendingChange = props.pendingChanges.find((change) => change.operationId === props.userEditOperation.id) - return pendingChange?.switchState - } - - const [hasBeenEdited, setHasBeenEdited] = React.useState( - getPendingState() !== undefined && getPendingState() !== props.userEditOperation.isActive +function PropertiesEditor({ + properties, + change, + setChange, + translationNamespace, +}: { + properties: UserEditingProperties['pieceTypeProperties'] + change: PendingChange + setChange: React.Dispatch> + translationNamespace: string[] +}): JSX.Element { + if (!properties) return <> + + const selectedGroupId = change.pieceTypeProperties.type + const selectedGroupSchema = properties.schema[selectedGroupId]?.schema + const parsedSchema = useMemo( + () => (selectedGroupSchema ? JSONBlobParse(selectedGroupSchema) : undefined), + [selectedGroupSchema] ) - React.useEffect(() => { - setHasBeenEdited(getPendingState() !== undefined && getPendingState() !== props.userEditOperation.isActive) - }, [props.userEditOperation.id, props.pendingChanges]) - - if (!props.userEditOperation.buttonType) return null - const addPendingChange = () => { - setHasBeenEdited(!hasBeenEdited) - props.setPendingChanges((prev) => { - // Find if there's an existing pending change for this operation - const existingChangeIndex = prev.findIndex((change) => change.operationId === props.userEditOperation.id) - - if (existingChangeIndex !== -1) { - // If exists, toggle the switch state - const newChanges = [...prev] - newChanges[existingChangeIndex] = { - ...newChanges[existingChangeIndex], - switchState: !newChanges[existingChangeIndex].switchState, - } - return newChanges - } - - // If doesn't exist, add new change with opposite of current state - return [ - ...prev, - { - operationId: props.userEditOperation.id, - userEditingType: UserEditingType.ACTION, - switchState: !props.userEditOperation.isActive, + const updateGroup = useCallback( + (key: string) => { + setChange({ + ...change, + pieceTypeProperties: { + type: key, + value: properties.schema[key]?.defaultValue ?? {}, }, - ] - }) - } - - switch (props.userEditOperation.buttonType) { - case UserEditingButtonType.BUTTON: - return ( - - ) - case UserEditingButtonType.SWITCH: - return ( -
- {hasBeenEdited && ( - <> - {' '} - - - )}{' '} - -
-
-   -   -
-
-
-
- {translateMessage(props.userEditOperation.label, t)} -
- ) - case UserEditingButtonType.HIDDEN || undefined: - return null - default: - assertNever(props.userEditOperation.buttonType) - return null - } -} - -function EditingTypeChangeForm(props: { - userEditOperation: CoreUserEditingDefinitionForm - segment: DBSegment | undefined - part: DBPart | undefined - rundownId: RundownId - pendingChanges: PendingChange[] - setPendingChanges: React.Dispatch> -}) { - const { t } = useTranslation() - - const jsonSchema = props.userEditOperation.schema - const schema = jsonSchema ? JSONBlobParse(jsonSchema) : undefined - const values = clone(props.userEditOperation.currentValues) - - return ( - <> - {schema && ( - <> - {t('Source')}: - -
-
- - )} - - ) -} - -function EditingTypeChangeSourceLayerSource(props: { - userEditOperation: CoreUserEditingDefinitionSourceLayerForm - segment: DBSegment | undefined - part: DBPart | undefined - rundownId: RundownId - pendingChanges: PendingChange[] - setPendingChanges: React.Dispatch> -}) { - const [selectedSourceGroup, setSelectedSourceButton] = React.useState( - props.pendingChanges.find((change) => change.operationId === props.userEditOperation.id)?.sourceLayerType || - props.userEditOperation.currentValues.type - ) - const [selectedValues, setSelectedValues] = React.useState>( - props.pendingChanges.find((change) => change.operationId === props.userEditOperation.id)?.value || - props.userEditOperation.currentValues.value - ) - - const getPendingState = () => { - const pendingChange = props.pendingChanges.find((change) => change.operationId === props.userEditOperation.id) - return pendingChange?.value - } - - const [hasBeenEdited, setHasBeenEdited] = React.useState( - getPendingState() !== undefined && getPendingState() !== props.userEditOperation.currentValues.value - ) - - const jsonSchema = Object.values(props.userEditOperation.schemas).find( - (layer) => layer.sourceLayerType === selectedSourceGroup - )?.schema - const selectedGroupSchema = jsonSchema ? JSONBlobParse(jsonSchema) : undefined - - React.useEffect(() => { - const pendingChange = props.pendingChanges.find((change) => change.operationId === props.userEditOperation.id) - - setSelectedSourceButton(pendingChange?.sourceLayerType || props.userEditOperation.currentValues.type) - - setSelectedValues(pendingChange?.value || props.userEditOperation.currentValues.value) - - setHasBeenEdited( - getPendingState() !== undefined && getPendingState() !== props.userEditOperation.currentValues.value - ) - }, [props.userEditOperation.id, props.pendingChanges]) - - const handleSourceChange = () => { - setSelectedValues(selectedValues) - setHasBeenEdited(true) - // Add to pending changes instead of executing immediately - props.setPendingChanges((prev) => { - const filtered = prev.filter( - (change) => - !( - change.operationId === props.userEditOperation.id && - change.userEditingType === UserEditingType.SOURCE_LAYER_FORM - ) - ) - // Only use the key,value pair from the selected source group: - const newKey = Object.keys(props.userEditOperation.schemas).find((key) => { - return props.userEditOperation.schemas[key].sourceLayerType === selectedSourceGroup }) - if (!newKey) return filtered - const newValue = selectedValues[newKey] - return [ - ...filtered, - { - operationId: props.userEditOperation.id, - userEditingType: UserEditingType.SOURCE_LAYER_FORM, - sourceLayerType: selectedSourceGroup, - value: { [newKey]: newValue }, + }, + [change] + ) + const onUpdate = useCallback( + (update: Record) => { + setChange({ + ...change, + pieceTypeProperties: { + type: change.pieceTypeProperties.type, + value: update, }, - ] - }) - } + }) + }, + [change] + ) + const value = change.pieceTypeProperties.value return ( <>
- {Object.values(props.userEditOperation.schemas).map((group, index) => { + {Object.entries(properties.schema).map(([key, group]) => { return ( ) })} -
-
- {selectedGroupSchema && ( -
- {hasBeenEdited && } -
- -
+
+ {parsedSchema && ( +
+
)}
) } + +function GlobalPropertiesEditor({ + schema, + change, + setChange, + translationNamespace, +}: { + schema: JSONBlob + change: PendingChange + setChange: React.Dispatch> + translationNamespace: string[] +}): JSX.Element { + const parsedSchema = schema ? JSONBlobParse(schema) : undefined + const currentValue = change.globalProperties + + const onUpdate = useCallback( + (update: Record) => { + setChange({ + ...change, + globalProperties: update, + }) + }, + [change] + ) + + return ( +
+ {parsedSchema ? ( + + ) : ( + <> + )} +
+ ) +} + +function ActionList({ + actions, + executeAction, +}: { + actions: UserEditingDefinitionAction[] + executeAction: (e: any, id: string) => void +}) { + const { t } = useTranslation() + + return ( +
+ {actions.map((action) => ( + + ))} +
+ ) +}