From 30320f6af1dfd2ef89f17568878427efa7a03f4d Mon Sep 17 00:00:00 2001 From: Njibhu Date: Mon, 6 Apr 2026 23:34:56 +0200 Subject: [PATCH] Implement a clipboard mechanism to copy paste feedback and actions across buttons --- .../lib/Controls/Entities/EntityInstance.ts | 4 +-- .../Controls/Entities/EntityListPoolBase.ts | 35 +++++++++++++++++++ companion/lib/Controls/EntitiesTrpcRouter.ts | 27 +++++++++++++- shared-lib/lib/Model/EntityModel.ts | 33 ++++++++++++++++- webui/src/ContextData.tsx | 3 ++ .../Controls/Components/AddEntityPanel.tsx | 30 +++++++++++++--- .../Components/EntityCellControls.tsx | 21 +++++++++-- .../Controls/ControlActionsService.ts | 4 +++ .../Controls/ControlEntitiesService.ts | 16 +++++++++ webui/src/Stores/EntityClipboardStore.ts | 28 +++++++++++++++ webui/src/Stores/RootAppStore.tsx | 3 ++ 11 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 webui/src/Stores/EntityClipboardStore.ts diff --git a/companion/lib/Controls/Entities/EntityInstance.ts b/companion/lib/Controls/Entities/EntityInstance.ts index d4819d5b24..914d16f4f8 100644 --- a/companion/lib/Controls/Entities/EntityInstance.ts +++ b/companion/lib/Controls/Entities/EntityInstance.ts @@ -570,9 +570,9 @@ export class ControlEntityInstance { /** * Add a child entity to this entity */ - addChild(groupId: string, entityModel: SomeEntityModel): ControlEntityInstance { + addChild(groupId: string, entityModel: SomeEntityModel, isCloned?: boolean): ControlEntityInstance { const childGroup = this.#getOrCreateChildGroup(groupId) - return childGroup.addEntity(entityModel) + return childGroup.addEntity(entityModel, isCloned) } /** diff --git a/companion/lib/Controls/Entities/EntityListPoolBase.ts b/companion/lib/Controls/Entities/EntityListPoolBase.ts index e73217f3da..a4ff27df1f 100644 --- a/companion/lib/Controls/Entities/EntityListPoolBase.ts +++ b/companion/lib/Controls/Entities/EntityListPoolBase.ts @@ -286,6 +286,41 @@ export abstract class ControlEntityListPoolBase { return true } + /** + * Paste one or more entities into this control, regenerating IDs + */ + entityPaste( + listId: SomeSocketEntityLocation, + ownerId: EntityOwner | null, + ...entityModels: SomeEntityModel[] + ): boolean { + if (entityModels.length === 0) return false + + const entityList = this.getEntityList(listId) + if (!entityList) return false + + let newEntities: ControlEntityInstance[] + if (ownerId) { + const parent = entityList.findById(ownerId.parentId) + if (!parent) throw new Error(`Failed to find parent entity ${ownerId.parentId} when pasting child entity`) + + newEntities = entityModels.map((entity) => parent.addChild(ownerId.childGroup, entity, true)) + } else { + newEntities = entityModels.map((entity) => entityList.addEntity(entity, true)) + } + + // Inform relevant module + for (const entity of newEntities) { + entity.subscribe(true) + } + + this.tryTriggerLocalVariablesChanged(...newEntities) + + this.commitChange() + + return true + } + /** * Duplicate an entity on this control */ diff --git a/companion/lib/Controls/EntitiesTrpcRouter.ts b/companion/lib/Controls/EntitiesTrpcRouter.ts index 02d3da6ed9..84f2d4ecf4 100644 --- a/companion/lib/Controls/EntitiesTrpcRouter.ts +++ b/companion/lib/Controls/EntitiesTrpcRouter.ts @@ -2,7 +2,12 @@ import z from 'zod' import { publicProcedure, router } from '../UI/TRPC.js' import type { SomeControl } from './IControlFragments.js' import type { InstanceDefinitions } from '../Instance/Definitions.js' -import { EntityModelType, zodEntityLocation, type EntityOwner } from '@companion-app/shared/Model/EntityModel.js' +import { + EntityModelType, + zodEntityLocation, + zodSomeEntityModel, + type EntityOwner, +} from '@companion-app/shared/Model/EntityModel.js' import type { ActiveLearningStore } from '../Resources/ActiveLearningStore.js' import LogController from '../Log/Controller.js' import type { VariableValues } from '@companion-app/shared/Model/Variables.js' @@ -157,6 +162,26 @@ export function createEntitiesTrpcRouter( return control.entities.entityDuplicate(entityLocation, entityId) }), + paste: publicProcedure + .input( + z.object({ + controlId: z.string(), + entityLocation: zodEntityLocation, + ownerId: zodEntityOwner.nullable(), + entities: z.array(zodSomeEntityModel), + }) + ) + .mutation(async ({ input }) => { + const { controlId, entityLocation, ownerId, entities } = input + + const control = controlsMap.get(controlId) + if (!control) return false + + if (!control.supportsEntities) throw new Error(`Control "${controlId}" does not support entities`) + + return control.entities.entityPaste(entityLocation, ownerId, ...entities) + }), + setOption: publicProcedure .input( z.object({ diff --git a/shared-lib/lib/Model/EntityModel.ts b/shared-lib/lib/Model/EntityModel.ts index bd915989be..aafcaa5711 100644 --- a/shared-lib/lib/Model/EntityModel.ts +++ b/shared-lib/lib/Model/EntityModel.ts @@ -1,7 +1,12 @@ import z from 'zod' import type { ActionSetId } from './ActionModel.js' import type { ButtonStyleProperties } from './StyleModel.js' -import type { ExpressionableOptionsObject, ExpressionOrValue } from './Options.js' +import { + ExpressionableOptionsObjectSchema, + createExpressionOrValueSchema, + type ExpressionableOptionsObject, + type ExpressionOrValue, +} from './Options.js' import type { VariableValue } from './Variables.js' import type { CompanionFeedbackButtonStyleResult } from '@companion-module/host' @@ -119,3 +124,29 @@ export function stringifySocketEntityLocation(location: SomeSocketEntityLocation if (typeof location === 'string') return location return `${location.stepId}_${location.setId}` } + +const zodEntityModelBase = z.object({ + id: z.string(), + definitionId: z.string(), + connectionId: z.string(), + headline: z.string().optional(), + options: ExpressionableOptionsObjectSchema, + disabled: z.boolean().optional(), + upgradeIndex: z.union([z.number(), z.undefined()]), +}) + +export const zodSomeEntityModel: z.ZodType = z.lazy(() => + z.union([ + zodEntityModelBase.extend({ + type: z.literal(EntityModelType.Action), + children: z.record(z.string(), z.array(zodSomeEntityModel).optional()).optional(), + }), + zodEntityModelBase.extend({ + type: z.literal(EntityModelType.Feedback), + isInverted: createExpressionOrValueSchema(z.boolean()).optional(), + variableName: z.string().optional(), + style: z.record(z.string(), z.any()).optional(), + children: z.record(z.string(), z.array(zodSomeEntityModel).optional()).optional(), + }), + ]) +) diff --git a/webui/src/ContextData.tsx b/webui/src/ContextData.tsx index e11bd24e47..2ca4031727 100644 --- a/webui/src/ContextData.tsx +++ b/webui/src/ContextData.tsx @@ -26,6 +26,7 @@ import { useModuleStoreRefreshProgressSubscription } from './Hooks/useModuleStor import { useModuleStoreListSubscription } from './Hooks/useModuleStoreListSubscription.js' import { HelpModal, type HelpModalRef } from './Instances/HelpModal.js' import { ViewControlStore } from '~/Stores/ViewControlStore.js' +import { EntityClipboardStore } from '~/Stores/EntityClipboardStore.js' import { WhatsNewModal, type WhatsNewModalRef } from './WhatsNewModal/WhatsNew.js' import { useGenericCollectionsSubscription } from './Hooks/useCollectionsSubscription.js' import { useCustomVariableCollectionsSubscription } from './Hooks/useCustomVariableCollectionsSubscription.js' @@ -95,6 +96,8 @@ export function ContextData({ children }: Readonly): React.JSX showWizard: () => showWizardEvent.dispatchEvent(new Event('show')), viewControl: new ViewControlStore(), + + entityClipboard: new EntityClipboardStore(), } satisfies RootAppStore }, [notifierObj, helpModalRef, whatsNewModalRef]) diff --git a/webui/src/Controls/Components/AddEntityPanel.tsx b/webui/src/Controls/Components/AddEntityPanel.tsx index ba34a36607..394c149ffa 100644 --- a/webui/src/Controls/Components/AddEntityPanel.tsx +++ b/webui/src/Controls/Components/AddEntityPanel.tsx @@ -1,13 +1,15 @@ import { CButton } from '@coreui/react' -import { faFolderOpen } from '@fortawesome/free-solid-svg-icons' +import { faFolderOpen, faPaste } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { useCallback, useRef } from 'react' +import { useCallback, useContext, useRef } from 'react' import { AddEntitiesModal, type AddEntitiesModalRef } from './AddEntitiesModal.js' import { MyErrorBoundary } from '~/Resources/Error.js' import type { EntityModelType, EntityOwner, FeedbackEntitySubType } from '@companion-app/shared/Model/EntityModel.js' import { AddEntityDropdown } from './AddEntityDropdown.js' import { usePanelCollapseHelperContext } from '~/Helpers/CollapseHelper.js' import { useEntityEditorContext } from './EntityEditorContext.js' +import { observer } from 'mobx-react-lite' +import { RootAppStoreContext } from '~/Stores/RootAppStore.js' interface AddEntityPanelProps { ownerId: EntityOwner | null @@ -16,13 +18,14 @@ interface AddEntityPanelProps { entityTypeLabel: string } -export function AddEntityPanel({ +export const AddEntityPanel = observer(function AddEntityPanel({ ownerId, entityType, feedbackListType, entityTypeLabel, }: AddEntityPanelProps): React.JSX.Element { const { serviceFactory, readonly } = useEntityEditorContext() + const { entityClipboard } = useContext(RootAppStoreContext) const addEntitiesRef = useRef(null) const showAddModal = useCallback(() => addEntitiesRef.current?.show(), []) @@ -46,6 +49,14 @@ export function AddEntityPanel({ [serviceFactory, entityType, ownerId, panelCollapseHelper] ) + const canPaste = entityClipboard.copiedEntityType === entityType + + const handlePaste = useCallback(() => { + const entity = entityClipboard.copiedEntity + if (!entity) return + serviceFactory.performPaste(ownerId, [entity]) + }, [entityClipboard, serviceFactory, ownerId]) + return (
+ {canPaste && ( + + + + )}
) -} +}) diff --git a/webui/src/Controls/Components/EntityCellControls.tsx b/webui/src/Controls/Components/EntityCellControls.tsx index d7b5bb21eb..28fd777268 100644 --- a/webui/src/Controls/Components/EntityCellControls.tsx +++ b/webui/src/Controls/Components/EntityCellControls.tsx @@ -1,11 +1,19 @@ -import { useCallback } from 'react' +import { useCallback, useContext } from 'react' import { CButtonGroup, CButton, CFormSwitch } from '@coreui/react' -import { faPencil, faExpandArrowsAlt, faCompressArrowsAlt, faClone, faTrash } from '@fortawesome/free-solid-svg-icons' +import { + faCopy, + faPencil, + faExpandArrowsAlt, + faCompressArrowsAlt, + faClone, + faTrash, +} from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import type { IEntityEditorActionService } from '~/Services/Controls/ControlEntitiesService.js' import { EntityModelType, type EntityOwner, type SomeEntityModel } from '@companion-app/shared/Model/EntityModel.js' import { TextInputField } from '~/Components/TextInputField.js' import { observer } from 'mobx-react-lite' +import { RootAppStoreContext } from '~/Stores/RootAppStore.js' interface EntityCellControlProps { service: IEntityEditorActionService @@ -36,6 +44,12 @@ export const EntityRowHeader = observer(function EntityRowHeader({ readonly, localVariablePrefix, }: EntityCellControlProps) { + const { entityClipboard } = useContext(RootAppStoreContext) + + const handleCopy = useCallback(() => { + entityClipboard.copyEntity(entity) + }, [entityClipboard, entity]) + const innerSetEnabled = useCallback( (e: React.ChangeEvent) => service.setEnabled?.(e.target.checked), [service] @@ -82,6 +96,9 @@ export const EntityRowHeader = observer(function EntityRowHeader({ )} + + + { + // Not supported in action recorder + }, + performLearn: undefined, setEnabled: undefined, setHeadline: undefined, diff --git a/webui/src/Services/Controls/ControlEntitiesService.ts b/webui/src/Services/Controls/ControlEntitiesService.ts index b6f5062ac7..b20c5e8174 100644 --- a/webui/src/Services/Controls/ControlEntitiesService.ts +++ b/webui/src/Services/Controls/ControlEntitiesService.ts @@ -31,6 +31,7 @@ export interface IEntityEditorService { ) => void performDelete: (entityId: string, entityTypeLabel: string) => void performDuplicate: (entityId: string) => void + performPaste: (ownerId: EntityOwner | null, entities: SomeEntityModel[]) => void setConnection: (entityId: string, connectionId: string) => void moveCard: ( dragListId: SomeSocketEntityLocation, @@ -76,6 +77,7 @@ export function useControlEntitiesEditorService( const setConnectionMutation = useMutationExt(trpc.controls.entities.setConnection.mutationOptions()) const removeMutation = useMutationExt(trpc.controls.entities.remove.mutationOptions()) const duplicateMutation = useMutationExt(trpc.controls.entities.duplicate.mutationOptions()) + const pasteMutation = useMutationExt(trpc.controls.entities.paste.mutationOptions()) const learnOptionsMutation = useMutationExt(trpc.controls.entities.learnOptions.mutationOptions()) const setEnabledMutation = useMutationExt(trpc.controls.entities.setEnabled.mutationOptions()) const setHeadlineMutation = useMutationExt(trpc.controls.entities.setHeadline.mutationOptions()) @@ -186,6 +188,19 @@ export function useControlEntitiesEditorService( }) }, + performPaste: (ownerId: EntityOwner | null, entities: SomeEntityModel[]) => { + pasteMutation + .mutateAsync({ + controlId, + entityLocation: listId, + ownerId, + entities, + }) + .catch((e) => { + console.error('Failed to paste control entity', e) + }) + }, + performLearn: (entityId: string) => { learnOptionsMutation .mutateAsync({ @@ -296,6 +311,7 @@ export function useControlEntitiesEditorService( setConnectionMutation, removeMutation, duplicateMutation, + pasteMutation, learnOptionsMutation, setEnabledMutation, setHeadlineMutation, diff --git a/webui/src/Stores/EntityClipboardStore.ts b/webui/src/Stores/EntityClipboardStore.ts new file mode 100644 index 0000000000..b424e4bab7 --- /dev/null +++ b/webui/src/Stores/EntityClipboardStore.ts @@ -0,0 +1,28 @@ +import { action, makeObservable, observable } from 'mobx' +import type { SomeEntityModel, EntityModelType } from '@companion-app/shared/Model/EntityModel.js' + +export class EntityClipboardStore { + protected _copiedEntity: SomeEntityModel | null = null + + constructor() { + makeObservable(this, { + _copiedEntity: observable, + }) + } + + get copiedEntity(): SomeEntityModel | null { + return this._copiedEntity + } + + get copiedEntityType(): EntityModelType | null { + return this._copiedEntity?.type ?? null + } + + copyEntity = action((entity: SomeEntityModel): void => { + this._copiedEntity = structuredClone(entity) + }) + + clear = action((): void => { + this._copiedEntity = null + }) +} diff --git a/webui/src/Stores/RootAppStore.tsx b/webui/src/Stores/RootAppStore.tsx index bc00ce941f..e60e8af591 100644 --- a/webui/src/Stores/RootAppStore.tsx +++ b/webui/src/Stores/RootAppStore.tsx @@ -12,6 +12,7 @@ import type { VariablesStore } from './VariablesStore.js' import type { ConnectionsStore } from './ConnectionsStore.js' import type { HelpModalRef } from '~/Instances/HelpModal.js' import type { ViewControlStore } from './ViewControlStore.js' +import type { EntityClipboardStore } from './EntityClipboardStore.js' import type { WhatsNewModalRef } from '~/WhatsNewModal/WhatsNew.js' import type { ExpressionVariablesListStore } from './ExpressionVariablesListStore.js' import type { SurfaceInstancesStore } from './SurfaceInstancesStore.js' @@ -51,4 +52,6 @@ export interface RootAppStore { readonly showWizard: () => void readonly viewControl: ViewControlStore + + readonly entityClipboard: EntityClipboardStore }