diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 529c3455f7..ebbec1a7de 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -36,7 +36,7 @@ jobs: cd webui yarn check-types - linux64: + linux-x64: runs-on: ubuntu-20.04 needs: check-types timeout-minutes: 20 @@ -69,7 +69,7 @@ jobs: yarn --immutable yarn update - yarn zx tools/build/complete.mjs + yarn zx tools/build/complete.mjs linux-x64 # manually tar it, to preserve the symlinks cd electron-output @@ -180,7 +180,7 @@ jobs: api-target: 'linux-arm64-tgz' api-secret: ${{ secrets.BITFOCUS_API_PROJECT_SECRET }} - osx: + osx-x64: runs-on: macos-12 needs: check-types timeout-minutes: 60 @@ -210,7 +210,7 @@ jobs: yarn --immutable yarn update - yarn zx tools/build/complete.mjs + yarn zx tools/build/complete.mjs darwin-x64 env: CI: 1 CSC_LINK: ${{ secrets.OSX_CSC_LINK }} @@ -322,7 +322,7 @@ jobs: api-target: 'mac-arm' api-secret: ${{ secrets.BITFOCUS_API_PROJECT_SECRET }} - win64: + win32-x64: runs-on: windows-2019 needs: check-types timeout-minutes: 30 @@ -353,7 +353,7 @@ jobs: yarn --immutable yarn update - yarn zx tools/build/complete.mjs + yarn zx tools/build/complete.mjs win32-x64 env: CI: 1 CSC_LINK: ${{ secrets.CSC_LINK }} @@ -393,7 +393,7 @@ jobs: timeout-minutes: 10 needs: - - linux64 + - linux-x64 - linux-arm64 env: @@ -401,14 +401,14 @@ jobs: steps: - name: Docker meta - if: ${{ needs.linux64.outputs.do-docker }} + if: ${{ needs.linux-x64.outputs.do-docker }} id: meta uses: docker/metadata-action@v5 with: images: | ghcr.io/${{ github.repository }}/${{ env.IMAGE_NAME }} tags: | - type=raw,value=${{ needs.linux64.outputs.version }} + type=raw,value=${{ needs.linux-x64.outputs.version }} type=ref,event=tag type=ref,event=branch @@ -444,4 +444,4 @@ jobs: labels: ${{ steps.meta.outputs.labels }} tags: '${{ steps.meta.outputs.tags }}' build-args: | - build_name=${{ needs.linux64.outputs.version }} + build_name=${{ needs.linux-x64.outputs.version }} diff --git a/bundled-modules b/bundled-modules index 3b271cca68..aecb344290 160000 --- a/bundled-modules +++ b/bundled-modules @@ -1 +1 @@ -Subproject commit 3b271cca681f17ab2622b869edbab63159a062ee +Subproject commit aecb34429087581c3fde2c784a841d875ef4527b diff --git a/companion/lib/Surface/Controller.js b/companion/lib/Surface/Controller.js index c9b628da4d..12dee4ac83 100644 --- a/companion/lib/Surface/Controller.js +++ b/companion/lib/Surface/Controller.js @@ -1,4 +1,3 @@ -// @ts-check /* * This file is part of the Companion project * Copyright (c) 2018 Bitfocus AS @@ -41,6 +40,7 @@ import SurfaceIPVideohubPanel from './IP/VideohubPanel.js' import FrameworkMacropadDriver from './USB/FrameworkMacropad.js' import CoreBase from '../Core/Base.js' import { SurfaceGroup } from './Group.js' +import { SurfaceLayoutRegistry } from './LayoutRegistry.js' // Force it to load the hidraw driver just in case HID.setDriverType('hidraw') @@ -49,6 +49,13 @@ HID.devices() const SurfacesRoom = 'surfaces' class SurfaceController extends CoreBase { + /** + * @type {SurfaceLayoutRegistry} + * @access private + * @readonly + */ + #surfaceLayouts + /** * The last sent json object * @type {Record } @@ -112,6 +119,8 @@ class SurfaceController extends CoreBase { constructor(registry) { super(registry, 'Surface/Controller') + this.#surfaceLayouts = new SurfaceLayoutRegistry() + this.#surfacesAllLocked = !!this.userconfig.getKey('link_lockouts') setImmediate(() => { @@ -422,6 +431,11 @@ class SurfaceController extends CoreBase { for (let surface of this.#surfaceHandlers.values()) { if (surface && surface.surfaceId == id) { surface.setPanelConfig(config) + + setImmediate(() => { + this.updateDevicesList() + }) + return surface.getPanelConfig() } } @@ -531,6 +545,10 @@ class SurfaceController extends CoreBase { return group.groupConfig }) + + client.onPromise('surfaces:get-layouts', () => { + return this.#surfaceLayouts.getLayouts() + }) } /** @@ -651,6 +669,21 @@ class SurfaceController extends CoreBase { isConnected: !!surfaceHandler, displayName: getSurfaceName(config, id), location: null, + xOffset: config.config?.xOffset ?? 0, + yOffset: config.config?.yOffset ?? 0, + layout: config.layout ?? null, + } + + // If the surface has a cached grid size, a crude layout can be generated + if (config.gridSize && !surfaceInfo.layout) { + surfaceInfo.layout = { + id: '__auto__', + name: surfaceInfo.displayName, + + type: 'grid', + rows: config.gridSize.rows, + columns: config.gridSize.columns, + } } if (surfaceHandler) { @@ -659,6 +692,15 @@ class SurfaceController extends CoreBase { surfaceInfo.location = location || null surfaceInfo.configFields = surfaceHandler.panel.info.configFields || [] + + surfaceInfo.layout = { + id: '__auto__', + name: surfaceInfo.displayName, + + type: 'grid', + rows: surfaceHandler.panelGridSize.rows, + columns: surfaceHandler.panelGridSize.columns, + } } return surfaceInfo diff --git a/companion/lib/Surface/LayoutRegistry.js b/companion/lib/Surface/LayoutRegistry.js new file mode 100644 index 0000000000..db950f3d9d --- /dev/null +++ b/companion/lib/Surface/LayoutRegistry.js @@ -0,0 +1,194 @@ +/* + * This file is part of the Companion project + * Copyright (c) 2018 Bitfocus AS + * Authors: William Viker , Håkon Nessjøen + * + * This program is free software. + * You should have received a copy of the MIT licence as well as the Bitfocus + * Individual Contributor License Agreement for companion along with + * this program. + * + * You can be released from the requirements of the license by purchasing + * a commercial license. Buying such a license is mandatory as soon as you + * develop commercial activities involving the Companion software without + * disclosing the source code of your own applications. + * + */ + +import { PRODUCTS as XKeysProducts } from 'xkeys' +import { contourShuttleXpressInfo, contourShuttleProV1Info, contourShuttleProV2Info } from './USB/ContourShuttle.js' + +export class SurfaceLayoutRegistry { + /** + * The list of known surface layouts + * @type {import("@companion-app/shared/Model/Surfaces.js").SurfaceLayoutSchema[]} + * @access private + * @readonly + */ + #layouts = [] + + constructor() { + this.#addCountourShuttleLayouts() + this.#addStreamdeckLayouts() + this.#addLoupedeckLayouts() + this.#addInfinittonLayouts() + this.#addVideohubLayouts() + this.#addXKeysLayouts() + + // Sort by name + this.#layouts.sort((a, b) => a.name.localeCompare(b.name)) + } + + #addCountourShuttleLayouts() { + this.#layouts.push( + { + id: 'contour-shuttle-xpress', + name: 'Contour Shuttle Xpress', + type: 'grid', + rows: contourShuttleXpressInfo.totalRows, + columns: contourShuttleXpressInfo.totalCols, + }, + { + id: 'contour-shuttle-pro-v1', + name: 'Contour Shuttle Pro v1', + type: 'grid', + rows: contourShuttleProV1Info.totalRows, + columns: contourShuttleProV1Info.totalCols, + }, + { + id: 'contour-shuttle-pro-v2', + name: 'Contour Shuttle Pro v2', + type: 'grid', + rows: contourShuttleProV2Info.totalRows, + columns: contourShuttleProV2Info.totalCols, + } + ) + } + + #addStreamdeckLayouts() { + this.#layouts.push( + { + id: 'streamdeck-15', + name: 'Elgato Streamdeck Original', + type: 'grid', + rows: 3, + columns: 5, + }, + { + id: 'streamdeck-xl', + name: 'Elgato Streamdeck XL', + type: 'grid', + rows: 4, + columns: 8, + }, + { + id: 'streamdeck-mini', + name: 'Elgato Streamdeck Mini', + type: 'grid', + rows: 2, + columns: 3, + }, + { + id: 'streamdeck-plus', + name: 'Elgato Streamdeck +', + type: 'grid', + rows: 4, + columns: 4, + }, + { + id: 'streamdeck-pedal', + name: 'Elgato Streamdeck Pedal', + type: 'grid', + rows: 1, + columns: 3, + }, + { + id: 'streamdeck-neo', + name: 'Elgato Streamdeck Neo', + type: 'grid', + rows: 3, + columns: 4, + } + ) + } + + #addLoupedeckLayouts() { + this.#layouts.push( + { + id: 'loupedeck-live', + name: 'Loupedeck Live', + type: 'grid', + rows: 4, + columns: 8, + }, + { + id: 'loupedeck-live-s', + name: 'Loupedeck Live S', + type: 'grid', + rows: 3, + columns: 7, + }, + { + id: 'razer-stream-controller', + name: 'Razer Stream Controller', + type: 'grid', + rows: 4, + columns: 8, + }, + { + id: 'razer-stream-controller-x', + name: 'Razer Stream Controller X', + type: 'grid', + rows: 3, + columns: 5, + }, + { + id: 'loupedeck-ct', + name: 'Loupedeck CT', + type: 'grid', + rows: 8, // TODO verify + columns: 8, + } + ) + } + + #addInfinittonLayouts() { + this.#layouts.push({ + id: 'infinitton-idisplay', + name: 'Infinitton idisplay', + type: 'grid', + rows: 3, + columns: 5, + }) + } + + #addVideohubLayouts() { + this.#layouts.push({ + id: 'blackmagic-videohub-smart-control', + name: 'Videohub Smart Control', + type: 'grid', + rows: 2, + columns: 20, + }) + } + + #addXKeysLayouts() { + for (const [id, product] of Object.entries(XKeysProducts)) { + this.#layouts.push({ + id: `xkeys-${id}`, + name: `XKeys ${product.name}`, + type: 'grid', + + rows: product.rowCount, + columns: product.colCount, + }) + } + } + + /** + * @returns {import("@companion-app/shared/Model/Surfaces.js").SurfaceLayoutSchema[]} + */ + getLayouts() { + return this.#layouts + } +} diff --git a/companion/lib/Surface/USB/ContourShuttle.js b/companion/lib/Surface/USB/ContourShuttle.js index b92820068a..a760154d75 100644 --- a/companion/lib/Surface/USB/ContourShuttle.js +++ b/companion/lib/Surface/USB/ContourShuttle.js @@ -20,7 +20,7 @@ import EventEmitter from 'events' import shuttleControlUSB from 'shuttle-control-usb' import LogController from '../../Log/Controller.js' -const contourShuttleXpressInfo = { +export const contourShuttleXpressInfo = { // Treat as: // 3 buttons // button, two encoders (jog and shuttle), button @@ -40,7 +40,7 @@ const contourShuttleXpressInfo = { [3, 1], ], } -const contourShuttleProV1Info = { +export const contourShuttleProV1Info = { // Same as Pro V2 only without the buttons either side of the encoders // Map the same for consistency and compatibility totalCols: 5, @@ -72,7 +72,7 @@ const contourShuttleProV1Info = { [2, 3], ], } -const contourShuttleProV2Info = { +export const contourShuttleProV2Info = { // 4 buttons // 5 buttons // button, two encoders (jog and shuttle), button diff --git a/shared-lib/lib/Model/Surfaces.ts b/shared-lib/lib/Model/Surfaces.ts index b16be6638b..532bb0d0ee 100644 --- a/shared-lib/lib/Model/Surfaces.ts +++ b/shared-lib/lib/Model/Surfaces.ts @@ -9,6 +9,10 @@ export interface ClientSurfaceItem { isConnected: boolean displayName: string location: string | null + + xOffset: number + yOffset: number + layout: SurfaceLayoutSchema | null } export interface ClientDevicesListItem { @@ -46,3 +50,32 @@ export interface SurfacesUpdateUpdateOp { patch: JsonPatchOperation[] } + +export type SurfaceLayoutSchema = SurfaceLayoutSchemaGrid | SurfaceLayoutSchemaComplex + +export interface SurfaceLayoutSchemaBase { + id: string + name: string +} + +export interface SurfaceLayoutSchemaGrid { + id: string + name: string + + type: 'grid' + + rows: number + columns: number +} + +export interface SurfaceLayoutSchemaComplex { + id: string + name: string + + type: 'complex' + + /** + * Background image, recommended to be a svg when possible + */ + backgroundImage: string +} diff --git a/shared-lib/lib/SocketIO.ts b/shared-lib/lib/SocketIO.ts index d6e4be6cbe..cca2a9abed 100644 --- a/shared-lib/lib/SocketIO.ts +++ b/shared-lib/lib/SocketIO.ts @@ -16,7 +16,13 @@ import type { HelpDescription, WrappedImage, } from './Model/Common.js' -import type { ClientDevicesListItem, SurfaceGroupConfig, SurfacePanelConfig, SurfacesUpdate } from './Model/Surfaces.js' +import type { + ClientDevicesListItem, + SurfaceGroupConfig, + SurfaceLayoutSchema, + SurfacePanelConfig, + SurfacesUpdate, +} from './Model/Surfaces.js' import type { ClientImportObject, ClientImportSelection, @@ -223,6 +229,7 @@ export interface ClientToBackendEventsMap { 'surfaces:config-get': (surfaceId: string) => SurfacePanelConfig | null 'surfaces:config-set': (surfaceId: string, panelConfig: SurfacePanelConfig) => SurfacePanelConfig | string 'surfaces:group-config-get': (groupId: string) => SurfaceGroupConfig + 'surfaces:get-layouts': () => SurfaceLayoutSchema[] 'emulator:startup': (emulatorId: string) => EmulatorConfig 'emulator:press': (emulatorId: string, column: number, row: number) => void diff --git a/tools/dev.mjs b/tools/dev.mjs index e9e205626a..af40039199 100644 --- a/tools/dev.mjs +++ b/tools/dev.mjs @@ -8,6 +8,8 @@ import { fileURLToPath } from 'url' import concurrently from 'concurrently' import dotenv from 'dotenv' +await $`zx ../tools/build_writefile.mjs` + dotenv.config({ path: path.resolve(process.cwd(), '..', '.env'), }) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 1ed0cafd9e..e6391fbb03 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -34,9 +34,9 @@ import { HTML5Backend } from 'react-dnd-html5-backend' import { TouchBackend } from 'react-dnd-touch-backend' import { MySidebar } from './Layout/Sidebar.js' import { MyHeader } from './Layout/Header.js' -import { Triggers } from './Triggers/index.js' +import { Triggers, TRIGGERS_PAGE_PREFIX } from './Triggers/index.js' import { ConnectionsPage } from './Connections/index.js' -import { ButtonsPage } from './Buttons/index.js' +import { BUTTONS_PAGE_PREFIX, ButtonsPage } from './Buttons/index.js' import { ContextData } from './ContextData.js' import { CloudPage } from './Cloud/index.js' import { WizardModal, WIZARD_CURRENT_VERSION, WizardModalRef } from './Wizard/index.js' @@ -447,7 +447,7 @@ const AppContent = observer(function AppContent({ buttonGridHotPress }: AppConte - + Buttons @@ -457,7 +457,7 @@ const AppContent = observer(function AppContent({ buttonGridHotPress }: AppConte - + Triggers @@ -495,7 +495,7 @@ const AppContent = observer(function AppContent({ buttonGridHotPress }: AppConte - + @@ -505,7 +505,7 @@ const AppContent = observer(function AppContent({ buttonGridHotPress }: AppConte - + diff --git a/webui/src/Buttons/ButtonGridPanel.tsx b/webui/src/Buttons/ButtonGridPanel.tsx index b6240196bd..3d80ff51dd 100644 --- a/webui/src/Buttons/ButtonGridPanel.tsx +++ b/webui/src/Buttons/ButtonGridPanel.tsx @@ -17,7 +17,6 @@ import { faFileExport, faHome, faPencil } from '@fortawesome/free-solid-svg-icon import { ConfirmExportModal, ConfirmExportModalRef } from '../Components/ConfirmExportModal.js' import { ButtonInfiniteGrid, ButtonInfiniteGridRef, PrimaryButtonGridIcon } from './ButtonInfiniteGrid.js' import { useHasBeenRendered } from '../Hooks/useHasBeenRendered.js' -import { useResizeObserver } from 'usehooks-ts' import { ButtonGridHeader } from './ButtonGridHeader.js' import { ButtonGridActions, ButtonGridActionsRef } from './ButtonGridActions.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' @@ -27,6 +26,8 @@ import { observer } from 'mobx-react-lite' import { ButtonGridZoomControl } from './ButtonGridZoomControl.js' import { GridZoomController } from './GridZoom.js' import { CModalExt } from '../Components/CModalExt.js' +import { GridViewAsController, GridViewSelectedSurfaceInfo } from './GridViewAs.js' +import { ButtonGridViewAsSurfaceControl } from './ButtonGridViewAsSurfaceControl.js' interface ButtonsGridPanelProps { pageNumber: number @@ -38,6 +39,7 @@ interface ButtonsGridPanelProps { clearSelectedButton: () => void gridZoomValue: number gridZoomController: GridZoomController + gridViewAsController: GridViewAsController } export const ButtonsGridPanel = observer(function ButtonsPage({ @@ -50,8 +52,9 @@ export const ButtonsGridPanel = observer(function ButtonsPage({ clearSelectedButton, gridZoomValue, gridZoomController, + gridViewAsController, }: ButtonsGridPanelProps) { - const { socket, pages, userConfig } = useContext(RootAppStoreContext) + const { pages } = useContext(RootAppStoreContext) const actionsRef = useRef(null) @@ -113,48 +116,8 @@ export const ButtonsGridPanel = observer(function ButtonsPage({ editRef.current?.show(Number(pageNumber), pageInfo) }, [pageNumber, pageInfo]) - const gridSize = userConfig.properties?.gridSize - - const doGrow = useCallback( - (direction: 'left' | 'right' | 'top' | 'bottom', amount: number) => { - if (amount <= 0 || !gridSize) return - - switch (direction) { - case 'left': - socket.emit('set_userconfig_key', 'gridSize', { - ...gridSize, - minColumn: gridSize.minColumn - (amount || 2), - }) - break - case 'right': - socket.emit('set_userconfig_key', 'gridSize', { - ...gridSize, - maxColumn: gridSize.maxColumn + (amount || 2), - }) - break - case 'top': - socket.emit('set_userconfig_key', 'gridSize', { - ...gridSize, - minRow: gridSize.minRow - (amount || 2), - }) - break - case 'bottom': - socket.emit('set_userconfig_key', 'gridSize', { - ...gridSize, - maxRow: gridSize.maxRow + (amount || 2), - }) - break - } - }, - [socket, gridSize] - ) - const [hasBeenInView, isInViewRef] = useHasBeenRendered() - const setSizeRef = useRef(null) - const holderSize = useResizeObserver({ ref: setSizeRef }) - const useCompactButtons = (holderSize.width ?? 0) < 700 // Cutoff for what of the header row fit in the large mode - return (
@@ -167,43 +130,50 @@ export const ButtonsGridPanel = observer(function ButtonsPage({ and what they should do when you press or click on them.

- + -   - {useCompactButtons ? '' : 'Export Page'} - {useCompactButtons ? '' : 'Edit Page'} + - {useCompactButtons ? '' : 'Home'} + +
- {hasBeenInView && gridSize && ( - - )} + {hasBeenInView && + (gridViewAsController.enabled ? ( + + ) : ( + + ))}
@@ -225,6 +195,127 @@ export const ButtonsGridPanel = observer(function ButtonsPage({ ) }) +interface ButtonFullGridLayoutProps { + gridRef: React.RefObject + isHot: boolean + pageNumber: number + buttonClick: (location: ControlLocation, isDown: boolean) => void + selectedButton: ControlLocation | null + gridZoomValue: number +} +function ButtonFullGridLayout({ + gridRef, + isHot, + pageNumber, + buttonClick, + selectedButton, + gridZoomValue, +}: ButtonFullGridLayoutProps) { + const { socket, userConfig } = useContext(RootAppStoreContext) + + const gridSize = userConfig.properties?.gridSize + + const doGrow = useCallback( + (direction: 'left' | 'right' | 'top' | 'bottom', amount: number) => { + if (amount <= 0 || !gridSize) return + + switch (direction) { + case 'left': + socket.emit('set_userconfig_key', 'gridSize', { + ...gridSize, + minColumn: gridSize.minColumn - (amount || 2), + }) + break + case 'right': + socket.emit('set_userconfig_key', 'gridSize', { + ...gridSize, + maxColumn: gridSize.maxColumn + (amount || 2), + }) + break + case 'top': + socket.emit('set_userconfig_key', 'gridSize', { + ...gridSize, + minRow: gridSize.minRow - (amount || 2), + }) + break + case 'bottom': + socket.emit('set_userconfig_key', 'gridSize', { + ...gridSize, + maxRow: gridSize.maxRow + (amount || 2), + }) + break + } + }, + [socket, gridSize] + ) + + return ( + gridSize && ( + + ) + ) +} + +interface ButtonViewAsLayoutProps { + gridRef: React.RefObject + isHot: boolean + pageNumber: number + buttonClick: (location: ControlLocation, isDown: boolean) => void + selectedButton: ControlLocation | null + gridZoomValue: number + gridViewAsSurface: GridViewSelectedSurfaceInfo +} +function ButtonViewAsLayout({ + gridRef, + isHot, + pageNumber, + buttonClick, + selectedButton, + gridZoomValue, + gridViewAsSurface, +}: ButtonViewAsLayoutProps) { + const { userConfig } = useContext(RootAppStoreContext) + + if (gridViewAsSurface.layout?.type === 'complex') { + return

TODO

+ } else { + // A custom view has been selected, limit what is shown + const gridSize = gridViewAsSurface.layout + ? { + minRow: gridViewAsSurface.yOffset, + minColumn: gridViewAsSurface.xOffset, + maxRow: gridViewAsSurface.yOffset + gridViewAsSurface.layout.rows - 1, + maxColumn: gridViewAsSurface.xOffset + gridViewAsSurface.layout.columns - 1, + } + : userConfig.properties?.gridSize // fallback to the default grid size + + return ( + gridSize && ( + + ) + ) + } +} + interface EditPagePropertiesModalRef { show(pageNumber: number, pageInfo: PagesStoreModel | undefined): void } diff --git a/webui/src/Buttons/ButtonGridViewAsSurfaceControl.tsx b/webui/src/Buttons/ButtonGridViewAsSurfaceControl.tsx new file mode 100644 index 0000000000..22a3cceab6 --- /dev/null +++ b/webui/src/Buttons/ButtonGridViewAsSurfaceControl.tsx @@ -0,0 +1,38 @@ +import { CButton } from '@coreui/react' +import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { observer } from 'mobx-react-lite' +import React from 'react' +import { GridViewAsController } from './GridViewAs.js' + +export interface ButtonGridViewAsSurfaceControlProps { + gridViewAsController: GridViewAsController +} + +export const ButtonGridViewAsSurfaceControl = observer(function ButtonGridViewAsSurfaceControl({ + gridViewAsController, +}: ButtonGridViewAsSurfaceControlProps) { + if (gridViewAsController.enabled) { + return ( + gridViewAsController.setEnabled(false)} + title="View Full Grid" + className="btn-right" + > + + + ) + } else { + return ( + gridViewAsController.setEnabled(true)} + title="View As Surface" + className="btn-right" + > + + + ) + } +}) diff --git a/webui/src/Buttons/ButtonGridZoomControl.tsx b/webui/src/Buttons/ButtonGridZoomControl.tsx index 62551fe7bc..9894d45c18 100644 --- a/webui/src/Buttons/ButtonGridZoomControl.tsx +++ b/webui/src/Buttons/ButtonGridZoomControl.tsx @@ -18,18 +18,16 @@ export interface ButtonGridZoomControlProps { useCompactButtons: boolean gridZoomValue: number gridZoomController: GridZoomController - style?: React.CSSProperties } export function ButtonGridZoomControl({ useCompactButtons, gridZoomValue, gridZoomController, - style, }: ButtonGridZoomControlProps) { return ( - + - Zoom + View Scale {useCompactButtons ? '' : `${Math.round(gridZoomValue)}%`} @@ -38,11 +36,11 @@ export function ButtonGridZoomControl({ gridZoomController.setZoom(parseInt(e.currentTarget.value))} /> @@ -55,7 +53,7 @@ export function ButtonGridZoomControl({ % - Zoom to 100% + Scale to 100% diff --git a/webui/src/Buttons/GridViewAs.tsx b/webui/src/Buttons/GridViewAs.tsx new file mode 100644 index 0000000000..b84a61cd7f --- /dev/null +++ b/webui/src/Buttons/GridViewAs.tsx @@ -0,0 +1,199 @@ +import { useCallback, useContext, useMemo, useState } from 'react' +import { RootAppStoreContext } from '../Stores/RootAppStore.js' +import { useComputed } from '../util.js' +import { DropdownChoice } from '@companion-module/base' +import { ClientSurfaceItem, SurfaceLayoutSchema } from '@companion-app/shared/Model/Surfaces.js' +import { cloneDeep } from 'lodash-es' + +export enum GridViewSpecialSurface { + Custom = '__custom__', +} + +interface GridViewAsStore { + enabled: boolean + surfaceId: GridViewSpecialSurface | string + custom: { + type: string + xOffset: number + yOffset: number + } +} + +const DEFAULT_STORED_VALUE = { + enabled: false, + surfaceId: GridViewSpecialSurface.Custom, + custom: { + type: 'streamdeck-xl', + xOffset: 0, + yOffset: 0, + }, +} + +export interface GridViewSelectedSurfaceInfo { + id: GridViewSpecialSurface | string + type: string + xOffset: number + yOffset: number + layout: SurfaceLayoutSchema | null +} + +export interface GridViewAsController { + readonly enabled: boolean + readonly selectedSurface: GridViewSelectedSurfaceInfo + readonly surfaceChoices: DropdownChoice[] + + setSelectedSurface: (surface: GridViewSpecialSurface | string) => void + + setEnabled: (enabled: boolean) => void + setCustomType: (type: string) => void + setCustomXOffset: (xOffset: number) => void + setCustomYOffset: (yOffset: number) => void +} + +export function useGridViewAs(): GridViewAsController { + const { surfaces } = useContext(RootAppStoreContext) + + const [storedData, setStoredData] = useState(() => { + try { + // // load the cached value, or start with default + const storedValue = JSON.parse(window.localStorage.getItem(`grid-view-as`) + '') + + // Load and ensure it looks sane + return { + enabled: Boolean(storedValue.enabled), + surfaceId: storedValue.surfaceId || DEFAULT_STORED_VALUE.surfaceId, + custom: { + type: storedValue.custom.type || DEFAULT_STORED_VALUE.custom.type, + xOffset: Number(storedValue.custom.xOffset) || DEFAULT_STORED_VALUE.custom.xOffset, + yOffset: Number(storedValue.custom.yOffset) || DEFAULT_STORED_VALUE.custom.yOffset, + }, + } + } catch { + // Ignore the error, + return cloneDeep(DEFAULT_STORED_VALUE) + } + }) + const updateStoredData = useCallback( + (update: (oldValue: GridViewAsStore) => GridViewAsStore) => { + return setStoredData((oldValue) => { + const newValue = update(oldValue) + + // Cache the value for future page loads + window.localStorage.setItem(`grid-view-as`, JSON.stringify(newValue)) + + return newValue + }) + }, + [setStoredData] + ) + + const selectedSurfaceIsValid = + storedData.surfaceId === GridViewSpecialSurface.Custom || + surfaces.getSurfaceItem(storedData.surfaceId) !== undefined + if (!selectedSurfaceIsValid) { + // If the selected surface is invalid, reset to the default + updateStoredData((oldStore) => ({ ...oldStore, surfaceId: GridViewSpecialSurface.Custom })) + } + + const surfaceChoices = useComputed(() => { + return [ + { + id: GridViewSpecialSurface.Custom, + label: 'Custom Surface', + }, + ...Array.from(surfaces.store.values()).flatMap((surfaceGroup): DropdownChoice[] => + surfaceGroup.surfaces.map((surface) => ({ + id: surface.id, + label: surface.displayName, + })) + ), + ] + }, [surfaces]) + + const controller = useMemo>(() => { + const updateCustomValue = (update: (oldValue: GridViewAsStore['custom']) => GridViewAsStore['custom']) => { + updateStoredData((oldStore) => { + if (oldStore.surfaceId !== GridViewSpecialSurface.Custom) return oldStore + + return { + ...oldStore, + custom: update(oldStore.custom), + } + }) + } + + return { + setEnabled: (enabled: boolean): void => { + updateStoredData((oldStore) => ({ + ...oldStore, + enabled, + })) + }, + setSelectedSurface: (surfaceId: GridViewSpecialSurface | string): void => { + updateStoredData((oldStore) => ({ + ...oldStore, + surfaceId, + })) + }, + + setCustomType: (type: string): void => { + updateCustomValue((oldCustom) => { + return { + ...oldCustom, + type, + } + }) + }, + setCustomXOffset: (xOffset: number): void => { + updateCustomValue((oldCustom) => { + return { + ...oldCustom, + xOffset, + } + }) + }, + setCustomYOffset: (yOffset: number): void => { + updateCustomValue((oldCustom) => { + return { + ...oldCustom, + yOffset, + } + }) + }, + } + }, [updateStoredData]) + + const selectedSurfaceInfo = surfaces.getSurfaceItem(storedData.surfaceId) + + return { + ...controller, + enabled: storedData.enabled, + selectedSurface: getSelectedSurface(storedData, selectedSurfaceInfo, surfaces.layouts), + surfaceChoices, + } +} + +function getSelectedSurface( + storedData: GridViewAsStore, + surfaceInfo: ClientSurfaceItem | undefined, + surfaceLayouts: SurfaceLayoutSchema[] +): GridViewSelectedSurfaceInfo { + switch (storedData.surfaceId) { + case GridViewSpecialSurface.Custom: + return { + id: storedData.surfaceId, + type: storedData.custom.type, + xOffset: storedData.custom.xOffset, + yOffset: storedData.custom.yOffset, + layout: surfaceLayouts.find((layout) => layout.id === storedData.custom.type) ?? null, + } + default: + return { + id: storedData.surfaceId, + type: surfaceInfo?.type ?? 'Unknown', + xOffset: surfaceInfo?.xOffset ?? 0, + yOffset: surfaceInfo?.yOffset ?? 0, + layout: surfaceInfo?.layout ?? null, + } + } +} diff --git a/webui/src/Buttons/GridViewAsPanel.tsx b/webui/src/Buttons/GridViewAsPanel.tsx new file mode 100644 index 0000000000..260d764de7 --- /dev/null +++ b/webui/src/Buttons/GridViewAsPanel.tsx @@ -0,0 +1,103 @@ +import React, { useContext } from 'react' +import { CAlert, CCol, CForm, CFormLabel, CFormSwitch } from '@coreui/react' +import { PreventDefaultHandler, useComputed } from '../util.js' +import { RootAppStoreContext } from '../Stores/RootAppStore.js' +import { observer } from 'mobx-react-lite' +import { GridViewAsController, GridViewSpecialSurface } from './GridViewAs.js' +import { DropdownInputField, NumberInputField } from '../Components/index.js' +import { DropdownChoice } from '@companion-module/base' + +interface GridViewAsPanelProps { + gridViewAsController: GridViewAsController +} + +export const GridViewAsPanel = observer(function GridViewAsPanel({ gridViewAsController }: GridViewAsPanelProps) { + const { surfaces } = useContext(RootAppStoreContext) + + const surfaceTypeChoices: DropdownChoice[] = useComputed(() => { + if (gridViewAsController.selectedSurface.id === GridViewSpecialSurface.Custom) { + return surfaces.layouts.map((layout) => ({ + id: layout.id, + label: layout.name, + })) + } else { + // Field is disabled, show just the current value + return [ + { + id: gridViewAsController.selectedSurface.type, + label: gridViewAsController.selectedSurface.type, + }, + ] + } + }, [surfaces, gridViewAsController.selectedSurface.id]) + + return ( +
+
View Grid As
+

Here you can change how the grid is displayed, to view it as a particular surface.

+ + + View Enabled + + gridViewAsController.setEnabled(!!e.currentTarget.checked)} + size="xl" + /> + + + + Surface + + gridViewAsController.setSelectedSurface(value as GridViewSpecialSurface | string)} + /> + + + + {!gridViewAsController.selectedSurface.layout && ( + + + The layout of this surface is not known, the full grid will be shown instead + + + )} + + Surface Type + + gridViewAsController.setCustomType(value as string)} + /> + + + + Horizontal Offset + + + + + + Vertical Offset + + + + + +
+ ) +}) diff --git a/webui/src/Buttons/index.tsx b/webui/src/Buttons/index.tsx index e5337059b8..7d3ff2b9c9 100644 --- a/webui/src/Buttons/index.tsx +++ b/webui/src/Buttons/index.tsx @@ -1,5 +1,5 @@ import { CCol, CNav, CNavItem, CNavLink, CRow, CTabContent, CTabPane } from '@coreui/react' -import { faCalculator, faGift, faVideoCamera } from '@fortawesome/free-solid-svg-icons' +import { faCalculator, faEye, faGift, faVideoCamera } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { nanoid } from 'nanoid' import { InstancePresets } from './Presets.js' @@ -15,6 +15,37 @@ import { observer } from 'mobx-react-lite' import { RootAppStoreContext } from '../Stores/RootAppStore.js' import classNames from 'classnames' import { useGridZoom } from './GridZoom.js' +import { NavigateFunction, useLocation, useNavigate } from 'react-router-dom' +import { GridViewAsPanel } from './GridViewAsPanel.js' +import { useGridViewAs } from './GridViewAs.js' + +export const BUTTONS_PAGE_PREFIX = '/buttons' +const SESSION_STORAGE_LAST_BUTTONS_PAGE = 'lastButtonsPage' + +function useUrlPageNumber(): number | null { + const routerLocation = useLocation() + if (!routerLocation.pathname.startsWith(BUTTONS_PAGE_PREFIX)) return null + + const fragments = routerLocation.pathname.slice(BUTTONS_PAGE_PREFIX.length + 1).split('/') + + const pageIndex = Number(fragments[0]) + if (isNaN(pageIndex) || pageIndex <= 0) return 0 + + return pageIndex +} + +function navigateToButtonsPage(navigate: NavigateFunction, pageNumber: number): void { + navigate(`${BUTTONS_PAGE_PREFIX}/${pageNumber}`) + window.sessionStorage.setItem(SESSION_STORAGE_LAST_BUTTONS_PAGE, pageNumber.toString()) +} + +function getLastPageNumber(): number { + const lastPage = Number(window.sessionStorage.getItem(SESSION_STORAGE_LAST_BUTTONS_PAGE)) + if (!isNaN(lastPage) && lastPage > 0) { + return lastPage + } + return 1 +} interface ButtonsPageProps { hotPress: boolean @@ -25,13 +56,22 @@ export const ButtonsPage = observer(function ButtonsPage({ hotPress }: ButtonsPa const clearModalRef = useRef(null) const [gridZoomController, gridZoomValue] = useGridZoom('grid') + const gridViewAsController = useGridViewAs() const [tabResetToken, setTabResetToken] = useState(nanoid()) const [activeTab, setActiveTab] = useState('presets') const [selectedButton, setSelectedButton] = useState(null) - const [pageNumber, setPageNumber] = useState(1) const [copyFromButton, setCopyFromButton] = useState<[ControlLocation, string] | null>(null) + const navigate = useNavigate() + const pageNumber = useUrlPageNumber() + const setPageNumber = useCallback( + (pageNumber: number) => { + navigateToButtonsPage(navigate, pageNumber) + }, + [navigate] + ) + const doChangeTab = useCallback((newTab: string) => { setActiveTab((oldTab) => { if (newTab !== 'edit' && oldTab !== newTab) { @@ -207,9 +247,16 @@ export const ButtonsPage = observer(function ButtonsPage({ hotPress }: ButtonsPa } } }, - [socket, selectedButton, copyFromButton, gridSize] + [socket, selectedButton, copyFromButton, gridSize, setPageNumber] ) + if (pageNumber === null) { + return <> + } else if (pageNumber <= 0) { + navigateToButtonsPage(navigate, getLastPageNumber()) + return <> + } + return ( @@ -226,6 +273,7 @@ export const ButtonsPage = observer(function ButtonsPage({ hotPress }: ButtonsPa clearSelectedButton={clearSelectedButton} gridZoomController={gridZoomController} gridZoomValue={gridZoomValue} + gridViewAsController={gridViewAsController} /> @@ -248,6 +296,11 @@ export const ButtonsPage = observer(function ButtonsPage({ hotPress }: ButtonsPa Presets + + doChangeTab('view-as')}> + Grid View + + doChangeTab('action-recorder')}> Recorder @@ -271,6 +324,11 @@ export const ButtonsPage = observer(function ButtonsPage({ hotPress }: ButtonsPa + + + + + diff --git a/webui/src/Connections/ConnectionList.tsx b/webui/src/Connections/ConnectionList.tsx index d4a22f9818..1252182c8d 100644 --- a/webui/src/Connections/ConnectionList.tsx +++ b/webui/src/Connections/ConnectionList.tsx @@ -383,21 +383,17 @@ const ConnectionsTableRow = observer(function ConnectionsTableRow({
- + - + 0) ? 0.2 : 1, - }} disabled={!isEnabled || !(connectionVariables && connectionVariables.size > 0)} > @@ -406,12 +402,11 @@ const ConnectionsTableRow = observer(function ConnectionsTableRow({ windowLinkOpen({ href: `/connection-debug/${id}`, title: 'View debug log' })} title="Logs" - style={{ padding: 4 }} > - + diff --git a/webui/src/ContextData.tsx b/webui/src/ContextData.tsx index aa50e49486..c25719e21e 100644 --- a/webui/src/ContextData.tsx +++ b/webui/src/ContextData.tsx @@ -26,6 +26,7 @@ import { UserConfigStore } from './Stores/UserConfigStore.js' import { VariablesStore } from './Stores/VariablesStore.js' import { useCustomVariablesSubscription } from './Hooks/useCustomVariablesSubscription.js' import { useVariablesSubscription } from './Hooks/useVariablesSubscription.js' +import { useSurfaceLayoutsSubscription } from './Hooks/useSurfaceLayoutsSubscription.js' interface ContextDataProps { children: (progressPercent: number, loadingComplete: boolean) => React.JSX.Element | React.JSX.Element[] @@ -73,6 +74,7 @@ export function ContextData({ children }: Readonly) { const pagesReady = usePagesInfoSubscription(socket, rootStore.pages) const userConfigReady = useUserConfigSubscription(socket, rootStore.userConfig) const surfacesReady = useSurfacesSubscription(socket, rootStore.surfaces) + const surfaceLayoutsReady = useSurfaceLayoutsSubscription(socket, rootStore.surfaces) const variablesReady = useVariablesSubscription(socket, rootStore.variablesStore) const customVariablesReady = useCustomVariablesSubscription(socket, rootStore.variablesStore) @@ -140,6 +142,7 @@ export function ContextData({ children }: Readonly) { customVariablesReady, userConfigReady, surfacesReady, + surfaceLayoutsReady, pagesReady, triggersListReady, activeLearnRequestsReady, diff --git a/webui/src/Hooks/useSurfaceLayoutsSubscription.ts b/webui/src/Hooks/useSurfaceLayoutsSubscription.ts new file mode 100644 index 0000000000..a5d0ad7867 --- /dev/null +++ b/webui/src/Hooks/useSurfaceLayoutsSubscription.ts @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react' +import type { SurfacesStore } from '../Stores/SurfacesStore.js' +import { CompanionSocketType, socketEmitPromise } from '../util.js' + +export function useSurfaceLayoutsSubscription( + socket: CompanionSocketType, + store: SurfacesStore, + setLoadError?: ((error: string | null) => void) | undefined, + retryToken?: string +): boolean { + const [ready, setReady] = useState(false) + + useEffect(() => { + setLoadError?.(null) + store.reset(null) + setReady(false) + + socketEmitPromise(socket, 'surfaces:get-layouts', []) + .then((surfaceLayouts) => { + setLoadError?.(null) + store.resetLayouts(surfaceLayouts) + setReady(true) + }) + .catch((e) => { + setLoadError?.(`Failed to load surface layouts list`) + console.error('Failed to load surface layouts list:', e) + store.reset(null) + }) + + return () => { + store.resetLayouts(null) + } + }, [socket, store, setLoadError, retryToken]) + + return ready +} diff --git a/webui/src/ImportExport/Import/Page.tsx b/webui/src/ImportExport/Import/Page.tsx index a80444df92..620433fff4 100644 --- a/webui/src/ImportExport/Import/Page.tsx +++ b/webui/src/ImportExport/Import/Page.tsx @@ -84,24 +84,17 @@ export const ImportPageWizard = observer(function ImportPageWizard({
Source Page
-
+ <> - - Home - - + > + + + +
{hasBeenRendered && sourceGridSize && ( @@ -114,38 +107,26 @@ export const ImportPageWizard = observer(function ImportPageWizard({ /> )}
-
+
Destination Page
-
+ <> - - Home - - - + + - + + + +
{hasBeenRendered && destinationGridSize && ( @@ -158,7 +139,7 @@ export const ImportPageWizard = observer(function ImportPageWizard({ /> )}
-
+
diff --git a/webui/src/Stores/SurfacesStore.tsx b/webui/src/Stores/SurfacesStore.tsx index 80152034fc..26d1bf1a07 100644 --- a/webui/src/Stores/SurfacesStore.tsx +++ b/webui/src/Stores/SurfacesStore.tsx @@ -1,4 +1,9 @@ -import { ClientDevicesListItem, SurfacesUpdate } from '@companion-app/shared/Model/Surfaces.js' +import { + ClientDevicesListItem, + ClientSurfaceItem, + SurfaceLayoutSchema, + SurfacesUpdate, +} from '@companion-app/shared/Model/Surfaces.js' import { action, observable } from 'mobx' import { assertNever } from '../util.js' import { applyPatch } from 'fast-json-patch' @@ -6,6 +11,19 @@ import { cloneDeep } from 'lodash-es' export class SurfacesStore { readonly store = observable.map() + readonly layouts = observable.array() + + public getSurfaceItem(id: string): ClientSurfaceItem | undefined { + for (const surfaceGroup of this.store.values()) { + for (const surface of surfaceGroup.surfaces) { + if (surface.id === id) { + return surface + } + } + } + + return undefined + } public reset = action((newData: Record | null): void => { this.store.clear() @@ -19,6 +37,14 @@ export class SurfacesStore { } }) + public resetLayouts = action((newData: SurfaceLayoutSchema[] | null): void => { + this.layouts.clear() + + if (newData) { + this.layouts.push(...newData) + } + }) + public applyChange = action((change: SurfacesUpdate) => { const changeType = change.type switch (change.type) { diff --git a/webui/src/Triggers/index.tsx b/webui/src/Triggers/index.tsx index 90167968b8..7053220a9b 100644 --- a/webui/src/Triggers/index.tsx +++ b/webui/src/Triggers/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useEffect, useState, useMemo, useRef } from 'react' +import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react' import { CButton, CButtonGroup, @@ -25,48 +25,65 @@ import { faTrash, } from '@fortawesome/free-solid-svg-icons' import { useDrag, useDrop } from 'react-dnd' -import { nanoid } from 'nanoid' import { EditTriggerPanel } from './EditPanel.js' import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal.js' -import { ParseControlId } from '@companion-app/shared/ControlId.js' +import { CreateTriggerControlId, ParseControlId } from '@companion-app/shared/ControlId.js' import { ConfirmExportModal, ConfirmExportModalRef } from '../Components/ConfirmExportModal.js' import classNames from 'classnames' import { ClientTriggerData } from '@companion-app/shared/Model/TriggerModel.js' import { observer } from 'mobx-react-lite' import { RootAppStoreContext } from '../Stores/RootAppStore.js' +import { NavigateFunction, useLocation, useNavigate } from 'react-router-dom' + +export const TRIGGERS_PAGE_PREFIX = '/triggers' + +function useSelectedTriggerId(): string | null { + const routerLocation = useLocation() + if (!routerLocation.pathname.startsWith(TRIGGERS_PAGE_PREFIX)) return null + + const fragments = routerLocation.pathname.slice(TRIGGERS_PAGE_PREFIX.length + 1).split('/') + + const triggerId = fragments[0] + if (!triggerId) return null + + return CreateTriggerControlId(triggerId) +} + +function navigateToTriggersPage(navigate: NavigateFunction, controlId: string | null): void { + if (!controlId) { + navigate(TRIGGERS_PAGE_PREFIX) + return + } + + navigate(`${TRIGGERS_PAGE_PREFIX}/${controlId}`) +} export const Triggers = observer(function Triggers() { const { socket, triggersList } = useContext(RootAppStoreContext) - const [editItemId, setEditItemId] = useState(null) - const [tabResetToken, setTabResetToken] = useState(nanoid()) - const [activeTab, setActiveTab] = useState<'placeholder' | 'edit'>('placeholder') + const editItemId = useSelectedTriggerId() + const activeTab = editItemId ? 'edit' : 'placeholder' + const navigate = useNavigate() // Ensure the selected trigger is valid useEffect(() => { - setEditItemId((currentId) => { - if (currentId && triggersList.triggers.get(currentId)) { - return currentId - } else { - return null - } - }) - }, [triggersList]) - - const doChangeTab = useCallback((newTab: 'placeholder' | 'edit') => { - setActiveTab((oldTab) => { - // const preserveButtonsTab = newTab === 'variables' && oldTab === 'edit' - if (newTab !== 'edit' && oldTab !== newTab /*&& !preserveButtonsTab*/) { - setEditItemId(null) - setTabResetToken(nanoid()) - } - return newTab - }) - }, []) - const doEditItem = useCallback((controlId: string) => { - setEditItemId(controlId) - setActiveTab('edit') + if (editItemId && !triggersList.triggers.get(editItemId)) { + navigateToTriggersPage(navigate, null) + } + }, [navigate, triggersList, editItemId]) + + const doChangeTab = useCallback((_newTab: 'placeholder' | 'edit') => { + // setActiveTab(newTab) }, []) + const doEditItem = useCallback( + (controlId: string) => { + const parsedId = ParseControlId(controlId) + if (parsedId?.type !== 'trigger') return + + navigateToTriggersPage(navigate, parsedId.trigger) + }, + [navigate] + ) const doAddNew = useCallback(() => { socketEmitPromise(socket, 'triggers:create', []) @@ -139,7 +156,7 @@ export const Triggers = observer(function Triggers() { )} - {editItemId ? : ''} + {editItemId ? : ''} diff --git a/webui/src/scss/_button-grid.scss b/webui/src/scss/_button-grid.scss index 576f2d8f92..c73472a172 100644 --- a/webui/src/scss/_button-grid.scss +++ b/webui/src/scss/_button-grid.scss @@ -19,10 +19,6 @@ .right-buttons { align-content: center; } - - .right-buttons .btn { - margin-top: 0; - } } .tab-pane .nav.nav-tabs .nav-link { @@ -38,6 +34,8 @@ grid-auto-flow: row; grid-auto-rows: 1fr; + height: 100%; + &.button-armed .buttongrid-row { background-color: rgba(255, 0, 0, 0.5); } @@ -94,16 +92,12 @@ min-height: 300px; } -.button-grid-panel-header { +.button-grid-header { .btn-right { float: right; - margin-top: 10px; + // margin-top: 10px; margin-left: 3px; } - - .btn-grid-scale { - min-width: 80px; - } } .button-infinite-grid { @@ -111,7 +105,7 @@ max-height: 60vh; background-color: #222; - border-radius: 5px; + // border-radius: 5px; // A radius here breaks safari, causing the div to draw over the scrollbars overflow: scroll; &.button-armed { diff --git a/webui/src/scss/_common.scss b/webui/src/scss/_common.scss index 8cf3351a58..793cd17668 100644 --- a/webui/src/scss/_common.scss +++ b/webui/src/scss/_common.scss @@ -11,7 +11,11 @@ h4 { .btn { text-transform: uppercase; - padding: 0px 6px; + padding: 4px; + + &:disabled { + opacity: 0.2; + } } .btn-primary.disabled {