diff --git a/companion/lib/Controls/ControlIncrementUtil.ts b/companion/lib/Controls/ControlIncrementUtil.ts new file mode 100644 index 0000000000..846b9a4973 --- /dev/null +++ b/companion/lib/Controls/ControlIncrementUtil.ts @@ -0,0 +1,256 @@ +import type { SomeControlModel } from '@companion-app/shared/Model/Controls.js' + +type IncrementPathSegment = string | number +type IncrementPath = IncrementPathSegment[] + +export interface ControlIncrementOption { + id: string + label: string + currentValue: string + valueType: 'number' | 'text' +} + +interface InternalControlIncrementOption extends ControlIncrementOption { + path: IncrementPath +} + +const numericTextRegex = /-?\d+(?:\.\d+)?/ +const numericTextGlobalRegex = /-?\d+(?:\.\d+)?/g +const hexColorRegex = /^#?[0-9a-f]{6,8}$/i + +export function getControlIncrementOptions(controlJson: SomeControlModel): ControlIncrementOption[] { + return collectControlIncrementOptions(controlJson).map(({ path: _path, ...field }) => field) +} + +export function incrementControlModelFields( + controlJson: SomeControlModel, + selectedFieldIds: string[], + incrementBy: number +): SomeControlModel { + if (!selectedFieldIds.length || incrementBy === 0) return controlJson + + const selectedFields = new Set(selectedFieldIds) + const candidates = collectControlIncrementOptions(controlJson) + + for (const candidate of candidates) { + if (!selectedFields.has(candidate.id)) continue + + const target = getTargetByPath(controlJson, candidate.path) + if (!target) continue + + const currentValue = getContainerValue(target.container, target.key) + if (typeof currentValue === 'number') { + setContainerValue(target.container, target.key, currentValue + incrementBy) + } else if (typeof currentValue === 'string') { + setContainerValue(target.container, target.key, incrementNumbersInString(currentValue, incrementBy)) + } + } + + return controlJson +} + +function collectControlIncrementOptions(controlJson: SomeControlModel): InternalControlIncrementOption[] { + const result: InternalControlIncrementOption[] = [] + + const visit = (value: unknown, path: IncrementPath, parent: unknown): void => { + if (isIncrementCandidateValue(value, path, parent)) { + result.push({ + id: encodePath(path), + label: formatIncrementPath(controlJson, path), + currentValue: String(value), + valueType: typeof value === 'number' ? 'number' : 'text', + path, + }) + } + + if (Array.isArray(value)) { + value.forEach((entry, index) => visit(entry, [...path, index], value)) + } else if (isObject(value)) { + for (const [key, entry] of Object.entries(value)) { + visit(entry, [...path, key], value) + } + } + } + + visit(controlJson, [], null) + + return result.sort(compareIncrementOptions) +} + +function compareIncrementOptions(a: InternalControlIncrementOption, b: InternalControlIncrementOption): number { + return getIncrementOptionSortPriority(a.path) - getIncrementOptionSortPriority(b.path) +} + +function getIncrementOptionSortPriority(path: IncrementPath): number { + return path[0] === 'localVariables' ? 0 : 1 +} + +function isIncrementCandidateValue(value: unknown, path: IncrementPath, parent: unknown): boolean { + if (path[path.length - 1] !== 'value') return false + if (!isExpressionValueObject(parent) || parent.isExpression) return false + + if (typeof value === 'number') { + if (!Number.isFinite(value)) return false + } else if (typeof value === 'string') { + if (!numericTextRegex.test(value)) return false + if (hexColorRegex.test(value.trim())) return false + } else { + return false + } + + return isAllowedExpressionValuePath(path) +} + +function isAllowedExpressionValuePath(path: IncrementPath): boolean { + const previous = path[path.length - 2] + + if (previous === 'text') return true + if (previous === 'override') return true + if (typeof previous === 'string' && previous.startsWith('opt:')) return true + + const optionsIndex = path.lastIndexOf('options') + return optionsIndex === path.length - 3 +} + +function incrementNumbersInString(value: string, incrementBy: number): string { + return value.replace(numericTextGlobalRegex, (match) => { + const nextValue = Number(match) + incrementBy + if (!Number.isFinite(nextValue)) return match + + if (match.includes('.')) { + return String(Number(nextValue.toFixed(12))) + } + + const unsignedOriginal = match.replace(/^-/, '') + const unsignedNext = String(Math.abs(nextValue)) + const paddedNext = unsignedNext.padStart(unsignedOriginal.length, '0') + + return nextValue < 0 ? `-${paddedNext}` : paddedNext + }) +} + +function encodePath(path: IncrementPath): string { + return `/${path.map((segment) => String(segment).replace(/~/g, '~0').replace(/\//g, '~1')).join('/')}` +} + +function getTargetByPath( + root: unknown, + path: IncrementPath +): { container: Record | any[]; key: string | number } | null { + if (path.length === 0) return null + + let container = root + for (const segment of path.slice(0, -1)) { + if (!isObject(container) && !Array.isArray(container)) return null + container = (container as any)[segment] + } + + if (!isObject(container) && !Array.isArray(container)) return null + + return { + container, + key: path[path.length - 1], + } +} + +function getContainerValue(container: Record | any[], key: string | number): unknown { + return Array.isArray(container) ? container[Number(key)] : container[String(key)] +} + +function setContainerValue(container: Record | any[], key: string | number, value: unknown): void { + if (Array.isArray(container)) { + container[Number(key)] = value + } else { + container[String(key)] = value + } +} + +function formatIncrementPath(controlJson: SomeControlModel, path: IncrementPath): string { + const optionKey = formatOptionKey(String(path[path.length - 2] ?? 'value')) + + const actionSetsIndex = path.indexOf('action_sets') + if (actionSetsIndex >= 0) { + const setId = path[actionSetsIndex + 1] + const actionIndex = path[actionSetsIndex + 2] + const actionNumber = typeof actionIndex === 'number' ? actionIndex + 1 : String(actionIndex ?? '?') + + return `${formatActionSetId(setId)} action ${actionNumber} / ${optionKey}` + } + + if (path[0] === 'feedbacks' && typeof path[1] === 'number') { + return `Feedback ${path[1] + 1} / ${optionKey}` + } + + if (path[0] === 'localVariables' && typeof path[1] === 'number') { + return `Local variable ${path[1] + 1}${formatLocalVariableName(controlJson, path[1])} | ${optionKey}:` + } + + if (path[0] === 'style' && path[1] === 'layers') { + return `Button label ${formatLayerName(controlJson, path)} / ${optionKey}` + } + + return path.map((segment) => formatOptionKey(String(segment))).join(' / ') +} + +function formatLayerName(controlJson: SomeControlModel, path: IncrementPath): string { + let layerName = '' + + for (let i = 0; i < path.length; i++) { + const previous = path[i - 1] + const segment = path[i] + if (typeof segment !== 'number' || (previous !== 'layers' && previous !== 'children')) continue + + const layer = getValueByPath(controlJson, path.slice(0, i + 1)) + if (isObject(layer) && typeof layer.name === 'string' && layer.name) { + layerName = layer.name + } + } + + return layerName ? `"${layerName}"` : '' +} + +function formatLocalVariableName(controlJson: SomeControlModel, index: number): string { + const variable = getValueByPath(controlJson, ['localVariables', index]) + + if (isObject(variable) && typeof variable.variableName === 'string' && variable.variableName) { + return ` (${variable.variableName})` + } + + return '' +} + +function getValueByPath(root: unknown, path: IncrementPath): unknown { + let value = root + for (const segment of path) { + if (!isObject(value) && !Array.isArray(value)) return undefined + value = (value as any)[segment] + } + return value +} + +function formatActionSetId(setId: IncrementPathSegment | undefined): string { + switch (setId) { + case 'down': + return 'Press' + case 'up': + return 'Release' + case 'rotate_left': + return 'Rotate left' + case 'rotate_right': + return 'Rotate right' + default: + return `Step ${String(setId ?? '?')}` + } +} + +function formatOptionKey(key: string): string { + return key.replace(/^opt:/, '').replace(/_/g, ' ') +} + +function isExpressionValueObject(value: unknown): value is { value: unknown; isExpression: boolean } { + return isObject(value) && 'value' in value && value.isExpression === false +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/companion/lib/Controls/ControlsTrpcRouter.ts b/companion/lib/Controls/ControlsTrpcRouter.ts index 2d503ea5cf..d938c78974 100644 --- a/companion/lib/Controls/ControlsTrpcRouter.ts +++ b/companion/lib/Controls/ControlsTrpcRouter.ts @@ -9,6 +9,7 @@ import type { IPageStore } from '../Page/Store.js' import { zodLocation } from '../Preview/Graphics.js' import { publicProcedure } from '../UI/TRPC.js' import type { ControlCommonEvents } from './ControlDependencies.js' +import { getControlIncrementOptions, incrementControlModelFields } from './ControlIncrementUtil.js' import type { ControlsController } from './Controller.js' import type { SomeControl } from './IControlFragments.js' @@ -176,6 +177,78 @@ export function createControlsTrpcRouter( return false }), + getControlIncrementOptions: publicProcedure + .input( + z.object({ + location: zodLocation, + }) + ) + .query(async ({ input }) => { + const controlId = pageStore.getControlIdAt(input.location) + if (!controlId) return [] + + const control = controlsMap.get(controlId) + if (!control) return [] + + return getControlIncrementOptions(control.toJSON(true)) + }), + + copyControlWithOffset: publicProcedure + .input( + z.object({ + fromLocation: zodLocation, + toLocation: zodLocation, + incrementFieldIds: z.array(z.string()), + incrementBy: z.number().int(), + }) + ) + .mutation(async ({ input }) => { + const { fromLocation, toLocation } = input + + // Don't try copying over itself + if ( + fromLocation.pageNumber === toLocation.pageNumber && + fromLocation.column === toLocation.column && + fromLocation.row === toLocation.row + ) + return false + + // Make sure target page number is valid + if (!pageStore.isPageValid(toLocation.pageNumber)) return false + + // Make sure there is something to copy + const fromControlId = pageStore.getControlIdAt(fromLocation) + if (!fromControlId) return false + + const fromControl = controlsMap.get(fromControlId) + if (!fromControl) return false + const controlJson = incrementControlModelFields( + fromControl.toJSON(true), + input.incrementFieldIds, + input.incrementBy + ) + + // Delete the control at the destination + const toControlId = pageStore.getControlIdAt(toLocation) + if (toControlId) { + controlsController.deleteControl(toControlId) + } + + const newControlId = CreateBankControlId(nanoid()) + const newControl = controlsController.createClassForControl(newControlId, 'button', controlJson, true) + if (newControl) { + controlsMap.set(newControlId, newControl) + + controlEvents.emit('controlPlacedAt', toLocation, newControlId) + + newControl.triggerRedraw() + + return true + } + + return false + }), + swapControl: publicProcedure .input( z.object({ diff --git a/companion/test/Controls/ControlIncrementUtil.test.ts b/companion/test/Controls/ControlIncrementUtil.test.ts new file mode 100644 index 0000000000..0dc7611783 --- /dev/null +++ b/companion/test/Controls/ControlIncrementUtil.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from 'vitest' +import type { SomeControlModel } from '@companion-app/shared/Model/Controls.js' +import { EntityModelType } from '@companion-app/shared/Model/EntityModel.js' +import { + ButtonGraphicsDecorationType, + ButtonGraphicsElementUsage, + ButtonGraphicsShowStatusIcons, +} from '@companion-app/shared/Model/StyleModel.js' +import { + getControlIncrementOptions, + incrementControlModelFields, +} from '../../lib/Controls/ControlIncrementUtil.js' + +function createControlModel(): SomeControlModel { + return { + type: 'button-layered', + options: { + stepProgression: 'auto', + rotaryActions: false, + canModifyStyleInApis: false, + }, + style: { + layers: [ + { + id: 'canvas', + name: 'Canvas', + usage: ButtonGraphicsElementUsage.Automatic, + type: 'canvas', + decoration: { isExpression: false, value: ButtonGraphicsDecorationType.FollowDefault }, + showStatusIcons: { isExpression: false, value: ButtonGraphicsShowStatusIcons.FollowDefault }, + }, + { + id: 'text0', + name: 'Text', + usage: ButtonGraphicsElementUsage.Automatic, + type: 'text', + enabled: { isExpression: false, value: true }, + opacity: { isExpression: false, value: 100 }, + x: { isExpression: false, value: 0 }, + y: { isExpression: false, value: 0 }, + width: { isExpression: false, value: 100 }, + height: { isExpression: false, value: 100 }, + rotation: { isExpression: false, value: 0 }, + text: { isExpression: false, value: 'CAM 001' }, + color: { isExpression: false, value: 0xffffff }, + outlineColor: { isExpression: false, value: 0 }, + halign: { isExpression: false, value: 'center' }, + valign: { isExpression: false, value: 'center' }, + fontsize: { isExpression: false, value: 'auto' }, + font: { isExpression: false, value: 'companion-sans' }, + }, + ], + }, + feedbacks: [ + { + type: EntityModelType.Feedback, + id: 'feedback-1', + connectionId: 'video', + definitionId: 'input_active', + options: { + input: { isExpression: false, value: 1 }, + }, + upgradeIndex: undefined, + }, + ], + steps: { + '0': { + action_sets: { + down: [ + { + type: EntityModelType.Action, + id: 'action-1', + connectionId: 'video', + definitionId: 'set_pgm', + options: { + input: { isExpression: false, value: 1 }, + aux: { isExpression: false, value: 'AUX 01' }, + expression: { isExpression: true, value: '$(internal:custom_1)' }, + color: { isExpression: false, value: '#ff0000' }, + }, + upgradeIndex: undefined, + }, + ], + up: [], + rotate_left: undefined, + rotate_right: undefined, + }, + options: { + runWhileHeld: [], + }, + }, + }, + localVariables: [ + { + type: EntityModelType.Feedback, + id: 'local-variable-1', + connectionId: 'internal', + definitionId: 'user_value', + variableName: 'input', + options: { + persist_value: { isExpression: false, value: true }, + startup_value: { isExpression: false, value: 1 }, + }, + children: {}, + upgradeIndex: undefined, + }, + ], + } +} + +describe('ControlIncrementUtil', () => { + test('finds incrementable options and label text, while skipping layout, expressions and colors', () => { + const fields = getControlIncrementOptions(createControlModel()) + + expect(fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'Press action 1 / input', currentValue: '1' }), + expect.objectContaining({ label: 'Press action 1 / aux', currentValue: 'AUX 01' }), + expect.objectContaining({ label: 'Feedback 1 / input', currentValue: '1' }), + expect.objectContaining({ label: 'Local variable 1 (input) | startup value:', currentValue: '1' }), + expect.objectContaining({ label: 'Button label "Text" / text', currentValue: 'CAM 001' }), + ]) + ) + + expect(fields).not.toEqual(expect.arrayContaining([expect.objectContaining({ currentValue: '#ff0000' })])) + expect(fields).not.toEqual(expect.arrayContaining([expect.objectContaining({ label: 'Button label "Text" / x' })])) + expect(fields).not.toEqual(expect.arrayContaining([expect.objectContaining({ label: 'Press action 1 / expression' })])) + }) + + test('increments only selected fields and preserves zero padding in text values', () => { + const model = createControlModel() + const fields = getControlIncrementOptions(model) + const inputField = fields.find((field) => field.label === 'Press action 1 / input') + const auxField = fields.find((field) => field.label === 'Press action 1 / aux') + const labelField = fields.find((field) => field.label === 'Button label "Text" / text') + + expect(inputField).toBeDefined() + expect(auxField).toBeDefined() + expect(labelField).toBeDefined() + + const result = incrementControlModelFields(model, [inputField!.id, auxField!.id, labelField!.id], 1) as any + + expect(result.steps['0'].action_sets.down[0].options.input.value).toBe(2) + expect(result.steps['0'].action_sets.down[0].options.aux.value).toBe('AUX 02') + expect(result.style.layers[1].text.value).toBe('CAM 002') + expect(result.feedbacks[0].options.input.value).toBe(1) + expect(result.style.layers[1].x.value).toBe(0) + }) +}) diff --git a/webui/src/Buttons/ButtonGridActions.tsx b/webui/src/Buttons/ButtonGridActions.tsx index dd0896e56f..ca73459256 100644 --- a/webui/src/Buttons/ButtonGridActions.tsx +++ b/webui/src/Buttons/ButtonGridActions.tsx @@ -2,13 +2,16 @@ import type { IconProp } from '@fortawesome/fontawesome-svg-core' import { faArrowsAlt, faArrowsLeftRight, faCompass, faCopy, faEraser, faTrash } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import classnames from 'classnames' -import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' +import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useResizeObserver } from 'usehooks-ts' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' import { Button, type ButtonColor } from '~/Components/Button' +import { CheckboxInputFieldWithLabel } from '~/Components/CheckboxInputField.js' import { GenericConfirmModal, type GenericConfirmModalRef } from '~/Components/GenericConfirmModal.js' import { Grid } from '~/Components/Grid' -import { trpc, useMutationExt } from '~/Resources/TRPC' +import { Modal } from '~/Components/Modal.js' +import { NumberInputField } from '~/Components/NumberInputField.js' +import { queryClient, trpc, useMutationExt, type RouterOutput } from '~/Resources/TRPC' export interface ButtonGridActionsRef { buttonClick: (location: ControlLocation, isDown: boolean) => void @@ -19,6 +22,8 @@ interface ButtonGridActionsProps { clearSelectedButton: () => void } +type ControlIncrementOption = RouterOutput['controls']['getControlIncrementOptions'][number] + export const ButtonGridActions = forwardRef(function ButtonGridActions( { isHot, pageNumber, clearSelectedButton }, ref @@ -28,15 +33,47 @@ export const ButtonGridActions = forwardRef(null) const [activeFunctionButton, setActiveFunctionButton] = useState(null) + const [copyIncrementModalOpen, setCopyIncrementModalOpen] = useState(false) + const [copyIncrementLoading, setCopyIncrementLoading] = useState(false) + const [copyIncrementLoadError, setCopyIncrementLoadError] = useState(null) + const [copyIncrementFields, setCopyIncrementFields] = useState([]) + const [copyIncrementSelectedFieldIds, setCopyIncrementSelectedFieldIds] = useState([]) + const [copyIncrementStep, setCopyIncrementStep] = useState(1) + const [copyIncrementPasteIndex, setCopyIncrementPasteIndex] = useState(1) + const [copyIncrementSettingsReady, setCopyIncrementSettingsReady] = useState(false) + const copyIncrementLoadRequestIdRef = useRef(0) + let hintText = '' if (activeFunction) { - if (!activeFunctionButton) { + if (activeFunction === 'copy-increment') { + if (!activeFunctionButton) { + hintText = 'Press the source button for Copy +N' + } else if (!copyIncrementSettingsReady) { + hintText = 'Choose which values to increment' + } else { + hintText = `Paste with ${copyIncrementPasteIndex * copyIncrementStep >= 0 ? '+' : ''}${ + copyIncrementPasteIndex * copyIncrementStep + }: press a target button` + } + } else if (!activeFunctionButton) { hintText = `Press the button you want to ${activeFunction}` } else { hintText = `Where do you want it?` } } + const resetCopyIncrementState = useCallback(() => { + copyIncrementLoadRequestIdRef.current += 1 + setCopyIncrementModalOpen(false) + setCopyIncrementLoading(false) + setCopyIncrementLoadError(null) + setCopyIncrementFields([]) + setCopyIncrementSelectedFieldIds([]) + setCopyIncrementStep(1) + setCopyIncrementPasteIndex(1) + setCopyIncrementSettingsReady(false) + }, []) + const startFunction = useCallback( (func: string) => { setActiveFunction((oldFunction) => { @@ -54,6 +91,53 @@ export const ButtonGridActions = forwardRef { setActiveFunction(null) setActiveFunctionButton(null) + resetCopyIncrementState() + }, [resetCopyIncrementState]) + + const prepareCopyIncrementSource = useCallback((location: ControlLocation) => { + const requestId = ++copyIncrementLoadRequestIdRef.current + + setActiveFunctionButton(location) + setCopyIncrementModalOpen(true) + setCopyIncrementLoading(true) + setCopyIncrementLoadError(null) + setCopyIncrementFields([]) + setCopyIncrementSelectedFieldIds([]) + setCopyIncrementStep(1) + setCopyIncrementPasteIndex(1) + setCopyIncrementSettingsReady(false) + + queryClient + .fetchQuery(trpc.controls.getControlIncrementOptions.queryOptions({ location })) + .then((fields) => { + if (copyIncrementLoadRequestIdRef.current !== requestId) return + + setCopyIncrementFields(fields) + setCopyIncrementSelectedFieldIds([]) + }) + .catch((e) => { + if (copyIncrementLoadRequestIdRef.current !== requestId) return + + setCopyIncrementLoadError(String(e)) + }) + .finally(() => { + if (copyIncrementLoadRequestIdRef.current !== requestId) return + + setCopyIncrementLoading(false) + }) + }, []) + + const applyCopyIncrementSettings = useCallback(() => { + setCopyIncrementSettingsReady(true) + setCopyIncrementPasteIndex(1) + setCopyIncrementModalOpen(false) + }, []) + + const setCopyIncrementFieldSelected = useCallback((fieldId: string, selected: boolean) => { + setCopyIncrementSelectedFieldIds((oldIds) => { + if (selected) return oldIds.includes(fieldId) ? oldIds : [...oldIds, fieldId] + return oldIds.filter((id) => id !== fieldId) + }) }, []) const setSizeRef = useRef(null) @@ -78,6 +162,27 @@ export const ButtonGridActions = forwardRef { + let color: ButtonColor = 'light' + let disabled = false + if (activeFunction === 'copy-increment') { + color = 'success' + } else if (activeFunction) { + disabled = true + } + + return ( + !disabled && ( + + ) + ) + } + const clearPageMutation = useMutationExt(trpc.pages.clearPage.mutationOptions()) const recreateNavMutation = useMutationExt(trpc.pages.recreateNav.mutationOptions()) @@ -120,6 +225,7 @@ export const ButtonGridActions = forwardRef { + if (copied) setCopyIncrementPasteIndex((oldIndex) => oldIndex + 1) + }) + .catch((e) => { + console.error(`copy +N failed: ${e}`) + }) + } + } else { + prepareCopyIncrementSource(location) + } + return true case 'move': if (activeFunctionButton) { const fromInfo = activeFunctionButton @@ -193,18 +321,40 @@ export const ButtonGridActions = forwardRef + setCopyIncrementSelectedFieldIds(copyIncrementFields.map((field) => field.id))} + onSelectNone={() => setCopyIncrementSelectedFieldIds([])} + onCancel={stopFunction} + onApply={applyCopyIncrementSettings} + />
{getButton('Copy', faCopy, 'copy')}   + {getCopyIncrementButton()} +   {getButton('Move', faArrowsAlt, 'move')}   {getButton('Swap', faArrowsLeftRight, 'swap')} @@ -233,3 +383,108 @@ export const ButtonGridActions = forwardRef ) }) + +interface CopyIncrementSettingsModalProps { + open: boolean + loading: boolean + error: string | null + fields: ControlIncrementOption[] + selectedFieldIds: string[] + step: number + onStepChange: (value: number) => void + onFieldSelected: (fieldId: string, selected: boolean) => void + onSelectAll: () => void + onSelectNone: () => void + onCancel: () => void + onApply: () => void +} + +function CopyIncrementSettingsModal({ + open, + loading, + error, + fields, + selectedFieldIds, + step, + onStepChange, + onFieldSelected, + onSelectAll, + onSelectNone, + onCancel, + onApply, +}: CopyIncrementSettingsModalProps): JSX.Element { + const selectedFieldIdSet = useMemo(() => new Set(selectedFieldIds), [selectedFieldIds]) + const stepIsValid = Number.isInteger(step) && step >= -999 && step <= 999 + + return ( + + + + + + + Copy +N + + +
+ + + +
+ + +
+ + {loading &&

Loading values...

} + {error &&

{error}

} + {!loading && !error && fields.length === 0 &&

No numeric values were found on this button.

} + {!loading && !error && fields.length > 0 && ( +
+ {fields.map((field) => ( + onFieldSelected(field.id, selected)} + disabled={loading} + label={ + + {field.label} + {field.currentValue} + + } + /> + ))} +
+ )} +
+
+ + + + +
+
+
+
+ ) +} diff --git a/webui/src/scss/_button-grid.scss b/webui/src/scss/_button-grid.scss index 8d16b05617..de5b3e620d 100644 --- a/webui/src/scss/_button-grid.scss +++ b/webui/src/scss/_button-grid.scss @@ -73,6 +73,45 @@ } } +.button-grid-copy-increment-settings { + display: grid; + gap: 0.75rem; +} + +.button-grid-copy-increment-icon { + display: inline-flex; + align-items: center; + gap: 0.15rem; +} + +.button-grid-copy-increment-icon-badge { + font-size: 0.72em; + font-weight: 700; + line-height: 1; +} + +.button-grid-copy-increment-toolbar { + display: flex; + gap: 0.5rem; +} + +.button-grid-copy-increment-fields { + display: grid; + gap: 0.4rem; + max-height: 42vh; + overflow: auto; + padding-right: 0.25rem; +} + +.button-grid-copy-increment-field-label { + margin-right: 0.5rem; +} + +.button-grid-copy-increment-field-value { + color: var(--cui-secondary-color); + font-family: var(--cui-font-monospace); +} + .c-sidebar-nav-link, .c-sidebar-nav-dropdown-toggle { transition: none;